aboutsummaryrefslogtreecommitdiff
path: root/app/javascript/mastodon/features
diff options
context:
space:
mode:
authorTakeshi Umeda <noel.yoshiba@gmail.com>2021-01-10 11:17:55 +0900
committerGitHub <noreply@github.com>2021-01-10 11:17:55 +0900
commit98a2603dc163210d3a0aab0a0c2b8ef74c7e5eb0 (patch)
tree761694d2d697c58faf02a3ff9ef26bf045fc0274 /app/javascript/mastodon/features
parent7cd4ed7d4298626d2b141cd6d8378e95bc248824 (diff)
parent087ed84367537ac168ed3e00bb7eb4bd582dc3d0 (diff)
downloadmastodon-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')
-rw-r--r--app/javascript/mastodon/features/account/components/header.js76
-rw-r--r--app/javascript/mastodon/features/account_gallery/components/media_item.js2
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js46
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js5
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js12
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js10
-rw-r--r--app/javascript/mastodon/features/audio/index.js112
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js26
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js6
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/reply_indicator.js6
-rw-r--r--app/javascript/mastodon/features/compose/containers/sensitive_button_container.js22
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js5
-rw-r--r--app/javascript/mastodon/features/getting_started/components/announcements.js10
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js41
-rw-r--r--app/javascript/mastodon/features/introduction/index.js3
-rw-r--r--app/javascript/mastodon/features/list_timeline/index.js30
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js45
-rw-r--r--app/javascript/mastodon/features/notifications/components/filter_bar.js8
-rw-r--r--app/javascript/mastodon/features/notifications/components/grant_permission_button.js19
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js58
-rw-r--r--app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js48
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js5
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js35
-rw-r--r--app/javascript/mastodon/features/notifications/index.js56
-rw-r--r--app/javascript/mastodon/features/picture_in_picture/components/footer.js159
-rw-r--r--app/javascript/mastodon/features/picture_in_picture/components/header.js47
-rw-r--r--app/javascript/mastodon/features/picture_in_picture/index.js85
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js16
-rw-r--r--app/javascript/mastodon/features/status/containers/detailed_status_container.js4
-rw-r--r--app/javascript/mastodon/features/status/index.js26
-rw-r--r--app/javascript/mastodon/features/ui/components/audio_modal.js32
-rw-r--r--app/javascript/mastodon/features/ui/components/boost_modal.js5
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js23
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js55
-rw-r--r--app/javascript/mastodon/features/ui/components/image_loader.js4
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js109
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js13
-rw-r--r--app/javascript/mastodon/features/ui/components/mute_modal.js44
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js54
-rw-r--r--app/javascript/mastodon/features/ui/components/zoomable_image.js340
-rw-r--r--app/javascript/mastodon/features/ui/index.js101
-rw-r--r--app/javascript/mastodon/features/video/index.js186
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>