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 end
def destroy def destroy
#UnEmojiReactionWorker.perform_async(current_account.id, @status.id) if UnEmojiReactionService.new.call(current_account, @status, params[:id], shortcode_only: true).present?
if UnEmojiReactionService.new.call(current_account, @status).present?
@status = Status.include_expired.find(params[:status_id]) @status = Status.include_expired.find(params[:status_id])
end end

View file

@ -877,20 +877,20 @@ export function emojiReactionFail(status, name, domain, url, static_url, error)
}; };
}; };
const findMyEmojiReaction = (status) => { const findMyEmojiReaction = (status, name) => {
return status.get('emoji_reactioned') && status.get('emoji_reactions').find(emoji_reaction => emoji_reaction.get('account_ids').includes(me)); 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) { return function (dispatch, getState) {
const emoji_reaction = findMyEmojiReaction(status); const emoji_reaction = findMyEmojiReaction(status, name);
if (emoji_reaction) { 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)); 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(importFetchedStatus(response.data));
dispatch(unEmojiReactionSuccess(status, name, domain, url, static_url)); dispatch(unEmojiReactionSuccess(status, name, domain, url, static_url));
}).catch(function (error) { }).catch(function (error) {

View file

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif } from 'mastodon/initial_state';
import { assetHost } from 'mastodon/utils/config'; import { assetHost } from 'mastodon/utils/config';
import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
@ -10,7 +9,6 @@ export default class Emoji extends React.PureComponent {
static propTypes = { static propTypes = {
emoji: PropTypes.string.isRequired, emoji: PropTypes.string.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
className: PropTypes.string, className: PropTypes.string,
hovered: PropTypes.bool.isRequired, hovered: PropTypes.bool.isRequired,
url: PropTypes.string, url: PropTypes.string,
@ -18,7 +16,7 @@ export default class Emoji extends React.PureComponent {
}; };
render () { render () {
const { emoji, emojiMap, hovered, url, static_url } = this.props; const { emoji, hovered, url, static_url } = this.props;
if (unicodeMapping[emoji]) { if (unicodeMapping[emoji]) {
const { filename, shortCode } = unicodeMapping[emoji]; const { filename, shortCode } = unicodeMapping[emoji];
@ -34,20 +32,6 @@ export default class Emoji extends React.PureComponent {
src={`${assetHost}/emoji/${filename}.svg`} 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) { } else if (url || static_url) {
const filename = (autoPlayGif || hovered) && url ? url : static_url; const filename = (autoPlayGif || hovered) && url ? url : static_url;
const shortCode = `:${emoji}:`; 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 ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { List } from 'immutable'; 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 TransitionMotion from 'react-motion/lib/TransitionMotion';
import AnimatedNumber from 'mastodon/components/animated_number'; import { reduceMotion, me } from 'mastodon/initial_state';
import { reduceMotion, me, disableReactions } from 'mastodon/initial_state';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import Overlay from 'react-overlays/lib/Overlay'; import EmojiReaction from './emoji_reactions';
import { isUserTouching } from 'mastodon/is_mobile';
import AccountPopup from 'mastodon/components/account_popup';
const getFilteredEmojiReaction = (emojiReaction, relationships) => { const mapStateToProps = (state, { status }) => ({
let filteredEmojiReaction = emojiReaction.update('account_ids', accountIds => accountIds.filterNot( accountId => { emojiReactions: status.get('emoji_reactions'),
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; const mergeProps = ({ emojiReactions }, dispatchProps, ownProps) => ({
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, ...ownProps,
...dispatchProps, ...dispatchProps,
emojiReaction: getFilteredEmojiReaction(emojiReaction, relationships), visibleReactions: emojiReactions.filter(x => x.get('count') > 0),
}); });
@connect(mapStateToProps, null, mergeProps) @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 { export default class EmojiReactionsBar extends ImmutablePureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
addEmojiReaction: PropTypes.func.isRequired, addEmojiReaction: PropTypes.func.isRequired,
removeEmojiReaction: PropTypes.func.isRequired, removeEmojiReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired, visibleReactions: ImmutablePropTypes.list.isRequired,
reactionLimitReached: PropTypes.bool,
}; };
willEnter () { willEnter () {
@ -156,22 +39,20 @@ export default class EmojiReactionsBar extends ImmutablePureComponent {
} }
render () { render () {
const { status } = this.props; const { status, addEmojiReaction, removeEmojiReaction, visibleReactions, reactionLimitReached } = this.props;
const emoji_reactions = status.get('emoji_reactions');
const visibleReactions = emoji_reactions.filter(x => x.get('count') > 0);
if (visibleReactions.isEmpty() ) { if (visibleReactions.isEmpty() ) {
return <Fragment />; return <Fragment />;
} }
const styles = visibleReactions.map(emoji_reaction => { const styles = visibleReactions.map(emojiReaction => {
const domain = emoji_reaction.get('domain', ''); const domain = emojiReaction.get('domain', '');
return { return {
key: `${emoji_reaction.get('name')}${domain ? `@${domain}` : ''}`, key: `${emojiReaction.get('name')}${domain ? `@${domain}` : ''}`,
data: { data: {
emojiReaction: emoji_reaction, emojiReaction: emojiReaction,
myReaction: emoji_reaction.get('account_ids', List()).includes(me), myReaction: emojiReaction.get('account_ids', List()).includes(me),
}, },
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
}; };
@ -187,10 +68,10 @@ export default class EmojiReactionsBar extends ImmutablePureComponent {
emojiReaction={data.emojiReaction} emojiReaction={data.emojiReaction}
myReaction={data.myReaction} myReaction={data.myReaction}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }} style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
status={this.props.status} status={status}
addEmojiReaction={this.props.addEmojiReaction} addEmojiReaction={addEmojiReaction}
removeEmojiReaction={this.props.removeEmojiReaction} removeEmojiReaction={removeEmojiReaction}
emojiMap={this.props.emojiMap} reactionLimitReached={reactionLimitReached}
/> />
))} ))}
</div> </div>

View file

@ -286,7 +286,6 @@ export default class ReactionPickerDropdown extends React.PureComponent {
openedViaKeyboard: PropTypes.bool, openedViaKeyboard: PropTypes.bool,
custom_emojis: ImmutablePropTypes.list, custom_emojis: ImmutablePropTypes.list,
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
onRemoveEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired, skinTone: PropTypes.number.isRequired,
@ -305,9 +304,7 @@ export default class ReactionPickerDropdown extends React.PureComponent {
}; };
handleClick = ({ target, type }) => { handleClick = ({ target, type }) => {
if (this.props.pressed) { if (this.state.id === this.props.openDropdownId) {
this.props.onRemoveEmoji();
} else if (this.state.id === this.props.openDropdownId) {
this.handleClose(); this.handleClose();
} else { } else {
const { top } = target.getBoundingClientRect(); const { top } = target.getBoundingClientRect();

View file

@ -165,9 +165,9 @@ class Status extends ImmutablePureComponent {
available: PropTypes.bool, available: PropTypes.bool,
}), }),
contextType: PropTypes.string, contextType: PropTypes.string,
emojiMap: ImmutablePropTypes.map,
addEmojiReaction: PropTypes.func.isRequired, addEmojiReaction: PropTypes.func.isRequired,
removeEmojiReaction: PropTypes.func.isRequired, removeEmojiReaction: PropTypes.func.isRequired,
reactionLimitReached: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -404,7 +404,7 @@ class Status extends ImmutablePureComponent {
let media = null; let media = null;
let statusAvatar, prepend, rebloggedByText; 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; let { status, account, ...other } = this.props;
@ -796,7 +796,7 @@ class Status extends ImmutablePureComponent {
status={status} status={status}
addEmojiReaction={this.props.addEmojiReaction} addEmojiReaction={this.props.addEmojiReaction}
removeEmojiReaction={this.props.removeEmojiReaction} removeEmojiReaction={this.props.removeEmojiReaction}
emojiMap={this.props.emojiMap} reactionLimitReached={reactionLimitReached}
/>} />}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} expired={expired} {...other} /> <StatusActionBar scrollKey={scrollKey} status={status} account={account} expired={expired} {...other} />
</div> </div>

View file

@ -111,6 +111,7 @@ class StatusActionBar extends ImmutablePureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
addEmojiReaction: PropTypes.func, addEmojiReaction: PropTypes.func,
removeEmojiReaction: PropTypes.func, removeEmojiReaction: PropTypes.func,
reactionLimitReached: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -327,7 +328,7 @@ class StatusActionBar extends ImmutablePureComponent {
} }
render () { 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 anonymousAccess = !me;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -339,7 +340,6 @@ class StatusActionBar extends ImmutablePureComponent {
const reblogged = status.get('reblogged'); const reblogged = status.get('reblogged');
const favourited = status.get('favourited'); const favourited = status.get('favourited');
const bookmarked = status.get('bookmarked'); const bookmarked = status.get('bookmarked');
const emoji_reactioned = status.get('emoji_reactioned');
const reblogsCount = status.get('reblogs_count'); const reblogsCount = status.get('reblogs_count');
const referredByCount = status.get('status_referred_by_count'); const referredByCount = status.get('status_referred_by_count');
const favouritesCount = status.get('favourites_count'); const favouritesCount = status.get('favourites_count');
@ -490,9 +490,7 @@ class StatusActionBar extends ImmutablePureComponent {
{enableReaction && <div className={classNames('status__action-bar-dropdown', { 'icon-button--with-counter': reactionsCounter })}> {enableReaction && <div className={classNames('status__action-bar-dropdown', { 'icon-button--with-counter': reactionsCounter })}>
<ReactionPickerDropdownContainer <ReactionPickerDropdownContainer
scrollKey={scrollKey} scrollKey={scrollKey}
disabled={disableReactions || expired || anonymousAccess} disabled={disableReactions || expired || anonymousAccess || reactionLimitReached}
active={emoji_reactioned}
pressed={emoji_reactioned}
className='status__action-bar-button' className='status__action-bar-button'
status={status} status={status}
title={intl.formatMessage(messages.emoji_reaction)} title={intl.formatMessage(messages.emoji_reaction)}
@ -502,6 +500,7 @@ class StatusActionBar extends ImmutablePureComponent {
counter={reactionsCounter} counter={reactionsCounter}
onPickEmoji={this.handleEmojiPick} onPickEmoji={this.handleEmojiPick}
onRemoveEmoji={this.handleEmojiRemove} onRemoveEmoji={this.handleEmojiRemove}
reactionLimitReached={reactionLimitReached}
/> />
</div>} </div>}

View file

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

View file

@ -65,10 +65,6 @@ const getCustomEmojis = createSelector([
} }
})); }));
const getState = (dispatch) => new Promise((resolve) => {
dispatch((dispatch, getState) => {resolve(getState())})
})
const mapStateToProps = state => ({ const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state), custom_emojis: getCustomEmojis(state),
skinTone: state.getIn(['settings', 'skinTone']), skinTone: state.getIn(['settings', 'skinTone']),

View file

@ -52,9 +52,8 @@ import { deployPictureInPicture } from '../actions/picture_in_picture';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, deleteModal, unfollowModal, unsubscribeModal, confirmDomainBlock } from '../initial_state'; import { boostModal, deleteModal, unfollowModal, unsubscribeModal, confirmDomainBlock } from '../initial_state';
import { showAlertForError } from '../actions/alerts'; import { showAlertForError } from '../actions/alerts';
import { List as ImmutableList } from 'immutable';
import { createSelector } from 'reselect'; import { me, maxReactionsPerAccount } from 'mastodon/initial_state';
import { Map as ImmutableMap } from 'immutable';
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -74,7 +73,6 @@ const messages = defineMessages({
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture(); 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 getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {
@ -84,16 +82,26 @@ const makeMapStateToProps = () => {
return { return {
status, status,
pictureInPicture: getPictureInPicture(state, props), pictureInPicture: getPictureInPicture(state, props),
emojiMap: customEmojiMap(state),
id, id,
referenced: state.getIn(['compose', 'references']).has(id), referenced: state.getIn(['compose', 'references']).has(id),
contextReferenced: state.getIn(['compose', 'context_references']).has(id), contextReferenced: state.getIn(['compose', 'context_references']).has(id),
emojiReactions: !!status ? status.get('emoji_reactions', ImmutableList()) : ImmutableList(),
}; };
}; };
return mapStateToProps; 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 }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) { onReply (status, router) {
@ -308,8 +316,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(addEmojiReaction(status, name, domain, url, static_url)); dispatch(addEmojiReaction(status, name, domain, url, static_url));
}, },
removeEmojiReaction (status) { removeEmojiReaction (status, name) {
dispatch(removeEmojiReaction(status)); dispatch(removeEmojiReaction(status, name));
}, },
onAddReference (id, change) { 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 { connect } from 'react-redux';
import StatusItem from '../components/status_item'; import StatusItem from '../components/status_item';
import { makeGetStatus } from '../selectors'; import { makeGetStatus } from '../selectors';
import { import { removeReference } from '../actions/compose';
removeReference,
} from '../actions/compose';
import { openModal } from '../actions/modal'; import { openModal } from '../actions/modal';
import { unselectReferenceModal } from '../initial_state'; import { unselectReferenceModal } from '../initial_state';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
const messages = defineMessages({ const messages = defineMessages({
unselectMessage: { id: 'confirmations.unselect.message', defaultMessage: 'Are you sure you want to unselect a reference?' }, unselectMessage: { id: 'confirmations.unselect.message', defaultMessage: 'Are you sure you want to unselect a reference?' },
unselectConfirm: { id: 'confirmations.unselect.confirm', defaultMessage: 'Unselect' }, unselectConfirm: { id: 'confirmations.unselect.confirm', defaultMessage: 'Unselect' },
@ -19,16 +14,10 @@ const messages = defineMessages({
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); 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 mapStateToProps = (state, props) => ({
const status = getStatus(state, props); status: getStatus(state, props),
});
return {
status,
emojiMap: customEmojiMap(state),
}
};
return mapStateToProps; return mapStateToProps;
}; };

View file

@ -12,8 +12,6 @@ import ScrollableList from '../../components/scrollable_list';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import ColumnHeader from '../../components/column_header'; import ColumnHeader from '../../components/column_header';
import Emoji from '../../components/emoji'; import Emoji from '../../components/emoji';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
import ReactedHeaderContaier from '../reactioned/containers/header_container'; import ReactedHeaderContaier from '../reactioned/containers/header_container';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { defaultColumnWidth } from 'mastodon/initial_state'; import { defaultColumnWidth } from 'mastodon/initial_state';
@ -24,8 +22,6 @@ const messages = defineMessages({
refresh: { id: 'refresh', defaultMessage: 'Refresh' }, 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 mapStateToProps = (state, { columnId, params }) => {
const uuid = columnId; const uuid = columnId;
const columns = state.getIn(['settings', 'columns']); 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']), emojiReactions: state.getIn(['user_lists', 'emoji_reactioned_by', params.statusId, 'items']),
isLoading: state.getIn(['user_lists', 'emoji_reactioned_by', params.statusId, 'isLoading'], true), isLoading: state.getIn(['user_lists', 'emoji_reactioned_by', params.statusId, 'isLoading'], true),
hasMore: !!state.getIn(['user_lists', 'emoji_reactioned_by', params.statusId, 'next']), hasMore: !!state.getIn(['user_lists', 'emoji_reactioned_by', params.statusId, 'next']),
emojiMap: customEmojiMap(state),
columnWidth: columnWidth ?? defaultColumnWidth, columnWidth: columnWidth ?? defaultColumnWidth,
}; };
}; };
@ -45,7 +40,6 @@ class Reaction extends ImmutablePureComponent {
static propTypes = { static propTypes = {
emojiReaction: ImmutablePropTypes.map.isRequired, emojiReaction: ImmutablePropTypes.map.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
}; };
state = { state = {
@ -57,11 +51,11 @@ class Reaction extends ImmutablePureComponent {
handleMouseLeave = () => this.setState({ hovered: false }) handleMouseLeave = () => this.setState({ hovered: false })
render () { render () {
const { emojiReaction, emojiMap } = this.props; const { emojiReaction } = this.props;
return ( return (
<div className='account__emoji_reaction' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <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> </div>
); );
}; };
@ -77,7 +71,6 @@ class EmojiReactions extends ImmutablePureComponent {
emojiReactions: ImmutablePropTypes.list, emojiReactions: ImmutablePropTypes.list,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
columnWidth: PropTypes.string, columnWidth: PropTypes.string,
emojiMap: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
@ -114,7 +107,7 @@ class EmojiReactions extends ImmutablePureComponent {
} }
render () { render () {
const { intl, emojiReactions, multiColumn, emojiMap, hasMore, isLoading, columnWidth } = this.props; const { intl, emojiReactions, multiColumn, hasMore, isLoading, columnWidth } = this.props;
if (!emojiReactions) { if (!emojiReactions) {
return ( return (
@ -149,7 +142,7 @@ class EmojiReactions extends ImmutablePureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{emojiReactions.map(emojiReaction => {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> </ScrollableList>
</Column> </Column>

View file

@ -56,7 +56,6 @@ class Notification extends ImmutablePureComponent {
cacheMediaWidth: PropTypes.func, cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number, cachedMediaWidth: PropTypes.number,
unread: PropTypes.bool, unread: PropTypes.bool,
emojiMap: ImmutablePropTypes.map,
}; };
handleMoveUp = () => { handleMoveUp = () => {
@ -349,7 +348,7 @@ class Notification extends ImmutablePureComponent {
} }
renderReaction (notification, link) { renderReaction (notification, link) {
const { intl, unread, emojiMap } = this.props; const { intl, unread } = this.props;
if (!notification.get('emoji_reaction')) { if (!notification.get('emoji_reaction')) {
return <Fragment />; 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={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='notification__message'>
<div className={classNames('notification__reaction-icon-wrapper', { wide })}> <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> </div>
<span title={notification.get('created_at')} className={classNames('notification__reaction-message-wrapper', { wide })}> <span title={notification.get('created_at')} className={classNames('notification__reaction-message-wrapper', { wide })}>

View file

@ -14,20 +14,16 @@ import {
revealStatus, revealStatus,
} from '../../../actions/statuses'; } from '../../../actions/statuses';
import { boostModal } from '../../../initial_state'; import { boostModal } from '../../../initial_state';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getNotification = makeGetNotification(); const getNotification = makeGetNotification();
const getStatus = makeGetStatus(); 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 mapStateToProps = (state, props) => {
const notification = getNotification(state, props.notification, props.accountId); const notification = getNotification(state, props.notification, props.accountId);
return { return {
notification: notification, notification: notification,
status: notification.get('status') ? getStatus(state, { id: notification.get('status') }) : null, 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, onMoveDown: PropTypes.func,
onDeleteScheduledStatus: PropTypes.func, onDeleteScheduledStatus: PropTypes.func,
onRedraftScheduledStatus: PropTypes.func, onRedraftScheduledStatus: PropTypes.func,
emojiMap: ImmutablePropTypes.map,
}; };
handleHotkeyMoveUp = () => { handleHotkeyMoveUp = () => {

View file

@ -4,8 +4,6 @@ import { deleteScheduledStatus, redraftScheduledStatus } from 'mastodon/actions/
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import { deleteScheduledStatusModal } from 'mastodon/initial_state'; import { deleteScheduledStatusModal } from 'mastodon/initial_state';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, 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?' }, 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 }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onDeleteScheduledStatus (id, e) { 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 = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
dispatch: PropTypes.func.isRequired,
referenced: PropTypes.bool, referenced: PropTypes.bool,
contextReferenced: PropTypes.bool, contextReferenced: PropTypes.bool,
relationship: ImmutablePropTypes.map, relationship: ImmutablePropTypes.map,
@ -102,6 +103,7 @@ class ActionBar extends React.PureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
addEmojiReaction: PropTypes.func.isRequired, addEmojiReaction: PropTypes.func.isRequired,
removeEmojiReaction: PropTypes.func.isRequired, removeEmojiReaction: PropTypes.func.isRequired,
reactionLimitReached: PropTypes.bool,
}; };
handleReplyClick = () => { handleReplyClick = () => {
@ -281,7 +283,7 @@ class ActionBar extends React.PureComponent {
} }
render () { 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 publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].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 reblogged = status.get('reblogged');
const favourited = status.get('favourited'); const favourited = status.get('favourited');
const bookmarked = status.get('bookmarked'); const bookmarked = status.get('bookmarked');
const emoji_reactioned = status.get('emoji_reactioned');
const reblogsCount = status.get('reblogs_count'); const reblogsCount = status.get('reblogs_count');
const referredByCount = status.get('status_referred_by_count'); const referredByCount = status.get('status_referred_by_count');
const favouritesCount = status.get('favourites_count'); const favouritesCount = status.get('favourites_count');
@ -433,9 +434,7 @@ class ActionBar extends React.PureComponent {
{enableReaction && <div className='detailed-status__action-bar-dropdown'> {enableReaction && <div className='detailed-status__action-bar-dropdown'>
<ReactionPickerDropdownContainer <ReactionPickerDropdownContainer
disabled={disableReactions || expired} disabled={disableReactions || expired || reactionLimitReached}
active={emoji_reactioned}
pressed={emoji_reactioned}
className='status__action-bar-button' className='status__action-bar-button'
status={status} status={status}
title={intl.formatMessage(messages.emoji_reaction)} title={intl.formatMessage(messages.emoji_reaction)}
@ -444,6 +443,7 @@ class ActionBar extends React.PureComponent {
direction='right' direction='right'
onPickEmoji={this.handleEmojiPick} onPickEmoji={this.handleEmojiPick}
onRemoveEmoji={this.handleEmojiRemove} onRemoveEmoji={this.handleEmojiRemove}
reactionLimitReached={reactionLimitReached}
/> />
</div>} </div>}

View file

@ -93,10 +93,10 @@ class DetailedStatus extends ImmutablePureComponent {
onToggleMediaVisibility: PropTypes.func, onToggleMediaVisibility: PropTypes.func,
showQuoteMedia: PropTypes.bool, showQuoteMedia: PropTypes.bool,
onToggleQuoteMediaVisibility: PropTypes.func, onToggleQuoteMediaVisibility: PropTypes.func,
emojiMap: ImmutablePropTypes.map,
addEmojiReaction: PropTypes.func.isRequired, addEmojiReaction: PropTypes.func.isRequired,
removeEmojiReaction: PropTypes.func.isRequired, removeEmojiReaction: PropTypes.func.isRequired,
onReference: PropTypes.func, onReference: PropTypes.func,
reactionLimitReached: PropTypes.bool,
}; };
state = { 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 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 quote_muted = this.props.quote_muted;
const outerStyle = { boxSizing: 'border-box' }; const outerStyle = { boxSizing: 'border-box' };
const { intl, compact, pictureInPicture, referenced, contextReferenced } = this.props; const { intl, compact, pictureInPicture, referenced, contextReferenced, reactionLimitReached } = this.props;
if (!status) { if (!status) {
return null; return null;
@ -467,7 +467,7 @@ class DetailedStatus extends ImmutablePureComponent {
status={status} status={status}
addEmojiReaction={this.props.addEmojiReaction} addEmojiReaction={this.props.addEmojiReaction}
removeEmojiReaction={this.props.removeEmojiReaction} removeEmojiReaction={this.props.removeEmojiReaction}
emojiMap={this.props.emojiMap} reactionLimitReached={reactionLimitReached}
/>} />}
<div className='detailed-status__meta'> <div className='detailed-status__meta'>

View file

@ -32,9 +32,6 @@ import { defineMessages, injectIntl } from 'react-intl';
import { boostModal, deleteModal } from '../../../initial_state'; import { boostModal, deleteModal } from '../../../initial_state';
import { showAlertForError } from '../../../actions/alerts'; import { showAlertForError } from '../../../actions/alerts';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, 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 makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture(); 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) => ({ const mapStateToProps = (state, props) => ({
status: getStatus(state, props), status: getStatus(state, props),
domain: state.getIn(['meta', 'domain']), domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, props), pictureInPicture: getPictureInPicture(state, props),
emojiMap: customEmojiMap(state),
}); });
return mapStateToProps; return mapStateToProps;
@ -184,8 +179,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(addEmojiReaction(status, name, domain, url, static_url)); dispatch(addEmojiReaction(status, name, domain, url, static_url));
}, },
removeEmojiReaction (status) { removeEmojiReaction (status, name) {
dispatch(removeEmojiReaction(status)); dispatch(removeEmojiReaction(status, name));
}, },
}); });

View file

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; 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 { createSelector } from 'reselect';
import { fetchStatus } from '../../actions/statuses'; import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator'; import MissingIndicator from '../../components/missing_indicator';
@ -63,7 +63,7 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from
import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import DetailedHeaderContaier from './containers/header_container'; 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 { changeSetting } from '../../actions/settings';
import { changeColumnParams } from '../../actions/columns'; import { changeColumnParams } from '../../actions/columns';
@ -86,7 +86,6 @@ const messages = defineMessages({
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture(); 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 getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
const getAncestorsIds = createSelector([ const getAncestorsIds = createSelector([
@ -160,6 +159,7 @@ const makeMapStateToProps = () => {
const descendantsIds = status ? getDescendantsIds(state, { id: status.get('id') }) : ImmutableList(); const descendantsIds = status ? getDescendantsIds(state, { id: status.get('id') }) : ImmutableList();
const referencesIds = status ? getReferencesIds(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 id = status ? getProper(status).get('id') : null;
const emojiReactions = status ? status.get('emoji_reactions', ImmutableList()) : ImmutableList();
return { return {
status, status,
@ -168,10 +168,10 @@ const makeMapStateToProps = () => {
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']), domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, { id: params.statusId }), pictureInPicture: getPictureInPicture(state, { id: params.statusId }),
emojiMap: customEmojiMap(state),
referenced: state.getIn(['compose', 'references']).has(id), referenced: state.getIn(['compose', 'references']).has(id),
contextReferenced: state.getIn(['compose', 'context_references']).has(id), contextReferenced: state.getIn(['compose', 'context_references']).has(id),
columnWidth: columnWidth ?? defaultColumnWidth, 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, inUse: PropTypes.bool,
available: PropTypes.bool, available: PropTypes.bool,
}), }),
emojiMap: ImmutablePropTypes.map, reactionLimitReached: PropTypes.bool,
}; };
state = { state = {
@ -473,8 +473,8 @@ class Status extends ImmutablePureComponent {
this.props.dispatch(addEmojiReaction(status, name, domain, url, static_url)); this.props.dispatch(addEmojiReaction(status, name, domain, url, static_url));
} }
handleRemoveEmojiReaction = (status) => { handleRemoveEmojiReaction = (status, name) => {
this.props.dispatch(removeEmojiReaction(status)); this.props.dispatch(removeEmojiReaction(status, name));
} }
handleAddReference = (id, change) => { handleAddReference = (id, change) => {
@ -575,7 +575,7 @@ class Status extends ImmutablePureComponent {
render () { render () {
let ancestors, descendants; 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; const { fullscreen } = this.state;
if (status === null) { if (status === null) {
@ -652,7 +652,6 @@ class Status extends ImmutablePureComponent {
pictureInPicture={pictureInPicture} pictureInPicture={pictureInPicture}
showQuoteMedia={this.state.showQuoteMedia} showQuoteMedia={this.state.showQuoteMedia}
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility} onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
emojiMap={emojiMap}
addEmojiReaction={this.handleAddEmojiReaction} addEmojiReaction={this.handleAddEmojiReaction}
removeEmojiReaction={this.handleRemoveEmojiReaction} removeEmojiReaction={this.handleRemoveEmojiReaction}
/> />
@ -662,6 +661,7 @@ class Status extends ImmutablePureComponent {
status={status} status={status}
referenced={referenced} referenced={referenced}
contextReferenced={contextReferenced} contextReferenced={contextReferenced}
reactionLimitReached={reactionLimitReached}
onReply={this.handleReplyClick} onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick} onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick} 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 hidePhotoPreview = getMeta('hide_photo_preview');
export const hideVideoPreview = getMeta('hide_video_preview'); export const hideVideoPreview = getMeta('hide_video_preview');
export const allowPollImage = getMeta('allow_poll_image'); 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; 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 EMOJI_REACTION_REQUEST:
case UN_EMOJI_REACTION_FAIL: case UN_EMOJI_REACTION_FAIL:
if (state.get(action.status.get('id')) !== undefined) { 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); state = addEmojiReaction(state, action.status.get('id'), action.name, action.domain, action.url, action.static_url);
} }
return state; return state;
case UN_EMOJI_REACTION_REQUEST: case UN_EMOJI_REACTION_REQUEST:
case EMOJI_REACTION_FAIL: case EMOJI_REACTION_FAIL:
if (state.get(action.status.get('id')) !== undefined) { 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); state = removeEmojiReaction(state, action.status.get('id'), action.name, action.domain, action.url, action.static_url);
} }
return state; return state;

View file

@ -40,16 +40,17 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
end end
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! return unless reaction
reaction = @original_status.emoji_reactions.create!(account: @account, name: shortcode, custom_emoji: emoji, uri: @json['id'])
reaction.tap do |reaction|
if @original_status.account.local? if @original_status.account.local?
NotifyService.new.call(@original_status.account, :emoji_reaction, reaction) NotifyService.new.call(@original_status.account, :emoji_reaction, reaction)
forward_for_emoji_reaction forward_for_emoji_reaction
relay_for_emoji_reaction relay_for_emoji_reaction
end end
end
rescue Seahorse::Client::NetworkingError rescue Seahorse::Client::NetworkingError
nil nil
end end

View file

@ -37,6 +37,7 @@ class Form::AdminSettings
require_invite_text require_invite_text
allow_poll_image allow_poll_image
poll_max_options poll_max_options
reaction_max_per_account
).freeze ).freeze
BOOLEAN_KEYS = %i( BOOLEAN_KEYS = %i(
@ -58,6 +59,7 @@ class Form::AdminSettings
INTEGER_KEYS = %i( INTEGER_KEYS = %i(
poll_max_options poll_max_options
reaction_max_per_account
).freeze ).freeze
UPLOAD_KEYS = %i( UPLOAD_KEYS = %i(
@ -78,6 +80,7 @@ class Form::AdminSettings
validates :show_domain_blocks, inclusion: { in: %w(disabled users all) } validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }
validates :show_domain_blocks_rationale, 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 :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 = {}) def initialize(_attributes = {})
super super

View file

@ -2,7 +2,8 @@
class InitialStateSerializer < ActiveModel::Serializer class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :search, :accounts, :lists, 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 has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
@ -162,6 +163,10 @@ class InitialStateSerializer < ActiveModel::Serializer
{ max_references: StatusReferenceValidator::LIMIT } { max_references: StatusReferenceValidator::LIMIT }
end end
def emoji_reactions
{ max_reactions_per_account: [EmojiReactionValidator::MAX_PER_ACCOUNT, Setting.reaction_max_per_account].max }
end
private private
def instance_presenter def instance_presenter

View file

@ -94,6 +94,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
emoji_reactions: { emoji_reactions: {
max_reactions: EmojiReactionValidator::LIMIT, max_reactions: EmojiReactionValidator::LIMIT,
max_reactions_per_account: [EmojiReactionValidator::MAX_PER_ACCOUNT, Setting.reaction_max_per_account].max,
}, },
status_references: { status_references: {

View file

@ -6,21 +6,17 @@ class EmojiReactionService < BaseService
def call(account, status, emoji) def call(account, status, emoji)
@account = account @account = account
emoji_reaction = EmojiReaction.find_by(account_id: account.id, status_id: status.id)
return emoji_reaction unless emoji_reaction.nil?
shortcode, domain = emoji.split("@") shortcode, domain = emoji.split("@")
return if account.nil? || status.nil? || shortcode.nil?
custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain) 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)
emoji_reaction = EmojiReaction.create!(account: account, status: status, name: shortcode, custom_emoji: custom_emoji) emoji_reaction.tap do |emoji_reaction|
create_notification(emoji_reaction) create_notification(emoji_reaction)
bump_potential_friendship(account, status) bump_potential_friendship(account, status)
end
emoji_reaction
end end
private private

View file

@ -3,14 +3,25 @@
class UnEmojiReactionService < BaseService class UnEmojiReactionService < BaseService
include Payloadable include Payloadable
def call(account, status) def call(account, status, emoji, **options)
@account = account @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_reactions.each do |emoji_reaction|
emoji_reaction.destroy! emoji_reaction.destroy!
create_notification(emoji_reaction) create_notification(emoji_reaction)
emoji_reaction end
end end
private private

View file

@ -3,12 +3,19 @@
class EmojiReactionValidator < ActiveModel::Validator class EmojiReactionValidator < ActiveModel::Validator
SUPPORTED_EMOJIS = Oj.load(File.read(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json'))).keys.freeze 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) def validate(reaction)
return if reaction.name.blank? 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 end
private private
@ -16,4 +23,8 @@ class EmojiReactionValidator < ActiveModel::Validator
def unicode_emoji?(name) def unicode_emoji?(name)
SUPPORTED_EMOJIS.include?(name) SUPPORTED_EMOJIS.include?(name)
end end
def reaction_per_account
EmojiReaction.where(account_id: @reaction.account_id, status_id: @reaction.status_id).size
end
end end

View file

@ -98,6 +98,9 @@
.fields-group .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 = 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/ %hr.spacer/
.fields-group .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: profile_directory:
desc_html: Allow users to be discoverable desc_html: Allow users to be discoverable
title: Enable profile directory 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: registrations:
closed_message: closed_message:
desc_html: Displayed on frontpage when registrations are closed. You can use HTML tags desc_html: Displayed on frontpage when registrations are closed. You can use HTML tags

View file

@ -646,6 +646,9 @@ ja:
profile_directory: profile_directory:
desc_html: ユーザーが見つかりやすくできるようになります desc_html: ユーザーが見つかりやすくできるようになります
title: ディレクトリを有効にする title: ディレクトリを有効にする
reaction_max_per_account:
desc_html: "1アカウントあたりの絵文字リアクションの最大数%{count}以下)を指定します。"
title: 絵文字リアクションの最大数
registrations: registrations:
closed_message: closed_message:
desc_html: 新規登録を停止しているときにフロントページに表示されます。HTMLタグが使えます desc_html: 新規登録を停止しているときにフロントページに表示されます。HTMLタグが使えます

View file

@ -366,7 +366,7 @@ Rails.application.routes.draw do
resource :pin, only: :create resource :pin, only: :create
post :unpin, to: 'pins#destroy' 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' post :emoji_unreaction, to: 'emoji_reactions#destroy'
resource :history, only: :show resource :history, only: :show

View file

@ -144,6 +144,7 @@ defaults: &defaults
hide_video_preview: false hide_video_preview: false
allow_poll_image: false allow_poll_image: false
poll_max_options: 4 poll_max_options: 4
reaction_max_per_account: 1
development: development:
<<: *defaults <<: *defaults