From 1898d6401d1cee31b1dec39aa99e5053f80319db Mon Sep 17 00:00:00 2001 From: Essem Date: Tue, 7 Nov 2023 18:43:47 -0600 Subject: [PATCH 1/4] Add support for emoji reactions Squashed, modified, and rebased from glitch-soc/mastodon#2221. Co-authored-by: fef Co-authored-by: Jeremy Kescher Co-authored-by: neatchee Co-authored-by: Ivan Rodriguez <104603218+IRod22@users.noreply.github.com> Co-authored-by: Plastikmensch --- .env.production.sample | 3 + .../api/v1/statuses/reactions_controller.rb | 31 ++++ .../flavours/glitch/actions/interactions.js | 82 ++++++++ .../flavours/glitch/actions/notifications.js | 1 + .../flavours/glitch/components/status.jsx | 19 +- .../glitch/components/status_action_bar.jsx | 27 ++- .../glitch/components/status_prepend.jsx | 11 ++ .../glitch/components/status_reactions.jsx | 175 ++++++++++++++++++ .../glitch/containers/status_container.js | 10 + .../components/emoji_picker_dropdown.jsx | 5 +- .../notifications/components/filter_bar.jsx | 8 + .../notifications/components/notification.jsx | 22 +++ .../features/status/components/action_bar.jsx | 29 ++- .../status/components/detailed_status.jsx | 15 ++ .../flavours/glitch/features/status/index.jsx | 18 ++ .../flavours/glitch/initial_state.js | 5 + .../flavours/glitch/locales/en.json | 5 + .../flavours/glitch/reducers/settings.js | 3 + .../flavours/glitch/reducers/statuses.js | 50 +++++ .../glitch/styles/components/accounts.scss | 4 + .../glitch/styles/components/status.scss | 19 +- app/javascript/mastodon/locales/en.json | 4 + app/javascript/mastodon/reducers/settings.js | 2 + app/lib/activitypub/activity.rb | 30 +++ app/lib/activitypub/activity/emoji_react.rb | 26 +++ app/lib/activitypub/activity/like.rb | 28 ++- app/lib/activitypub/activity/undo.rb | 27 +++ app/models/concerns/account_associations.rb | 1 + app/models/concerns/account_interactions.rb | 4 + app/models/concerns/has_user_settings.rb | 14 ++ app/models/notification.rb | 10 +- app/models/status.rb | 16 ++ app/models/status_reaction.rb | 33 ++++ app/models/user_settings.rb | 1 + app/policies/status_policy.rb | 4 + .../activitypub/emoji_reaction_serializer.rb | 39 ++++ .../undo_emoji_reaction_serializer.rb | 19 ++ app/serializers/initial_state_serializer.rb | 7 +- app/serializers/rest/instance_serializer.rb | 4 + .../rest/notification_serializer.rb | 2 +- app/serializers/rest/reaction_serializer.rb | 14 ++ app/serializers/rest/status_serializer.rb | 5 + .../rest/v1/instance_serializer.rb | 4 + app/services/react_service.rb | 31 ++++ app/services/unreact_service.rb | 23 +++ app/validators/status_reaction_validator.rb | 28 +++ .../preferences/appearance/show.html.haml | 3 + app/workers/unreact_worker.rb | 11 ++ config/locales-glitch/en.yml | 5 + config/locales-glitch/simple_form.en.yml | 1 + config/routes/api.rb | 5 + config/settings.yml | 1 + .../20221124114030_create_status_reactions.rb | 16 ++ ...15350_fix_foreign_keys_status_reactions.rb | 18 ++ ...0215074425_move_emoji_reaction_settings.rb | 49 +++++ db/schema.rb | 15 ++ .../fabricators/status_reaction_fabricator.rb | 8 + spec/models/status_reaction_spec.rb | 3 + 58 files changed, 1043 insertions(+), 10 deletions(-) create mode 100644 app/controllers/api/v1/statuses/reactions_controller.rb create mode 100644 app/javascript/flavours/glitch/components/status_reactions.jsx create mode 100644 app/lib/activitypub/activity/emoji_react.rb create mode 100644 app/models/status_reaction.rb create mode 100644 app/serializers/activitypub/emoji_reaction_serializer.rb create mode 100644 app/serializers/activitypub/undo_emoji_reaction_serializer.rb create mode 100644 app/services/react_service.rb create mode 100644 app/services/unreact_service.rb create mode 100644 app/validators/status_reaction_validator.rb create mode 100644 app/workers/unreact_worker.rb create mode 100644 db/migrate/20221124114030_create_status_reactions.rb create mode 100644 db/migrate/20221218015350_fix_foreign_keys_status_reactions.rb create mode 100644 db/migrate/20230215074425_move_emoji_reaction_settings.rb create mode 100644 spec/fabricators/status_reaction_fabricator.rb create mode 100644 spec/models/status_reaction_spec.rb diff --git a/.env.production.sample b/.env.production.sample index 7bcce0f7e59b98..b604c4b04dfa41 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -269,6 +269,9 @@ MAX_POLL_OPTIONS=5 # Maximum allowed poll option characters MAX_POLL_OPTION_CHARS=100 +# Maximum number of emoji reactions per toot and user (minimum 1) +MAX_REACTIONS=1 + # Maximum image and video/audio upload sizes # Units are in bytes # 1048576 bytes equals 1 megabyte diff --git a/app/controllers/api/v1/statuses/reactions_controller.rb b/app/controllers/api/v1/statuses/reactions_controller.rb new file mode 100644 index 00000000000000..2d7e4f59846220 --- /dev/null +++ b/app/controllers/api/v1/statuses/reactions_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::ReactionsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:favourites' } + before_action :require_user! + before_action :set_status + + def create + ReactService.new.call(current_account, @status, params[:id]) + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + UnreactWorker.perform_async(current_account.id, @status.id, params[:id]) + + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reactions_map: { @status.id => false }) + rescue Mastodon::NotPermittedError + not_found + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 7d0144438aa29c..60f46f0cbcedb3 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -51,6 +51,16 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; +export const REACTION_UPDATE = 'REACTION_UPDATE'; + +export const REACTION_ADD_REQUEST = 'REACTION_ADD_REQUEST'; +export const REACTION_ADD_SUCCESS = 'REACTION_ADD_SUCCESS'; +export const REACTION_ADD_FAIL = 'REACTION_ADD_FAIL'; + +export const REACTION_REMOVE_REQUEST = 'REACTION_REMOVE_REQUEST'; +export const REACTION_REMOVE_SUCCESS = 'REACTION_REMOVE_SUCCESS'; +export const REACTION_REMOVE_FAIL = 'REACTION_REMOVE_FAIL'; + export function reblog(status, visibility) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -516,3 +526,75 @@ export function unpinFail(status, error) { skipLoading: true, }; } + +export const addReaction = (statusId, name, url) => (dispatch, getState) => { + const status = getState().get('statuses').get(statusId); + let alreadyAdded = false; + if (status) { + const reaction = status.get('reactions').find(x => x.get('name') === name); + if (reaction && reaction.get('me')) { + alreadyAdded = true; + } + } + if (!alreadyAdded) { + dispatch(addReactionRequest(statusId, name, url)); + } + + // encodeURIComponent is required for the Keycap Number Sign emoji, see: + // + api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => { + dispatch(addReactionSuccess(statusId, name)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(statusId, name, err)); + } + }); +}; + +export const addReactionRequest = (statusId, name, url) => ({ + type: REACTION_ADD_REQUEST, + id: statusId, + name, + url, +}); + +export const addReactionSuccess = (statusId, name) => ({ + type: REACTION_ADD_SUCCESS, + id: statusId, + name, +}); + +export const addReactionFail = (statusId, name, error) => ({ + type: REACTION_ADD_FAIL, + id: statusId, + name, + error, +}); + +export const removeReaction = (statusId, name) => (dispatch, getState) => { + dispatch(removeReactionRequest(statusId, name)); + + api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(name)}`).then(() => { + dispatch(removeReactionSuccess(statusId, name)); + }).catch(err => { + dispatch(removeReactionFail(statusId, name, err)); + }); +}; + +export const removeReactionRequest = (statusId, name) => ({ + type: REACTION_REMOVE_REQUEST, + id: statusId, + name, +}); + +export const removeReactionSuccess = (statusId, name) => ({ + type: REACTION_REMOVE_SUCCESS, + id: statusId, + name, +}); + +export const removeReactionFail = (statusId, name) => ({ + type: REACTION_REMOVE_FAIL, + id: statusId, + name, +}); diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 72d55d7bd0dfff..e63f10359b58c6 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -144,6 +144,7 @@ const excludeTypesFromFilter = filter => { 'follow', 'follow_request', 'favourite', + 'reaction', 'reblog', 'mention', 'poll', diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 9339f3e4dfb750..a47b3b4d956de7 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -20,7 +20,7 @@ import Card from '../features/status/components/card'; // to use the progress bar to show download progress import Bundle from '../features/ui/components/bundle'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; -import { displayMedia } from '../initial_state'; +import { displayMedia, visibleReactions } from '../initial_state'; import AttachmentList from './attachment_list'; import { getHashtagBarForStatus } from './hashtag_bar'; @@ -29,6 +29,7 @@ import StatusContent from './status_content'; import StatusHeader from './status_header'; import StatusIcons from './status_icons'; import StatusPrepend from './status_prepend'; +import StatusReactions from './status_reactions'; const domParser = new DOMParser(); @@ -71,6 +72,10 @@ export const defaultMediaVisibility = (status, settings) => { class Status extends ImmutablePureComponent { + static contextTypes = { + identity: PropTypes.object, + }; + static propTypes = { containerId: PropTypes.string, id: PropTypes.string, @@ -86,6 +91,8 @@ class Status extends ImmutablePureComponent { onDelete: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, + onReactionAdd: PropTypes.func, + onReactionRemove: PropTypes.func, onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, @@ -751,6 +758,7 @@ class Status extends ImmutablePureComponent { if (this.props.prepend && account) { const notifKind = { favourite: 'favourited', + reaction: 'reacted', reblog: 'boosted', reblogged_by: 'boosted', status: 'posted', @@ -837,6 +845,15 @@ class Status extends ImmutablePureComponent { {...statusContentProps} /> + + {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( { + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl); + }; + handleReblogClick = e => { const { signedIn } = this.context.identity; @@ -195,6 +202,8 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onAddFilter(this.props.status); }; + handleNoOp = () => {}; // hack for reaction add button + render () { const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props; const { permissions, signedIn } = this.context.identity; @@ -300,6 +309,17 @@ class StatusActionBar extends ImmutablePureComponent { ); + const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const reactButton = ( + + ); + return (
+ { + permissions + ? + : reactButton + } {filterButton} diff --git a/app/javascript/flavours/glitch/components/status_prepend.jsx b/app/javascript/flavours/glitch/components/status_prepend.jsx index 31e84c6e11f050..c6c3741404e6d8 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.jsx +++ b/app/javascript/flavours/glitch/components/status_prepend.jsx @@ -59,6 +59,14 @@ export default class StatusPrepend extends PureComponent { values={{ name : link }} /> ); + case 'reaction': + return ( + + ); case 'reblog': return ( x.get('count') > 0) + .sort((a, b) => b.get('count') - a.get('count')); + + if (numVisible >= 0) { + visibleReactions = visibleReactions.filter((_, i) => i < numVisible); + } + + const styles = visibleReactions.map(reaction => ({ + key: reaction.get('name'), + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} +
+ )} +
+ ); + } + +} + +class Reaction extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string, + reaction: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + canReact: PropTypes.bool.isRequired, + style: PropTypes.object, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { reaction, statusId, addReaction, removeReaction } = this.props; + + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); + } + } + + handleMouseEnter = () => this.setState({ hovered: true }) + + handleMouseLeave = () => this.setState({ hovered: false }) + + render() { + const { reaction } = this.props; + + return ( + + ); + } + +} + +class Emoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.string.isRequired, + hovered: PropTypes.bool.isRequired, + url: PropTypes.string, + staticUrl: PropTypes.string, + }; + + render() { + const { emoji, hovered, url, staticUrl } = this.props; + + if (unicodeMapping[emoji]) { + const { filename, shortCode } = unicodeMapping[this.props.emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + return ( + {emoji} + ); + } else { + const filename = (autoPlayGif || hovered) ? url : staticUrl; + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } + } + +} diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index f6bdf9400d415e..50bb4dfc2e36dd 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -21,6 +21,8 @@ import { unbookmark, pin, unpin, + addReaction, + removeReaction, } from 'flavours/glitch/actions/interactions'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; import { openModal } from 'flavours/glitch/actions/modal'; @@ -173,6 +175,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onReactionAdd (statusId, name, url) { + dispatch(addReaction(statusId, name, url)); + }, + + onReactionRemove (statusId, name) { + dispatch(removeReaction(statusId, name)); + }, + onEmbed (status) { dispatch(openModal({ modalType: 'EMBED', diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx index 4195316794da00..5330eeeca5a08a 100644 --- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx @@ -324,6 +324,7 @@ class EmojiPickerDropdown extends PureComponent { onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, button: PropTypes.node, + disabled: PropTypes.bool, }; state = { @@ -357,7 +358,7 @@ class EmojiPickerDropdown extends PureComponent { }; onToggle = (e) => { - if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (!this.state.disabled && !this.state.loading && (!e.key || e.key === 'Enter')) { if (this.state.active) { this.onHideDropdown(); } else { @@ -395,7 +396,7 @@ class EmojiPickerDropdown extends PureComponent { />}
- + {({ props, placement })=> (
diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx index 04247226ac41c9..de1e1b9519fe8a 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx @@ -8,6 +8,7 @@ import { Icon } from 'flavours/glitch/components/icon'; const tooltips = defineMessages({ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' }, + reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, @@ -75,6 +76,13 @@ class FilterBar extends PureComponent { > +