Merge branch 'custom-emoji-reacts' into 'develop'

Support custom emoji reacts

See merge request soapbox-pub/soapbox!2360
This commit is contained in:
marcin mikołajczak 2023-03-19 19:03:53 +00:00
commit 4124b85ede
17 changed files with 109 additions and 52 deletions

View File

@ -25,7 +25,7 @@ const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL';
const noOp = () => () => new Promise(f => f(undefined)); const noOp = () => () => new Promise(f => f(undefined));
const simpleEmojiReact = (status: Status, emoji: string) => const simpleEmojiReact = (status: Status, emoji: string, custom?: string) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch) => {
const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList(); const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList();
@ -43,7 +43,7 @@ const simpleEmojiReact = (status: Status, emoji: string) =>
if (emoji === '👍') { if (emoji === '👍') {
dispatch(favourite(status)); dispatch(favourite(status));
} else { } else {
dispatch(emojiReact(status, emoji)); dispatch(emojiReact(status, emoji, custom));
} }
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
@ -70,11 +70,11 @@ const fetchEmojiReacts = (id: string, emoji: string) =>
}); });
}; };
const emojiReact = (status: Status, emoji: string) => const emojiReact = (status: Status, emoji: string, custom?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp()); if (!isLoggedIn(getState)) return dispatch(noOp());
dispatch(emojiReactRequest(status, emoji)); dispatch(emojiReactRequest(status, emoji, custom));
return api(getState) return api(getState)
.put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`) .put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
@ -120,10 +120,11 @@ const fetchEmojiReactsFail = (id: string, error: AxiosError) => ({
error, error,
}); });
const emojiReactRequest = (status: Status, emoji: string) => ({ const emojiReactRequest = (status: Status, emoji: string, custom?: string) => ({
type: EMOJI_REACT_REQUEST, type: EMOJI_REACT_REQUEST,
status, status,
emoji, emoji,
custom,
skipLoading: true, skipLoading: true,
}); });

View File

@ -91,6 +91,7 @@ export interface IAccount {
showEdit?: boolean showEdit?: boolean
approvalStatus?: StatusApprovalStatus approvalStatus?: StatusApprovalStatus
emoji?: string emoji?: string
emojiUrl?: string
note?: string note?: string
} }
@ -116,6 +117,7 @@ const Account = ({
showEdit = false, showEdit = false,
approvalStatus, approvalStatus,
emoji, emoji,
emojiUrl,
note, note,
}: IAccount) => { }: IAccount) => {
const overflowRef = useRef<HTMLDivElement>(null); const overflowRef = useRef<HTMLDivElement>(null);
@ -193,6 +195,7 @@ const Account = ({
<Emoji <Emoji
className='absolute bottom-0 -right-1.5 h-5 w-5' className='absolute bottom-0 -right-1.5 h-5 w-5'
emoji={emoji} emoji={emoji}
src={emojiUrl}
/> />
)} )}
</LinkEl> </LinkEl>

View File

@ -538,7 +538,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
allowedEmoji, allowedEmoji,
).reduce((acc, cur) => acc + cur.get('count'), 0); ).reduce((acc, cur) => acc + cur.get('count'), 0);
const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; const meEmojiReact = getReactForStatus(status, allowedEmoji);
const meEmojiName = meEmojiReact?.get('name') as keyof typeof reactMessages | undefined;
const reactMessages = { const reactMessages = {
'👍': messages.reactionLike, '👍': messages.reactionLike,
@ -550,7 +551,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
'': messages.favourite, '': messages.favourite,
}; };
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiName || ''] || messages.favourite);
const menu = _makeMenu(publicStatus); const menu = _makeMenu(publicStatus);
let reblogIcon = require('@tabler/icons/repeat.svg'); let reblogIcon = require('@tabler/icons/repeat.svg');
@ -635,7 +636,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
icon={require('@tabler/icons/heart.svg')} icon={require('@tabler/icons/heart.svg')}
filled filled
color='accent' color='accent'
active={Boolean(meEmojiReact)} active={Boolean(meEmojiName)}
count={emojiReactCount} count={emojiReactCount}
emoji={meEmojiReact} emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined} text={withLabels ? meEmojiTitle : undefined}
@ -648,7 +649,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
color='accent' color='accent'
filled filled
onClick={handleFavouriteClick} onClick={handleFavouriteClick}
active={Boolean(meEmojiReact)} active={Boolean(meEmojiName)}
count={favouriteCount} count={favouriteCount}
text={withLabels ? meEmojiTitle : undefined} text={withLabels ? meEmojiTitle : undefined}
/> />

View File

@ -4,6 +4,8 @@ import React from 'react';
import { Text, Icon, Emoji } from 'soapbox/components/ui'; import { Text, Icon, Emoji } from 'soapbox/components/ui';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { shortNumberFormat } from 'soapbox/utils/numbers';
import type { Map as ImmutableMap } from 'immutable';
const COLORS = { const COLORS = {
accent: 'accent', accent: 'accent',
success: 'success', success: 'success',
@ -31,7 +33,7 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonEleme
active?: boolean active?: boolean
color?: Color color?: Color
filled?: boolean filled?: boolean
emoji?: string emoji?: ImmutableMap<string, any>
text?: React.ReactNode text?: React.ReactNode
} }
@ -42,7 +44,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
if (emoji) { if (emoji) {
return ( return (
<span className='flex h-6 w-6 items-center justify-center'> <span className='flex h-6 w-6 items-center justify-center'>
<Emoji className='h-full w-full p-0.5' emoji={emoji} /> <Emoji className='h-full w-full p-0.5' emoji={emoji.get('name')} src={emoji.get('url')} />
</span> </span>
); );
} else { } else {

View File

@ -60,9 +60,9 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
} }
}; };
const handleReact = (emoji: string): void => { const handleReact = (emoji: string, custom?: string): void => {
if (ownAccount) { if (ownAccount) {
dispatch(simpleEmojiReact(status, emoji)); dispatch(simpleEmojiReact(status, emoji, custom));
} else { } else {
handleUnauthorized(); handleUnauthorized();
} }
@ -71,7 +71,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
}; };
const handleClick: React.EventHandler<React.MouseEvent> = e => { const handleClick: React.EventHandler<React.MouseEvent> = e => {
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍'; const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || '👍';
if (isUserTouching()) { if (isUserTouching()) {
if (ownAccount) { if (ownAccount) {

View File

@ -5,9 +5,9 @@ import { usePopper } from 'react-popper';
import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui'; import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown'; import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown';
import { useSoapboxConfig } from 'soapbox/hooks'; import { useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import type { Emoji, NativeEmoji } from 'soapbox/features/emoji'; import type { Emoji } from 'soapbox/features/emoji';
interface IEmojiButton { interface IEmojiButton {
/** Unicode emoji character. */ /** Unicode emoji character. */
@ -39,7 +39,7 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
interface IEmojiSelector { interface IEmojiSelector {
onClose?(): void onClose?(): void
/** Event handler when an emoji is clicked. */ /** Event handler when an emoji is clicked. */
onReact(emoji: string): void onReact(emoji: string, custom?: string): void
/** Element that triggers the EmojiSelector Popper */ /** Element that triggers the EmojiSelector Popper */
referenceElement: HTMLElement | null referenceElement: HTMLElement | null
placement?: Placement placement?: Placement
@ -62,6 +62,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
all = true, all = true,
}): JSX.Element => { }): JSX.Element => {
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
const { customEmojiReacts } = useFeatures();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -102,7 +103,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
}; };
const handlePickEmoji = (emoji: Emoji) => { const handlePickEmoji = (emoji: Emoji) => {
onReact((emoji as NativeEmoji).native); onReact(emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined);
}; };
useEffect(() => () => { useEffect(() => () => {
@ -148,7 +149,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
visible={expanded} visible={expanded}
setVisible={setExpanded} setVisible={setExpanded}
update={update} update={update}
withCustom={false} withCustom={customEmojiReacts}
onPickEmoji={handlePickEmoji} onPickEmoji={handlePickEmoji}
/> />
) : ( ) : (

View File

@ -10,7 +10,7 @@ interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
/** A single emoji image. */ /** A single emoji image. */
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => { const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const { emoji, alt, ...rest } = props; const { emoji, alt, src, ...rest } = props;
const codepoints = toCodePoints(removeVS16s(emoji)); const codepoints = toCodePoints(removeVS16s(emoji));
const filename = codepoints.join('-'); const filename = codepoints.join('-');
@ -20,7 +20,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
<img <img
draggable='false' draggable='false'
alt={alt || emoji} alt={alt || emoji}
src={joinPublicPath(`packs/emoji/${filename}.svg`)} src={src || joinPublicPath(`packs/emoji/${filename}.svg`)}
{...rest} {...rest}
/> />
); );

View File

@ -28,7 +28,7 @@ export interface CustomEmoji {
export interface NativeEmoji { export interface NativeEmoji {
id: string id: string
colons: string colons: string
custom?: boolean custom?: false
unified: string unified: string
native: string native: string
} }

View File

@ -269,6 +269,7 @@ const Notification: React.FC<INotificaton> = (props) => {
return ( return (
<Emoji <Emoji
emoji={notification.emoji} emoji={notification.emoji}
src={notification.emoji_url || undefined}
className='h-4 w-4 flex-none' className='h-4 w-4 flex-none'
/> />
); );

View File

@ -154,6 +154,7 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
key={i} key={i}
className='h-4.5 w-4.5 flex-none' className='h-4.5 w-4.5 flex-none'
emoji={e.get('name')} emoji={e.get('name')}
src={e.get('url')}
/> />
); );
})} })}

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions'; import { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions';
@ -17,6 +17,12 @@ const messages = defineMessages({
all: { id: 'reactions.all', defaultMessage: 'All' }, all: { id: 'reactions.all', defaultMessage: 'All' },
}); });
interface IAccountWithReaction {
id: string
reaction: string
reactionUrl?: string
}
interface IReactionsModal { interface IReactionsModal {
onClose: (string: string) => void onClose: (string: string) => void
statusId: string statusId: string
@ -54,7 +60,7 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
reactions!.forEach(reaction => items.push( reactions!.forEach(reaction => items.push(
{ {
text: <div className='flex items-center gap-1'> text: <div className='flex items-center gap-1'>
<Emoji className='h-4 w-4' emoji={reaction.name} /> <Emoji className='h-4 w-4' emoji={reaction.name} src={reaction.url || undefined} />
{reaction.count} {reaction.count}
</div>, </div>,
action: () => setReaction(reaction.name), action: () => setReaction(reaction.name),
@ -65,17 +71,25 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
return <Tabs items={items} activeItem={reaction || 'all'} />; return <Tabs items={items} activeItem={reaction || 'all'} />;
}; };
const accounts = useMemo((): ImmutableList<IAccountWithReaction> | undefined => {
if (!reactions) return;
if (reaction) {
const reactionRecord = reactions.find(({ name }) => name === reaction);
if (reactionRecord) return reactionRecord.accounts.map(account => ({ id: account, reaction: reaction, reactionUrl: reactionRecord.url || undefined })).toList();
} else {
return reactions.map(({ accounts, name, url }) => accounts.map(account => ({ id: account, reaction: name, reactionUrl: url }))).flatten() as ImmutableList<IAccountWithReaction>;
}
}, [reactions, reaction]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, []); }, []);
const accounts = reactions && (reaction
? reactions.find(({ name }) => name === reaction)?.accounts.map(account => ({ id: account, reaction: reaction }))
: reactions.map(({ accounts, name }) => accounts.map(account => ({ id: account, reaction: name }))).flatten()) as ImmutableList<{ id: string, reaction: string }>;
let body; let body;
if (!accounts) { if (!accounts || !reactions) {
body = <Spinner />; body = <Spinner />;
} else { } else {
const emptyMessage = <FormattedMessage id='status.reactions.empty' defaultMessage='No one has reacted to this post yet. When someone does, they will show up here.' />; const emptyMessage = <FormattedMessage id='status.reactions.empty' defaultMessage='No one has reacted to this post yet. When someone does, they will show up here.' />;
@ -91,7 +105,7 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
itemClassName='pb-3' itemClassName='pb-3'
> >
{accounts.map((account) => {accounts.map((account) =>
<AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} emoji={account.reaction} />, <AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} emoji={account.reaction} emojiUrl={account.reactionUrl} />,
)} )}
</ScrollableList> </ScrollableList>
</>); </>);

View File

@ -17,6 +17,7 @@ export const NotificationRecord = ImmutableRecord({
chat_message: null as ImmutableMap<string, any> | string | null, // pleroma:chat_mention chat_message: null as ImmutableMap<string, any> | string | null, // pleroma:chat_mention
created_at: new Date(), created_at: new Date(),
emoji: null as string | null, // pleroma:emoji_reaction emoji: null as string | null, // pleroma:emoji_reaction
emoji_url: null as string | null, // pleroma:emoji_reaction
id: '', id: '',
status: null as EmbeddedEntity<Status>, status: null as EmbeddedEntity<Status>,
target: null as EmbeddedEntity<Account>, // move target: null as EmbeddedEntity<Account>, // move

View File

@ -242,7 +242,7 @@ export default function statuses(state = initialState, action: AnyAction): State
return state return state
.updateIn( .updateIn(
[action.status.get('id'), 'pleroma', 'emoji_reactions'], [action.status.get('id'), 'pleroma', 'emoji_reactions'],
emojiReacts => simulateEmojiReact(emojiReacts as any, action.emoji), emojiReacts => simulateEmojiReact(emojiReacts as any, action.emoji, action.custom),
); );
case UNEMOJI_REACT_REQUEST: case UNEMOJI_REACT_REQUEST:
return state return state

View File

@ -82,6 +82,7 @@ export const ReactionRecord = ImmutableRecord({
accounts: ImmutableOrderedSet<string>(), accounts: ImmutableOrderedSet<string>(),
count: 0, count: 0,
name: '', name: '',
url: null as string | null,
}); });
const ReactionListRecord = ImmutableRecord({ const ReactionListRecord = ImmutableRecord({

View File

@ -52,15 +52,15 @@ describe('mergeEmojiFavourites', () => {
describe('with existing 👍 reacts', () => { describe('with existing 👍 reacts', () => {
const emojiReacts = fromJS([ const emojiReacts = fromJS([
{ 'count': 20, 'me': false, 'name': '👍' }, { 'count': 20, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 15, 'me': false, 'name': '❤' }, { 'count': 15, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 7, 'me': false, 'name': '😯' }, { 'count': 7, 'me': false, 'name': '😯', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>; ]) as ImmutableList<ImmutableMap<string, any>>;
it('combines 👍 reacts with favourites', () => { it('combines 👍 reacts with favourites', () => {
expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([ expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([
{ 'count': 32, 'me': true, 'name': '👍' }, { 'count': 32, 'me': true, 'name': '👍', 'url': undefined },
{ 'count': 15, 'me': false, 'name': '❤' }, { 'count': 15, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 7, 'me': false, 'name': '😯' }, { 'count': 7, 'me': false, 'name': '😯', 'url': undefined },
])); ]));
}); });
}); });
@ -146,12 +146,12 @@ describe('getReactForStatus', () => {
], ],
}, },
})); }));
expect(getReactForStatus(status, ALLOWED_EMOJI)).toEqual('❤'); expect(getReactForStatus(status, ALLOWED_EMOJI)?.get('name')).toEqual('❤');
}); });
it('returns a thumbs-up for a favourite', () => { it('returns a thumbs-up for a favourite', () => {
const status = normalizeStatus(fromJS({ favourites_count: 1, favourited: true })); const status = normalizeStatus(fromJS({ favourites_count: 1, favourited: true }));
expect(getReactForStatus(status)).toEqual('👍'); expect(getReactForStatus(status)?.get('name')).toEqual('👍');
}); });
it('returns undefined when a status has no reacts (or favourites)', () => { it('returns undefined when a status has no reacts (or favourites)', () => {
@ -173,24 +173,36 @@ describe('getReactForStatus', () => {
describe('simulateEmojiReact', () => { describe('simulateEmojiReact', () => {
it('adds the emoji to the list', () => { it('adds the emoji to the list', () => {
const emojiReacts = fromJS([ const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>; ]) as ImmutableList<ImmutableMap<string, any>>;
expect(simulateEmojiReact(emojiReacts, '❤')).toEqual(fromJS([ expect(simulateEmojiReact(emojiReacts, '❤')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 3, 'me': true, 'name': '❤' }, { 'count': 3, 'me': true, 'name': '❤', 'url': undefined },
])); ]));
}); });
it('creates the emoji if it didn\'t already exist', () => { it('creates the emoji if it didn\'t already exist', () => {
const emojiReacts = fromJS([ const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>; ]) as ImmutableList<ImmutableMap<string, any>>;
expect(simulateEmojiReact(emojiReacts, '😯')).toEqual(fromJS([ expect(simulateEmojiReact(emojiReacts, '😯')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 1, 'me': true, 'name': '😯' }, { 'count': 1, 'me': true, 'name': '😯', 'url': undefined },
]));
});
it('adds a custom emoji to the list', () => {
const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>;
expect(simulateEmojiReact(emojiReacts, 'soapbox', 'https://gleasonator.com/emoji/Gleasonator/soapbox.png')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 1, 'me': true, 'name': 'soapbox', 'url': 'https://gleasonator.com/emoji/Gleasonator/soapbox.png' },
])); ]));
}); });
}); });
@ -218,4 +230,16 @@ describe('simulateUnEmojiReact', () => {
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤' },
])); ]));
}); });
it ('removes custom emoji from the list', () => {
const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
{ 'count': 1, 'me': true, 'name': 'soapbox', 'url': 'https://gleasonator.com/emoji/Gleasonator/soapbox.png' },
]) as ImmutableList<ImmutableMap<string, any>>;
expect(simulateUnEmojiReact(emojiReacts, 'soapbox')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
]));
});
}); });

View File

@ -74,19 +74,19 @@ export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReact>, favouritesCo
allowedEmoji, allowedEmoji,
)); ));
export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): string | undefined => { export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): EmojiReact | undefined => {
const result = reduceEmoji( const result = reduceEmoji(
status.pleroma.get('emoji_reactions', ImmutableList()), status.pleroma.get('emoji_reactions', ImmutableList()),
status.favourites_count || 0, status.favourites_count || 0,
status.favourited, status.favourited,
allowedEmoji, allowedEmoji,
).filter(e => e.get('me') === true) ).filter(e => e.get('me') === true)
.getIn([0, 'name']); .get(0);
return typeof result === 'string' ? result : undefined; return typeof result?.get('name') === 'string' ? result : undefined;
}; };
export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji: string) => { export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji: string, url?: string) => {
const idx = emojiReacts.findIndex(e => e.get('name') === emoji); const idx = emojiReacts.findIndex(e => e.get('name') === emoji);
const emojiReact = emojiReacts.get(idx); const emojiReact = emojiReacts.get(idx);
@ -94,12 +94,14 @@ export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji
return emojiReacts.set(idx, emojiReact.merge({ return emojiReacts.set(idx, emojiReact.merge({
count: emojiReact.get('count') + 1, count: emojiReact.get('count') + 1,
me: true, me: true,
url,
})); }));
} else { } else {
return emojiReacts.push(ImmutableMap({ return emojiReacts.push(ImmutableMap({
count: 1, count: 1,
me: true, me: true,
name: emoji, name: emoji,
url,
})); }));
} }
}; };

View File

@ -324,6 +324,11 @@ const getInstanceFeatures = (instance: Instance) => {
v.software === TAKAHE, v.software === TAKAHE,
]), ]),
/**
* Ability to add non-standard reactions to a status.
*/
customEmojiReacts: v.software === PLEROMA && gte(v.version, '2.5.50'),
/** /**
* Legacy DMs timeline where messages are displayed chronologically without groupings. * Legacy DMs timeline where messages are displayed chronologically without groupings.
* @see GET /api/v1/timelines/direct * @see GET /api/v1/timelines/direct