diff options
author | Takeshi Umeda <noel.yoshiba@gmail.com> | 2021-01-10 11:17:55 +0900 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-01-10 11:17:55 +0900 |
commit | 98a2603dc163210d3a0aab0a0c2b8ef74c7e5eb0 (patch) | |
tree | 761694d2d697c58faf02a3ff9ef26bf045fc0274 /app/javascript/mastodon/features | |
parent | 7cd4ed7d4298626d2b141cd6d8378e95bc248824 (diff) | |
parent | 087ed84367537ac168ed3e00bb7eb4bd582dc3d0 (diff) | |
download | mastodon-feature-limited-visibility-bearcaps.tar mastodon-feature-limited-visibility-bearcaps.tar.gz mastodon-feature-limited-visibility-bearcaps.tar.bz2 mastodon-feature-limited-visibility-bearcaps.zip |
Merge branch 'master' into feature-limited-visibility-bearcapsfeature-limited-visibility-bearcaps
Diffstat (limited to 'app/javascript/mastodon/features')
43 files changed, 1607 insertions, 386 deletions
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 61ecf045d..b47ebed62 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { autoPlayGif, me, isStaff } from 'mastodon/initial_state'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; +import IconButton from 'mastodon/components/icon_button'; import Avatar from 'mastodon/components/avatar'; import { counterRenderer } from 'mastodon/components/common_counter'; import ShortNumber from 'mastodon/components/short_number'; @@ -35,6 +36,8 @@ const messages = defineMessages({ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, + enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' }, + disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, @@ -68,8 +71,9 @@ class Header extends ImmutablePureComponent { onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, - onReport: PropTypes.func.isRequired, onReblogToggle: PropTypes.func.isRequired, + onNotifyToggle: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, onUnblockDomain: PropTypes.func.isRequired, @@ -140,8 +144,11 @@ class Header extends ImmutablePureComponent { return null; } + const suspended = account.get('suspended'); + let info = []; let actionBtn = ''; + let bellBtn = ''; let lockedIcon = ''; let menu = []; @@ -157,13 +164,17 @@ class Header extends ImmutablePureComponent { info.push(<span key='domain_blocked' className='relationship-tag'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain blocked' /></span>); } + if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) { + bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />; + } + if (me !== account.get('id')) { if (!account.get('relationship')) { // Wait until the relationship is loaded actionBtn = ''; } else if (account.getIn(['relationship', 'requested'])) { - actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; + actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; } else if (!account.getIn(['relationship', 'blocking'])) { - actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />; + actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />; } else if (account.getIn(['relationship', 'blocking'])) { actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; } @@ -268,7 +279,7 @@ class Header extends ImmutablePureComponent { <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}> <div className='account__header__image'> <div className='account__header__info'> - {info} + {!suspended && info} </div> <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' /> @@ -282,11 +293,14 @@ class Header extends ImmutablePureComponent { <div className='spacer' /> - <div className='account__header__tabs__buttons'> - {actionBtn} + {!suspended && ( + <div className='account__header__tabs__buttons'> + {actionBtn} + {bellBtn} - <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> - </div> + <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> + </div> + )} </div> <div className='account__header__tabs__name'> @@ -298,7 +312,7 @@ class Header extends ImmutablePureComponent { <div className='account__header__extra'> <div className='account__header__bio'> - { (fields.size > 0 || identity_proofs.size > 0) && ( + {(fields.size > 0 || identity_proofs.size > 0) && ( <div className='account__header__fields'> {identity_proofs.map((proof, i) => ( <dl key={i}> @@ -324,33 +338,35 @@ class Header extends ImmutablePureComponent { </div> )} - {account.get('id') !== me && <AccountNoteContainer account={account} />} + {account.get('id') !== me && !suspended && <AccountNoteContainer account={account} />} {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />} </div> - <div className='account__header__extra__links'> - <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}> - <ShortNumber - value={account.get('statuses_count')} - renderer={counterRenderer('statuses')} - /> - </NavLink> + {!suspended && ( + <div className='account__header__extra__links'> + <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}> + <ShortNumber + value={account.get('statuses_count')} + renderer={counterRenderer('statuses')} + /> + </NavLink> - <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}> - <ShortNumber - value={account.get('following_count')} - renderer={counterRenderer('following')} - /> - </NavLink> + <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}> + <ShortNumber + value={account.get('following_count')} + renderer={counterRenderer('following')} + /> + </NavLink> - <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}> - <ShortNumber - value={account.get('followers_count')} - renderer={counterRenderer('followers')} - /> - </NavLink> - </div> + <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}> + <ShortNumber + value={account.get('followers_count')} + renderer={counterRenderer('followers')} + /> + </NavLink> + </div> + )} </div> </div> </div> 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 c9a7af7f7..ba7ec46a3 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -122,7 +122,7 @@ export default class MediaItem extends ImmutablePureComponent { <div className='media-gallery__gifv'> {content} - <span className='media-gallery__gifv__label'>{label}</span> + {label && <span className='media-gallery__gifv__label'>{label}</span>} </div> ); } diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index fc5aead48..015a6a6d7 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -15,12 +15,15 @@ import { ScrollContainer } from 'react-router-scroll-4'; import LoadMore from 'mastodon/components/load_more'; import MissingIndicator from 'mastodon/components/missing_indicator'; import { openModal } from 'mastodon/actions/modal'; +import { FormattedMessage } from 'react-intl'; const mapStateToProps = (state, props) => ({ isAccount: !!state.getIn(['accounts', 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']), + suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false), + blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false), }); class LoadMoreMedia extends ImmutablePureComponent { @@ -56,6 +59,8 @@ class AccountGallery extends ImmutablePureComponent { isLoading: PropTypes.bool, hasMore: PropTypes.bool, isAccount: PropTypes.bool, + blockedBy: PropTypes.bool, + suspended: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -100,15 +105,18 @@ class AccountGallery extends ImmutablePureComponent { } handleOpenMedia = attachment => { + const { dispatch } = this.props; + const statusId = attachment.getIn(['status', 'id']); + if (attachment.get('type') === 'video') { - this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } })); + dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } })); } else if (attachment.get('type') === 'audio') { - this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } })); + dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } })); } 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') })); + dispatch(openModal('MEDIA', { media, index, statusId })); } } @@ -119,7 +127,7 @@ class AccountGallery extends ImmutablePureComponent { } render () { - const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn } = this.props; + const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props; const { width } = this.state; if (!isAccount) { @@ -144,6 +152,14 @@ class AccountGallery extends ImmutablePureComponent { loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />; } + let emptyMessage; + + if (suspended) { + emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; + } else if (blockedBy) { + emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; + } + return ( <Column> <ColumnBackButton multiColumn={multiColumn} /> @@ -152,15 +168,21 @@ 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' 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={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} /> - ))} + {(suspended || blockedBy) ? ( + <div className='empty-column-indicator'> + {emptyMessage} + </div> + ) : ( + <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={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} /> + ))} - {loadOlder} - </div> + {loadOlder} + </div> + )} {isLoading && attachments.size === 0 && ( <div className='scrollable__append'> diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index abb15edcc..6b52defe4 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -55,6 +55,10 @@ export default class Header extends ImmutablePureComponent { this.props.onReblogToggle(this.props.account); } + handleNotifyToggle = () => { + this.props.onNotifyToggle(this.props.account); + } + handleMute = () => { this.props.onMute(this.props.account); } @@ -106,6 +110,7 @@ export default class Header extends ImmutablePureComponent { onMention={this.handleMention} onDirect={this.handleDirect} onReblogToggle={this.handleReblogToggle} + onNotifyToggle={this.handleNotifyToggle} onReport={this.handleReport} onMute={this.handleMute} onBlockDomain={this.handleBlockDomain} diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 8728b4806..e12019547 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -76,9 +76,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onReblogToggle (account) { if (account.getIn(['relationship', 'showing_reblogs'])) { - dispatch(followAccount(account.get('id'), false)); + dispatch(followAccount(account.get('id'), { reblogs: false })); } else { - dispatch(followAccount(account.get('id'), true)); + dispatch(followAccount(account.get('id'), { reblogs: true })); } }, @@ -90,6 +90,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onNotifyToggle (account) { + if (account.getIn(['relationship', 'notifying'])) { + dispatch(followAccount(account.get('id'), { notify: false })); + } else { + dispatch(followAccount(account.get('id'), { notify: true })); + } + }, + onReport (account) { dispatch(initReport(account)); }, diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index b9a616266..fa4239d6f 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -31,6 +31,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), + suspended: state.getIn(['accounts', accountId, 'suspended'], false), blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), }; }; @@ -57,6 +58,7 @@ class AccountTimeline extends ImmutablePureComponent { withReplies: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + suspended: PropTypes.bool, remote: PropTypes.bool, remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, @@ -113,7 +115,7 @@ class AccountTimeline extends ImmutablePureComponent { } render () { - const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn, remote, remoteUrl } = this.props; + const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -134,7 +136,9 @@ class AccountTimeline extends ImmutablePureComponent { let emptyMessage; - if (blockedBy) { + if (suspended) { + emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; + } else if (blockedBy) { emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; } else if (remote && statusIds.isEmpty()) { emptyMessage = <RemoteHint url={remoteUrl} />; @@ -153,7 +157,7 @@ class AccountTimeline extends ImmutablePureComponent { alwaysPrepend append={remoteMessage} scrollKey='account_timeline' - statusIds={blockedBy ? emptyList : statusIds} + statusIds={(suspended || blockedBy) ? emptyList : statusIds} featuredStatusIds={featuredStatusIds} isLoading={isLoading} hasMore={hasMore} diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js index 5b8172694..c47f55dd1 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -37,7 +37,11 @@ class Audio extends React.PureComponent { backgroundColor: PropTypes.string, foregroundColor: PropTypes.string, accentColor: PropTypes.string, + currentTime: PropTypes.number, autoPlay: PropTypes.bool, + volume: PropTypes.number, + muted: PropTypes.bool, + deployPictureInPicture: PropTypes.func, }; state = { @@ -64,6 +68,19 @@ class Audio extends React.PureComponent { } } + _pack() { + return { + src: this.props.src, + volume: this.audio.volume, + muted: this.audio.muted, + currentTime: this.audio.currentTime, + poster: this.props.poster, + backgroundColor: this.props.backgroundColor, + foregroundColor: this.props.foregroundColor, + accentColor: this.props.accentColor, + }; + } + _setDimensions () { const width = this.player.offsetWidth; const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); @@ -112,6 +129,10 @@ class Audio extends React.PureComponent { componentWillUnmount () { window.removeEventListener('scroll', this.handleScroll); window.removeEventListener('resize', this.handleResize); + + if (!this.state.paused && this.audio && this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('audio', this._pack()); + } } togglePlay = () => { @@ -225,7 +246,7 @@ class Audio extends React.PureComponent { handleTimeUpdate = () => { this.setState({ currentTime: this.audio.currentTime, - duration: Math.floor(this.audio.duration), + duration: this.audio.duration, }); } @@ -248,7 +269,13 @@ class Audio extends React.PureComponent { const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); if (!this.state.paused && !inView) { - this.setState({ paused: true }, () => this.audio.pause()); + this.audio.pause(); + + if (this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('audio', this._pack()); + } + + this.setState({ paused: true }); } }, 150, { trailing: true }); @@ -261,10 +288,22 @@ class Audio extends React.PureComponent { } handleLoadedData = () => { - const { autoPlay } = this.props; + const { autoPlay, currentTime, volume, muted } = this.props; + + if (currentTime) { + this.audio.currentTime = currentTime; + } + + if (volume !== undefined) { + this.audio.volume = volume; + } + + if (muted !== undefined) { + this.audio.muted = muted; + } if (autoPlay) { - this.audio.play(); + this.togglePlay(); } } @@ -347,13 +386,59 @@ class Audio extends React.PureComponent { return this.props.foregroundColor || '#ffffff'; } + seekBy (time) { + const currentTime = this.audio.currentTime + time; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.audio.currentTime = currentTime; + }); + } + } + + handleAudioKeyDown = e => { + // On the audio element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + } + } + + handleKeyDown = e => { + switch(e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + this.toggleMute(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(10); + break; + } + } + render () { const { src, intl, alt, editable, autoPlay } = this.props; const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; - const progress = (currentTime / duration) * 100; + const progress = Math.min((currentTime / duration) * 100, 100); return ( - <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}> <audio src={src} ref={this.setAudioRef} @@ -367,12 +452,14 @@ class Audio extends React.PureComponent { <canvas role='button' + tabIndex='0' className='audio-player__canvas' width={this.state.width} height={this.state.height} style={{ width: '100%', position: 'absolute', top: 0, left: 0 }} ref={this.setCanvasRef} onClick={this.togglePlay} + onKeyDown={this.handleAudioKeyDown} title={alt} aria-label={alt} /> @@ -393,20 +480,21 @@ class Audio extends React.PureComponent { className={classNames('video-player__seek__handle', { active: dragging })} tabIndex='0' style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }} + onKeyDown={this.handleAudioKeyDown} /> </div> <div className='video-player__controls active'> <div className='video-player__buttons-bar'> <div className='video-player__buttons left'> - <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> - <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> + <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}> <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} /> <span - className={classNames('video-player__volume__handle')} + className='video-player__volume__handle' tabIndex='0' style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} /> @@ -415,12 +503,14 @@ class Audio extends React.PureComponent { <span className='video-player__time'> <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span> <span className='video-player__time-sep'>/</span> - <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span> + <span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span> </span> </div> <div className='video-player__buttons right'> - <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button> + <a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download> + <Icon id={'download'} fixedWidth /> + </a> </div> </div> </div> diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 47e189251..8af806ec4 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -77,6 +77,18 @@ class ComposeForm extends ImmutablePureComponent { } } + getFulltextForCharacterCounting = () => { + return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join(''); + } + + canSubmit = () => { + const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; + const fulltext = this.getFulltextForCharacterCounting(); + const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0; + + return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia)); + } + handleSubmit = () => { if (this.props.text !== this.autosuggestTextarea.textarea.value) { // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) @@ -84,11 +96,7 @@ class ComposeForm extends ImmutablePureComponent { this.props.onChange(this.autosuggestTextarea.textarea.value); } - // Submit disabled: - const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; - const fulltext = [this.props.spoilerText, countableText(this.props.text)].join(''); - - if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { + if (!this.canSubmit()) { return; } @@ -178,10 +186,8 @@ class ComposeForm extends ImmutablePureComponent { } render () { - const { intl, onPaste, showSearch, anyMedia } = this.props; + const { intl, onPaste, showSearch } = this.props; const disabled = this.props.isSubmitting; - const text = [this.props.spoilerText, countableText(this.props.text)].join(''); - const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia); let publishText = ''; if (this.props.privacy === 'private' || this.props.privacy === 'direct') { @@ -243,11 +249,11 @@ class ComposeForm extends ImmutablePureComponent { <PrivacyDropdownContainer /> <SpoilerButtonContainer /> </div> - <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> + <div className='character-counter__wrapper'><CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} /></div> </div> <div className='compose-form__publish'> - <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabledButton} block /></div> + <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block /></div> </div> </div> ); diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index e8a36a923..dc4f48060 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -5,8 +5,9 @@ import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components' import Overlay from 'react-overlays/lib/Overlay'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import detectPassiveEvents from 'detect-passive-events'; +import { supportsPassiveEvents } from 'detect-passive-events'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -25,11 +26,10 @@ const messages = defineMessages({ flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, }); -const assetHost = process.env.CDN_HOST || ''; let EmojiPicker, Emoji; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; -const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; class ModifierPickerMenu extends React.PureComponent { diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index 5223025fb..309f46290 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -5,7 +5,7 @@ import IconButton from '../../../components/icon_button'; import Overlay from 'react-overlays/lib/Overlay'; import Motion from '../../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; -import detectPassiveEvents from 'detect-passive-events'; +import { supportsPassiveEvents } from 'detect-passive-events'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -21,7 +21,7 @@ const messages = defineMessages({ change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, }); -const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; class PrivacyDropdownMenu extends React.PureComponent { diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js index 66dc85742..856383893 100644 --- a/app/javascript/mastodon/features/compose/components/reply_indicator.js +++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js @@ -6,7 +6,6 @@ import IconButton from '../../../components/icon_button'; import DisplayName from '../../../components/display_name'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { isRtl } from '../../../rtl'; import AttachmentList from 'mastodon/components/attachment_list'; const messages = defineMessages({ @@ -45,9 +44,6 @@ class ReplyIndicator extends ImmutablePureComponent { } const content = { __html: status.get('contentHtml') }; - const style = { - direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr', - }; return ( <div className='reply-indicator'> @@ -60,7 +56,7 @@ class ReplyIndicator extends ImmutablePureComponent { </a> </div> - <div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} /> + <div className='reply-indicator__content' dangerouslySetInnerHTML={content} /> {status.get('media_attachments').size > 0 && ( <AttachmentList 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 7073f76c2..1bcce5731 100644 --- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js @@ -6,13 +6,20 @@ import { changeComposeSensitivity } from 'mastodon/actions/compose'; import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; const messages = defineMessages({ - marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, - unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' }, + marked: { + id: 'compose_form.sensitive.marked', + defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}', + }, + unmarked: { + id: 'compose_form.sensitive.unmarked', + defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}', + }, }); const mapStateToProps = state => ({ active: state.getIn(['compose', 'sensitive']), disabled: state.getIn(['compose', 'spoiler']), + mediaCount: state.getIn(['compose', 'media_attachments']).size, }); const mapDispatchToProps = dispatch => ({ @@ -28,16 +35,17 @@ class SensitiveButton extends React.PureComponent { static propTypes = { active: PropTypes.bool, disabled: PropTypes.bool, + mediaCount: PropTypes.number, onClick: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; render () { - const { active, disabled, onClick, intl } = this.props; + const { active, disabled, mediaCount, onClick, intl } = this.props; return ( <div className='compose-form__sensitive-button'> - <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}> + <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}> <input name='mark-sensitive' type='checkbox' @@ -48,7 +56,11 @@ class SensitiveButton extends React.PureComponent { <span className={classNames('checkbox', { active })} /> - <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' /> + <FormattedMessage + id='compose_form.sensitive.hide' + defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}' + values={{ count: mediaCount }} + /> </label> </div> ); diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index f7d3cfd08..4e37f3a80 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -1,18 +1,17 @@ import { autoPlayGif } from '../../initial_state'; import unicodeMapping from './emoji_unicode_mapping_light'; +import { assetHost } from 'mastodon/utils/config'; import Trie from 'substring-trie'; const trie = new Trie(Object.keys(unicodeMapping)); -const assetHost = process.env.CDN_HOST || ''; - // Convert to file names from emojis. (For different variation selector emojis) const emojiFilenames = (emojis) => { return emojis.map(v => unicodeMapping[v].filename); }; // Emoji requiring extra borders depending on theme -const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴']); +const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺']); const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']); const emojiFilename = (filename) => { diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js index 1896994da..5bc3abac6 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.js +++ b/app/javascript/mastodon/features/getting_started/components/announcements.js @@ -6,7 +6,7 @@ import PropTypes from 'prop-types'; import IconButton from 'mastodon/components/icon_button'; import Icon from 'mastodon/components/icon'; import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; -import { autoPlayGif, reduceMotion } from 'mastodon/initial_state'; +import { autoPlayGif, reduceMotion, disableSwiping } from 'mastodon/initial_state'; import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; import { mascot } from 'mastodon/initial_state'; import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; @@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_pick import AnimatedNumber from 'mastodon/components/animated_number'; import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent { } -const assetHost = process.env.CDN_HOST || ''; - class Emoji extends React.PureComponent { static propTypes = { @@ -397,7 +396,7 @@ class Announcements extends ImmutablePureComponent { _markAnnouncementAsRead () { const { dismissAnnouncement, announcements } = this.props; const { index } = this.state; - const announcement = announcements.get(index); + const announcement = announcements.get(announcements.size - 1 - index); if (!announcement.get('read')) dismissAnnouncement(announcement.get('id')); } @@ -436,8 +435,9 @@ class Announcements extends ImmutablePureComponent { removeReaction={this.props.removeReaction} intl={intl} selected={index === idx} + disabled={disableSwiping} /> - ))} + )).reverse()} </ReactSwipeableViews> {announcements.size > 1 && ( diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index d9838e1c7..1b9994612 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -10,7 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { me, profile_directory, showTrends } from '../../initial_state'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; import { List as ImmutableList } from 'immutable'; -import NavigationBar from '../compose/components/navigation_bar'; +import NavigationContainer from '../compose/containers/navigation_container'; import Icon from 'mastodon/components/icon'; import LinkFooter from 'mastodon/features/ui/components/link_footer'; import TrendsContainer from './containers/trends_container'; @@ -40,6 +40,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ myAccount: state.getIn(['accounts', me]), + columns: state.getIn(['settings', 'columns']), unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, }); @@ -89,60 +90,66 @@ class GettingStarted extends ImmutablePureComponent { } render () { - const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props; + const { intl, myAccount, columns, multiColumn, unreadFollowRequests } = this.props; const navItems = []; - let i = 1; let height = (multiColumn) ? 0 : 60; if (multiColumn) { navItems.push( - <ColumnSubheading key={i++} text={intl.formatMessage(messages.discover)} />, - <ColumnLink key={i++} icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />, - <ColumnLink key={i++} icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />, + <ColumnSubheading key='header-discover' text={intl.formatMessage(messages.discover)} />, + <ColumnLink key='community_timeline' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />, + <ColumnLink key='public_timeline' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />, ); height += 34 + 48*2; if (profile_directory) { navItems.push( - <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />, + <ColumnLink key='directory' icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />, ); height += 48; } navItems.push( - <ColumnSubheading key={i++} text={intl.formatMessage(messages.personal)} />, + <ColumnSubheading key='header-personal' text={intl.formatMessage(messages.personal)} />, ); height += 34; } else if (profile_directory) { navItems.push( - <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />, + <ColumnLink key='directory' icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />, ); height += 48; } + if (multiColumn && !columns.find(item => item.get('id') === 'HOME')) { + navItems.push( + <ColumnLink key='home' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />, + ); + height += 48; + } + navItems.push( - <ColumnLink key={i++} icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />, - <ColumnLink key={i++} icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />, - <ColumnLink key={i++} icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, - <ColumnLink key={i++} icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />, + <ColumnLink key='direct' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />, + <ColumnLink key='bookmark' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />, + <ColumnLink key='favourites' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, + <ColumnLink key='lists' icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />, ); height += 48*4; if (myAccount.get('locked') || unreadFollowRequests > 0) { - navItems.push(<ColumnLink key={i++} icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); + navItems.push(<ColumnLink key='follow_requests' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); height += 48; } if (!multiColumn) { navItems.push( - <ColumnSubheading key={i++} text={intl.formatMessage(messages.settings_subheading)} />, - <ColumnLink key={i++} icon='gears' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />, + <ColumnSubheading key='header-settings' text={intl.formatMessage(messages.settings_subheading)} />, + <ColumnLink key='preferences' icon='gears' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />, ); height += 34 + 48; @@ -161,7 +168,7 @@ class GettingStarted extends ImmutablePureComponent { <div className='getting-started'> <div className='getting-started__wrapper' style={{ height }}> - {!multiColumn && <NavigationBar account={myAccount} />} + {!multiColumn && <NavigationContainer />} {navItems} </div> diff --git a/app/javascript/mastodon/features/introduction/index.js b/app/javascript/mastodon/features/introduction/index.js index 754477bb9..5820750a4 100644 --- a/app/javascript/mastodon/features/introduction/index.js +++ b/app/javascript/mastodon/features/introduction/index.js @@ -9,6 +9,7 @@ import screenHello from '../../../images/screen_hello.svg'; import screenFederation from '../../../images/screen_federation.svg'; import screenInteractions from '../../../images/screen_interactions.svg'; import logoTransparent from '../../../images/logo_transparent.svg'; +import { disableSwiping } from 'mastodon/initial_state'; const FrameWelcome = ({ domain, onNext }) => ( <div className='introduction__frame'> @@ -171,7 +172,7 @@ class Introduction extends React.PureComponent { return ( <div className='introduction'> - <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='introduction__pager'> + <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} disabled={disableSwiping} className='introduction__pager'> {pages.map((page, i) => ( <div key={i} className={classNames('introduction__frame-wrapper', { 'active': i === currentIndex })}>{page}</div> ))} diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js index f3205b2bf..8eb645630 100644 --- a/app/javascript/mastodon/features/list_timeline/index.js +++ b/app/javascript/mastodon/features/list_timeline/index.js @@ -10,15 +10,19 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { connectListStream } from '../../actions/streaming'; import { expandListTimeline } from '../../actions/timelines'; -import { fetchList, deleteList } from '../../actions/lists'; +import { fetchList, deleteList, updateList } from '../../actions/lists'; import { openModal } from '../../actions/modal'; import MissingIndicator from '../../components/missing_indicator'; import LoadingIndicator from '../../components/loading_indicator'; import Icon from 'mastodon/components/icon'; +import RadioButton from 'mastodon/components/radio_button'; const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, + followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' }, + none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' }, + list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' }, }); const mapStateToProps = (state, props) => ({ @@ -131,11 +135,18 @@ class ListTimeline extends React.PureComponent { })); } + handleRepliesPolicyChange = ({ target }) => { + const { dispatch } = this.props; + const { id } = this.props.params; + dispatch(updateList(id, undefined, false, target.value)); + } + render () { - const { shouldUpdateScroll, hasUnread, columnId, multiColumn, list } = this.props; + const { shouldUpdateScroll, hasUnread, columnId, multiColumn, list, intl } = this.props; const { id } = this.props.params; const pinned = !!columnId; const title = list ? list.get('title') : id; + const replies_policy = list ? list.get('replies_policy') : undefined; if (typeof list === 'undefined') { return ( @@ -166,7 +177,7 @@ class ListTimeline extends React.PureComponent { pinned={pinned} multiColumn={multiColumn} > - <div className='column-header__links'> + <div className='column-settings__row column-header__links'> <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}> <Icon id='pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' /> </button> @@ -175,6 +186,19 @@ class ListTimeline extends React.PureComponent { <Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> </button> </div> + + { replies_policy !== undefined && ( + <div role='group' aria-labelledby={`list-${id}-replies-policy`}> + <span id={`list-${id}-replies-policy`} className='column-settings__section'> + <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /> + </span> + <div className='column-settings__row'> + { ['none', 'list', 'followed'].map(policy => ( + <RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} /> + ))} + </div> + </div> + )} </ColumnHeader> <StatusListContainer diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 8bd03fbda..8339a367e 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import ClearColumnButton from './clear_column_button'; +import GrantPermissionButton from './grant_permission_button'; import SettingToggle from './setting_toggle'; export default class ColumnSettings extends React.PureComponent { @@ -12,6 +13,10 @@ export default class ColumnSettings extends React.PureComponent { pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, + onRequestNotificationPermission: PropTypes.func, + alertsEnabled: PropTypes.bool, + browserSupport: PropTypes.bool, + browserPermission: PropTypes.bool, }; onPushChange = (path, checked) => { @@ -19,7 +24,7 @@ export default class ColumnSettings extends React.PureComponent { } render () { - const { settings, pushSettings, onChange, onClear } = this.props; + const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props; const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; @@ -32,6 +37,20 @@ export default class ColumnSettings extends React.PureComponent { return ( <div> + {alertsEnabled && browserSupport && browserPermission === 'denied' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span> + </div> + )} + + {alertsEnabled && browserSupport && browserPermission === 'default' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'> + <FormattedMessage id='notifications.permission_required' defaultMessage='Desktop notifications are unavailable because the required permission has not been granted.' /> <GrantPermissionButton onClick={onRequestNotificationPermission} /> + </span> + </div> + )} + <div className='column-settings__row'> <ClearColumnButton onClick={onClear} /> </div> @@ -40,6 +59,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-filter-bar' className='column-settings__section'> <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> </span> + <div className='column-settings__row'> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} /> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} /> @@ -50,7 +70,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> @@ -61,7 +81,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> @@ -72,7 +92,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> @@ -83,7 +103,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> @@ -94,7 +114,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> @@ -105,12 +125,23 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> </div> </div> + + <div role='group' aria-labelledby='notifications-status'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span> + + <div className='column-settings__row'> + <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} /> + </div> + </div> </div> ); } diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js index 2fd28d832..368eb0b7e 100644 --- a/app/javascript/mastodon/features/notifications/components/filter_bar.js +++ b/app/javascript/mastodon/features/notifications/components/filter_bar.js @@ -9,6 +9,7 @@ const tooltips = defineMessages({ boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, + statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' }, }); export default @injectIntl @@ -88,6 +89,13 @@ class FilterBar extends React.PureComponent { <Icon id='tasks' fixedWidth /> </button> <button + className={selectedFilter === 'status' ? 'active' : ''} + onClick={this.onClick('status')} + title={intl.formatMessage(tooltips.statuses)} + > + <Icon id='home' fixedWidth /> + </button> + <button className={selectedFilter === 'follow' ? 'active' : ''} onClick={this.onClick('follow')} title={intl.formatMessage(tooltips.follows)} diff --git a/app/javascript/mastodon/features/notifications/components/grant_permission_button.js b/app/javascript/mastodon/features/notifications/components/grant_permission_button.js new file mode 100644 index 000000000..798e4c787 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/grant_permission_button.js @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default class GrantPermissionButton extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func.isRequired, + }; + + render () { + return ( + <button className='text-btn column-header__permission-btn' tabIndex='0' onClick={this.props.onClick}> + <FormattedMessage id='notifications.grant_permission' defaultMessage='Grant permission.' /> + </button> + ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 74065e5e2..94fdbd6f4 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -10,6 +10,7 @@ import AccountContainer from 'mastodon/containers/account_container'; import FollowRequestContainer from '../containers/follow_request_container'; import Icon from 'mastodon/components/icon'; import Permalink from 'mastodon/components/permalink'; +import classNames from 'classnames'; const messages = defineMessages({ favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, @@ -17,6 +18,7 @@ const messages = defineMessages({ ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' }, poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }, reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, + status: { id: 'notification.status', defaultMessage: '{name} just posted' }, }); const notificationForScreenReader = (intl, message, timestamp) => { @@ -49,6 +51,7 @@ class Notification extends ImmutablePureComponent { updateScrollBottom: PropTypes.func, cacheMediaWidth: PropTypes.func, cachedMediaWidth: PropTypes.number, + unread: PropTypes.bool, }; handleMoveUp = () => { @@ -113,11 +116,11 @@ class Notification extends ImmutablePureComponent { } renderFollow (notification, account, link) { - const { intl } = this.props; + const { intl, unread } = this.props; return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}> + <div className={classNames('notification notification-follow focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='user-plus' fixedWidth /> @@ -135,11 +138,11 @@ class Notification extends ImmutablePureComponent { } renderFollowRequest (notification, account, link) { - const { intl } = this.props; + const { intl, unread } = this.props; return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-follow-request focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}> + <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='user' fixedWidth /> @@ -169,16 +172,17 @@ class Notification extends ImmutablePureComponent { updateScrollBottom={this.props.updateScrollBottom} cachedMediaWidth={this.props.cachedMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth} + unread={this.props.unread} /> ); } renderFavourite (notification, link) { - const { intl } = this.props; + const { intl, unread } = this.props; return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> + <div className={classNames('notification notification-favourite focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='star' className='star-icon' fixedWidth /> @@ -206,11 +210,11 @@ class Notification extends ImmutablePureComponent { } renderReblog (notification, link) { - const { intl } = this.props; + const { intl, unread } = this.props; return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> + <div className={classNames('notification notification-reblog focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='retweet' fixedWidth /> @@ -237,14 +241,46 @@ class Notification extends ImmutablePureComponent { ); } + renderStatus (notification, link) { + const { intl, unread } = this.props; + + return ( + <HotKeys handlers={this.getHandlers()}> + <div className={classNames('notification notification-status focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon id='home' fixedWidth /> + </div> + + <span title={notification.get('created_at')}> + <FormattedMessage id='notification.status' defaultMessage='{name} just posted' values={{ name: link }} /> + </span> + </div> + + <StatusContainer + id={notification.get('status')} + account={notification.get('account')} + muted + withDismiss + hidden={this.props.hidden} + getScrollPosition={this.props.getScrollPosition} + updateScrollBottom={this.props.updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + /> + </div> + </HotKeys> + ); + } + renderPoll (notification, account) { - const { intl } = this.props; + const { intl, unread } = this.props; const ownPoll = me === account.get('id'); const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll); return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}> + <div className={classNames('notification notification-poll focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='tasks' fixedWidth /> @@ -292,6 +328,8 @@ class Notification extends ImmutablePureComponent { return this.renderFavourite(notification, link); case 'reblog': return this.renderReblog(notification, link); + case 'status': + return this.renderStatus(notification, link); case 'poll': return this.renderPoll(notification, account); } diff --git a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js new file mode 100644 index 000000000..df9b7fb1b --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js @@ -0,0 +1,48 @@ +import React from 'react'; +import Icon from 'mastodon/components/icon'; +import Button from 'mastodon/components/button'; +import IconButton from 'mastodon/components/icon_button'; +import { requestBrowserPermission } from 'mastodon/actions/notifications'; +import { changeSetting } from 'mastodon/actions/settings'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @connect() +@injectIntl +class NotificationsPermissionBanner extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.dispatch(requestBrowserPermission()); + } + + handleClose = () => { + this.props.dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true)); + } + + render () { + const { intl } = this.props; + + return ( + <div className='notifications-permission-banner'> + <div className='notifications-permission-banner__close'> + <IconButton icon='times' onClick={this.handleClose} title={intl.formatMessage(messages.close)} /> + </div> + + <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2> + <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p> + <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js index e6f593ef8..c4c8bffbe 100644 --- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -12,6 +12,7 @@ export default class SettingToggle extends React.PureComponent { label: PropTypes.node.isRequired, onChange: PropTypes.func.isRequired, defaultValue: PropTypes.bool, + disabled: PropTypes.bool, } onChange = ({ target }) => { @@ -19,12 +20,12 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingPath, label, defaultValue } = this.props; + const { prefix, settings, settingPath, label, defaultValue, disabled } = this.props; const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); return ( <div className='setting-toggle'> - <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> </div> ); diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index a67f26295..9a70bd4f3 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting } from '../../../actions/settings'; import { setFilter } from '../../../actions/notifications'; -import { clearNotifications } from '../../../actions/notifications'; +import { clearNotifications, requestBrowserPermission } from '../../../actions/notifications'; import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; import { openModal } from '../../../actions/modal'; +import { showAlert } from '../../../actions/alerts'; const messages = defineMessages({ clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, + permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' }, }); const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), pushSettings: state.get('push_notifications'), + alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), + browserSupport: state.getIn(['notifications', 'browserSupport']), + browserPermission: state.getIn(['notifications', 'browserPermission']), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (path, checked) { if (path[0] === 'push') { - dispatch(changePushNotifications(path.slice(1), checked)); + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changePushNotifications(path.slice(1), checked)); + } } else if (path[0] === 'quickFilter') { dispatch(changeSetting(['notifications', ...path], checked)); dispatch(setFilter('all')); + } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', ...path], checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } } else { dispatch(changeSetting(['notifications', ...path], checked)); } @@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, + onRequestNotificationPermission () { + dispatch(requestBrowserPermission()); + }, + }); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index d16a0f33a..108470c9a 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -4,7 +4,15 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { expandNotifications, scrollTopNotifications, loadPending, mountNotifications, unmountNotifications } from '../../actions/notifications'; +import { + expandNotifications, + scrollTopNotifications, + loadPending, + mountNotifications, + unmountNotifications, + markNotificationsAsRead, +} from '../../actions/notifications'; +import { submitMarkers } from '../../actions/markers'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import NotificationContainer from './containers/notification_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; @@ -15,15 +23,25 @@ import { List as ImmutableList } from 'immutable'; import { debounce } from 'lodash'; import ScrollableList from '../../components/scrollable_list'; import LoadGap from '../../components/load_gap'; +import Icon from 'mastodon/components/icon'; +import compareId from 'mastodon/compare_id'; +import NotificationsPermissionBanner from './components/notifications_permission_banner'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' }, +}); + +const getExcludedTypes = createSelector([ + state => state.getIn(['settings', 'notifications', 'shows']), +], (shows) => { + return ImmutableList(shows.filter(item => !item).keys()); }); const getNotifications = createSelector([ state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']), state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']), - state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), + getExcludedTypes, state => state.getIn(['notifications', 'items']), ], (showFilterBar, allowedType, excludedTypes, notifications) => { if (!showFilterBar || allowedType === 'all') { @@ -32,7 +50,7 @@ const getNotifications = createSelector([ // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); } - return notifications.filter(item => item !== null && allowedType === item.get('type')); + return notifications.filter(item => item === null || allowedType === item.get('type')); }); const mapStateToProps = state => ({ @@ -42,6 +60,9 @@ const mapStateToProps = state => ({ isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0, hasMore: state.getIn(['notifications', 'hasMore']), numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, + lastReadId: state.getIn(['notifications', 'readMarkerId']), + canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), + needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']), }); export default @connect(mapStateToProps) @@ -60,6 +81,9 @@ class Notifications extends React.PureComponent { multiColumn: PropTypes.bool, hasMore: PropTypes.bool, numPending: PropTypes.number, + lastReadId: PropTypes.string, + canMarkAsRead: PropTypes.bool, + needsNotificationPermission: PropTypes.bool, }; static defaultProps = { @@ -146,8 +170,13 @@ class Notifications extends React.PureComponent { } } + handleMarkAsRead = () => { + this.props.dispatch(markNotificationsAsRead()); + this.props.dispatch(submitMarkers({ immediate: true })); + }; + render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props; const pinned = !!columnId; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; @@ -174,6 +203,7 @@ class Notifications extends React.PureComponent { accountId={item.get('account')} onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} + unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0} /> )); } else { @@ -190,6 +220,8 @@ class Notifications extends React.PureComponent { showLoading={isLoading && notifications.size === 0} hasMore={hasMore} numPending={numPending} + prepend={needsNotificationPermission && <NotificationsPermissionBanner />} + alwaysPrepend emptyMessage={emptyMessage} onLoadMore={this.handleLoadOlder} onLoadPending={this.handleLoadPending} @@ -202,6 +234,21 @@ class Notifications extends React.PureComponent { </ScrollableList> ); + let extraButton = null; + + if (canMarkAsRead) { + extraButton = ( + <button + aria-label={intl.formatMessage(messages.markAsRead)} + title={intl.formatMessage(messages.markAsRead)} + onClick={this.handleMarkAsRead} + className='column-header__button' + > + <Icon id='check' /> + </button> + ); + } + return ( <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}> <ColumnHeader @@ -213,6 +260,7 @@ class Notifications extends React.PureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} + extraButton={extraButton} > <ColumnSettingsContainer /> </ColumnHeader> diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js new file mode 100644 index 000000000..1b1ec6d54 --- /dev/null +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js @@ -0,0 +1,159 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'mastodon/components/icon_button'; +import classNames from 'classnames'; +import { me, boostModal } from 'mastodon/initial_state'; +import { defineMessages, injectIntl } from 'react-intl'; +import { replyCompose } from 'mastodon/actions/compose'; +import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions'; +import { makeGetStatus } from 'mastodon/selectors'; +import { openModal } from 'mastodon/actions/modal'; + +const messages = defineMessages({ + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, { statusId }) => ({ + status: getStatus(state, { id: statusId }), + askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, + }); + + return mapStateToProps; +}; + +export default @connect(makeMapStateToProps) +@injectIntl +class Footer extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + statusId: PropTypes.string.isRequired, + status: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + askReplyConfirmation: PropTypes.bool, + withOpenButton: PropTypes.bool, + onClose: PropTypes.func, + }; + + _performReply = () => { + const { dispatch, status, onClose } = this.props; + const { router } = this.context; + + if (onClose) { + onClose(); + } + + dispatch(replyCompose(status, router.history)); + }; + + handleReplyClick = () => { + const { dispatch, askReplyConfirmation, intl } = this.props; + + if (askReplyConfirmation) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: this._performReply, + })); + } else { + this._performReply(); + } + }; + + handleFavouriteClick = () => { + const { dispatch, status } = this.props; + + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }; + + _performReblog = () => { + const { dispatch, status } = this.props; + dispatch(reblog(status)); + } + + handleReblogClick = e => { + const { dispatch, status } = this.props; + + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else if ((e && e.shiftKey) || !boostModal) { + this._performReblog(); + } else { + dispatch(openModal('BOOST', { status, onReblog: this._performReblog })); + } + }; + + handleOpenClick = e => { + const { router } = this.context; + + if (e.button !== 0 || !router) { + return; + } + + const { status } = this.props; + + router.history.push(`/statuses/${status.get('id')}`); + } + + render () { + const { status, intl, withOpenButton } = this.props; + + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; + + let replyIcon, replyTitle; + + if (status.get('in_reply_to_id', null) === null) { + replyIcon = 'reply'; + replyTitle = intl.formatMessage(messages.reply); + } else { + replyIcon = 'reply-all'; + replyTitle = intl.formatMessage(messages.replyAll); + } + + let reblogTitle = ''; + + if (status.get('reblogged')) { + reblogTitle = intl.formatMessage(messages.cancel_reblog_private); + } else if (publicStatus) { + reblogTitle = intl.formatMessage(messages.reblog); + } else if (reblogPrivate) { + reblogTitle = intl.formatMessage(messages.reblog_private); + } else { + reblogTitle = intl.formatMessage(messages.cannot_reblog); + } + + return ( + <div className='picture-in-picture__footer'> + <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount /> + <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} /> + <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> + {withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} />} + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.js b/app/javascript/mastodon/features/picture_in_picture/components/header.js new file mode 100644 index 000000000..7dd199b75 --- /dev/null +++ b/app/javascript/mastodon/features/picture_in_picture/components/header.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'mastodon/components/icon_button'; +import { Link } from 'react-router-dom'; +import Avatar from 'mastodon/components/avatar'; +import DisplayName from 'mastodon/components/display_name'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +const mapStateToProps = (state, { accountId }) => ({ + account: state.getIn(['accounts', accountId]), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Header extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { account, statusId, onClose, intl } = this.props; + + return ( + <div className='picture-in-picture__header'> + <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'> + <Avatar account={account} size={36} /> + <DisplayName account={account} /> + </Link> + + <IconButton icon='times' onClick={onClose} title={intl.formatMessage(messages.close)} /> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/picture_in_picture/index.js b/app/javascript/mastodon/features/picture_in_picture/index.js new file mode 100644 index 000000000..1e59fbcd3 --- /dev/null +++ b/app/javascript/mastodon/features/picture_in_picture/index.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Video from 'mastodon/features/video'; +import Audio from 'mastodon/features/audio'; +import { removePictureInPicture } from 'mastodon/actions/picture_in_picture'; +import Header from './components/header'; +import Footer from './components/footer'; + +const mapStateToProps = state => ({ + ...state.get('picture_in_picture'), +}); + +export default @connect(mapStateToProps) +class PictureInPicture extends React.Component { + + static propTypes = { + statusId: PropTypes.string, + accountId: PropTypes.string, + type: PropTypes.string, + src: PropTypes.string, + muted: PropTypes.bool, + volume: PropTypes.number, + currentTime: PropTypes.number, + poster: PropTypes.string, + backgroundColor: PropTypes.string, + foregroundColor: PropTypes.string, + accentColor: PropTypes.string, + dispatch: PropTypes.func.isRequired, + }; + + handleClose = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + } + + render () { + const { type, src, currentTime, accountId, statusId } = this.props; + + if (!currentTime) { + return null; + } + + let player; + + if (type === 'video') { + player = ( + <Video + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + autoPlay + inline + alwaysVisible + /> + ); + } else if (type === 'audio') { + player = ( + <Audio + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + poster={this.props.poster} + backgroundColor={this.props.backgroundColor} + foregroundColor={this.props.foregroundColor} + accentColor={this.props.accentColor} + autoPlay + /> + ); + } + + return ( + <div className='picture-in-picture'> + <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} /> + + {player} + + <Footer statusId={statusId} /> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index b1ae0b2cc..043a749ed 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -15,6 +15,7 @@ import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; import AnimatedNumber from 'mastodon/components/animated_number'; +import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, @@ -40,6 +41,10 @@ class DetailedStatus extends ImmutablePureComponent { domain: PropTypes.string.isRequired, compact: PropTypes.bool, showMedia: PropTypes.bool, + pictureInPicture: ImmutablePropTypes.contains({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), onToggleMediaVisibility: PropTypes.func, }; @@ -56,8 +61,8 @@ class DetailedStatus extends ImmutablePureComponent { e.stopPropagation(); } - handleOpenVideo = (media, options) => { - this.props.onOpenVideo(media, options); + handleOpenVideo = (options) => { + this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options); } handleExpandedToggle = () => { @@ -100,7 +105,7 @@ class DetailedStatus extends ImmutablePureComponent { render () { const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; const outerStyle = { boxSizing: 'border-box' }; - const { intl, compact } = this.props; + const { intl, compact, pictureInPicture } = this.props; if (!status) { return null; @@ -116,7 +121,9 @@ class DetailedStatus extends ImmutablePureComponent { outerStyle.height = `${this.state.height}px`; } - if (status.get('media_attachments').size > 0) { + if (pictureInPicture.get('inUse')) { + media = <PictureInPicturePlaceholder />; + } else if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { const attachment = status.getIn(['media_attachments', 0]); @@ -138,6 +145,7 @@ class DetailedStatus extends ImmutablePureComponent { media = ( <Video preview={attachment.get('preview_url')} + frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} blurhash={attachment.get('blurhash')} src={attachment.get('url')} alt={attachment.get('description')} diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js index 6d5c33240..0ac4519c8 100644 --- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js +++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import DetailedStatus from '../components/detailed_status'; -import { makeGetStatus } from '../../../selectors'; +import { makeGetStatus, makeGetPictureInPicture } from '../../../selectors'; import { replyCompose, mentionCompose, @@ -40,10 +40,12 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); const mapStateToProps = (state, props) => ({ status: getStatus(state, props), domain: state.getIn(['meta', 'domain']), + pictureInPicture: getPictureInPicture(state, props), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 179df53a1..09822f372 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -43,7 +43,7 @@ import { import { initMuteModal } from '../../actions/mutes'; import { initBlockModal } from '../../actions/blocks'; import { initReport } from '../../actions/reports'; -import { makeGetStatus } from '../../selectors'; +import { makeGetStatus, makeGetPictureInPicture } from '../../selectors'; import { ScrollContainer } from 'react-router-scroll-4'; import ColumnBackButton from '../../components/column_back_button'; import ColumnHeader from '../../components/column_header'; @@ -72,6 +72,7 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); const getAncestorsIds = createSelector([ (_, { id }) => id, @@ -129,11 +130,12 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => { const status = getStatus(state, { id: props.params.statusId }); - let ancestorsIds = Immutable.List(); + + let ancestorsIds = Immutable.List(); let descendantsIds = Immutable.List(); if (status) { - ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); + ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); descendantsIds = getDescendantsIds(state, { id: status.get('id') }); } @@ -143,6 +145,7 @@ const makeMapStateToProps = () => { descendantsIds, askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, domain: state.getIn(['meta', 'domain']), + pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }), }; }; @@ -167,6 +170,10 @@ class Status extends ImmutablePureComponent { askReplyConfirmation: PropTypes.bool, multiColumn: PropTypes.bool, domain: PropTypes.string.isRequired, + pictureInPicture: ImmutablePropTypes.contains({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), }; state = { @@ -274,22 +281,20 @@ class Status extends ImmutablePureComponent { } handleOpenMedia = (media, index) => { - this.props.dispatch(openModal('MEDIA', { media, index })); + this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.get('id'), media, index })); } handleOpenVideo = (media, options) => { - this.props.dispatch(openModal('VIDEO', { media, options })); + this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options })); } handleHotkeyOpenMedia = e => { - const status = this._properStatus(); + const { status } = this.props; e.preventDefault(); if (status.get('media_attachments').size > 0) { - if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { - // TODO: toggle play/paused? - } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 }); } else { this.handleOpenMedia(status.get('media_attachments'), 0); @@ -492,7 +497,7 @@ class Status extends ImmutablePureComponent { render () { let ancestors, descendants; - const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props; + const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props; const { fullscreen } = this.state; if (status === null) { @@ -550,6 +555,7 @@ class Status extends ImmutablePureComponent { domain={domain} showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} + pictureInPicture={pictureInPicture} /> <ActionBar diff --git a/app/javascript/mastodon/features/ui/components/audio_modal.js b/app/javascript/mastodon/features/ui/components/audio_modal.js index a80776b22..0676bd9cf 100644 --- a/app/javascript/mastodon/features/ui/components/audio_modal.js +++ b/app/javascript/mastodon/features/ui/components/audio_modal.js @@ -4,13 +4,11 @@ import PropTypes from 'prop-types'; import Audio from 'mastodon/features/audio'; import { connect } from 'react-redux'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; import { previewState } from './video_modal'; -import classNames from 'classnames'; -import Icon from 'mastodon/components/icon'; +import Footer from 'mastodon/features/picture_in_picture/components/footer'; -const mapStateToProps = (state, { status }) => ({ - account: state.getIn(['accounts', status.get('account')]), +const mapStateToProps = (state, { statusId }) => ({ + accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']), }); export default @connect(mapStateToProps) @@ -18,12 +16,13 @@ class AudioModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, - status: ImmutablePropTypes.map, + statusId: PropTypes.string.isRequired, + accountStaticAvatar: PropTypes.string.isRequired, options: PropTypes.shape({ autoPlay: PropTypes.bool, }), - account: ImmutablePropTypes.map, onClose: PropTypes.func.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, }; static contextTypes = { @@ -52,15 +51,8 @@ class AudioModal 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, status, account } = this.props; + const { media, accountStaticAvatar, statusId, onClose } = this.props; const options = this.props.options || {}; return ( @@ -71,7 +63,7 @@ class AudioModal extends ImmutablePureComponent { alt={media.get('description')} duration={media.getIn(['meta', 'original', 'duration'], 0)} height={150} - poster={media.get('preview_url') || account.get('avatar_static')} + poster={media.get('preview_url') || accountStaticAvatar} backgroundColor={media.getIn(['meta', 'colors', 'background'])} foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} accentColor={media.getIn(['meta', 'colors', 'accent'])} @@ -79,11 +71,9 @@ class AudioModal extends ImmutablePureComponent { /> </div> - {status && ( - <div className={classNames('media-modal__meta')}> - <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> - </div> - )} + <div className='media-modal__overlay'> + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> </div> ); } diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js index 00c0481f3..963bb5dc4 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.js +++ b/app/javascript/mastodon/features/ui/components/boost_modal.js @@ -75,9 +75,10 @@ class BoostModal extends ImmutablePureComponent { <div className={classNames('status', `status-${status.get('visibility')}`, 'light')}> <div className='boost-modal__status-header'> <div className='boost-modal__status-time'> - <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> + <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> + <RelativeTimestamp timestamp={status.get('created_at')} /></a> </div> - <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> <div className='status__avatar'> diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 9b03cf26d..6837450eb 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -8,6 +8,8 @@ import ReactSwipeableViews from 'react-swipeable-views'; import TabsBar, { links, getIndex, getLink } from './tabs_bar'; import { Link } from 'react-router-dom'; +import { disableSwiping } from 'mastodon/initial_state'; + import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import DrawerLoading from './drawer_loading'; @@ -29,7 +31,7 @@ import Icon from 'mastodon/components/icon'; import ComposePanel from './compose_panel'; import NavigationPanel from './navigation_panel'; -import detectPassiveEvents from 'detect-passive-events'; +import { supportsPassiveEvents } from 'detect-passive-events'; import { scrollRight } from '../../../scroll'; const componentMap = { @@ -73,12 +75,14 @@ class ColumnsArea extends ImmutablePureComponent { } componentWillReceiveProps() { - this.setState({ shouldAnimate: false }); + if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) { + this.setState({ shouldAnimate: false }); + } } componentDidMount() { if (!this.props.singleColumn) { - this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); } this.lastIndex = getIndex(this.context.router.history.location.pathname); @@ -95,10 +99,15 @@ class ColumnsArea extends ImmutablePureComponent { componentDidUpdate(prevProps) { if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { - this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); + } + + const newIndex = getIndex(this.context.router.history.location.pathname); + + if (this.lastIndex !== newIndex) { + this.lastIndex = newIndex; + this.setState({ shouldAnimate: true }); } - this.lastIndex = getIndex(this.context.router.history.location.pathname); - this.setState({ shouldAnimate: true }); } componentWillUnmount () { @@ -185,7 +194,7 @@ class ColumnsArea extends ImmutablePureComponent { const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>; const content = columnIndex !== -1 ? ( - <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}> + <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}> {links.map(this.renderView)} </ReactSwipeableViews> ) : ( diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 7348d9599..ffa783e3b 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -18,6 +18,11 @@ import { length } from 'stringz'; import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; import GIFV from 'mastodon/components/gifv'; import { me } from 'mastodon/initial_state'; +// eslint-disable-next-line import/no-extraneous-dependencies +import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; +// eslint-disable-next-line import/extensions +import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -48,8 +53,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') .replace(/\n/g, ' ') .replace(/\*\*\*\*\*\*/g, '\n\n'); -const assetHost = process.env.CDN_HOST || ''; - class ImageLoader extends React.PureComponent { static propTypes = { @@ -104,6 +107,7 @@ class FocalPointModal extends ImmutablePureComponent { dirty: false, progress: 0, loading: true, + ocrStatus: '', }; componentWillMount () { @@ -219,11 +223,18 @@ class FocalPointModal extends ImmutablePureComponent { this.setState({ detecting: true }); - fetchTesseract().then(({ TesseractWorker }) => { - const worker = new TesseractWorker({ - workerPath: `${assetHost}/packs/ocr/worker.min.js`, - corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`, - langPath: `${assetHost}/ocr/lang-data`, + fetchTesseract().then(({ createWorker }) => { + const worker = createWorker({ + workerPath: tesseractWorkerPath, + corePath: tesseractCorePath, + langPath: `${assetHost}/ocr/lang-data/`, + logger: ({ status, progress }) => { + if (status === 'recognizing text') { + this.setState({ ocrStatus: 'detecting', progress }); + } else { + this.setState({ ocrStatus: 'preparing', progress }); + } + }, }); let media_url = media.get('url'); @@ -236,12 +247,18 @@ class FocalPointModal extends ImmutablePureComponent { } } - worker.recognize(media_url) - .progress(({ progress }) => this.setState({ progress })) - .finally(() => worker.terminate()) - .then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false })) - .catch(() => this.setState({ detecting: false })); - }).catch(() => this.setState({ detecting: false })); + (async () => { + await worker.load(); + await worker.loadLanguage('eng'); + await worker.initialize('eng'); + const { data: { text } } = await worker.recognize(media_url); + this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }); + await worker.terminate(); + })(); + }).catch((e) => { + console.error(e); + this.setState({ detecting: false }); + }); } handleThumbnailChange = e => { @@ -261,7 +278,7 @@ class FocalPointModal extends ImmutablePureComponent { render () { const { media, intl, account, onClose, isUploadingThumbnail } = this.props; - const { x, y, dragging, description, dirty, detecting, progress } = this.state; + const { x, y, dragging, description, dirty, detecting, progress, ocrStatus } = this.state; const width = media.getIn(['meta', 'original', 'width']) || null; const height = media.getIn(['meta', 'original', 'height']) || null; @@ -282,6 +299,13 @@ class FocalPointModal extends ImmutablePureComponent { descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />; } + let ocrMessage = ''; + if (ocrStatus === 'detecting') { + ocrMessage = <FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />; + } else { + ocrMessage = <FormattedMessage id='upload_modal.preparing_ocr' defaultMessage='Preparing OCR…' />; + } + return ( <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}> <div className='report-modal__target'> @@ -333,7 +357,7 @@ class FocalPointModal extends ImmutablePureComponent { /> <div className='setting-text__modifiers'> - <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} /> + <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={ocrMessage} /> </div> </div> @@ -364,6 +388,7 @@ class FocalPointModal extends ImmutablePureComponent { {media.get('type') === 'video' && ( <Video preview={media.get('preview_url')} + frameRate={media.getIn(['meta', 'original', 'frame_rate'])} blurhash={media.get('blurhash')} src={media.get('url')} detailed diff --git a/app/javascript/mastodon/features/ui/components/image_loader.js b/app/javascript/mastodon/features/ui/components/image_loader.js index 5e1cf75af..c6f16a792 100644 --- a/app/javascript/mastodon/features/ui/components/image_loader.js +++ b/app/javascript/mastodon/features/ui/components/image_loader.js @@ -13,6 +13,7 @@ export default class ImageLoader extends React.PureComponent { width: PropTypes.number, height: PropTypes.number, onClick: PropTypes.func, + zoomButtonHidden: PropTypes.bool, } static defaultProps = { @@ -151,6 +152,9 @@ export default class ImageLoader extends React.PureComponent { alt={alt} src={src} onClick={onClick} + width={width} + height={height} + zoomButtonHidden={this.props.zoomButtonHidden} /> )} </div> diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index d7f97f210..08da10330 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -4,12 +4,15 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Video from 'mastodon/features/video'; import classNames from 'classnames'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl } 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'; import GIFV from 'mastodon/components/gifv'; +import { disableSwiping } from 'mastodon/initial_state'; +import Footer from 'mastodon/features/picture_in_picture/components/footer'; +import { getAverageFromBlurhash } from 'mastodon/blurhash'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -24,10 +27,14 @@ class MediaModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.list.isRequired, - status: ImmutablePropTypes.map, + statusId: PropTypes.string, index: PropTypes.number.isRequired, onClose: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, + currentTime: PropTypes.number, + autoPlay: PropTypes.bool, + volume: PropTypes.number, }; static contextTypes = { @@ -37,23 +44,40 @@ class MediaModal extends ImmutablePureComponent { state = { index: null, navigationHidden: false, + zoomButtonHidden: false, }; handleSwipe = (index) => { this.setState({ index: index % this.props.media.size }); } + handleTransitionEnd = () => { + this.setState({ + zoomButtonHidden: false, + }); + } + handleNextClick = () => { - this.setState({ index: (this.getIndex() + 1) % this.props.media.size }); + this.setState({ + index: (this.getIndex() + 1) % this.props.media.size, + zoomButtonHidden: true, + }); } handlePrevClick = () => { - this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size }); + this.setState({ + index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size, + zoomButtonHidden: true, + }); } handleChangeIndex = (e) => { const index = Number(e.currentTarget.getAttribute('data-index')); - this.setState({ index: index % this.props.media.size }); + + this.setState({ + index: index % this.props.media.size, + zoomButtonHidden: true, + }); } handleKeyDown = (e) => { @@ -83,6 +107,25 @@ class MediaModal extends ImmutablePureComponent { this.props.onClose(); }); } + + this._sendBackgroundColor(); + } + + componentDidUpdate (prevProps, prevState) { + if (prevState.index !== this.state.index) { + this._sendBackgroundColor(); + } + } + + _sendBackgroundColor () { + const { media, onChangeBackgroundColor } = this.props; + const index = this.getIndex(); + const blurhash = media.getIn([index, 'blurhash']); + + if (blurhash) { + const backgroundColor = getAverageFromBlurhash(blurhash); + onChangeBackgroundColor(backgroundColor); + } } componentWillUnmount () { @@ -95,6 +138,8 @@ class MediaModal extends ImmutablePureComponent { this.context.router.history.goBack(); } } + + this.props.onChangeBackgroundColor(null); } getIndex () { @@ -110,30 +155,19 @@ 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')}`); + this.context.router.history.push(`/statuses/${this.props.statusId}`); } } render () { - const { media, status, intl, onClose } = this.props; + const { media, statusId, intl, onClose } = this.props; const { navigationHidden } = this.state; const index = this.getIndex(); - let pagination = []; const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>; const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></button>; - if (media.size > 1) { - pagination = media.map((item, i) => { - const classes = ['media-modal__button']; - if (i === index) { - classes.push('media-modal__button--active'); - } - return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>); - }); - } - const content = media.map((image) => { const width = image.getIn(['meta', 'original', 'width']) || null; const height = image.getIn(['meta', 'original', 'height']) || null; @@ -148,10 +182,11 @@ class MediaModal extends ImmutablePureComponent { alt={image.get('description')} key={image.get('url')} onClick={this.toggleNavigation} + zoomButtonHidden={this.state.zoomButtonHidden} /> ); } else if (image.get('type') === 'video') { - const { time } = this.props; + const { currentTime, autoPlay, volume } = this.props; return ( <Video @@ -160,7 +195,10 @@ class MediaModal extends ImmutablePureComponent { src={image.get('url')} width={image.get('width')} height={image.get('height')} - startTime={time || 0} + frameRate={image.getIn(['meta', 'original', 'frame_rate'])} + currentTime={currentTime || 0} + autoPlay={autoPlay || false} + volume={volume || 1} onCloseVideo={onClose} detailed alt={image.get('description')} @@ -200,18 +238,26 @@ class MediaModal extends ImmutablePureComponent { 'media-modal__navigation--hidden': navigationHidden, }); + let pagination; + + if (media.size > 1) { + pagination = media.map((item, i) => ( + <button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}> + {i + 1} + </button> + )); + } + return ( <div className='modal-root__modal media-modal'> - <div - className='media-modal__closer' - role='presentation' - onClick={onClose} - > + <div className='media-modal__closer' role='presentation' onClick={onClose} > <ReactSwipeableViews style={swipeableViewsStyle} containerStyle={containerStyle} onChangeIndex={this.handleSwipe} + onTransitionEnd={this.handleTransitionEnd} index={index} + disabled={disableSwiping} > {content} </ReactSwipeableViews> @@ -223,15 +269,10 @@ class MediaModal extends ImmutablePureComponent { {leftNav} {rightNav} - {status && ( - <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}> - <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> - </div> - )} - - <ul className='media-modal__pagination'> - {pagination} - </ul> + <div className='media-modal__overlay'> + {pagination && <ul className='media-modal__pagination'>{pagination}</ul>} + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> </div> </div> ); diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 5cf70a0cc..3403830e4 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -45,6 +45,10 @@ export default class ModalRoot extends React.PureComponent { onClose: PropTypes.func.isRequired, }; + state = { + backgroundColor: null, + }; + getSnapshotBeforeUpdate () { return { visible: !!this.props.type }; } @@ -59,6 +63,10 @@ export default class ModalRoot extends React.PureComponent { } } + setBackgroundColor = color => { + this.setState({ backgroundColor: color }); + } + renderLoading = modalId => () => { return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; } @@ -71,13 +79,14 @@ export default class ModalRoot extends React.PureComponent { render () { const { type, props, onClose } = this.props; + const { backgroundColor } = this.state; const visible = !!type; return ( - <Base onClose={onClose}> + <Base backgroundColor={backgroundColor} onClose={onClose}> {visible && ( <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> - {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} + {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={onClose} />} </BundleContainer> )} </Base> diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js index 852830c3c..d8d8e68c3 100644 --- a/app/javascript/mastodon/features/ui/components/mute_modal.js +++ b/app/javascript/mastodon/features/ui/components/mute_modal.js @@ -1,25 +1,32 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; import Button from '../../../components/button'; import { closeModal } from '../../../actions/modal'; import { muteAccount } from '../../../actions/accounts'; -import { toggleHideNotifications } from '../../../actions/mutes'; +import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes'; +const messages = defineMessages({ + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, + indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' }, +}); const mapStateToProps = state => { return { account: state.getIn(['mutes', 'new', 'account']), notifications: state.getIn(['mutes', 'new', 'notifications']), + muteDuration: state.getIn(['mutes', 'new', 'duration']), }; }; const mapDispatchToProps = dispatch => { return { - onConfirm(account, notifications) { - dispatch(muteAccount(account.get('id'), notifications)); + onConfirm(account, notifications, muteDuration) { + dispatch(muteAccount(account.get('id'), notifications, muteDuration)); }, onClose() { @@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => { onToggleNotifications() { dispatch(toggleHideNotifications()); }, + + onChangeMuteDuration(e) { + dispatch(changeMuteDuration(e.target.value)); + }, }; }; @@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent { onConfirm: PropTypes.func.isRequired, onToggleNotifications: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + muteDuration: PropTypes.number.isRequired, + onChangeMuteDuration: PropTypes.func.isRequired, }; componentDidMount() { @@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent { handleClick = () => { this.props.onClose(); - this.props.onConfirm(this.props.account, this.props.notifications); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration); } handleCancel = () => { @@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent { this.props.onToggleNotifications(); } + changeMuteDuration = (e) => { + this.props.onChangeMuteDuration(e); + } + render () { - const { account, notifications } = this.props; + const { account, notifications, muteDuration, intl } = this.props; return ( <div className='modal-root__modal mute-modal'> @@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent { <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> </label> </div> + <div> + <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={muteDuration} onChange={this.changeMuteDuration}> + <option value={0}>{intl.formatMessage(messages.indefinite)}</option> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> </div> <div className='mute-modal__action-bar'> diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js index e28bd5b49..2f13a175a 100644 --- a/app/javascript/mastodon/features/ui/components/video_modal.js +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -3,9 +3,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Video from 'mastodon/features/video'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import classNames from 'classnames'; -import Icon from 'mastodon/components/icon'; +import Footer from 'mastodon/features/picture_in_picture/components/footer'; +import { getAverageFromBlurhash } from 'mastodon/blurhash'; export const previewState = 'previewVideoModal'; @@ -13,13 +12,14 @@ export default class VideoModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, - status: ImmutablePropTypes.map, + statusId: PropTypes.string, options: PropTypes.shape({ startTime: PropTypes.number, autoPlay: PropTypes.bool, defaultVolume: PropTypes.number, }), onClose: PropTypes.func.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, }; static contextTypes = { @@ -27,36 +27,35 @@ export default class VideoModal extends ImmutablePureComponent { }; componentDidMount () { - if (this.context.router) { - const history = this.context.router.history; + const { router } = this.context; + const { media, onChangeBackgroundColor, onClose } = this.props; - history.push(history.location.pathname, previewState); + if (router) { + router.history.push(router.history.location.pathname, previewState); + this.unlistenHistory = router.history.listen(() => onClose()); + } + + const backgroundColor = getAverageFromBlurhash(media.get('blurhash')); - this.unlistenHistory = history.listen(() => { - this.props.onClose(); - }); + if (backgroundColor) { + onChangeBackgroundColor(backgroundColor); } } componentWillUnmount () { - if (this.context.router) { + const { router } = this.context; + + if (router) { this.unlistenHistory(); - if (this.context.router.history.location.state === previewState) { - this.context.router.history.goBack(); + if (router.history.location.state === previewState) { + 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, status, onClose } = this.props; + const { media, statusId, onClose } = this.props; const options = this.props.options || {}; return ( @@ -64,22 +63,21 @@ export default class VideoModal extends ImmutablePureComponent { <div className='video-modal__container'> <Video preview={media.get('preview_url')} + frameRate={media.getIn(['meta', 'original', 'frame_rate'])} blurhash={media.get('blurhash')} src={media.get('url')} - startTime={options.startTime} + currentTime={options.startTime} autoPlay={options.autoPlay} - defaultVolume={options.defaultVolume} + volume={options.defaultVolume} onCloseVideo={onClose} detailed alt={media.get('description')} /> </div> - {status && ( - <div className={classNames('media-modal__meta')}> - <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> - </div> - )} + <div className='media-modal__overlay'> + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> </div> ); } diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.js b/app/javascript/mastodon/features/ui/components/zoomable_image.js index 3f6562bc9..1cf263cb9 100644 --- a/app/javascript/mastodon/features/ui/components/zoomable_image.js +++ b/app/javascript/mastodon/features/ui/components/zoomable_image.js @@ -1,8 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; +import IconButton from 'mastodon/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' }, + expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' }, +}); const MIN_SCALE = 1; const MAX_SCALE = 4; +const NAV_BAR_HEIGHT = 66; const getMidpoint = (p1, p2) => ({ x: (p1.clientX + p2.clientX) / 2, @@ -14,7 +22,77 @@ const getDistance = (p1, p2) => const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); -export default class ZoomableImage extends React.PureComponent { +// Normalizing mousewheel speed across browsers +// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js +const normalizeWheel = event => { + // Reasonable defaults + const PIXEL_STEP = 10; + const LINE_HEIGHT = 40; + const PAGE_HEIGHT = 800; + + let sX = 0, + sY = 0, // spinX, spinY + pX = 0, + pY = 0; // pixelX, pixelY + + // Legacy + if ('detail' in event) { + sY = event.detail; + } + if ('wheelDelta' in event) { + sY = -event.wheelDelta / 120; + } + if ('wheelDeltaY' in event) { + sY = -event.wheelDeltaY / 120; + } + if ('wheelDeltaX' in event) { + sX = -event.wheelDeltaX / 120; + } + + // side scrolling on FF with DOMMouseScroll + if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) { + sX = sY; + sY = 0; + } + + pX = sX * PIXEL_STEP; + pY = sY * PIXEL_STEP; + + if ('deltaY' in event) { + pY = event.deltaY; + } + if ('deltaX' in event) { + pX = event.deltaX; + } + + if ((pX || pY) && event.deltaMode) { + if (event.deltaMode === 1) { // delta in LINE units + pX *= LINE_HEIGHT; + pY *= LINE_HEIGHT; + } else { // delta in PAGE units + pX *= PAGE_HEIGHT; + pY *= PAGE_HEIGHT; + } + } + + // Fall-back if spin cannot be determined + if (pX && !sX) { + sX = (pX < 1) ? -1 : 1; + } + if (pY && !sY) { + sY = (pY < 1) ? -1 : 1; + } + + return { + spinX: sX, + spinY: sY, + pixelX: pX, + pixelY: pY, + }; +}; + +export default @injectIntl +class ZoomableImage extends React.PureComponent { static propTypes = { alt: PropTypes.string, @@ -22,6 +100,8 @@ export default class ZoomableImage extends React.PureComponent { width: PropTypes.number, height: PropTypes.number, onClick: PropTypes.func, + zoomButtonHidden: PropTypes.bool, + intl: PropTypes.object.isRequired, } static defaultProps = { @@ -32,6 +112,26 @@ export default class ZoomableImage extends React.PureComponent { state = { scale: MIN_SCALE, + zoomMatrix: { + type: null, // 'width' 'height' + fullScreen: null, // bool + rate: null, // full screen scale rate + clientWidth: null, + clientHeight: null, + offsetWidth: null, + offsetHeight: null, + clientHeightFixed: null, + scrollTop: null, + scrollLeft: null, + translateX: null, + translateY: null, + }, + zoomState: 'expand', // 'expand' 'compress' + navigationHidden: false, + dragPosition: { top: 0, left: 0, x: 0, y: 0 }, + dragged: false, + lockScroll: { x: 0, y: 0 }, + lockTranslate: { x: 0, y: 0 }, } removers = []; @@ -49,17 +149,105 @@ export default class ZoomableImage extends React.PureComponent { // https://www.chromestatus.com/features/5093566007214080 this.container.addEventListener('touchmove', handler, { passive: false }); this.removers.push(() => this.container.removeEventListener('touchend', handler)); + + handler = this.mouseDownHandler; + this.container.addEventListener('mousedown', handler); + this.removers.push(() => this.container.removeEventListener('mousedown', handler)); + + handler = this.mouseWheelHandler; + this.container.addEventListener('wheel', handler); + this.removers.push(() => this.container.removeEventListener('wheel', handler)); + // Old Chrome + this.container.addEventListener('mousewheel', handler); + this.removers.push(() => this.container.removeEventListener('mousewheel', handler)); + // Old Firefox + this.container.addEventListener('DOMMouseScroll', handler); + this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler)); + + this.initZoomMatrix(); } componentWillUnmount () { this.removeEventListeners(); } + componentDidUpdate () { + this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' }); + + if (this.state.scale === MIN_SCALE) { + this.container.style.removeProperty('cursor'); + } + } + + UNSAFE_componentWillReceiveProps () { + // reset when slide to next image + if (this.props.zoomButtonHidden) { + this.setState({ + scale: MIN_SCALE, + lockTranslate: { x: 0, y: 0 }, + }, () => { + this.container.scrollLeft = 0; + this.container.scrollTop = 0; + }); + } + } + removeEventListeners () { this.removers.forEach(listeners => listeners()); this.removers = []; } + mouseWheelHandler = e => { + e.preventDefault(); + + const event = normalizeWheel(e); + + if (this.state.zoomMatrix.type === 'width') { + // full width, scroll vertical + this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y); + } else { + // full height, scroll horizontal + this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x); + } + + // lock horizontal scroll + this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x); + } + + mouseDownHandler = e => { + this.container.style.cursor = 'grabbing'; + this.container.style.userSelect = 'none'; + + this.setState({ dragPosition: { + left: this.container.scrollLeft, + top: this.container.scrollTop, + // Get the current mouse position + x: e.clientX, + y: e.clientY, + } }); + + this.image.addEventListener('mousemove', this.mouseMoveHandler); + this.image.addEventListener('mouseup', this.mouseUpHandler); + } + + mouseMoveHandler = e => { + const dx = e.clientX - this.state.dragPosition.x; + const dy = e.clientY - this.state.dragPosition.y; + + this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x); + this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y); + + this.setState({ dragged: true }); + } + + mouseUpHandler = () => { + this.container.style.cursor = 'grab'; + this.container.style.removeProperty('user-select'); + + this.image.removeEventListener('mousemove', this.mouseMoveHandler); + this.image.removeEventListener('mouseup', this.mouseUpHandler); + } + handleTouchStart = e => { if (e.touches.length !== 2) return; @@ -80,7 +268,8 @@ export default class ZoomableImage extends React.PureComponent { const distance = getDistance(...e.touches); const midpoint = getMidpoint(...e.touches); - const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance); + const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate); + const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance); this.zoom(scale, midpoint); @@ -89,7 +278,7 @@ export default class ZoomableImage extends React.PureComponent { } zoom(nextScale, midpoint) { - const { scale } = this.state; + const { scale, zoomMatrix } = this.state; const { scrollLeft, scrollTop } = this.container; // math memo: @@ -104,14 +293,105 @@ export default class ZoomableImage extends React.PureComponent { this.setState({ scale: nextScale }, () => { this.container.scrollLeft = nextScrollLeft; this.container.scrollTop = nextScrollTop; + // reset the translateX/Y constantly + if (nextScale < zoomMatrix.rate) { + this.setState({ + lockTranslate: { + x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)), + y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)), + }, + }); + } }); } handleClick = e => { // don't propagate event to MediaModal e.stopPropagation(); + const dragged = this.state.dragged; + this.setState({ dragged: false }); + if (dragged) return; const handler = this.props.onClick; if (handler) handler(); + this.setState({ navigationHidden: !this.state.navigationHidden }); + } + + handleMouseDown = e => { + e.preventDefault(); + } + + initZoomMatrix = () => { + const { width, height } = this.props; + const { clientWidth, clientHeight } = this.container; + const { offsetWidth, offsetHeight } = this.image; + const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT; + + const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height'; + const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed; + const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight; + const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2; + const scrollLeft = (clientWidth - offsetWidth) / 2; + const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0; + const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0; + + this.setState({ + zoomMatrix: { + type: type, + fullScreen: fullScreen, + rate: rate, + clientWidth: clientWidth, + clientHeight: clientHeight, + offsetWidth: offsetWidth, + offsetHeight: offsetHeight, + clientHeightFixed: clientHeightFixed, + scrollTop: scrollTop, + scrollLeft: scrollLeft, + translateX: translateX, + translateY: translateY, + }, + }); + } + + handleZoomClick = e => { + e.preventDefault(); + e.stopPropagation(); + + const { scale, zoomMatrix } = this.state; + + if ( scale >= zoomMatrix.rate ) { + this.setState({ + scale: MIN_SCALE, + lockScroll: { + x: 0, + y: 0, + }, + lockTranslate: { + x: 0, + y: 0, + }, + }, () => { + this.container.scrollLeft = 0; + this.container.scrollTop = 0; + }); + } else { + this.setState({ + scale: zoomMatrix.rate, + lockScroll: { + x: zoomMatrix.scrollLeft, + y: zoomMatrix.scrollTop, + }, + lockTranslate: { + x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX, + y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY, + }, + }, () => { + this.container.scrollLeft = zoomMatrix.scrollLeft; + this.container.scrollTop = zoomMatrix.scrollTop; + }); + } + + this.container.style.cursor = 'grab'; + this.container.style.removeProperty('user-select'); } setContainerRef = c => { @@ -123,29 +403,47 @@ export default class ZoomableImage extends React.PureComponent { } render () { - const { alt, src } = this.props; - const { scale } = this.state; - const overflow = scale === 1 ? 'hidden' : 'scroll'; + const { alt, src, width, height, intl } = this.props; + const { scale, lockTranslate } = this.state; + const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll'; + const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : ''; + const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand); return ( - <div - className='zoomable-image' - ref={this.setContainerRef} - style={{ overflow }} - > - <img - role='presentation' - ref={this.setImageRef} - alt={alt} - title={alt} - src={src} + <React.Fragment> + <IconButton + className={`media-modal__zoom-button ${zoomButtonShouldHide}`} + title={zoomButtonTitle} + icon={this.state.zoomState} + onClick={this.handleZoomClick} + size={40} style={{ - transform: `scale(${scale})`, - transformOrigin: '0 0', + fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */ }} - onClick={this.handleClick} /> - </div> + <div + className='zoomable-image' + ref={this.setContainerRef} + style={{ overflow }} + > + <img + role='presentation' + ref={this.setImageRef} + alt={alt} + title={alt} + src={src} + width={width} + height={height} + style={{ + transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`, + transformOrigin: '0 0', + }} + draggable={false} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + /> + </div> + </React.Fragment> ); } diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 553cb3365..507ac1df1 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -8,19 +8,20 @@ import PropTypes from 'prop-types'; import NotificationsContainer from './containers/notifications_container'; import LoadingBarContainer from './containers/loading_bar_container'; import ModalContainer from './containers/modal_container'; -import { isMobile } from '../../is_mobile'; +import { layoutFromWindow } from 'mastodon/is_mobile'; import { debounce } from 'lodash'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { expandHomeTimeline } from '../../actions/timelines'; import { expandNotifications } from '../../actions/notifications'; import { fetchFilters } from '../../actions/filters'; import { clearHeight } from '../../actions/height_cache'; -import { focusApp, unfocusApp } from 'mastodon/actions/app'; -import { synchronouslySubmitMarkers } from 'mastodon/actions/markers'; +import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; +import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import UploadArea from './components/upload_area'; import ColumnsAreaContainer from './containers/columns_area_container'; import DocumentTitle from './components/document_title'; +import PictureInPicture from 'mastodon/features/picture_in_picture'; import { Compose, Status, @@ -51,7 +52,7 @@ import { Search, Directory, } from './util/async-components'; -import { me, forceSingleColumn } from '../../initial_state'; +import { me } from '../../initial_state'; import { previewState as previewMediaState } from './components/media_modal'; import { previewState as previewVideoState } from './components/video_modal'; @@ -64,6 +65,7 @@ const messages = defineMessages({ }); const mapStateToProps = state => ({ + layout: state.getIn(['meta', 'layout']), isComposing: state.getIn(['compose', 'is_composing']), hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, @@ -109,17 +111,11 @@ class SwitchingColumnsArea extends React.PureComponent { static propTypes = { children: PropTypes.node, location: PropTypes.object, - onLayoutChange: PropTypes.func.isRequired, - }; - - state = { - mobile: isMobile(window.innerWidth), + mobile: PropTypes.bool, }; componentWillMount () { - window.addEventListener('resize', this.handleResize, { passive: true }); - - if (this.state.mobile || forceSingleColumn) { + if (this.props.mobile) { document.body.classList.toggle('layout-single-column', true); document.body.classList.toggle('layout-multiple-columns', false); } else { @@ -128,44 +124,21 @@ class SwitchingColumnsArea extends React.PureComponent { } } - componentDidUpdate (prevProps, prevState) { + componentDidUpdate (prevProps) { if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { this.node.handleChildrenContentChange(); } - if (prevState.mobile !== this.state.mobile && !forceSingleColumn) { - document.body.classList.toggle('layout-single-column', this.state.mobile); - document.body.classList.toggle('layout-multiple-columns', !this.state.mobile); + if (prevProps.mobile !== this.props.mobile) { + document.body.classList.toggle('layout-single-column', this.props.mobile); + document.body.classList.toggle('layout-multiple-columns', !this.props.mobile); } } - componentWillUnmount () { - window.removeEventListener('resize', this.handleResize); - } - shouldUpdateScroll (_, { location }) { return location.state !== previewMediaState && location.state !== previewVideoState; } - handleLayoutChange = debounce(() => { - // The cached heights are no longer accurate, invalidate - this.props.onLayoutChange(); - }, 500, { - trailing: true, - }) - - handleResize = () => { - const mobile = isMobile(window.innerWidth); - - if (mobile !== this.state.mobile) { - this.handleLayoutChange.cancel(); - this.props.onLayoutChange(); - this.setState({ mobile }); - } else { - this.handleLayoutChange(); - } - } - setRef = c => { if (c) { this.node = c.getWrappedInstance(); @@ -173,13 +146,11 @@ class SwitchingColumnsArea extends React.PureComponent { } render () { - const { children } = this.props; - const { mobile } = this.state; - const singleColumn = forceSingleColumn || mobile; - const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />; + const { children, mobile } = this.props; + const redirect = mobile ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />; return ( - <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}> + <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}> <WrappedSwitch> {redirect} <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> @@ -243,6 +214,7 @@ class UI extends React.PureComponent { location: PropTypes.object, intl: PropTypes.object.isRequired, dropdownMenuIsOpen: PropTypes.bool, + layout: PropTypes.string.isRequired, }; state = { @@ -265,17 +237,13 @@ class UI extends React.PureComponent { handleWindowFocus = () => { this.props.dispatch(focusApp()); + this.props.dispatch(submitMarkers({ immediate: true })); } handleWindowBlur = () => { this.props.dispatch(unfocusApp()); } - handleLayoutChange = () => { - // The cached heights are no longer accurate, invalidate - this.props.dispatch(clearHeight()); - } - handleDragEnter = (e) => { e.preventDefault(); @@ -349,10 +317,28 @@ class UI extends React.PureComponent { } } - componentWillMount () { + handleLayoutChange = debounce(() => { + this.props.dispatch(clearHeight()); // The cached heights are no longer accurate, invalidate + }, 500, { + trailing: true, + }); + + handleResize = () => { + const layout = layoutFromWindow(); + + if (layout !== this.props.layout) { + this.handleLayoutChange.cancel(); + this.props.dispatch(changeLayout(layout)); + } else { + this.handleLayoutChange(); + } + } + + componentDidMount () { window.addEventListener('focus', this.handleWindowFocus, false); window.addEventListener('blur', this.handleWindowBlur, false); window.addEventListener('beforeunload', this.handleBeforeUnload, false); + window.addEventListener('resize', this.handleResize, { passive: true }); document.addEventListener('dragenter', this.handleDragEnter, false); document.addEventListener('dragover', this.handleDragOver, false); @@ -364,19 +350,14 @@ class UI extends React.PureComponent { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); } - if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - window.setTimeout(() => Notification.requestPermission(), 120 * 1000); - } - + this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); setTimeout(() => this.props.dispatch(fetchFilters()), 500); - } - componentDidMount () { this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); }; } @@ -384,6 +365,7 @@ class UI extends React.PureComponent { window.removeEventListener('focus', this.handleWindowFocus); window.removeEventListener('blur', this.handleWindowBlur); window.removeEventListener('beforeunload', this.handleBeforeUnload); + window.removeEventListener('resize', this.handleResize); document.removeEventListener('dragenter', this.handleDragEnter); document.removeEventListener('dragover', this.handleDragOver); @@ -514,7 +496,7 @@ class UI extends React.PureComponent { render () { const { draggingOver } = this.state; - const { children, isComposing, location, dropdownMenuIsOpen } = this.props; + const { children, isComposing, location, dropdownMenuIsOpen, layout } = this.props; const handlers = { help: this.handleHotkeyToggleHelp, @@ -541,10 +523,11 @@ class UI extends React.PureComponent { return ( <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}> - <SwitchingColumnsArea location={location} onLayoutChange={this.handleLayoutChange}> + <SwitchingColumnsArea location={location} mobile={layout === 'mobile' || layout === 'single-column'}> {children} </SwitchingColumnsArea> + {layout !== 'mobile' && <PictureInPicture />} <NotificationsContainer /> <LoadingBarContainer className='loading-bar' /> <ModalContainer /> diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 99dcdca22..70e3cd6e8 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { fromJS, is } from 'immutable'; +import { is } from 'immutable'; import { throttle, debounce } from 'lodash'; import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; @@ -99,25 +99,33 @@ class Video extends React.PureComponent { static propTypes = { preview: PropTypes.string, + frameRate: PropTypes.string, src: PropTypes.string.isRequired, alt: PropTypes.string, width: PropTypes.number, height: PropTypes.number, sensitive: PropTypes.bool, - startTime: PropTypes.number, + currentTime: PropTypes.number, onOpenVideo: PropTypes.func, onCloseVideo: PropTypes.func, detailed: PropTypes.bool, inline: PropTypes.bool, editable: PropTypes.bool, + alwaysVisible: PropTypes.bool, cacheWidth: PropTypes.func, visible: PropTypes.bool, onToggleVisibility: PropTypes.func, + deployPictureInPicture: PropTypes.func, intl: PropTypes.object.isRequired, blurhash: PropTypes.string, - link: PropTypes.node, autoPlay: PropTypes.bool, - defaultVolume: PropTypes.number, + volume: PropTypes.number, + muted: PropTypes.bool, + componetIndex: PropTypes.number, + }; + + static defaultProps = { + frameRate: '25', }; state = { @@ -195,7 +203,7 @@ class Video extends React.PureComponent { handleTimeUpdate = () => { this.setState({ currentTime: this.video.currentTime, - duration: Math.floor(this.video.duration), + duration:this.video.duration, }); } @@ -263,6 +271,81 @@ class Video extends React.PureComponent { } }, 15); + seekBy (time) { + const currentTime = this.video.currentTime + time; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.video.currentTime = currentTime; + }); + } + } + + handleVideoKeyDown = e => { + // On the video element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + } + } + + handleKeyDown = e => { + const frameTime = 1 / this.getFrameRate(); + + switch(e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + this.toggleMute(); + break; + case 'f': + e.preventDefault(); + e.stopPropagation(); + this.toggleFullscreen(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(10); + break; + case ',': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-frameTime); + break; + case '.': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(frameTime); + break; + } + + // If we are in fullscreen mode, we don't want any hotkeys + // interacting with the UI that's not visible + + if (this.state.fullscreen) { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + exitFullscreen(); + } + } + } + togglePlay = () => { if (this.state.paused) { this.setState({ paused: false }, () => this.video.play()); @@ -297,6 +380,15 @@ class Video extends React.PureComponent { document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + + if (!this.state.paused && this.video && this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('video', { + src: this.props.src, + currentTime: this.video.currentTime, + muted: this.video.muted, + volume: this.video.volume, + }); + } } componentWillReceiveProps (nextProps) { @@ -328,7 +420,18 @@ class Video extends React.PureComponent { const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); if (!this.state.paused && !inView) { - this.setState({ paused: true }, () => this.video.pause()); + this.video.pause(); + + if (this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('video', { + src: this.props.src, + currentTime: this.video.currentTime, + muted: this.video.muted, + volume: this.video.volume, + }); + } + + this.setState({ paused: true }); } }, 150, { trailing: true }) @@ -361,15 +464,21 @@ class Video extends React.PureComponent { } handleLoadedData = () => { - if (this.props.startTime) { - this.video.currentTime = this.props.startTime; + const { currentTime, volume, muted, autoPlay } = this.props; + + if (currentTime) { + this.video.currentTime = currentTime; + } + + if (volume !== undefined) { + this.video.volume = volume; } - if (this.props.defaultVolume !== undefined) { - this.video.volume = this.props.defaultVolume; + if (muted !== undefined) { + this.video.muted = muted; } - if (this.props.autoPlay) { + if (autoPlay) { this.video.play(); } } @@ -387,25 +496,14 @@ class Video extends React.PureComponent { } handleOpenVideo = () => { - const { src, preview, width, height, alt } = this.props; - - const media = fromJS({ - type: 'video', - url: src, - preview_url: preview, - description: alt, - width, - height, - }); + this.video.pause(); - const options = { + this.props.onOpenVideo({ startTime: this.video.currentTime, autoPlay: !this.state.paused, defaultVolume: this.state.volume, - }; - - this.video.pause(); - this.props.onOpenVideo(media, options); + componetIndex: this.props.componetIndex, + }); } handleCloseVideo = () => { @@ -413,10 +511,21 @@ class Video extends React.PureComponent { this.props.onCloseVideo(); } + getFrameRate () { + if (this.props.frameRate && isNaN(this.props.frameRate)) { + // The frame rate is returned as a fraction string so we + // need to convert it to a number + + return this.props.frameRate.split('/').reduce((p, c) => p / c); + } + + return this.props.frameRate; + } + render () { - const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props; + const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; - const progress = (currentTime / duration) * 100; + const progress = Math.min((currentTime / duration) * 100, 100); const playerStyle = {}; let { width, height } = this.props; @@ -430,7 +539,7 @@ class Video extends React.PureComponent { let preload; - if (startTime || fullscreen || dragging) { + if (this.props.currentTime || fullscreen || dragging) { preload = 'auto'; } else if (detailed) { preload = 'metadata'; @@ -455,6 +564,7 @@ class Video extends React.PureComponent { onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClickRoot} + onKeyDown={this.handleKeyDown} tabIndex={0} > <Blurhash @@ -478,6 +588,7 @@ class Video extends React.PureComponent { height={height} volume={volume} onClick={this.togglePlay} + onKeyDown={this.handleVideoKeyDown} onPlay={this.handlePlay} onPause={this.handlePause} onLoadedData={this.handleLoadedData} @@ -500,13 +611,14 @@ class Video extends React.PureComponent { className={classNames('video-player__seek__handle', { active: dragging })} tabIndex='0' style={{ left: `${progress}%` }} + onKeyDown={this.handleVideoKeyDown} /> </div> <div className='video-player__buttons-bar'> <div className='video-player__buttons left'> - <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> - <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> + <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> <div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> <div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} /> @@ -522,18 +634,16 @@ class Video extends React.PureComponent { <span className='video-player__time'> <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span> <span className='video-player__time-sep'>/</span> - <span className='video-player__time-total'>{formatTime(duration)}</span> + <span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span> </span> )} - - {link && <span className='video-player__link'>{link}</span>} </div> <div className='video-player__buttons right'> - {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} - {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} - {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} - <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> + {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} + {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} className='player-button' onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} + {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} className='player-button' onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} + <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> </div> </div> </div> |