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:
Patrick Figel 2017-04-17 12:14:03 +02:00
parent 1955a3f444
commit ffb99325ca
12 changed files with 65 additions and 28 deletions

View File

@ -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,17 +159,25 @@ const Item = React.createClass({
/> />
); );
} else if (attachment.get('type') === 'gifv') { } else if (attachment.get('type') === 'gifv') {
if (isIOS() || !this.props.autoPlayGif) {
return (
<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}>
<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>
</div>
);
} else {
thumbnail = ( thumbnail = (
<video <video
src={attachment.get('url')} src={attachment.get('url')}
onClick={this.handleClick} onClick={this.handleClick}
autoPlay={!isIOS()} autoPlay
loop={true} loop={true}
muted={true} muted={true}
style={gifvThumbStyle} style={gifvThumbStyle}
/> />
); );
} }
}
return ( return (
<div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> <div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
@ -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 (

View File

@ -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} />;
} }
} }

View File

@ -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;

View File

@ -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));

View File

@ -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')} />;

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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