From ffb99325cafb5b00ee652c6b3ed2811a4d643fc8 Mon Sep 17 00:00:00 2001 From: Patrick Figel <patrick@figel.email> Date: Mon, 17 Apr 2017 12:14:03 +0200 Subject: [PATCH] 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 --- .../components/components/media_gallery.jsx | 36 ++++++++++++------- .../components/components/status.jsx | 3 +- .../containers/status_container.jsx | 3 +- .../features/account/components/header.jsx | 23 ++++++++---- .../status/components/detailed_status.jsx | 3 +- .../components/features/status/index.jsx | 10 +++--- .../settings/preferences_controller.rb | 5 +-- app/models/user.rb | 4 +++ app/views/home/initial_state.json.rabl | 1 + app/views/settings/preferences/show.html.haml | 3 ++ config/locales/simple_form.en.yml | 1 + config/settings.yml | 1 + 12 files changed, 65 insertions(+), 28 deletions(-) diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx index 325fd81577..c6c726a4eb 100644 --- a/app/assets/javascripts/components/components/media_gallery.jsx +++ b/app/assets/javascripts/components/components/media_gallery.jsx @@ -78,7 +78,8 @@ const Item = React.createClass({ attachment: ImmutablePropTypes.map.isRequired, index: 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], @@ -158,16 +159,24 @@ const Item = React.createClass({ /> ); } else if (attachment.get('type') === 'gifv') { - thumbnail = ( - <video - src={attachment.get('url')} - onClick={this.handleClick} - autoPlay={!isIOS()} - loop={true} - muted={true} - style={gifvThumbStyle} - /> - ); + 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 = ( + <video + src={attachment.get('url')} + onClick={this.handleClick} + autoPlay + loop={true} + muted={true} + style={gifvThumbStyle} + /> + ); + } } return ( @@ -192,7 +201,8 @@ const MediaGallery = React.createClass({ media: ImmutablePropTypes.list.isRequired, height: React.PropTypes.number.isRequired, onOpenMedia: React.PropTypes.func.isRequired, - intl: React.PropTypes.object.isRequired + intl: React.PropTypes.object.isRequired, + autoPlayGif: React.PropTypes.bool.isRequired }, mixins: [PureRenderMixin], @@ -227,7 +237,7 @@ const MediaGallery = React.createClass({ ); } else { 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 ( diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx index d2d2aaf200..abc123f26e 100644 --- a/app/assets/javascripts/components/components/status.jsx +++ b/app/assets/javascripts/components/components/status.jsx @@ -29,6 +29,7 @@ const Status = React.createClass({ onBlock: React.PropTypes.func, me: React.PropTypes.number, boostModal: React.PropTypes.bool, + autoPlayGif: React.PropTypes.bool, muted: React.PropTypes.bool }, @@ -79,7 +80,7 @@ const Status = React.createClass({ if (status.getIn(['media_attachments', 0, 'type']) === 'video') { media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />; } 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} />; } } diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index f704ac7229..df091de049 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -27,7 +27,8 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props.id), 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; diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index c4619a3c79..c097fbbd69 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -5,6 +5,7 @@ import escapeTextContentForBrowser from 'escape-html'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import IconButton from '../../../components/icon_button'; import { Motion, spring } from 'react-motion'; +import { connect } from 'react-redux'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, @@ -12,10 +13,19 @@ const messages = defineMessages({ 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({ propTypes: { - account: ImmutablePropTypes.map.isRequired + account: ImmutablePropTypes.map.isRequired, + autoPlayGif: React.PropTypes.bool.isRequired }, getInitialState () { @@ -37,7 +47,7 @@ const Avatar = React.createClass({ }, render () { - const { account } = this.props; + const { account, autoPlayGif } = this.props; const { isHovered } = this.state; return ( @@ -53,7 +63,7 @@ const Avatar = React.createClass({ onMouseOut={this.handleMouseOut} onFocus={this.handleMouseOver} 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> } </Motion> @@ -68,7 +78,8 @@ const Header = React.createClass({ account: ImmutablePropTypes.map, me: React.PropTypes.number.isRequired, onFollow: React.PropTypes.func.isRequired, - intl: React.PropTypes.object.isRequired + intl: React.PropTypes.object.isRequired, + autoPlayGif: React.PropTypes.bool.isRequired }, mixins: [PureRenderMixin], @@ -119,7 +130,7 @@ const Header = React.createClass({ return ( <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> <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 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)); diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx index ceafc1a32a..bd386b2517 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -19,6 +19,7 @@ const DetailedStatus = React.createClass({ status: ImmutablePropTypes.map.isRequired, onOpenMedia: React.PropTypes.func.isRequired, onOpenVideo: React.PropTypes.func.isRequired, + autoPlayGif: React.PropTypes.bool, }, mixins: [PureRenderMixin], @@ -42,7 +43,7 @@ const DetailedStatus = React.createClass({ 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 />; } 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 { media = <CardContainer statusId={status.get('id')} />; diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx index 7ead688074..ca6e08cdcd 100644 --- a/app/assets/javascripts/components/features/status/index.jsx +++ b/app/assets/javascripts/components/features/status/index.jsx @@ -39,7 +39,8 @@ const makeMapStateToProps = () => { ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]), descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]), 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; @@ -57,7 +58,8 @@ const Status = React.createClass({ ancestorsIds: ImmutablePropTypes.list, descendantsIds: ImmutablePropTypes.list, me: React.PropTypes.number, - boostModal: React.PropTypes.bool + boostModal: React.PropTypes.bool, + autoPlayGif: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -126,7 +128,7 @@ const Status = React.createClass({ render () { let ancestors, descendants; - const { status, ancestorsIds, descendantsIds, me } = this.props; + const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props; if (status === null) { return ( @@ -155,7 +157,7 @@ const Status = React.createClass({ <div className='scrollable'> {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} /> {descendants} diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index c758e4ef2f..f66eb97528 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -24,8 +24,9 @@ class Settings::PreferencesController < ApplicationController current_user.settings['default_privacy'] = user_params[:setting_default_privacy] 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') else render action: :show @@ -35,6 +36,6 @@ class Settings::PreferencesController < ApplicationController private 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 diff --git a/app/models/user.rb b/app/models/user.rb index 27a38674e4..d50101baf2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -32,4 +32,8 @@ class User < ApplicationRecord def setting_boost_modal settings.boost_modal end + + def setting_auto_play_gif + settings.auto_play_gif + end end diff --git a/app/views/home/initial_state.json.rabl b/app/views/home/initial_state.json.rabl index ce7bfbd44c..a2ab2d060d 100644 --- a/app/views/home/initial_state.json.rabl +++ b/app/views/home/initial_state.json.rabl @@ -9,6 +9,7 @@ node(:meta) do me: current_account.id, admin: @admin.try(:id), boost_modal: current_account.user.setting_boost_modal, + auto_play_gif: current_account.user.setting_auto_play_gif, } end diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index e819429b62..3fdcca0417 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -25,5 +25,8 @@ .fields-group = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label + .fields-group + = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label + .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index c25407f2b5..5335b0927c 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -28,6 +28,7 @@ en: note: Bio otp_attempt: Two-factor code password: Password + setting_auto_play_gif: Auto-play animated GIFs setting_boost_modal: Show confirmation dialog before boosting setting_default_privacy: Post privacy severity: Severity diff --git a/config/settings.yml b/config/settings.yml index 04213fd0bc..9813963b28 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -15,6 +15,7 @@ defaults: &defaults open_registrations: true closed_registrations_message: '' boost_modal: false + auto_play_gif: true notification_emails: follow: false reblog: false