diff options
author | Thibaut Girka <thib@sitedethib.com> | 2019-05-04 16:37:26 +0200 |
---|---|---|
committer | Thibaut Girka <thib@sitedethib.com> | 2019-05-04 16:37:26 +0200 |
commit | 33c80e07838d932efc6214cb1642cefaeb624b67 (patch) | |
tree | 51cae2d9096888cc27ae38ec4b0ceceb1853bb73 /app/javascript | |
parent | 2c2f649200ba5b742ba9d17ac5ba553c752b59aa (diff) | |
parent | c88d9e524b02cba895de9bdb7cba0e29e37703d5 (diff) |
Merge branch 'master' into glitch-soc/merge-upstream
Conflicts: - app/models/media_attachment.rb
Diffstat (limited to 'app/javascript')
22 files changed, 555 insertions, 236 deletions
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index d65d41048..0ee663766 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -203,8 +203,8 @@ export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); - const total = Array.from(files).reduce((a, v) => a + v.size, 0); const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); if (files.length + media.size > uploadLimit) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); @@ -224,6 +224,8 @@ export function uploadCompose(files) { resizeImage(f).then(file => { const data = new FormData(); data.append('file', file); + // Account for disparity in size of original image and resized data + total += file.size - f.size; return api(getState).post('/api/v1/media', data, { onUploadProgress: function({ loaded }){ diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index d92385e95..06c21b96b 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -96,7 +96,7 @@ export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); -export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); +export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index a2bc95255..abd17647e 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; import { autoPlayGif, displayMedia } from '../initial_state'; +import { decode } from 'blurhash'; const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, @@ -21,6 +22,7 @@ class Item extends React.PureComponent { size: PropTypes.number.isRequired, onClick: PropTypes.func.isRequired, displayWidth: PropTypes.number, + visible: PropTypes.bool.isRequired, }; static defaultProps = { @@ -29,6 +31,10 @@ class Item extends React.PureComponent { size: 1, }; + state = { + loaded: false, + }; + handleMouseEnter = (e) => { if (this.hoverToPlay()) { e.target.play(); @@ -62,8 +68,40 @@ class Item extends React.PureComponent { e.stopPropagation(); } + componentDidMount () { + if (this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + _decode () { + const hash = this.props.attachment.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ loaded: true }); + } + render () { - const { attachment, index, size, standalone, displayWidth } = this.props; + const { attachment, index, size, standalone, displayWidth, visible } = this.props; let width = 50; let height = 100; @@ -116,12 +154,20 @@ class Item extends React.PureComponent { let thumbnail = ''; - if (attachment.get('type') === 'image') { + if (attachment.get('type') === 'unknown') { + return ( + <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}> + <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' /> + </a> + </div> + ); + } else if (attachment.get('type') === 'image') { const previewUrl = attachment.get('preview_url'); const previewWidth = attachment.getIn(['meta', 'small', 'width']); - const originalUrl = attachment.get('url'); - const originalWidth = attachment.getIn(['meta', 'original', 'width']); + const originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; @@ -147,6 +193,7 @@ class Item extends React.PureComponent { alt={attachment.get('description')} title={attachment.get('description')} style={{ objectPosition: `${x}% ${y}%` }} + onLoad={this.handleImageLoad} /> </a> ); @@ -176,7 +223,8 @@ class Item extends React.PureComponent { return ( <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> - {thumbnail} + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} /> + {visible && thumbnail} </div> ); } @@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent { if (node /*&& this.isStandaloneEligible()*/) { // offsetWidth triggers a layout, so only calculate when we need to if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); + this.setState({ width: node.offsetWidth, }); @@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent { const width = this.state.width || defaultWidth; - let children; + let children, spoilerButton; const style = {}; @@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent { style.height = height; } - if (!visible) { - let warning; + const size = media.take(4).size; - if (sensitive) { - warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; - } else { - warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; - } + if (this.isStandaloneEligible()) { + children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; + } else { + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />); + } - children = ( - <button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}> - <span className='media-spoiler__warning'>{warning}</span> - <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + if (visible) { + spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />; + } else { + spoilerButton = ( + <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span> </button> ); - } else { - const size = media.take(4).size; - - if (this.isStandaloneEligible()) { - children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />; - } else { - children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />); - } } return ( <div className='media-gallery' style={style} ref={this.handleRef}> - <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> - <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> + <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}> + {spoilerButton} </div> {children} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index cea9a0c2e..95ca4a548 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent { if (status.get('poll')) { media = <PollContainer pollId={status.get('poll')} />; } else if (status.get('media_attachments').size > 0) { - if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + if (this.props.muted) { media = ( <AttachmentList compact @@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent { {Component => ( <Component preview={video.get('preview_url')} + blurhash={video.get('blurhash')} src={video.get('url')} alt={video.get('description')} width={this.props.cachedMediaWidth} diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index e417f9a2b..745e6422d 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent { handleMoveUp = (id, featured) => { const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, true); } handleMoveDown = (id, featured) => { const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, false); } handleLoadOlder = debounce(() => { this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined); }, 300, { leading: true }) - _selectChild (index) { - const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + _selectChild (index, align_top) { + const container = this.node.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } element.focus(); } } diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index 80ac9d9ec..8d462996e 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -1,62 +1,142 @@ import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import Permalink from '../../../components/permalink'; -import { displayMedia } from '../../../initial_state'; -import Icon from 'mastodon/components/icon'; +import { autoPlayGif, displayMedia } from 'mastodon/initial_state'; +import classNames from 'classnames'; +import { decode } from 'blurhash'; +import { isIOS } from 'mastodon/is_mobile'; export default class MediaItem extends ImmutablePureComponent { static propTypes = { - media: ImmutablePropTypes.map.isRequired, + attachment: ImmutablePropTypes.map.isRequired, + displayWidth: PropTypes.number.isRequired, + onOpenMedia: PropTypes.func.isRequired, }; state = { - visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all', + visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all', + loaded: false, }; - handleClick = () => { - if (!this.state.visible) { - this.setState({ visible: true }); - return true; + componentDidMount () { + if (this.props.attachment.get('blurhash')) { + this._decode(); } + } - return false; + componentDidUpdate (prevProps) { + if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { + this._decode(); + } } - render () { - const { media } = this.props; - const { visible } = this.state; - const status = media.get('status'); - const focusX = media.getIn(['meta', 'focus', 'x']); - const focusY = media.getIn(['meta', 'focus', 'y']); - const x = ((focusX / 2) + .5) * 100; - const y = ((focusY / -2) + .5) * 100; - const style = {}; - - let label, icon; - - if (media.get('type') === 'gifv') { - label = <span className='media-gallery__gifv__label'>GIF</span>; + _decode () { + const hash = this.props.attachment.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ loaded: true }); + } + + handleMouseEnter = e => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = e => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; } + } + + hoverToPlay () { + return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1; + } + + handleClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + + if (this.state.visible) { + this.props.onOpenMedia(this.props.attachment); + } else { + this.setState({ visible: true }); + } + } + } + + render () { + const { attachment, displayWidth } = this.props; + const { visible, loaded } = this.state; + + const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; + const height = width; + const status = attachment.get('status'); + + let thumbnail = ''; + + if (attachment.get('type') === 'unknown') { + // Skip + } else if (attachment.get('type') === 'image') { + const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; + const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + thumbnail = ( + <img + src={attachment.get('preview_url')} + alt={attachment.get('description')} + title={attachment.get('description')} + style={{ objectPosition: `${x}% ${y}%` }} + onLoad={this.handleImageLoad} + /> + ); + } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) { + const autoPlay = !isIOS() && autoPlayGif; + + thumbnail = ( + <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> + <video + className='media-gallery__item-gifv-thumbnail' + aria-label={attachment.get('description')} + title={attachment.get('description')} + role='application' + src={attachment.get('url')} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlay} + loop + muted + /> - if (visible) { - style.backgroundImage = `url(${media.get('preview_url')})`; - style.backgroundPosition = `${x}% ${y}%`; - } else { - icon = ( - <span className='account-gallery__item__icons'> - <Icon id='eye-slash' /> - </span> + <span className='media-gallery__gifv__label'>GIF</span> + </div> ); } return ( - <div className='account-gallery__item'> - <Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}> - {icon} - {label} - </Permalink> + <div className='account-gallery__item' style={{ width, height }}> + <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' style={{ cursor: 'pointer' }} onClick={this.handleClick}> + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} /> + {visible && thumbnail} + </a> </div> ); } diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 73be58d6a..5d6a53e18 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -2,24 +2,25 @@ import React from 'react'; import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { fetchAccount } from '../../actions/accounts'; +import { fetchAccount } from 'mastodon/actions/accounts'; import { expandAccountMediaTimeline } from '../../actions/timelines'; -import LoadingIndicator from '../../components/loading_indicator'; +import LoadingIndicator from 'mastodon/components/loading_indicator'; import Column from '../ui/components/column'; -import ColumnBackButton from '../../components/column_back_button'; +import ColumnBackButton from 'mastodon/components/column_back_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { getAccountGallery } from '../../selectors'; +import { getAccountGallery } from 'mastodon/selectors'; import MediaItem from './components/media_item'; import HeaderContainer from '../account_timeline/containers/header_container'; import { ScrollContainer } from 'react-router-scroll-4'; -import LoadMore from '../../components/load_more'; +import LoadMore from 'mastodon/components/load_more'; import MissingIndicator from 'mastodon/components/missing_indicator'; +import { openModal } from 'mastodon/actions/modal'; const mapStateToProps = (state, props) => ({ isAccount: !!state.getIn(['accounts', props.params.accountId]), - medias: getAccountGallery(state, props.params.accountId), + attachments: getAccountGallery(state, props.params.accountId), isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), - hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), + hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), }); class LoadMoreMedia extends ImmutablePureComponent { @@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, - medias: ImmutablePropTypes.list.isRequired, + attachments: ImmutablePropTypes.list.isRequired, isLoading: PropTypes.bool, hasMore: PropTypes.bool, isAccount: PropTypes.bool, }; + state = { + width: 323, + }; + componentDidMount () { this.props.dispatch(fetchAccount(this.props.params.accountId)); this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); @@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent { handleScrollToBottom = () => { if (this.props.hasMore) { - this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined); + this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined); } } - handleScroll = (e) => { + handleScroll = e => { const { scrollTop, scrollHeight, clientHeight } = e.target; const offset = scrollHeight - scrollTop - clientHeight; @@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent { this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); }; - handleLoadOlder = (e) => { + handleLoadOlder = e => { e.preventDefault(); this.handleScrollToBottom(); } + handleOpenMedia = attachment => { + if (attachment.get('type') === 'video') { + this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') })); + } else { + const media = attachment.getIn(['status', 'media_attachments']); + const index = media.findIndex(x => x.get('id') === attachment.get('id')); + + this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') })); + } + } + + handleRef = c => { + if (c) { + this.setState({ width: c.offsetWidth }); + } + } + render () { - const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props; + const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props; + const { width } = this.state; if (!isAccount) { return ( @@ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent { ); } - let loadOlder = null; - - if (!medias && isLoading) { + if (!attachments && isLoading) { return ( <Column> <LoadingIndicator /> @@ -114,7 +135,9 @@ class AccountGallery extends ImmutablePureComponent { ); } - if (hasMore && !(isLoading && medias.size === 0)) { + let loadOlder = null; + + if (hasMore && !(isLoading && attachments.size === 0)) { loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />; } @@ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent { <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> <HeaderContainer accountId={this.props.params.accountId} /> - <div role='feed' className='account-gallery__container'> - {medias.map((media, index) => media === null ? ( - <LoadMoreMedia - key={'more:' + medias.getIn(index + 1, 'id')} - maxId={index > 0 ? medias.getIn(index - 1, 'id') : null} - onLoadMore={this.handleLoadMore} - /> + <div role='feed' className='account-gallery__container' ref={this.handleRef}> + {attachments.map((attachment, index) => attachment === null ? ( + <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> ) : ( - <MediaItem - key={media.get('id')} - media={media} - /> + <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} /> ))} + {loadOlder} </div> - {isLoading && medias.size === 0 && ( + {isLoading && attachments.size === 0 && ( <div className='scrollable__append'> <LoadingIndicator /> </div> diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 13514fa0f..03738f1de 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -10,7 +10,6 @@ import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; -import SensitiveButtonContainer from '../containers/sensitive_button_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import PollFormContainer from '../containers/poll_form_container'; import UploadFormContainer from '../containers/upload_form_container'; @@ -215,7 +214,6 @@ class ComposeForm extends ImmutablePureComponent { <UploadButtonContainer /> <PollButtonContainer /> <PrivacyDropdownContainer /> - <SensitiveButtonContainer /> <SpoilerButtonContainer /> </div> <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div> diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js index b7f112205..9ff2aa0fa 100644 --- a/app/javascript/mastodon/features/compose/components/upload_form.js +++ b/app/javascript/mastodon/features/compose/components/upload_form.js @@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import UploadProgressContainer from '../containers/upload_progress_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import UploadContainer from '../containers/upload_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; export default class UploadForm extends ImmutablePureComponent { @@ -22,6 +23,8 @@ export default class UploadForm extends ImmutablePureComponent { <UploadContainer id={id} key={id} /> ))} </div> + + {!mediaIds.isEmpty() && <SensitiveButtonContainer />} </div> ); } diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js index 43de8f213..50612b086 100644 --- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js @@ -2,11 +2,9 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import IconButton from '../../../components/icon_button'; -import { changeComposeSensitivity } from '../../../actions/compose'; -import Motion from '../../ui/util/optional_motion'; -import spring from 'react-motion/lib/spring'; -import { injectIntl, defineMessages } from 'react-intl'; +import { changeComposeSensitivity } from 'mastodon/actions/compose'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; +import Icon from 'mastodon/components/icon'; const messages = defineMessages({ marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, @@ -14,7 +12,6 @@ const messages = defineMessages({ }); const mapStateToProps = state => ({ - visible: state.getIn(['compose', 'media_attachments']).size > 0, active: state.getIn(['compose', 'sensitive']), disabled: state.getIn(['compose', 'spoiler']), }); @@ -30,7 +27,6 @@ const mapDispatchToProps = dispatch => ({ class SensitiveButton extends React.PureComponent { static propTypes = { - visible: PropTypes.bool, active: PropTypes.bool, disabled: PropTypes.bool, onClick: PropTypes.func.isRequired, @@ -38,32 +34,14 @@ class SensitiveButton extends React.PureComponent { }; render () { - const { visible, active, disabled, onClick, intl } = this.props; + const { active, disabled, onClick, intl } = this.props; return ( - <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> - {({ scale }) => { - const icon = active ? 'eye-slash' : 'eye'; - const className = classNames('compose-form__sensitive-button', { - 'compose-form__sensitive-button--visible': visible, - }); - return ( - <div className={className} style={{ transform: `scale(${scale})` }}> - <IconButton - className='compose-form__sensitive-button__icon' - title={intl.formatMessage(active ? messages.marked : messages.unmarked)} - icon={icon} - onClick={onClick} - size={18} - active={active} - disabled={disabled} - style={{ lineHeight: null, height: null }} - inverted - /> - </div> - ); - }} - </Motion> + <div className='compose-form__sensitive-button'> + <button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}> + <Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' /> + </button> + </div> ); } diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js index 635c03c1d..8867bbd73 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js +++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js @@ -20,18 +20,24 @@ export default class ConversationsList extends ImmutablePureComponent { handleMoveUp = id => { const elementIndex = this.getCurrentIndex(id) - 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, true); } handleMoveDown = id => { const elementIndex = this.getCurrentIndex(id) + 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, false); } - _selectChild (index) { - const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + _selectChild (index, align_top) { + const container = this.node.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } element.focus(); } } diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 9430b2050..006c45657 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -113,18 +113,24 @@ class Notifications extends React.PureComponent { handleMoveUp = id => { const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, true); } handleMoveDown = id => { const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, false); } - _selectChild (index) { - const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + _selectChild (index, align_top) { + const container = this.column.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } element.focus(); } } diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js index 2552d94d8..c29e517da 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.js +++ b/app/javascript/mastodon/features/report/components/status_check_box.js @@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent { {Component => ( <Component preview={video.get('preview_url')} + blurhash={video.get('blurhash')} src={video.get('url')} alt={video.get('description')} width={239} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 5c79f9f19..84471f9a3 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar'; import DisplayName from '../../../components/display_name'; import StatusContent from '../../../components/status_content'; import MediaGallery from '../../../components/media_gallery'; -import AttachmentList from '../../../components/attachment_list'; import { Link } from 'react-router-dom'; import { FormattedDate, FormattedNumber } from 'react-intl'; import Card from './card'; @@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent { if (status.get('poll')) { media = <PollContainer pollId={status.get('poll')} />; } else if (status.get('media_attachments').size > 0) { - if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { - media = <AttachmentList media={status.get('media_attachments')} />; - } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const video = status.getIn(['media_attachments', 0]); media = ( <Video preview={video.get('preview_url')} + blurhash={video.get('blurhash')} src={video.get('url')} alt={video.get('description')} width={300} diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 567af6be9..6279bb468 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -316,15 +316,15 @@ class Status extends ImmutablePureComponent { const { status, ancestorsIds, descendantsIds } = this.props; if (id === status.get('id')) { - this._selectChild(ancestorsIds.size - 1); + this._selectChild(ancestorsIds.size - 1, true); } else { let index = ancestorsIds.indexOf(id); if (index === -1) { index = descendantsIds.indexOf(id); - this._selectChild(ancestorsIds.size + index); + this._selectChild(ancestorsIds.size + index, true); } else { - this._selectChild(index - 1); + this._selectChild(index - 1, true); } } } @@ -333,23 +333,29 @@ class Status extends ImmutablePureComponent { const { status, ancestorsIds, descendantsIds } = this.props; if (id === status.get('id')) { - this._selectChild(ancestorsIds.size + 1); + this._selectChild(ancestorsIds.size + 1, false); } else { let index = ancestorsIds.indexOf(id); if (index === -1) { index = descendantsIds.indexOf(id); - this._selectChild(ancestorsIds.size + index + 2); + this._selectChild(ancestorsIds.size + index + 2, false); } else { - this._selectChild(index + 1); + this._selectChild(index + 1, false); } } } - _selectChild (index) { - const element = this.node.querySelectorAll('.focusable')[index]; + _selectChild (index, align_top) { + const container = this.node; + const element = container.querySelectorAll('.focusable')[index]; if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } element.focus(); } } diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index 2120746da..da2ac5f26 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -2,11 +2,11 @@ import React from 'react'; import ReactSwipeableViews from 'react-swipeable-views'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import Video from '../../video'; -import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import Video from 'mastodon/features/video'; +import ExtendedVideoPlayer from 'mastodon/components/extended_video_player'; import classNames from 'classnames'; -import { defineMessages, injectIntl } from 'react-intl'; -import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from 'mastodon/components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; import ImageLoader from './image_loader'; import Icon from 'mastodon/components/icon'; @@ -24,6 +24,7 @@ class MediaModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.list.isRequired, + status: ImmutablePropTypes.map, index: PropTypes.number.isRequired, onClose: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, @@ -72,9 +73,12 @@ class MediaModal extends ImmutablePureComponent { componentDidMount () { window.addEventListener('keydown', this.handleKeyDown, false); + if (this.context.router) { const history = this.context.router.history; + history.push(history.location.pathname, previewState); + this.unlistenHistory = history.listen(() => { this.props.onClose(); }); @@ -83,6 +87,7 @@ class MediaModal extends ImmutablePureComponent { componentWillUnmount () { window.removeEventListener('keydown', this.handleKeyDown); + if (this.context.router) { this.unlistenHistory(); @@ -102,8 +107,15 @@ class MediaModal extends ImmutablePureComponent { })); }; + handleStatusClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + } + render () { - const { media, intl, onClose } = this.props; + const { media, status, intl, onClose } = this.props; const { navigationHidden } = this.state; const index = this.getIndex(); @@ -144,6 +156,7 @@ class MediaModal extends ImmutablePureComponent { return ( <Video preview={image.get('preview_url')} + blurhash={image.get('blurhash')} src={image.get('url')} width={image.get('width')} height={image.get('height')} @@ -206,10 +219,19 @@ class MediaModal extends ImmutablePureComponent { {content} </ReactSwipeableViews> </div> + <div className={navigationClassName}> <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + {leftNav} {rightNav} + + {status && ( + <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}> + <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> + </div> + )} + <ul className='media-modal__pagination'> {pagination} </ul> diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js index 7cf3eb4d4..213d31316 100644 --- a/app/javascript/mastodon/features/ui/components/video_modal.js +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -1,28 +1,69 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import Video from '../../video'; +import Video from 'mastodon/features/video'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage } from 'react-intl'; + +export const previewState = 'previewVideoModal'; export default class VideoModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, time: PropTypes.number, onClose: PropTypes.func.isRequired, }; + static contextTypes = { + router: PropTypes.object, + }; + + componentDidMount () { + if (this.context.router) { + const history = this.context.router.history; + + history.push(history.location.pathname, previewState); + + this.unlistenHistory = history.listen(() => { + this.props.onClose(); + }); + } + } + + componentWillUnmount () { + if (this.context.router) { + this.unlistenHistory(); + + if (this.context.router.history.location.state === previewState) { + this.context.router.history.goBack(); + } + } + } + + handleStatusClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + } + render () { - const { media, time, onClose } = this.props; + const { media, status, time, onClose } = this.props; + + const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>; return ( <div className='modal-root__modal video-modal'> <div> <Video preview={media.get('preview_url')} + blurhash={media.get('blurhash')} src={media.get('url')} startTime={time} onCloseVideo={onClose} + link={link} detailed alt={media.get('description')} /> diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 93e45678f..c14eba992 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -367,11 +367,16 @@ class UI extends React.PureComponent { handleHotkeyFocusColumn = e => { const index = (e.key * 1) + 1; // First child is drawer, skip that const column = this.node.querySelector(`.column:nth-child(${index})`); + if (!column) return; + const container = column.querySelector('.scrollable'); - if (column) { - const status = column.querySelector('.focusable'); + if (container) { + const status = container.querySelector('.focusable'); if (status) { + if (container.scrollTop > status.offsetTop) { + status.scrollIntoView(true); + } status.focus(); } } diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 55dd249e1..00a63a3d9 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -7,6 +7,7 @@ import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; import { displayMedia } from '../../initial_state'; import Icon from 'mastodon/components/icon'; +import { decode } from 'blurhash'; const messages = defineMessages({ play: { id: 'video.play', defaultMessage: 'Play' }, @@ -102,6 +103,8 @@ class Video extends React.PureComponent { inline: PropTypes.bool, cacheWidth: PropTypes.func, intl: PropTypes.object.isRequired, + blurhash: PropTypes.string, + link: PropTypes.node, }; state = { @@ -139,6 +142,7 @@ class Video extends React.PureComponent { setVideoRef = c => { this.video = c; + if (this.video) { this.setState({ volume: this.video.volume, muted: this.video.muted }); } @@ -152,6 +156,10 @@ class Video extends React.PureComponent { this.volume = c; } + setCanvasRef = c => { + this.canvas = c; + } + handleClickRoot = e => e.stopPropagation(); handlePlay = () => { @@ -170,7 +178,6 @@ class Video extends React.PureComponent { } handleVolumeMouseDown = e => { - document.addEventListener('mousemove', this.handleMouseVolSlide, true); document.addEventListener('mouseup', this.handleVolumeMouseUp, true); document.addEventListener('touchmove', this.handleMouseVolSlide, true); @@ -190,7 +197,6 @@ class Video extends React.PureComponent { } handleMouseVolSlide = throttle(e => { - const rect = this.volume.getBoundingClientRect(); const x = (e.clientX - rect.left) / this.volWidth; //x position within the element. @@ -261,6 +267,10 @@ class Video extends React.PureComponent { document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + + if (this.props.blurhash) { + this._decode(); + } } componentWillUnmount () { @@ -270,6 +280,24 @@ class Video extends React.PureComponent { document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); } + componentDidUpdate (prevProps) { + if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) { + this._decode(); + } + } + + _decode () { + const hash = this.props.blurhash; + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + handleFullscreenChange = () => { this.setState({ fullscreen: isFullscreen() }); } @@ -314,6 +342,7 @@ class Video extends React.PureComponent { handleOpenVideo = () => { const { src, preview, width, height, alt } = this.props; + const media = fromJS({ type: 'video', url: src, @@ -333,7 +362,7 @@ class Video extends React.PureComponent { } render () { - const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive } = this.props; + const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = (currentTime / duration) * 100; @@ -351,6 +380,7 @@ class Video extends React.PureComponent { } let preload; + if (startTime || fullscreen || dragging) { preload = 'auto'; } else if (detailed) { @@ -360,6 +390,7 @@ class Video extends React.PureComponent { } let warning; + if (sensitive) { warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; } else { @@ -377,7 +408,9 @@ class Video extends React.PureComponent { onClick={this.handleClickRoot} tabIndex={0} > - <video + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} /> + + {revealed && <video ref={this.setVideoRef} src={src} poster={preview} @@ -397,12 +430,13 @@ class Video extends React.PureComponent { onLoadedData={this.handleLoadedData} onProgress={this.handleProgress} onVolumeChange={this.handleVolumeChange} - /> + />} - <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}> - <span className='video-player__spoiler__title'>{warning}</span> - <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </button> + <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}> + <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}> + <span className='spoiler-button__overlay__label'>{warning}</span> + </button> + </div> <div className={classNames('video-player__controls', { active: paused || hovered })}> <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}> @@ -420,6 +454,7 @@ class Video extends React.PureComponent { <div className='video-player__buttons left'> <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} /> <span @@ -429,17 +464,19 @@ class Video extends React.PureComponent { /> </div> - {(detailed || fullscreen) && + {(detailed || fullscreen) && ( <span> <span className='video-player__time-current'>{formatTime(currentTime)}</span> <span className='video-player__time-sep'>/</span> <span className='video-player__time-total'>{formatTime(duration)}</span> </span> - } + )} + + {link && <span className='video-player__link'>{link}</span>} </div> <div className='video-player__buttons right'> - {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye' fixedWidth /></button>} + {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json index d155619c9..ca7732d85 100644 --- a/app/javascript/mastodon/locales/hy.json +++ b/app/javascript/mastodon/locales/hy.json @@ -243,7 +243,7 @@ "navigation_bar.pins": "Ամրացված թթեր", "navigation_bar.preferences": "Նախապատվություններ", "navigation_bar.public_timeline": "Դաշնային հոսք", - "navigation_bar.security": "Security", + "navigation_bar.security": "Անվտանգություն", "notification.favourite": "{name} հավանեց թութդ", "notification.follow": "{name} սկսեց հետեւել քեզ", "notification.mention": "{name} նշեց քեզ", @@ -309,7 +309,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", - "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "search_results.total": "{count, number} {count, plural, one {արդյունք} other {արդյունք}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", "status.block": "Արգելափակել @{name}֊ին", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 7ea4ad07c..5c6f1a6f6 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -264,6 +264,16 @@ .compose-form { padding: 10px; + &__sensitive-button { + padding: 10px; + padding-top: 0; + + .icon-button { + font-size: 14px; + font-weight: 500; + } + } + .compose-form__warning { color: $inverted-text-color; margin-bottom: 10px; @@ -2412,7 +2422,7 @@ a.account__display-name { & > div { background: rgba($base-shadow-color, 0.6); - border-radius: 4px; + border-radius: 8px; padding: 12px 9px; flex: 0 0 auto; display: flex; @@ -2423,19 +2433,18 @@ a.account__display-name { button, a { display: inline; - color: $primary-text-color; + color: $secondary-text-color; background: transparent; border: 0; - padding: 0 5px; + padding: 0 8px; text-decoration: none; - opacity: 0.6; font-size: 18px; line-height: 18px; &:hover, &:active, &:focus { - opacity: 1; + color: $primary-text-color; } } @@ -2932,15 +2941,49 @@ a.status-card.compact:hover { } .spoiler-button { - display: none; - left: 4px; + top: 0; + left: 0; + width: 100%; + height: 100%; position: absolute; - text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; - top: 4px; z-index: 100; - &.spoiler-button--visible { + &--minified { display: block; + left: 4px; + top: 4px; + width: auto; + height: auto; + } + + &--hidden { + display: none; + } + + &__overlay { + display: block; + background: transparent; + width: 100%; + height: 100%; + border: 0; + + &__label { + display: inline-block; + background: rgba($base-overlay-background, 0.5); + border-radius: 8px; + padding: 8px 12px; + color: $primary-text-color; + font-weight: 500; + font-size: 14px; + } + + &:hover, + &:focus, + &:active { + .spoiler-button__overlay__label { + background: rgba($base-overlay-background, 0.8); + } + } } } @@ -3728,6 +3771,31 @@ a.status-card.compact:hover { pointer-events: none; } +.media-modal__meta { + text-align: center; + position: absolute; + left: 0; + bottom: 20px; + width: 100%; + pointer-events: none; + + &--shifted { + bottom: 62px; + } + + a { + text-decoration: none; + font-weight: 500; + color: $ui-secondary-color; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} + .media-modal__page-dot { display: inline-block; } @@ -4200,6 +4268,7 @@ a.status-card.compact:hover { pointer-events: none; opacity: 0.9; transition: opacity 0.1s ease; + line-height: 18px; } .media-gallery__gifv { @@ -4313,6 +4382,8 @@ a.status-card.compact:hover { text-decoration: none; color: $secondary-text-color; line-height: 0; + position: relative; + z-index: 1; &, img { @@ -4325,6 +4396,21 @@ a.status-card.compact:hover { } } +.media-gallery__preview { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + .media-gallery__gifv { height: 100%; overflow: hidden; @@ -4620,6 +4706,23 @@ a.status-card.compact:hover { } } + &__link { + padding: 2px 10px; + + a { + text-decoration: none; + font-size: 14px; + font-weight: 500; + color: $white; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } + } + &__seek { cursor: pointer; height: 24px; @@ -4712,62 +4815,18 @@ a.status-card.compact:hover { .account-gallery__container { display: flex; - justify-content: center; flex-wrap: wrap; - padding: 2px; + padding: 4px 2px; } .account-gallery__item { - flex-grow: 1; - width: 50%; - overflow: hidden; + border: none; + box-sizing: border-box; + display: block; position: relative; - - &::before { - content: ""; - display: block; - padding-top: 100%; - } - - a { - display: block; - width: calc(100% - 4px); - height: calc(100% - 4px); - margin: 2px; - top: 0; - left: 0; - background-color: $base-overlay-background; - background-size: cover; - background-position: center; - position: absolute; - color: $darker-text-color; - text-decoration: none; - border-radius: 4px; - - &:hover, - &:active, - &:focus { - outline: 0; - color: $secondary-text-color; - - &::before { - content: ""; - display: block; - width: 100%; - height: 100%; - background: rgba($base-overlay-background, 0.3); - border-radius: 4px; - } - } - } - - &__icons { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 24px; - } + border-radius: 4px; + overflow: hidden; + margin: 2px; } .notification__filter-bar, diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 91888d305..2b8d7a682 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -533,6 +533,17 @@ code { color: $error-value-color; } + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } + } + p { margin-bottom: 15px; } |