From 45e1d4ed7479ccc1b5779d54be113ca15e9e2dc2 Mon Sep 17 00:00:00 2001 From: Essem Date: Tue, 7 Nov 2023 18:43:47 -0600 Subject: [PATCH] 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 | 15 +- .../glitch/components/status_action_bar.jsx | 29 ++- .../glitch/components/status_prepend.jsx | 13 ++ .../glitch/components/status_reactions.jsx | 175 ++++++++++++++++++ .../glitch/containers/status_container.js | 10 + .../components/emoji_picker_dropdown.jsx | 3 +- .../notifications/components/filter_bar.jsx | 9 + .../notifications/components/notification.jsx | 27 +++ .../features/status/components/action_bar.jsx | 31 +++- .../status/components/detailed_status.jsx | 16 ++ .../flavours/glitch/features/status/index.jsx | 18 ++ .../flavours/glitch/initial_state.js | 5 + .../flavours/glitch/locales/en.json | 8 +- .../flavours/glitch/reducers/settings.js | 3 + .../flavours/glitch/reducers/statuses.js | 50 +++++ .../flavours/glitch/styles/components.scss | 15 +- app/javascript/mastodon/locales/en.json | 4 + app/javascript/mastodon/reducers/settings.js | 2 + .../400-24px/add_reaction-fill.svg | 1 + .../material-icons/400-24px/add_reaction.svg | 1 + 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/user/has_settings.rb | 14 ++ app/models/notification.rb | 12 +- 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 | 15 +- 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 + 59 files changed, 1055 insertions(+), 12 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/javascript/material-icons/400-24px/add_reaction-fill.svg create mode 100644 app/javascript/material-icons/400-24px/add_reaction.svg 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 1b3c511042..fa75a70c57 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -274,6 +274,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 0000000000..2d7e4f5984 --- /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 43673642f5..db9b5a2dbd 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -47,6 +47,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 * from "./interactions_typed"; export function favourite(status) { @@ -494,3 +504,75 @@ export function toggleFavourite(statusId, skipModal = false) { } }; } + +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 7a97e71bdd..d39d648776 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -169,6 +169,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 a037895b4e..a6167699ea 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -21,7 +21,7 @@ import Card from '../features/status/components/card'; import Bundle from '../features/ui/components/bundle'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context'; -import { displayMedia } from '../initial_state'; +import { displayMedia, visibleReactions } from '../initial_state'; import AttachmentList from './attachment_list'; import { CollapseButton } from './collapse_button'; @@ -31,6 +31,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(); @@ -91,6 +92,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, @@ -755,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', @@ -839,6 +843,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.props.identity; @@ -205,6 +213,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.props.identity; @@ -318,6 +328,18 @@ 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 0777ffcda6..6373edb19d 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.jsx +++ b/app/javascript/flavours/glitch/components/status_prepend.jsx @@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import AddReactionIcon from '@/material-icons/400-24px/add_reaction.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; @@ -70,6 +71,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 955adc5fa7..42c5c028f8 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -18,6 +18,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'; @@ -134,6 +136,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 c556f15366..a26d3e3888 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 @@ -327,6 +327,7 @@ class EmojiPickerDropdown extends PureComponent { onPickEmoji: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, + disabled: PropTypes.bool, }; state = { @@ -361,7 +362,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 { 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 7fabe78a94..9459b50ebc 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.jsx @@ -3,6 +3,7 @@ import { PureComponent } from 'react'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import AddReactionIcon from '@/material-icons/400-24px/add_reaction.svg?react'; import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; @@ -14,6 +15,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' }, @@ -81,6 +83,13 @@ class FilterBar extends PureComponent { > +