Add gif auto-play/pause preference
This introduces a new per-user preference called "Auto-play animated GIFs", which is enabled by default. When a user disables this setting, gifs in toots become click-to-play. Previews of animated gifs were changed to display the video play button so that users can distinguish them from regular images. This setting also affects account avatars in the detailed account view, which was changed to use the same hover-to-play mechanism that is used for animated avatars in timelines. Fixes #1652
This commit is contained in:
parent
1955a3f444
commit
ffb99325ca
12 changed files with 65 additions and 28 deletions
|
@ -78,7 +78,8 @@ const Item = React.createClass({
|
||||||
attachment: ImmutablePropTypes.map.isRequired,
|
attachment: ImmutablePropTypes.map.isRequired,
|
||||||
index: React.PropTypes.number.isRequired,
|
index: React.PropTypes.number.isRequired,
|
||||||
size: React.PropTypes.number.isRequired,
|
size: React.PropTypes.number.isRequired,
|
||||||
onClick: React.PropTypes.func.isRequired
|
onClick: React.PropTypes.func.isRequired,
|
||||||
|
autoPlayGif: React.PropTypes.bool.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
@ -158,16 +159,24 @@ const Item = React.createClass({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (attachment.get('type') === 'gifv') {
|
} else if (attachment.get('type') === 'gifv') {
|
||||||
thumbnail = (
|
if (isIOS() || !this.props.autoPlayGif) {
|
||||||
<video
|
return (
|
||||||
src={attachment.get('url')}
|
<div key={attachment.get('id')} style={{ ...itemStyle, background: `url(${attachment.get('preview_url')}) no-repeat center`, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }} onClick={this.handleClick}>
|
||||||
onClick={this.handleClick}
|
<div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
|
||||||
autoPlay={!isIOS()}
|
</div>
|
||||||
loop={true}
|
);
|
||||||
muted={true}
|
} else {
|
||||||
style={gifvThumbStyle}
|
thumbnail = (
|
||||||
/>
|
<video
|
||||||
);
|
src={attachment.get('url')}
|
||||||
|
onClick={this.handleClick}
|
||||||
|
autoPlay
|
||||||
|
loop={true}
|
||||||
|
muted={true}
|
||||||
|
style={gifvThumbStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -192,7 +201,8 @@ const MediaGallery = React.createClass({
|
||||||
media: ImmutablePropTypes.list.isRequired,
|
media: ImmutablePropTypes.list.isRequired,
|
||||||
height: React.PropTypes.number.isRequired,
|
height: React.PropTypes.number.isRequired,
|
||||||
onOpenMedia: React.PropTypes.func.isRequired,
|
onOpenMedia: React.PropTypes.func.isRequired,
|
||||||
intl: React.PropTypes.object.isRequired
|
intl: React.PropTypes.object.isRequired,
|
||||||
|
autoPlayGif: React.PropTypes.bool.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
@ -227,7 +237,7 @@ const MediaGallery = React.createClass({
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const size = media.take(4).size;
|
const size = media.take(4).size;
|
||||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
|
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -29,6 +29,7 @@ const Status = React.createClass({
|
||||||
onBlock: React.PropTypes.func,
|
onBlock: React.PropTypes.func,
|
||||||
me: React.PropTypes.number,
|
me: React.PropTypes.number,
|
||||||
boostModal: React.PropTypes.bool,
|
boostModal: React.PropTypes.bool,
|
||||||
|
autoPlayGif: React.PropTypes.bool,
|
||||||
muted: React.PropTypes.bool
|
muted: React.PropTypes.bool
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -79,7 +80,7 @@ const Status = React.createClass({
|
||||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />;
|
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />;
|
||||||
} else {
|
} else {
|
||||||
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />;
|
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,8 @@ const makeMapStateToProps = () => {
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
status: getStatus(state, props.id),
|
status: getStatus(state, props.id),
|
||||||
me: state.getIn(['meta', 'me']),
|
me: state.getIn(['meta', 'me']),
|
||||||
boostModal: state.getIn(['meta', 'boost_modal'])
|
boostModal: state.getIn(['meta', 'boost_modal']),
|
||||||
|
autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import escapeTextContentForBrowser from 'escape-html';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import IconButton from '../../../components/icon_button';
|
import IconButton from '../../../components/icon_button';
|
||||||
import { Motion, spring } from 'react-motion';
|
import { Motion, spring } from 'react-motion';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
@ -12,10 +13,19 @@ const messages = defineMessages({
|
||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
|
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeMapStateToProps = () => {
|
||||||
|
const mapStateToProps = (state, props) => ({
|
||||||
|
autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStateToProps;
|
||||||
|
};
|
||||||
|
|
||||||
const Avatar = React.createClass({
|
const Avatar = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
account: ImmutablePropTypes.map.isRequired
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
|
autoPlayGif: React.PropTypes.bool.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
getInitialState () {
|
getInitialState () {
|
||||||
|
@ -37,7 +47,7 @@ const Avatar = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account } = this.props;
|
const { account, autoPlayGif } = this.props;
|
||||||
const { isHovered } = this.state;
|
const { isHovered } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -53,7 +63,7 @@ const Avatar = React.createClass({
|
||||||
onMouseOut={this.handleMouseOut}
|
onMouseOut={this.handleMouseOut}
|
||||||
onFocus={this.handleMouseOver}
|
onFocus={this.handleMouseOver}
|
||||||
onBlur={this.handleMouseOut}>
|
onBlur={this.handleMouseOut}>
|
||||||
<img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
|
<img src={autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</Motion>
|
</Motion>
|
||||||
|
@ -68,7 +78,8 @@ const Header = React.createClass({
|
||||||
account: ImmutablePropTypes.map,
|
account: ImmutablePropTypes.map,
|
||||||
me: React.PropTypes.number.isRequired,
|
me: React.PropTypes.number.isRequired,
|
||||||
onFollow: React.PropTypes.func.isRequired,
|
onFollow: React.PropTypes.func.isRequired,
|
||||||
intl: React.PropTypes.object.isRequired
|
intl: React.PropTypes.object.isRequired,
|
||||||
|
autoPlayGif: React.PropTypes.bool.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
@ -119,7 +130,7 @@ const Header = React.createClass({
|
||||||
return (
|
return (
|
||||||
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
|
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
|
||||||
<div style={{ padding: '20px 10px' }}>
|
<div style={{ padding: '20px 10px' }}>
|
||||||
<Avatar account={account} />
|
<Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
|
||||||
|
|
||||||
<span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
|
<span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
|
||||||
<span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
|
<span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
|
||||||
|
@ -134,4 +145,4 @@ const Header = React.createClass({
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default injectIntl(Header);
|
export default connect(makeMapStateToProps)(injectIntl(Header));
|
||||||
|
|
|
@ -19,6 +19,7 @@ const DetailedStatus = React.createClass({
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
onOpenMedia: React.PropTypes.func.isRequired,
|
onOpenMedia: React.PropTypes.func.isRequired,
|
||||||
onOpenVideo: React.PropTypes.func.isRequired,
|
onOpenVideo: React.PropTypes.func.isRequired,
|
||||||
|
autoPlayGif: React.PropTypes.bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
@ -42,7 +43,7 @@ const DetailedStatus = React.createClass({
|
||||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} onOpenVideo={this.props.onOpenVideo} autoplay />;
|
media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} onOpenVideo={this.props.onOpenVideo} autoplay />;
|
||||||
} else {
|
} else {
|
||||||
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
|
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
media = <CardContainer statusId={status.get('id')} />;
|
media = <CardContainer statusId={status.get('id')} />;
|
||||||
|
|
|
@ -39,7 +39,8 @@ const makeMapStateToProps = () => {
|
||||||
ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
|
ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
|
||||||
descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
|
descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
|
||||||
me: state.getIn(['meta', 'me']),
|
me: state.getIn(['meta', 'me']),
|
||||||
boostModal: state.getIn(['meta', 'boost_modal'])
|
boostModal: state.getIn(['meta', 'boost_modal']),
|
||||||
|
autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
|
@ -57,7 +58,8 @@ const Status = React.createClass({
|
||||||
ancestorsIds: ImmutablePropTypes.list,
|
ancestorsIds: ImmutablePropTypes.list,
|
||||||
descendantsIds: ImmutablePropTypes.list,
|
descendantsIds: ImmutablePropTypes.list,
|
||||||
me: React.PropTypes.number,
|
me: React.PropTypes.number,
|
||||||
boostModal: React.PropTypes.bool
|
boostModal: React.PropTypes.bool,
|
||||||
|
autoPlayGif: React.PropTypes.bool
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
@ -126,7 +128,7 @@ const Status = React.createClass({
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let ancestors, descendants;
|
let ancestors, descendants;
|
||||||
const { status, ancestorsIds, descendantsIds, me } = this.props;
|
const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
|
||||||
|
|
||||||
if (status === null) {
|
if (status === null) {
|
||||||
return (
|
return (
|
||||||
|
@ -155,7 +157,7 @@ const Status = React.createClass({
|
||||||
<div className='scrollable'>
|
<div className='scrollable'>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
<DetailedStatus status={status} me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} />
|
<DetailedStatus status={status} autoPlayGif={autoPlayGif} me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} />
|
||||||
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} />
|
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} />
|
||||||
|
|
||||||
{descendants}
|
{descendants}
|
||||||
|
|
|
@ -24,8 +24,9 @@ class Settings::PreferencesController < ApplicationController
|
||||||
|
|
||||||
current_user.settings['default_privacy'] = user_params[:setting_default_privacy]
|
current_user.settings['default_privacy'] = user_params[:setting_default_privacy]
|
||||||
current_user.settings['boost_modal'] = user_params[:setting_boost_modal] == '1'
|
current_user.settings['boost_modal'] = user_params[:setting_boost_modal] == '1'
|
||||||
|
current_user.settings['auto_play_gif'] = user_params[:setting_auto_play_gif] == '1'
|
||||||
|
|
||||||
if current_user.update(user_params.except(:notification_emails, :interactions, :setting_default_privacy, :setting_boost_modal))
|
if current_user.update(user_params.except(:notification_emails, :interactions, :setting_default_privacy, :setting_boost_modal, :setting_auto_play_gif))
|
||||||
redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg')
|
redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg')
|
||||||
else
|
else
|
||||||
render action: :show
|
render action: :show
|
||||||
|
@ -35,6 +36,6 @@ class Settings::PreferencesController < ApplicationController
|
||||||
private
|
private
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:locale, :setting_default_privacy, :setting_boost_modal, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention, :digest], interactions: [:must_be_follower, :must_be_following])
|
params.require(:user).permit(:locale, :setting_default_privacy, :setting_boost_modal, :setting_auto_play_gif, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention, :digest], interactions: [:must_be_follower, :must_be_following])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -32,4 +32,8 @@ class User < ApplicationRecord
|
||||||
def setting_boost_modal
|
def setting_boost_modal
|
||||||
settings.boost_modal
|
settings.boost_modal
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def setting_auto_play_gif
|
||||||
|
settings.auto_play_gif
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,6 +9,7 @@ node(:meta) do
|
||||||
me: current_account.id,
|
me: current_account.id,
|
||||||
admin: @admin.try(:id),
|
admin: @admin.try(:id),
|
||||||
boost_modal: current_account.user.setting_boost_modal,
|
boost_modal: current_account.user.setting_boost_modal,
|
||||||
|
auto_play_gif: current_account.user.setting_auto_play_gif,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -25,5 +25,8 @@
|
||||||
.fields-group
|
.fields-group
|
||||||
= f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
|
= f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
|
||||||
|
|
||||||
|
.fields-group
|
||||||
|
= f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label
|
||||||
|
|
||||||
.actions
|
.actions
|
||||||
= f.button :button, t('generic.save_changes'), type: :submit
|
= f.button :button, t('generic.save_changes'), type: :submit
|
||||||
|
|
|
@ -28,6 +28,7 @@ en:
|
||||||
note: Bio
|
note: Bio
|
||||||
otp_attempt: Two-factor code
|
otp_attempt: Two-factor code
|
||||||
password: Password
|
password: Password
|
||||||
|
setting_auto_play_gif: Auto-play animated GIFs
|
||||||
setting_boost_modal: Show confirmation dialog before boosting
|
setting_boost_modal: Show confirmation dialog before boosting
|
||||||
setting_default_privacy: Post privacy
|
setting_default_privacy: Post privacy
|
||||||
severity: Severity
|
severity: Severity
|
||||||
|
|
|
@ -15,6 +15,7 @@ defaults: &defaults
|
||||||
open_registrations: true
|
open_registrations: true
|
||||||
closed_registrations_message: ''
|
closed_registrations_message: ''
|
||||||
boost_modal: false
|
boost_modal: false
|
||||||
|
auto_play_gif: true
|
||||||
notification_emails:
|
notification_emails:
|
||||||
follow: false
|
follow: false
|
||||||
reblog: false
|
reblog: false
|
||||||
|
|
Loading…
Reference in a new issue