Add multiple emoji reaction support

This commit is contained in:
noellabo 2023-03-06 02:09:25 +09:00
parent c12cefc619
commit 8a31e765cb
36 changed files with 289 additions and 304 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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 (
<img
draggable='false'
className={className}
alt={shortCode}
title={shortCode}
src={filename}
/>
);
} else if (url || static_url) {
const filename = (autoPlayGif || hovered) && url ? url : static_url;
const shortCode = `:${emoji}:`;

View File

@ -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 <Fragment />;
}
let shortCode = emojiReaction.get('name');
if (unicodeMapping[shortCode]) {
shortCode = unicodeMapping[shortCode].shortCode;
}
return (
<Fragment>
<div className='reactions-bar__item-wrapper' ref={this.setTargetRef}>
<button className={classNames('reactions-bar__item', { active: myReaction })} disabled={disableReactions || !myReaction && reactionLimitReached} onClick={this.handleClick} title={`:${shortCode}:`} style={style}>
<span className='reactions-bar__item__emoji'><Emoji className='reaction' hovered={this.state.hovered} emoji={emojiReaction.get('name')} url={emojiReaction.get('url')} static_url={emojiReaction.get('static_url')} /></span>
<span className='reactions-bar__item__count'><AnimatedNumber value={emojiReaction.get('count')} /></span>
</button>
</div>
{!isUserTouching() &&
<Overlay show={this.state.hovered} placement={this.state.placement} target={this.findTarget}>
<AccountPopup accountIds={emojiReaction.get('account_ids', List())} />
</Overlay>
}
</Fragment>
);
};
}

View File

@ -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 <Fragment />;
}
let shortCode = emojiReaction.get('name');
if (unicodeMapping[shortCode]) {
shortCode = unicodeMapping[shortCode].shortCode;
}
return (
<Fragment>
<div className='reactions-bar__item-wrapper' ref={this.setTargetRef}>
<button className={classNames('reactions-bar__item', { active: myReaction })} disabled={disableReactions || status.get('emoji_reactioned') && !myReaction} onClick={this.handleClick} title={`:${shortCode}:`} style={this.props.style}>
<span className='reactions-bar__item__emoji'><Emoji className='reaction' hovered={this.state.hovered} emoji={emojiReaction.get('name')} emojiMap={this.props.emojiMap} url={emojiReaction.get('url')} static_url={emojiReaction.get('static_url')} /></span>
<span className='reactions-bar__item__count'><AnimatedNumber value={emojiReaction.get('count')} /></span>
</button>
</div>
{!isUserTouching() &&
<Overlay show={this.state.hovered} placement={this.state.placement} target={this.findTarget}>
<AccountPopup accountIds={emojiReaction.get('account_ids', List())} />
</Overlay>
}
</Fragment>
);
};
}
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 <Fragment />;
}
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}
/>
))}
</div>

View File

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

View File

@ -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}
/>}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} expired={expired} {...other} />
</div>

View File

@ -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 (
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' disabled={disablePost || expired} title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
{enableStatusReference && me && <IconButton className={classNames('status__action-bar-button link-icon', {referenced, 'context-referenced': contextReferenced})} animate disabled={disablePost || referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} />}
{enableStatusReference && me && <IconButton className={classNames('status__action-bar-button link-icon', { referenced, 'context-referenced': contextReferenced })} animate disabled={disablePost || referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} />}
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={disableReactions || !publicStatus && !reblogPrivate || expired} active={reblogged} pressed={reblogged} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate disabled={disableReactions || !favourited && expired} active={favourited} pressed={favourited} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{show_quote_button && <IconButton className='status__action-bar-button' disabled={disablePost || anonymousAccess || !publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />}
@ -490,9 +490,7 @@ class StatusActionBar extends ImmutablePureComponent {
{enableReaction && <div className={classNames('status__action-bar-dropdown', { 'icon-button--with-counter': reactionsCounter })}>
<ReactionPickerDropdownContainer
scrollKey={scrollKey}
disabled={disableReactions || expired || anonymousAccess}
active={emoji_reactioned}
pressed={emoji_reactioned}
disabled={disableReactions || expired || anonymousAccess || reactionLimitReached}
className='status__action-bar-button'
status={status}
title={intl.formatMessage(messages.emoji_reaction)}
@ -502,6 +500,7 @@ class StatusActionBar extends ImmutablePureComponent {
counter={reactionsCounter}
onPickEmoji={this.handleEmojiPick}
onRemoveEmoji={this.handleEmojiRemove}
reactionLimitReached={reactionLimitReached}
/>
</div>}

View File

@ -27,7 +27,6 @@ class StatusItem extends ImmutablePureComponent {
status: ImmutablePropTypes.map,
onClick: PropTypes.func,
onUnselectReference: PropTypes.func,
emojiMap: ImmutablePropTypes.map,
};
updateOnProps = [

View File

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

View File

@ -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));

View File

@ -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;
};

View File

@ -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 (
<div className='account__emoji_reaction' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<Emoji className='reaction' hovered={this.state.hovered} emoji={emojiReaction.get('name')} emojiMap={emojiMap} url={emojiReaction.get('url')} static_url={emojiReaction.get('static_url')} />
<Emoji className='reaction' hovered={this.state.hovered} emoji={emojiReaction.get('name')} url={emojiReaction.get('url')} static_url={emojiReaction.get('static_url')} />
</div>
);
};
@ -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 =>
<AccountContainer key={emojiReaction.get('account')+emojiReaction.get('name')} id={emojiReaction.get('account')} withNote={false} append={<Reaction emojiReaction={emojiReaction} emojiMap={emojiMap} />} />,
<AccountContainer key={emojiReaction.get('account')+emojiReaction.get('name')} id={emojiReaction.get('account')} withNote={false} append={<Reaction emojiReaction={emojiReaction} />} />,
)}
</ScrollableList>
</Column>

View File

@ -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 <Fragment />;
@ -362,7 +361,7 @@ class Notification extends ImmutablePureComponent {
<div className={classNames('notification notification-reaction focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.emoji_reaction, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className={classNames('notification__reaction-icon-wrapper', { wide })}>
<Emoji hovered={false} emoji={notification.getIn(['emoji_reaction', 'name'])} emojiMap={emojiMap} url={notification.getIn(['emoji_reaction', 'url'])} static_url={notification.getIn(['emoji_reaction', 'static_url'])} />
<Emoji hovered={false} emoji={notification.getIn(['emoji_reaction', 'name'])} url={notification.getIn(['emoji_reaction', 'url'])} static_url={notification.getIn(['emoji_reaction', 'static_url'])} />
</div>
<span title={notification.get('created_at')} className={classNames('notification__reaction-message-wrapper', { wide })}>

View File

@ -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),
};
};

View File

@ -40,7 +40,6 @@ class ScheduledStatus extends ImmutablePureComponent {
onMoveDown: PropTypes.func,
onDeleteScheduledStatus: PropTypes.func,
onRedraftScheduledStatus: PropTypes.func,
emojiMap: ImmutablePropTypes.map,
};
handleHotkeyMoveUp = () => {

View File

@ -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));

View File

@ -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 (
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton disabled={disablePost || expired} title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
{enableStatusReference && me && <div className='detailed-status__button'><IconButton className={classNames('link-icon', {referenced, 'context-referenced': contextReferenced})} animate disabled={disablePost || referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} /></div>}
{enableStatusReference && me && <div className='detailed-status__button'><IconButton className={classNames('link-icon', { referenced, 'context-referenced': contextReferenced })} animate disabled={disablePost || referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} /></div>}
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={disableReactions || !publicStatus && !reblogPrivate || expired} active={reblogged} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={favourited} disabled={disableReactions || !favourited && expired} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{show_quote_button && <div className='detailed-status__button'><IconButton disabled={disablePost || !publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>}
@ -433,9 +434,7 @@ class ActionBar extends React.PureComponent {
{enableReaction && <div className='detailed-status__action-bar-dropdown'>
<ReactionPickerDropdownContainer
disabled={disableReactions || expired}
active={emoji_reactioned}
pressed={emoji_reactioned}
disabled={disableReactions || expired || reactionLimitReached}
className='status__action-bar-button'
status={status}
title={intl.formatMessage(messages.emoji_reaction)}
@ -444,6 +443,7 @@ class ActionBar extends React.PureComponent {
direction='right'
onPickEmoji={this.handleEmojiPick}
onRemoveEmoji={this.handleEmojiRemove}
reactionLimitReached={reactionLimitReached}
/>
</div>}

View File

@ -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}
/>}
<div className='detailed-status__meta'>

View File

@ -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));
},
});

View File

@ -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}

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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タグが使えます

View File

@ -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

View File

@ -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