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 (
-
- );
} 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