From 8a31e765cb002c55d7eac3701f3cac107b9f86ae Mon Sep 17 00:00:00 2001 From: noellabo Date: Mon, 6 Mar 2023 02:09:25 +0900 Subject: [PATCH] Add multiple emoji reaction support --- .../v1/statuses/emoji_reactions_controller.rb | 3 +- .../mastodon/actions/interactions.js | 12 +- app/javascript/mastodon/components/emoji.js | 18 +- .../mastodon/components/emoji_reactions.js | 137 +++++++++++++++ .../components/emoji_reactions_bar.js | 157 +++--------------- .../components/reaction_picker_dropdown.js | 5 +- app/javascript/mastodon/components/status.js | 6 +- .../mastodon/components/status_action_bar.js | 11 +- .../mastodon/components/status_item.js | 1 - .../reaction_picker_dropdown_container.js | 6 +- .../mastodon/containers/status_container.js | 24 ++- .../containers/status_item_container.js | 19 +-- .../features/emoji_reactions/index.js | 15 +- .../notifications/components/notification.js | 5 +- .../containers/notification_container.js | 4 - .../components/scheduled_status.js | 1 - .../containers/scheduled_status_container.js | 14 +- .../features/status/components/action_bar.js | 12 +- .../status/components/detailed_status.js | 6 +- .../containers/detailed_status_container.js | 9 +- .../mastodon/features/status/index.js | 18 +- app/javascript/mastodon/initial_state.js | 1 + app/javascript/mastodon/reducers/statuses.js | 2 - app/lib/activitypub/activity/like.rb | 15 +- app/models/form/admin_settings.rb | 3 + app/serializers/initial_state_serializer.rb | 7 +- app/serializers/rest/instance_serializer.rb | 1 + app/services/emoji_reaction_service.rb | 20 +-- app/services/un_emoji_reaction_service.rb | 23 ++- app/validators/emoji_reaction_validator.rb | 15 +- app/views/admin/settings/edit.html.haml | 3 + app/workers/un_emoji_reaction_worker.rb | 11 -- config/locales/en.yml | 3 + config/locales/ja.yml | 3 + config/routes.rb | 2 +- config/settings.yml | 1 + 36 files changed, 289 insertions(+), 304 deletions(-) create mode 100644 app/javascript/mastodon/components/emoji_reactions.js delete mode 100644 app/workers/un_emoji_reaction_worker.rb diff --git a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb index 8f840b6c0..375116f48 100644 --- a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb +++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb @@ -18,8 +18,7 @@ class Api::V1::Statuses::EmojiReactionsController < Api::BaseController end def destroy - #UnEmojiReactionWorker.perform_async(current_account.id, @status.id) - if UnEmojiReactionService.new.call(current_account, @status).present? + if UnEmojiReactionService.new.call(current_account, @status, params[:id], shortcode_only: true).present? @status = Status.include_expired.find(params[:status_id]) end diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index aeee2b21d..f54cbfa8b 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -877,20 +877,20 @@ export function emojiReactionFail(status, name, domain, url, static_url, error) }; }; -const findMyEmojiReaction = (status) => { - return status.get('emoji_reactioned') && status.get('emoji_reactions').find(emoji_reaction => emoji_reaction.get('account_ids').includes(me)); +const findMyEmojiReaction = (status, name) => { + return status.get('emoji_reactions').find(emoji_reaction => emoji_reaction.get('account_ids').includes(me) && emoji_reaction.get('name') === name); }; -export function removeEmojiReaction(status) { +export function removeEmojiReaction(status, name) { return function (dispatch, getState) { - const emoji_reaction = findMyEmojiReaction(status); + const emoji_reaction = findMyEmojiReaction(status, name); if (emoji_reaction) { - const {name, domain, url, static_url} = emoji_reaction.toObject(); + const { name, domain, url, static_url } = emoji_reaction.toObject(); dispatch(unEmojiReactionRequest(status, name, domain, url, static_url)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreaction`).then(function (response) { + api(getState).delete(`/api/v1/statuses/${status.get('id')}/emoji_reactions/${name}${domain ? `@${domain}` : ''}`).then(function (response) { dispatch(importFetchedStatus(response.data)); dispatch(unEmojiReactionSuccess(status, name, domain, url, static_url)); }).catch(function (error) { diff --git a/app/javascript/mastodon/components/emoji.js b/app/javascript/mastodon/components/emoji.js index 0618060d7..2a344302a 100644 --- a/app/javascript/mastodon/components/emoji.js +++ b/app/javascript/mastodon/components/emoji.js @@ -1,6 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import { autoPlayGif } from 'mastodon/initial_state'; import { assetHost } from 'mastodon/utils/config'; import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; @@ -10,7 +9,6 @@ export default class Emoji extends React.PureComponent { static propTypes = { emoji: PropTypes.string.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, className: PropTypes.string, hovered: PropTypes.bool.isRequired, url: PropTypes.string, @@ -18,7 +16,7 @@ export default class Emoji extends React.PureComponent { }; render () { - const { emoji, emojiMap, hovered, url, static_url } = this.props; + const { emoji, hovered, url, static_url } = this.props; if (unicodeMapping[emoji]) { const { filename, shortCode } = unicodeMapping[emoji]; @@ -34,20 +32,6 @@ export default class Emoji extends React.PureComponent { src={`${assetHost}/emoji/${filename}.svg`} /> ); - } else if (emojiMap.get(emoji)) { - const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); - const shortCode = `:${emoji}:`; - const className = classNames('emojione custom-emoji', this.props.className); - - return ( - {shortCode} - ); } else if (url || static_url) { const filename = (autoPlayGif || hovered) && url ? url : static_url; const shortCode = `:${emoji}:`; diff --git a/app/javascript/mastodon/components/emoji_reactions.js b/app/javascript/mastodon/components/emoji_reactions.js new file mode 100644 index 000000000..d26eccb5c --- /dev/null +++ b/app/javascript/mastodon/components/emoji_reactions.js @@ -0,0 +1,137 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { List } from 'immutable'; +import classNames from 'classnames'; +import Emoji from './emoji'; +import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; +import AnimatedNumber from 'mastodon/components/animated_number'; +import { disableReactions } from 'mastodon/initial_state'; +import Overlay from 'react-overlays/lib/Overlay'; +import { isUserTouching } from 'mastodon/is_mobile'; +import AccountPopup from 'mastodon/components/account_popup'; + +const getFilteredEmojiReaction = (emojiReaction, relationships) => { + let filteredEmojiReaction = emojiReaction.update('account_ids', accountIds => accountIds.filterNot( accountId => { + const relationship = relationships.get(accountId); + return relationship?.get('blocking') || relationship?.get('blocked_by') || relationship?.get('domain_blocking') || relationship?.get('muting') + })); + + const count = filteredEmojiReaction.get('account_ids').size; + + if (count > 0) { + return filteredEmojiReaction.set('count', count); + } else { + return null; + } +}; + +const mapStateToProps = (state, { emojiReaction }) => { + const relationship = new Map(); + emojiReaction.get('account_ids').forEach(accountId => relationship.set(accountId, state.getIn(['relationships', accountId]))); + + return { + emojiReaction: emojiReaction, + relationships: relationship, + }; +}; + +const mergeProps = ({ emojiReaction, relationships }, dispatchProps, ownProps) => ({ + ...ownProps, + ...dispatchProps, + emojiReaction: getFilteredEmojiReaction(emojiReaction, relationships), +}); + +@connect(mapStateToProps, null, mergeProps) +export default class EmojiReaction extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + emojiReaction: ImmutablePropTypes.map, + myReaction: PropTypes.bool.isRequired, + addEmojiReaction: PropTypes.func.isRequired, + removeEmojiReaction: PropTypes.func.isRequired, + style: PropTypes.object, + reactionLimitReached: PropTypes.bool, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { emojiReaction, status, addEmojiReaction, removeEmojiReaction, myReaction } = this.props; + + if (myReaction) { + removeEmojiReaction(status, emojiReaction.get('name')); + } else { + addEmojiReaction(status, emojiReaction.get('name'), emojiReaction.get('domain', null), emojiReaction.get('url', null), emojiReaction.get('static_url', null)); + } + }; + + handleMouseEnter = ({ target }) => { + const { top } = target.getBoundingClientRect(); + + this.setState({ + hovered: true, + placement: top * 2 < innerHeight ? 'bottom' : 'top', + }); + }; + + handleMouseLeave = () => { + this.setState({ + hovered: false, + }); + }; + + setTargetRef = c => { + this.target = c; + }; + + findTarget = () => { + return this.target; + }; + + componentDidMount () { + this.target?.addEventListener('mouseenter', this.handleMouseEnter, { capture: true }); + this.target?.addEventListener('mouseleave', this.handleMouseLeave, false); + } + + componentWillUnmount () { + this.target?.removeEventListener('mouseenter', this.handleMouseEnter, { capture: true }); + this.target?.removeEventListener('mouseleave', this.handleMouseLeave, false); + } + + render () { + const { style, emojiReaction, myReaction, reactionLimitReached } = this.props; + + if (!emojiReaction) { + return ; + } + + let shortCode = emojiReaction.get('name'); + + if (unicodeMapping[shortCode]) { + shortCode = unicodeMapping[shortCode].shortCode; + } + + return ( + +
+ +
+ {!isUserTouching() && + + + + } +
+ ); + }; + +} diff --git a/app/javascript/mastodon/components/emoji_reactions_bar.js b/app/javascript/mastodon/components/emoji_reactions_bar.js index 66f11fb9d..63d69013f 100644 --- a/app/javascript/mastodon/components/emoji_reactions_bar.js +++ b/app/javascript/mastodon/components/emoji_reactions_bar.js @@ -4,147 +4,30 @@ import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { List } from 'immutable'; -import classNames from 'classnames'; -import Emoji from './emoji'; -import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; import TransitionMotion from 'react-motion/lib/TransitionMotion'; -import AnimatedNumber from 'mastodon/components/animated_number'; -import { reduceMotion, me, disableReactions } from 'mastodon/initial_state'; +import { reduceMotion, me } from 'mastodon/initial_state'; import spring from 'react-motion/lib/spring'; -import Overlay from 'react-overlays/lib/Overlay'; -import { isUserTouching } from 'mastodon/is_mobile'; -import AccountPopup from 'mastodon/components/account_popup'; +import EmojiReaction from './emoji_reactions'; -const getFilteredEmojiReaction = (emojiReaction, relationships) => { - let filteredEmojiReaction = emojiReaction.update('account_ids', accountIds => accountIds.filterNot( accountId => { - const relationship = relationships.get(accountId); - return relationship?.get('blocking') || relationship?.get('blocked_by') || relationship?.get('domain_blocking') || relationship?.get('muting') - })); +const mapStateToProps = (state, { status }) => ({ + emojiReactions: status.get('emoji_reactions'), +}); - const count = filteredEmojiReaction.get('account_ids').size; - - if (count > 0) { - return filteredEmojiReaction.set('count', count); - } else { - return null; - } -}; - -const mapStateToProps = (state, { emojiReaction }) => { - const relationship = new Map(); - emojiReaction.get('account_ids').forEach(accountId => relationship.set(accountId, state.getIn(['relationships', accountId]))); - - return { - emojiReaction: emojiReaction, - relationships: relationship, - }; -}; - -const mergeProps = ({ emojiReaction, relationships }, dispatchProps, ownProps) => ({ +const mergeProps = ({ emojiReactions }, dispatchProps, ownProps) => ({ ...ownProps, ...dispatchProps, - emojiReaction: getFilteredEmojiReaction(emojiReaction, relationships), + visibleReactions: emojiReactions.filter(x => x.get('count') > 0), }); @connect(mapStateToProps, null, mergeProps) -class EmojiReaction extends ImmutablePureComponent { - - static propTypes = { - status: ImmutablePropTypes.map.isRequired, - emojiReaction: ImmutablePropTypes.map, - myReaction: PropTypes.bool.isRequired, - addEmojiReaction: PropTypes.func.isRequired, - removeEmojiReaction: PropTypes.func.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, - style: PropTypes.object, - }; - - state = { - hovered: false, - }; - - handleClick = () => { - const { emojiReaction, status, addEmojiReaction, removeEmojiReaction, myReaction } = this.props; - - if (myReaction) { - removeEmojiReaction(status); - } else { - addEmojiReaction(status, emojiReaction.get('name'), emojiReaction.get('domain', null), emojiReaction.get('url', null), emojiReaction.get('static_url', null)); - } - }; - - handleMouseEnter = ({ target }) => { - const { top } = target.getBoundingClientRect(); - - this.setState({ - hovered: true, - placement: top * 2 < innerHeight ? 'bottom' : 'top', - }); - }; - - handleMouseLeave = () => { - this.setState({ - hovered: false, - }); - }; - - setTargetRef = c => { - this.target = c; - }; - - findTarget = () => { - return this.target; - }; - - componentDidMount () { - this.target?.addEventListener('mouseenter', this.handleMouseEnter, { capture: true }); - this.target?.addEventListener('mouseleave', this.handleMouseLeave, false); - } - - componentWillUnmount () { - this.target?.removeEventListener('mouseenter', this.handleMouseEnter, { capture: true }); - this.target?.removeEventListener('mouseleave', this.handleMouseLeave, false); - } - - render () { - const { emojiReaction, status, myReaction } = this.props; - - if (!emojiReaction) { - return ; - } - - let shortCode = emojiReaction.get('name'); - - if (unicodeMapping[shortCode]) { - shortCode = unicodeMapping[shortCode].shortCode; - } - - return ( - -
- -
- {!isUserTouching() && - - - - } -
- ); - }; - -} - export default class EmojiReactionsBar extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, addEmojiReaction: PropTypes.func.isRequired, removeEmojiReaction: PropTypes.func.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, + visibleReactions: ImmutablePropTypes.list.isRequired, + reactionLimitReached: PropTypes.bool, }; willEnter () { @@ -156,22 +39,20 @@ export default class EmojiReactionsBar extends ImmutablePureComponent { } render () { - const { status } = this.props; - const emoji_reactions = status.get('emoji_reactions'); - const visibleReactions = emoji_reactions.filter(x => x.get('count') > 0); + const { status, addEmojiReaction, removeEmojiReaction, visibleReactions, reactionLimitReached } = this.props; if (visibleReactions.isEmpty() ) { return ; } - const styles = visibleReactions.map(emoji_reaction => { - const domain = emoji_reaction.get('domain', ''); + const styles = visibleReactions.map(emojiReaction => { + const domain = emojiReaction.get('domain', ''); return { - key: `${emoji_reaction.get('name')}${domain ? `@${domain}` : ''}`, + key: `${emojiReaction.get('name')}${domain ? `@${domain}` : ''}`, data: { - emojiReaction: emoji_reaction, - myReaction: emoji_reaction.get('account_ids', List()).includes(me), + emojiReaction: emojiReaction, + myReaction: emojiReaction.get('account_ids', List()).includes(me), }, style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, }; @@ -187,10 +68,10 @@ export default class EmojiReactionsBar extends ImmutablePureComponent { emojiReaction={data.emojiReaction} myReaction={data.myReaction} style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }} - status={this.props.status} - addEmojiReaction={this.props.addEmojiReaction} - removeEmojiReaction={this.props.removeEmojiReaction} - emojiMap={this.props.emojiMap} + status={status} + addEmojiReaction={addEmojiReaction} + removeEmojiReaction={removeEmojiReaction} + reactionLimitReached={reactionLimitReached} /> ))} diff --git a/app/javascript/mastodon/components/reaction_picker_dropdown.js b/app/javascript/mastodon/components/reaction_picker_dropdown.js index 90bb7841a..449aaef7c 100644 --- a/app/javascript/mastodon/components/reaction_picker_dropdown.js +++ b/app/javascript/mastodon/components/reaction_picker_dropdown.js @@ -286,7 +286,6 @@ export default class ReactionPickerDropdown extends React.PureComponent { openedViaKeyboard: PropTypes.bool, custom_emojis: ImmutablePropTypes.list, onPickEmoji: PropTypes.func.isRequired, - onRemoveEmoji: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, @@ -305,9 +304,7 @@ export default class ReactionPickerDropdown extends React.PureComponent { }; handleClick = ({ target, type }) => { - if (this.props.pressed) { - this.props.onRemoveEmoji(); - } else if (this.state.id === this.props.openDropdownId) { + if (this.state.id === this.props.openDropdownId) { this.handleClose(); } else { const { top } = target.getBoundingClientRect(); diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 9a0d93e13..912129e04 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -165,9 +165,9 @@ class Status extends ImmutablePureComponent { available: PropTypes.bool, }), contextType: PropTypes.string, - emojiMap: ImmutablePropTypes.map, addEmojiReaction: PropTypes.func.isRequired, removeEmojiReaction: PropTypes.func.isRequired, + reactionLimitReached: PropTypes.bool, }; static defaultProps = { @@ -404,7 +404,7 @@ class Status extends ImmutablePureComponent { let media = null; let statusAvatar, prepend, rebloggedByText; - const { intl, hidden, featured, otherAccounts, unread, showThread, showCard, scrollKey, pictureInPicture, contextType, quote_muted, referenced, contextReferenced } = this.props; + const { intl, hidden, featured, otherAccounts, unread, showThread, showCard, scrollKey, pictureInPicture, contextType, quote_muted, referenced, contextReferenced, reactionLimitReached } = this.props; let { status, account, ...other } = this.props; @@ -796,7 +796,7 @@ class Status extends ImmutablePureComponent { status={status} addEmojiReaction={this.props.addEmojiReaction} removeEmojiReaction={this.props.removeEmojiReaction} - emojiMap={this.props.emojiMap} + reactionLimitReached={reactionLimitReached} />} diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 7dbae7aed..4b39e5746 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -111,6 +111,7 @@ class StatusActionBar extends ImmutablePureComponent { intl: PropTypes.object.isRequired, addEmojiReaction: PropTypes.func, removeEmojiReaction: PropTypes.func, + reactionLimitReached: PropTypes.bool, }; static defaultProps = { @@ -327,7 +328,7 @@ class StatusActionBar extends ImmutablePureComponent { } render () { - const { status, relationship, intl, withDismiss, scrollKey, expired, referenced, contextReferenced, referenceCountLimit, contextType } = this.props; + const { status, relationship, intl, withDismiss, scrollKey, expired, referenced, contextReferenced, referenceCountLimit, contextType, reactionLimitReached } = this.props; const anonymousAccess = !me; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -339,7 +340,6 @@ class StatusActionBar extends ImmutablePureComponent { const reblogged = status.get('reblogged'); const favourited = status.get('favourited'); const bookmarked = status.get('bookmarked'); - const emoji_reactioned = status.get('emoji_reactioned'); const reblogsCount = status.get('reblogs_count'); const referredByCount = status.get('status_referred_by_count'); const favouritesCount = status.get('favourites_count'); @@ -480,7 +480,7 @@ class StatusActionBar extends ImmutablePureComponent { return (
- {enableStatusReference && me && } + {enableStatusReference && me && } {show_quote_button && } @@ -490,9 +490,7 @@ class StatusActionBar extends ImmutablePureComponent { {enableReaction &&
} diff --git a/app/javascript/mastodon/components/status_item.js b/app/javascript/mastodon/components/status_item.js index ecd98e59b..cfcadb722 100644 --- a/app/javascript/mastodon/components/status_item.js +++ b/app/javascript/mastodon/components/status_item.js @@ -27,7 +27,6 @@ class StatusItem extends ImmutablePureComponent { status: ImmutablePropTypes.map, onClick: PropTypes.func, onUnselectReference: PropTypes.func, - emojiMap: ImmutablePropTypes.map, }; updateOnProps = [ diff --git a/app/javascript/mastodon/containers/reaction_picker_dropdown_container.js b/app/javascript/mastodon/containers/reaction_picker_dropdown_container.js index dc92946a9..36a5f85ea 100644 --- a/app/javascript/mastodon/containers/reaction_picker_dropdown_container.js +++ b/app/javascript/mastodon/containers/reaction_picker_dropdown_container.js @@ -65,10 +65,6 @@ const getCustomEmojis = createSelector([ } })); -const getState = (dispatch) => new Promise((resolve) => { - dispatch((dispatch, getState) => {resolve(getState())}) -}) - const mapStateToProps = state => ({ custom_emojis: getCustomEmojis(state), skinTone: state.getIn(['settings', 'skinTone']), @@ -90,7 +86,7 @@ const mapDispatchToProps = (dispatch, { status, onPickEmoji, scrollKey }) => ({ onPickEmoji(emoji); } }, - + onOpen(id, dropdownPlacement, keyboard) { dispatch((_, getState) => { let state = getState(); diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 4742553b7..63f756798 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -52,9 +52,8 @@ import { deployPictureInPicture } from '../actions/picture_in_picture'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { boostModal, deleteModal, unfollowModal, unsubscribeModal, confirmDomainBlock } from '../initial_state'; import { showAlertForError } from '../actions/alerts'; - -import { createSelector } from 'reselect'; -import { Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList } from 'immutable'; +import { me, maxReactionsPerAccount } from 'mastodon/initial_state'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -74,7 +73,6 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const getPictureInPicture = makeGetPictureInPicture(); - const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status; const mapStateToProps = (state, props) => { @@ -84,16 +82,26 @@ const makeMapStateToProps = () => { return { status, pictureInPicture: getPictureInPicture(state, props), - emojiMap: customEmojiMap(state), id, referenced: state.getIn(['compose', 'references']).has(id), contextReferenced: state.getIn(['compose', 'context_references']).has(id), + emojiReactions: !!status ? status.get('emoji_reactions', ImmutableList()) : ImmutableList(), }; }; return mapStateToProps; }; +const mergeProps = ({ status, pictureInPicture, referenced, contextReferenced, emojiReactions }, dispatchProps, ownProps) => ({ + ...ownProps, + ...dispatchProps, + status, + pictureInPicture, + referenced, + contextReferenced, + reactionLimitReached: emojiReactions.count((emojiReaction) => emojiReaction.get('account_ids', ImmutableList()).includes(me)) >= maxReactionsPerAccount, +}); + const mapDispatchToProps = (dispatch, { intl }) => ({ onReply (status, router) { @@ -308,8 +316,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(addEmojiReaction(status, name, domain, url, static_url)); }, - removeEmojiReaction (status) { - dispatch(removeEmojiReaction(status)); + removeEmojiReaction (status, name) { + dispatch(removeEmojiReaction(status, name)); }, onAddReference (id, change) { @@ -322,4 +330,4 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }); -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps, mergeProps)(Status)); diff --git a/app/javascript/mastodon/containers/status_item_container.js b/app/javascript/mastodon/containers/status_item_container.js index a9f94dd8a..4fe7291ed 100644 --- a/app/javascript/mastodon/containers/status_item_container.js +++ b/app/javascript/mastodon/containers/status_item_container.js @@ -1,17 +1,12 @@ import { connect } from 'react-redux'; import StatusItem from '../components/status_item'; import { makeGetStatus } from '../selectors'; -import { - removeReference, -} from '../actions/compose'; +import { removeReference } from '../actions/compose'; import { openModal } from '../actions/modal'; import { unselectReferenceModal } from '../initial_state'; import { injectIntl, defineMessages } from 'react-intl'; -import { createSelector } from 'reselect'; -import { Map as ImmutableMap } from 'immutable'; - const messages = defineMessages({ unselectMessage: { id: 'confirmations.unselect.message', defaultMessage: 'Are you sure you want to unselect a reference?' }, unselectConfirm: { id: 'confirmations.unselect.confirm', defaultMessage: 'Unselect' }, @@ -19,16 +14,10 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); - const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); - const mapStateToProps = (state, props) => { - const status = getStatus(state, props); - - return { - status, - emojiMap: customEmojiMap(state), - } - }; + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props), + }); return mapStateToProps; }; diff --git a/app/javascript/mastodon/features/emoji_reactions/index.js b/app/javascript/mastodon/features/emoji_reactions/index.js index ac69bc5de..43eb8a23b 100644 --- a/app/javascript/mastodon/features/emoji_reactions/index.js +++ b/app/javascript/mastodon/features/emoji_reactions/index.js @@ -12,8 +12,6 @@ import ScrollableList from '../../components/scrollable_list'; import Icon from 'mastodon/components/icon'; import ColumnHeader from '../../components/column_header'; import Emoji from '../../components/emoji'; -import { createSelector } from 'reselect'; -import { Map as ImmutableMap } from 'immutable'; import ReactedHeaderContaier from '../reactioned/containers/header_container'; import { debounce } from 'lodash'; import { defaultColumnWidth } from 'mastodon/initial_state'; @@ -24,8 +22,6 @@ const messages = defineMessages({ refresh: { id: 'refresh', defaultMessage: 'Refresh' }, }); -const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); - const mapStateToProps = (state, { columnId, params }) => { const uuid = columnId; const columns = state.getIn(['settings', 'columns']); @@ -36,7 +32,6 @@ const mapStateToProps = (state, { columnId, params }) => { emojiReactions: state.getIn(['user_lists', 'emoji_reactioned_by', params.statusId, 'items']), isLoading: state.getIn(['user_lists', 'emoji_reactioned_by', params.statusId, 'isLoading'], true), hasMore: !!state.getIn(['user_lists', 'emoji_reactioned_by', params.statusId, 'next']), - emojiMap: customEmojiMap(state), columnWidth: columnWidth ?? defaultColumnWidth, }; }; @@ -45,7 +40,6 @@ class Reaction extends ImmutablePureComponent { static propTypes = { emojiReaction: ImmutablePropTypes.map.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, }; state = { @@ -57,11 +51,11 @@ class Reaction extends ImmutablePureComponent { handleMouseLeave = () => this.setState({ hovered: false }) render () { - const { emojiReaction, emojiMap } = this.props; + const { emojiReaction } = this.props; return (
- +
); }; @@ -77,7 +71,6 @@ class EmojiReactions extends ImmutablePureComponent { emojiReactions: ImmutablePropTypes.list, multiColumn: PropTypes.bool, columnWidth: PropTypes.string, - emojiMap: ImmutablePropTypes.map.isRequired, intl: PropTypes.object.isRequired, hasMore: PropTypes.bool, isLoading: PropTypes.bool, @@ -114,7 +107,7 @@ class EmojiReactions extends ImmutablePureComponent { } render () { - const { intl, emojiReactions, multiColumn, emojiMap, hasMore, isLoading, columnWidth } = this.props; + const { intl, emojiReactions, multiColumn, hasMore, isLoading, columnWidth } = this.props; if (!emojiReactions) { return ( @@ -149,7 +142,7 @@ class EmojiReactions extends ImmutablePureComponent { bindToDocument={!multiColumn} > {emojiReactions.map(emojiReaction => - } />, + } />, )} diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 90badba32..d9730caa8 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -56,7 +56,6 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth: PropTypes.func, cachedMediaWidth: PropTypes.number, unread: PropTypes.bool, - emojiMap: ImmutablePropTypes.map, }; handleMoveUp = () => { @@ -349,7 +348,7 @@ class Notification extends ImmutablePureComponent { } renderReaction (notification, link) { - const { intl, unread, emojiMap } = this.props; + const { intl, unread } = this.props; if (!notification.get('emoji_reaction')) { return ; @@ -362,7 +361,7 @@ class Notification extends ImmutablePureComponent {
- +
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 4546ea0a3..460074185 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -14,20 +14,16 @@ import { revealStatus, } from '../../../actions/statuses'; import { boostModal } from '../../../initial_state'; -import { createSelector } from 'reselect'; -import { Map as ImmutableMap } from 'immutable'; const makeMapStateToProps = () => { const getNotification = makeGetNotification(); const getStatus = makeGetStatus(); - const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); const mapStateToProps = (state, props) => { const notification = getNotification(state, props.notification, props.accountId); return { notification: notification, status: notification.get('status') ? getStatus(state, { id: notification.get('status') }) : null, - emojiMap: customEmojiMap(state), }; }; diff --git a/app/javascript/mastodon/features/scheduled_statuses/components/scheduled_status.js b/app/javascript/mastodon/features/scheduled_statuses/components/scheduled_status.js index 02dc324d8..8ca9e2562 100644 --- a/app/javascript/mastodon/features/scheduled_statuses/components/scheduled_status.js +++ b/app/javascript/mastodon/features/scheduled_statuses/components/scheduled_status.js @@ -40,7 +40,6 @@ class ScheduledStatus extends ImmutablePureComponent { onMoveDown: PropTypes.func, onDeleteScheduledStatus: PropTypes.func, onRedraftScheduledStatus: PropTypes.func, - emojiMap: ImmutablePropTypes.map, }; handleHotkeyMoveUp = () => { diff --git a/app/javascript/mastodon/features/scheduled_statuses/containers/scheduled_status_container.js b/app/javascript/mastodon/features/scheduled_statuses/containers/scheduled_status_container.js index e8dca2eb3..9a8e3c9ac 100644 --- a/app/javascript/mastodon/features/scheduled_statuses/containers/scheduled_status_container.js +++ b/app/javascript/mastodon/features/scheduled_statuses/containers/scheduled_status_container.js @@ -4,8 +4,6 @@ import { deleteScheduledStatus, redraftScheduledStatus } from 'mastodon/actions/ import { openModal } from 'mastodon/actions/modal'; import { defineMessages, injectIntl } from 'react-intl'; import { deleteScheduledStatusModal } from 'mastodon/initial_state'; -import { createSelector } from 'reselect'; -import { Map as ImmutableMap } from 'immutable'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -14,16 +12,6 @@ const messages = defineMessages({ redraftMessage: { id: 'confirmations.redraft_scheduled_status.message', defaultMessage: 'Redraft now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); -const makeMapStateToProps = () => { - const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); - - const mapStateToProps = (state) => ({ - emojiMap: customEmojiMap(state), - }); - - return mapStateToProps; -}; - const mapDispatchToProps = (dispatch, { intl }) => ({ onDeleteScheduledStatus (id, e) { @@ -56,4 +44,4 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }); -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(ScheduledStatus)); +export default injectIntl(connect(null, mapDispatchToProps)(ScheduledStatus)); diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 053da6b42..472e57141 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -72,6 +72,7 @@ class ActionBar extends React.PureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + dispatch: PropTypes.func.isRequired, referenced: PropTypes.bool, contextReferenced: PropTypes.bool, relationship: ImmutablePropTypes.map, @@ -102,6 +103,7 @@ class ActionBar extends React.PureComponent { intl: PropTypes.object.isRequired, addEmojiReaction: PropTypes.func.isRequired, removeEmojiReaction: PropTypes.func.isRequired, + reactionLimitReached: PropTypes.bool, }; handleReplyClick = () => { @@ -281,7 +283,7 @@ class ActionBar extends React.PureComponent { } render () { - const { status, relationship, intl, referenced, contextReferenced, referenceCountLimit } = this.props; + const { status, relationship, intl, referenced, contextReferenced, referenceCountLimit, reactionLimitReached } = this.props; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility')); @@ -292,7 +294,6 @@ class ActionBar extends React.PureComponent { const reblogged = status.get('reblogged'); const favourited = status.get('favourited'); const bookmarked = status.get('bookmarked'); - const emoji_reactioned = status.get('emoji_reactioned'); const reblogsCount = status.get('reblogs_count'); const referredByCount = status.get('status_referred_by_count'); const favouritesCount = status.get('favourites_count'); @@ -424,7 +425,7 @@ class ActionBar extends React.PureComponent { return (
- {enableStatusReference && me &&
} + {enableStatusReference && me &&
}
{show_quote_button &&
} @@ -433,9 +434,7 @@ class ActionBar extends React.PureComponent { {enableReaction &&
} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index dcacf0747..9cdfcdd81 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -93,10 +93,10 @@ class DetailedStatus extends ImmutablePureComponent { onToggleMediaVisibility: PropTypes.func, showQuoteMedia: PropTypes.bool, onToggleQuoteMediaVisibility: PropTypes.func, - emojiMap: ImmutablePropTypes.map, addEmojiReaction: PropTypes.func.isRequired, removeEmojiReaction: PropTypes.func.isRequired, onReference: PropTypes.func, + reactionLimitReached: PropTypes.bool, }; state = { @@ -181,7 +181,7 @@ class DetailedStatus extends ImmutablePureComponent { const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; const quote_muted = this.props.quote_muted; const outerStyle = { boxSizing: 'border-box' }; - const { intl, compact, pictureInPicture, referenced, contextReferenced } = this.props; + const { intl, compact, pictureInPicture, referenced, contextReferenced, reactionLimitReached } = this.props; if (!status) { return null; @@ -467,7 +467,7 @@ class DetailedStatus extends ImmutablePureComponent { status={status} addEmojiReaction={this.props.addEmojiReaction} removeEmojiReaction={this.props.removeEmojiReaction} - emojiMap={this.props.emojiMap} + reactionLimitReached={reactionLimitReached} />}
diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js index d239757b1..9c57d1d42 100644 --- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js +++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js @@ -32,9 +32,6 @@ import { defineMessages, injectIntl } from 'react-intl'; import { boostModal, deleteModal } from '../../../initial_state'; import { showAlertForError } from '../../../actions/alerts'; -import { createSelector } from 'reselect'; -import { Map as ImmutableMap } from 'immutable'; - const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, @@ -47,13 +44,11 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const getPictureInPicture = makeGetPictureInPicture(); - const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); const mapStateToProps = (state, props) => ({ status: getStatus(state, props), domain: state.getIn(['meta', 'domain']), pictureInPicture: getPictureInPicture(state, props), - emojiMap: customEmojiMap(state), }); return mapStateToProps; @@ -184,8 +179,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(addEmojiReaction(status, name, domain, url, static_url)); }, - removeEmojiReaction (status) { - dispatch(removeEmojiReaction(status)); + removeEmojiReaction (status, name) { + dispatch(removeEmojiReaction(status, name)); }, }); diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index e99bc0405..134884447 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList } from 'immutable'; import { createSelector } from 'reselect'; import { fetchStatus } from '../../actions/statuses'; import MissingIndicator from '../../components/missing_indicator'; @@ -63,7 +63,7 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; import Icon from 'mastodon/components/icon'; import DetailedHeaderContaier from './containers/header_container'; -import { defaultColumnWidth } from 'mastodon/initial_state'; +import { defaultColumnWidth, me, maxReactionsPerAccount } from 'mastodon/initial_state'; import { changeSetting } from '../../actions/settings'; import { changeColumnParams } from '../../actions/columns'; @@ -86,7 +86,6 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const getPictureInPicture = makeGetPictureInPicture(); - const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status; const getAncestorsIds = createSelector([ @@ -160,6 +159,7 @@ const makeMapStateToProps = () => { const descendantsIds = status ? getDescendantsIds(state, { id: status.get('id') }) : ImmutableList(); const referencesIds = status ? getReferencesIds(state, { id: status.get('id') }) : ImmutableList(); const id = status ? getProper(status).get('id') : null; + const emojiReactions = status ? status.get('emoji_reactions', ImmutableList()) : ImmutableList(); return { status, @@ -168,10 +168,10 @@ const makeMapStateToProps = () => { askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, domain: state.getIn(['meta', 'domain']), pictureInPicture: getPictureInPicture(state, { id: params.statusId }), - emojiMap: customEmojiMap(state), referenced: state.getIn(['compose', 'references']).has(id), contextReferenced: state.getIn(['compose', 'context_references']).has(id), columnWidth: columnWidth ?? defaultColumnWidth, + reactionLimitReached: emojiReactions.count((emojiReaction) => emojiReaction.get('account_ids', ImmutableList()).includes(me)) >= maxReactionsPerAccount, }; }; @@ -203,7 +203,7 @@ class Status extends ImmutablePureComponent { inUse: PropTypes.bool, available: PropTypes.bool, }), - emojiMap: ImmutablePropTypes.map, + reactionLimitReached: PropTypes.bool, }; state = { @@ -473,8 +473,8 @@ class Status extends ImmutablePureComponent { this.props.dispatch(addEmojiReaction(status, name, domain, url, static_url)); } - handleRemoveEmojiReaction = (status) => { - this.props.dispatch(removeEmojiReaction(status)); + handleRemoveEmojiReaction = (status, name) => { + this.props.dispatch(removeEmojiReaction(status, name)); } handleAddReference = (id, change) => { @@ -575,7 +575,7 @@ class Status extends ImmutablePureComponent { render () { let ancestors, descendants; - const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture, emojiMap, referenced, contextReferenced, columnWidth } = this.props; + const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture, referenced, contextReferenced, columnWidth, reactionLimitReached } = this.props; const { fullscreen } = this.state; if (status === null) { @@ -652,7 +652,6 @@ class Status extends ImmutablePureComponent { pictureInPicture={pictureInPicture} showQuoteMedia={this.state.showQuoteMedia} onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility} - emojiMap={emojiMap} addEmojiReaction={this.handleAddEmojiReaction} removeEmojiReaction={this.handleRemoveEmojiReaction} /> @@ -662,6 +661,7 @@ class Status extends ImmutablePureComponent { status={status} referenced={referenced} contextReferenced={contextReferenced} + reactionLimitReached={reactionLimitReached} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 41a3dfa5f..6ef3b6ad4 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -79,6 +79,7 @@ export const hideLinkPreview = getMeta('hide_link_preview'); export const hidePhotoPreview = getMeta('hide_photo_preview'); export const hideVideoPreview = getMeta('hide_video_preview'); export const allowPollImage = getMeta('allow_poll_image'); +export const maxReactionsPerAccount = initialState?.emoji_reactions?.max_reactions_per_account ?? 1; export const maxChars = initialState?.max_toot_chars ?? 500; diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 15949a090..712fe3636 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -87,14 +87,12 @@ export default function statuses(state = initialState, action) { case EMOJI_REACTION_REQUEST: case UN_EMOJI_REACTION_FAIL: if (state.get(action.status.get('id')) !== undefined) { - state = state.setIn([action.status.get('id'), 'emoji_reactioned'], true); state = addEmojiReaction(state, action.status.get('id'), action.name, action.domain, action.url, action.static_url); } return state; case UN_EMOJI_REACTION_REQUEST: case EMOJI_REACTION_FAIL: if (state.get(action.status.get('id')) !== undefined) { - state = state.setIn([action.status.get('id'), 'emoji_reactioned'], false); state = removeEmojiReaction(state, action.status.get('id'), action.name, action.domain, action.url, action.static_url); } return state; diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index 516714bc7..07c673e5f 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -40,15 +40,16 @@ class ActivityPub::Activity::Like < ActivityPub::Activity end end - return if @account.reacted?(@original_status, shortcode, emoji) + reaction = EmojiReaction.find_or_create_by(account: @account, status: @original_status, name: shortcode, custom_emoji: emoji, uri: @json['id']) - EmojiReaction.find_by(account: @account, status: @original_status)&.destroy! - reaction = @original_status.emoji_reactions.create!(account: @account, name: shortcode, custom_emoji: emoji, uri: @json['id']) + return unless reaction - if @original_status.account.local? - NotifyService.new.call(@original_status.account, :emoji_reaction, reaction) - forward_for_emoji_reaction - relay_for_emoji_reaction + reaction.tap do |reaction| + if @original_status.account.local? + NotifyService.new.call(@original_status.account, :emoji_reaction, reaction) + forward_for_emoji_reaction + relay_for_emoji_reaction + end end rescue Seahorse::Client::NetworkingError nil diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 3eb8b98ce..7c0e70c57 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -37,6 +37,7 @@ class Form::AdminSettings require_invite_text allow_poll_image poll_max_options + reaction_max_per_account ).freeze BOOLEAN_KEYS = %i( @@ -58,6 +59,7 @@ class Form::AdminSettings INTEGER_KEYS = %i( poll_max_options + reaction_max_per_account ).freeze UPLOAD_KEYS = %i( @@ -78,6 +80,7 @@ class Form::AdminSettings validates :show_domain_blocks, inclusion: { in: %w(disabled users all) } validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) } validates :poll_max_options, numericality: { greater_than: 2, less_than_or_equal_to: PollValidator::MAX_OPTIONS_LIMIT } + validates :reaction_max_per_account, numericality: { greater_than_or_equal: 1, less_than_or_equal_to: EmojiReactionValidator::MAX_PER_ACCOUNT_LIMIT } def initialize(_attributes = {}) super diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index b116f8085..2d5cd7ca4 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -2,7 +2,8 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :search, :accounts, :lists, - :media_attachments, :status_references, :settings, :max_toot_chars + :media_attachments, :status_references, :emoji_reactions, + :settings, :max_toot_chars has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer @@ -162,6 +163,10 @@ class InitialStateSerializer < ActiveModel::Serializer { max_references: StatusReferenceValidator::LIMIT } end + def emoji_reactions + { max_reactions_per_account: [EmojiReactionValidator::MAX_PER_ACCOUNT, Setting.reaction_max_per_account].max } + end + private def instance_presenter diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 3809d4ae6..04bbe310a 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -94,6 +94,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer emoji_reactions: { max_reactions: EmojiReactionValidator::LIMIT, + max_reactions_per_account: [EmojiReactionValidator::MAX_PER_ACCOUNT, Setting.reaction_max_per_account].max, }, status_references: { diff --git a/app/services/emoji_reaction_service.rb b/app/services/emoji_reaction_service.rb index e7ef81d15..4439f99a2 100644 --- a/app/services/emoji_reaction_service.rb +++ b/app/services/emoji_reaction_service.rb @@ -5,22 +5,18 @@ class EmojiReactionService < BaseService include Payloadable def call(account, status, emoji) - @account = account - - emoji_reaction = EmojiReaction.find_by(account_id: account.id, status_id: status.id) - - return emoji_reaction unless emoji_reaction.nil? - + @account = account shortcode, domain = emoji.split("@") - custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain) + return if account.nil? || status.nil? || shortcode.nil? - emoji_reaction = EmojiReaction.create!(account: account, status: status, name: shortcode, custom_emoji: custom_emoji) + custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain) + emoji_reaction = EmojiReaction.find_or_create_by!(account_id: account.id, status_id: status.id, name: shortcode, custom_emoji: custom_emoji) - create_notification(emoji_reaction) - bump_potential_friendship(account, status) - - emoji_reaction + emoji_reaction.tap do |emoji_reaction| + create_notification(emoji_reaction) + bump_potential_friendship(account, status) + end end private diff --git a/app/services/un_emoji_reaction_service.rb b/app/services/un_emoji_reaction_service.rb index 89ab8d6d1..018a6f718 100644 --- a/app/services/un_emoji_reaction_service.rb +++ b/app/services/un_emoji_reaction_service.rb @@ -3,14 +3,25 @@ class UnEmojiReactionService < BaseService include Payloadable - def call(account, status) - @account = account + def call(account, status, emoji, **options) + @account = account + shortcode, domain = emoji&.split("@") - emoji_reaction = EmojiReaction.find_by!(account: account, status: status) + if shortcode + if options[:shortcode_only] + emoji_reactions = EmojiReaction.where(account: account, status: status, name: shortcode) + else + custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain) + emoji_reactions = EmojiReaction.where(account: account, status: status, name: shortcode, custom_emoji: custom_emoji) + end + else + emoji_reactions = EmojiReaction.where(account: account, status: status) + end - emoji_reaction.destroy! - create_notification(emoji_reaction) - emoji_reaction + emoji_reactions.each do |emoji_reaction| + emoji_reaction.destroy! + create_notification(emoji_reaction) + end end private diff --git a/app/validators/emoji_reaction_validator.rb b/app/validators/emoji_reaction_validator.rb index a7860b9b2..f7150acbd 100644 --- a/app/validators/emoji_reaction_validator.rb +++ b/app/validators/emoji_reaction_validator.rb @@ -2,13 +2,20 @@ class EmojiReactionValidator < ActiveModel::Validator SUPPORTED_EMOJIS = Oj.load(File.read(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json'))).keys.freeze - LIMIT = 20 + LIMIT = 20 + MAX_PER_ACCOUNT = 1 + MAX_PER_ACCOUNT_LIMIT = 20 def validate(reaction) return if reaction.name.blank? - reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) if reaction.custom_emoji_id.blank? && !unicode_emoji?(reaction.name) + @reaction = reaction + max_per_account = [MAX_PER_ACCOUNT, Setting.reaction_max_per_account].max + + reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) if reaction.custom_emoji_id.blank? && !unicode_emoji?(reaction.name) + reaction.errors.add(:name, I18n.t('reactions.errors.limit_reached', max: max_per_account)) if reaction.account.local? && reaction_per_account >= max_per_account + reaction.errors.add(:name, I18n.t('reactions.errors.limit_reached', max: MAX_PER_ACCOUNT_LIMIT)) if !reaction.account.local? && reaction_per_account >= MAX_PER_ACCOUNT_LIMIT end private @@ -16,4 +23,8 @@ class EmojiReactionValidator < ActiveModel::Validator def unicode_emoji?(name) SUPPORTED_EMOJIS.include?(name) end + + def reaction_per_account + EmojiReaction.where(account_id: @reaction.account_id, status_id: @reaction.status_id).size + end end diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 9d0acd86f..30fe06238 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -98,6 +98,9 @@ .fields-group = f.input :poll_max_options, wrapper: :with_label, label: t('admin.settings.poll_max_options.title'), hint: t('admin.settings.poll_max_options.desc_html', count: PollValidator::MAX_OPTIONS_LIMIT), fedibird_features: true + .fields-group + = f.input :reaction_max_per_account, wrapper: :with_label, label: t('admin.settings.reaction_max_per_account.title'), hint: t('admin.settings.reaction_max_per_account.desc_html', count: EmojiReactionValidator::MAX_PER_ACCOUNT_LIMIT), fedibird_features: true + %hr.spacer/ .fields-group diff --git a/app/workers/un_emoji_reaction_worker.rb b/app/workers/un_emoji_reaction_worker.rb deleted file mode 100644 index ed16eb7ea..000000000 --- a/app/workers/un_emoji_reaction_worker.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class UnEmojiReactionWorker - include Sidekiq::Worker - - def perform(account_id, status_id) - UnEmojiReactionService.new.call(Account.find(account_id), Status.find(status_id)) - rescue ActiveRecord::RecordNotFound - true - end -end diff --git a/config/locales/en.yml b/config/locales/en.yml index bab7b7721..dbbbe5327 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -667,6 +667,9 @@ en: profile_directory: desc_html: Allow users to be discoverable title: Enable profile directory + reaction_max_per_account: + desc_html: "Specifies the maximum number of emoji reaction per account (<= %{count})" + title: Maximum number of emoji reaction per account registrations: closed_message: desc_html: Displayed on frontpage when registrations are closed. You can use HTML tags diff --git a/config/locales/ja.yml b/config/locales/ja.yml index aa04de037..134b5026a 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -646,6 +646,9 @@ ja: profile_directory: desc_html: ユーザーが見つかりやすくできるようになります title: ディレクトリを有効にする + reaction_max_per_account: + desc_html: "1アカウントあたりの絵文字リアクションの最大数(%{count}以下)を指定します。" + title: 絵文字リアクションの最大数 registrations: closed_message: desc_html: 新規登録を停止しているときにフロントページに表示されます。HTMLタグが使えます diff --git a/config/routes.rb b/config/routes.rb index 276121a6e..36835f963 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -366,7 +366,7 @@ Rails.application.routes.draw do resource :pin, only: :create post :unpin, to: 'pins#destroy' - resources :emoji_reactions, only: :update, constraints: { id: /[^\/]+/ } + resources :emoji_reactions, only: [:update, :destroy], constraints: { id: /[^\/]+/ } post :emoji_unreaction, to: 'emoji_reactions#destroy' resource :history, only: :show diff --git a/config/settings.yml b/config/settings.yml index b75e5f34d..9dc51c1c9 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -144,6 +144,7 @@ defaults: &defaults hide_video_preview: false allow_poll_image: false poll_max_options: 4 + reaction_max_per_account: 1 development: <<: *defaults