Add "This Message" reactions

This commit is contained in:
yash-signal
2025-06-06 12:44:38 -05:00
committed by GitHub
parent ac58f3178e
commit 9e3f397032
12 changed files with 503 additions and 225 deletions
+4
View File
@@ -3104,6 +3104,10 @@
"messageformat": "Search Results",
"description": "FunPicker > Emojis Panel > Section Title > Search Results"
},
"icu:FunPanelEmojis__SectionTitle--ThisMessage": {
"messageformat": "This Message",
"description": "FunPicker > Emojis Panel > Section Title > Recents > This Message"
},
"icu:FunPanelEmojis__SectionTitle--Recents": {
"messageformat": "Recently Used",
"description": "FunPicker > Emojis Panel > Section Title > Recents"
+207 -184
View File
@@ -9,11 +9,11 @@ import type {
ReactNode,
RefObject,
} from 'react';
import React from 'react';
import React, { forwardRef, useRef } from 'react';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import getDirection from 'direction';
import { drop, groupBy, orderBy, take, unescape } from 'lodash';
import { drop, take, unescape } from 'lodash';
import { Manager, Popper, Reference } from 'react-popper';
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
import type { ReadonlyDeep } from 'type-fest';
@@ -40,7 +40,10 @@ import { ContactName } from './ContactName';
import type { QuotedAttachmentForUIType } from './Quote';
import { Quote } from './Quote';
import { EmbeddedContact } from './EmbeddedContact';
import type { OwnProps as ReactionViewerProps } from './ReactionViewer';
import type {
OwnProps as ReactionViewerProps,
Reaction,
} from './ReactionViewer';
import { ReactionViewer } from './ReactionViewer';
import { LinkPreviewDate } from './LinkPreviewDate';
import type { LinkPreviewForUIType } from '../../types/message/LinkPreviews';
@@ -84,7 +87,6 @@ import type {
CustomColorType,
} from '../../types/Colors';
import { createRefMerger } from '../../util/refMerger';
import { emojiToData } from '../emoji/lib';
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
import type { ServiceIdString } from '../../types/ServiceId';
import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations';
@@ -118,6 +120,7 @@ import {
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from '../fun/data/emojis';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
@@ -444,6 +447,191 @@ type State = {
hasDeleteForEveryoneTimerExpired: boolean;
};
// Function component for reactions that can use hooks
type MessageReactionsProps = {
reactions: Array<Reaction>;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
theme: ThemeType;
outgoing: boolean;
toggleReactionViewer: (onlyRemove?: boolean) => void;
reactionViewerRoot: HTMLDivElement | null;
popperPreventOverflowModifier: () => Partial<PreventOverflowModifier>;
};
const MessageReactions = forwardRef(function MessageReactions(
{
reactions,
getPreferredBadge,
i18n,
theme,
outgoing,
toggleReactionViewer,
reactionViewerRoot,
popperPreventOverflowModifier,
}: MessageReactionsProps,
parentRef
): JSX.Element {
const ordered = useGroupedAndOrderedReactions(reactions);
const reactionsContainerRefMerger = useRef(createRefMerger());
// Take the first three groups for rendering
const toRender = take(ordered, 3).map(res => {
const isMe = res.some(re => Boolean(re.from.isMe));
const count = res.length;
const { emoji } = res[0];
let label: string;
if (isMe) {
label = i18n('icu:Message__reaction-emoji-label--you', { emoji });
} else if (count === 1) {
label = i18n('icu:Message__reaction-emoji-label--single', {
title: res[0].from.title,
emoji,
});
} else {
label = i18n('icu:Message__reaction-emoji-label--many', {
count,
emoji,
});
}
return {
count,
emoji,
isMe,
label,
};
});
const someNotRendered = ordered.length > 3;
// We only drop two here because the third emoji would be replaced by the
// more button
const maybeNotRendered = drop(ordered, 2);
const maybeNotRenderedTotal = maybeNotRendered.reduce(
(sum, res) => sum + res.length,
0
);
const notRenderedIsMe =
someNotRendered &&
maybeNotRendered.some(res => res.some(re => Boolean(re.from.isMe)));
const popperPlacement = outgoing ? 'bottom-end' : 'bottom-start';
return (
<Manager>
<Reference>
{({ ref: popperRef }) => (
<div
ref={reactionsContainerRefMerger.current(parentRef, popperRef)}
className={classNames(
'module-message__reactions',
outgoing
? 'module-message__reactions--outgoing'
: 'module-message__reactions--incoming'
)}
onDoubleClick={ev => {
ev.stopPropagation();
}}
>
{toRender.map((re, i) => {
const isLast = i === toRender.length - 1;
const isMore = isLast && someNotRendered;
const isMoreWithMe = isMore && notRenderedIsMe;
return (
<button
aria-label={re.label}
type="button"
// eslint-disable-next-line react/no-array-index-key
key={`${re.emoji}-${i}`}
className={classNames(
'module-message__reactions__reaction',
re.count > 1
? 'module-message__reactions__reaction--with-count'
: null,
outgoing
? 'module-message__reactions__reaction--outgoing'
: 'module-message__reactions__reaction--incoming',
isMoreWithMe || (re.isMe && !isMoreWithMe)
? 'module-message__reactions__reaction--is-me'
: null
)}
onClick={e => {
e.stopPropagation();
e.preventDefault();
toggleReactionViewer(false);
}}
onKeyDown={e => {
// Prevent enter key from opening stickers/attachments
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
>
{isMore ? (
<span
className={classNames(
'module-message__reactions__reaction__count',
'module-message__reactions__reaction__count--no-emoji',
isMoreWithMe
? 'module-message__reactions__reaction__count--is-me'
: null
)}
>
+{maybeNotRenderedTotal}
</span>
) : (
<>
<ReactionEmoji emojiVariantValue={re.emoji} />
{re.count > 1 ? (
<span
className={classNames(
'module-message__reactions__reaction__count',
re.isMe
? 'module-message__reactions__reaction__count--is-me'
: null
)}
>
{re.count}
</span>
) : null}
</>
)}
</button>
);
})}
</div>
)}
</Reference>
{reactionViewerRoot &&
createPortal(
<Popper
placement={popperPlacement}
strategy="fixed"
modifiers={[popperPreventOverflowModifier()]}
>
{({ ref, style }) => (
<ReactionViewer
ref={ref}
style={{
...style,
zIndex: 2,
}}
getPreferredBadge={getPreferredBadge}
reactions={reactions}
i18n={i18n}
onClose={toggleReactionViewer}
theme={theme}
/>
)}
</Popper>,
reactionViewerRoot
)}
</Manager>
);
});
export class Message extends React.PureComponent<Props, State> {
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
@@ -458,8 +646,6 @@ export class Message extends React.PureComponent<Props, State> {
#metadataRef: React.RefObject<HTMLDivElement> = React.createRef();
public reactionsContainerRefMerger = createRefMerger();
public expirationCheckInterval: NodeJS.Timeout | undefined;
public giftBadgeInterval: NodeJS.Timeout | undefined;
@@ -2733,7 +2919,7 @@ export class Message extends React.PureComponent<Props, State> {
);
}
#popperPreventOverflowModifier(): Partial<PreventOverflowModifier> {
#popperPreventOverflowModifier = (): Partial<PreventOverflowModifier> => {
const { containerElementRef } = this.props;
return {
name: 'preventOverflow',
@@ -2748,7 +2934,7 @@ export class Message extends React.PureComponent<Props, State> {
},
},
};
}
};
public toggleReactionViewer = (onlyRemove = false): void => {
this.setState(oldState => {
@@ -2796,185 +2982,22 @@ export class Message extends React.PureComponent<Props, State> {
return null;
}
const reactionsWithEmojiData = reactions.map(reaction => ({
...reaction,
...emojiToData(reaction.emoji),
}));
// Group by emoji and order each group by timestamp descending
const groupedAndSortedReactions = Object.values(
groupBy(reactionsWithEmojiData, 'short_name')
).map(groupedReactions =>
orderBy(
groupedReactions,
[reaction => reaction.from.isMe, 'timestamp'],
['desc', 'desc']
)
);
// Order groups by length and subsequently by most recent reaction
const ordered = orderBy(
groupedAndSortedReactions,
['length', ([{ timestamp }]) => timestamp],
['desc', 'desc']
);
// Take the first three groups for rendering
const toRender = take(ordered, 3).map(res => {
const isMe = res.some(re => Boolean(re.from.isMe));
const count = res.length;
const { emoji } = res[0];
let label: string;
if (isMe) {
label = i18n('icu:Message__reaction-emoji-label--you', { emoji });
} else if (count === 1) {
label = i18n('icu:Message__reaction-emoji-label--single', {
title: res[0].from.title,
emoji,
});
} else {
label = i18n('icu:Message__reaction-emoji-label--many', {
count,
emoji,
});
}
return {
count,
emoji,
isMe,
label,
};
});
const someNotRendered = ordered.length > 3;
// We only drop two here because the third emoji would be replaced by the
// more button
const maybeNotRendered = drop(ordered, 2);
const maybeNotRenderedTotal = maybeNotRendered.reduce(
(sum, res) => sum + res.length,
0
);
const notRenderedIsMe =
someNotRendered &&
maybeNotRendered.some(res => res.some(re => Boolean(re.from.isMe)));
const { reactionViewerRoot } = this.state;
const popperPlacement = outgoing ? 'bottom-end' : 'bottom-start';
return (
<Manager>
<Reference>
{({ ref: popperRef }) => (
<div
ref={this.reactionsContainerRefMerger(
this.reactionsContainerRef,
popperRef
)}
className={classNames(
'module-message__reactions',
outgoing
? 'module-message__reactions--outgoing'
: 'module-message__reactions--incoming'
)}
onDoubleClick={ev => {
ev.stopPropagation();
}}
>
{toRender.map((re, i) => {
const isLast = i === toRender.length - 1;
const isMore = isLast && someNotRendered;
const isMoreWithMe = isMore && notRenderedIsMe;
return (
<button
aria-label={re.label}
type="button"
// eslint-disable-next-line react/no-array-index-key
key={`${re.emoji}-${i}`}
className={classNames(
'module-message__reactions__reaction',
re.count > 1
? 'module-message__reactions__reaction--with-count'
: null,
outgoing
? 'module-message__reactions__reaction--outgoing'
: 'module-message__reactions__reaction--incoming',
isMoreWithMe || (re.isMe && !isMoreWithMe)
? 'module-message__reactions__reaction--is-me'
: null
)}
onClick={e => {
e.stopPropagation();
e.preventDefault();
this.toggleReactionViewer(false);
}}
onKeyDown={e => {
// Prevent enter key from opening stickers/attachments
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
>
{isMore ? (
<span
className={classNames(
'module-message__reactions__reaction__count',
'module-message__reactions__reaction__count--no-emoji',
isMoreWithMe
? 'module-message__reactions__reaction__count--is-me'
: null
)}
>
+{maybeNotRenderedTotal}
</span>
) : (
<>
<ReactionEmoji emojiVariantValue={re.emoji} />
{re.count > 1 ? (
<span
className={classNames(
'module-message__reactions__reaction__count',
re.isMe
? 'module-message__reactions__reaction__count--is-me'
: null
)}
>
{re.count}
</span>
) : null}
</>
)}
</button>
);
})}
</div>
)}
</Reference>
{reactionViewerRoot &&
createPortal(
<Popper
placement={popperPlacement}
strategy="fixed"
modifiers={[this.#popperPreventOverflowModifier()]}
>
{({ ref, style }) => (
<ReactionViewer
ref={ref}
style={{
...style,
zIndex: 2,
}}
getPreferredBadge={getPreferredBadge}
reactions={reactions}
i18n={i18n}
onClose={this.toggleReactionViewer}
theme={theme}
/>
)}
</Popper>,
reactionViewerRoot
)}
</Manager>
<MessageReactions
reactions={reactions}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
theme={theme}
outgoing={outgoing}
toggleReactionViewer={() => {
this.toggleReactionViewer();
}}
reactionViewerRoot={reactionViewerRoot}
popperPreventOverflowModifier={this.#popperPreventOverflowModifier}
ref={this.reactionsContainerRef}
/>
);
}
@@ -13,7 +13,7 @@ import {
ReactionPickerPickerMoreButton,
ReactionPickerPickerStyle,
} from '../ReactionPickerPicker';
import type { EmojiSkinTone } from '../fun/data/emojis';
import type { EmojiSkinTone, EmojiVariantKey } from '../fun/data/emojis';
import { getEmojiVariantByKey } from '../fun/data/emojis';
import { FunEmojiPicker } from '../fun/FunEmojiPicker';
import type { FunEmojiSelection } from '../fun/panels/FunPanelEmojis';
@@ -37,6 +37,7 @@ export type OwnProps = {
preferredReactionEmoji: ReadonlyArray<string>;
renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement;
theme?: ThemeType;
messageEmojis?: ReadonlyArray<EmojiVariantKey>;
};
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
@@ -54,6 +55,7 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
selected,
style,
theme,
messageEmojis,
},
ref
) {
@@ -156,6 +158,7 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
theme={theme}
showCustomizePreferredReactionsButton
closeOnSelect
messageEmojis={messageEmojis}
>
<Button
aria-label={i18n('icu:Reactions--more')}
+24 -1
View File
@@ -3,7 +3,13 @@
import classNames from 'classnames';
import { noop } from 'lodash';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { Ref } from 'react';
import { ContextMenuTrigger } from 'react-contextmenu';
import { createPortal } from 'react-dom';
@@ -43,6 +49,8 @@ import {
useHandleMessageContextMenu,
} from './MessageContextMenu';
import { ForwardMessagesModalType } from '../ForwardMessagesModal';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions';
import { isNotNil } from '../../util/isNotNil';
export type PropsData = {
canDownload: boolean;
@@ -284,6 +292,19 @@ export function TimelineMessage(props: Props): JSX.Element {
toggleReactionPickerKeyboard
);
const groupedReactions = useGroupedAndOrderedReactions(
props.reactions,
'variantKey'
);
const messageEmojis = useMemo(() => {
return groupedReactions
.map(groupedReaction => {
return groupedReaction?.[0]?.variantKey;
})
.filter(isNotNil);
}, [groupedReactions]);
const renderMenu = useCallback(() => {
return (
<Manager>
@@ -321,6 +342,7 @@ export function TimelineMessage(props: Props): JSX.Element {
});
},
renderEmojiPicker,
messageEmojis,
})
}
</Popper>,
@@ -348,6 +370,7 @@ export function TimelineMessage(props: Props): JSX.Element {
renderEmojiPicker,
toggleReactionPicker,
id,
messageEmojis,
]);
return (
+36 -7
View File
@@ -6,10 +6,11 @@ import { action } from '@storybook/addon-actions';
import { type ComponentMeta } from '../../storybook/types';
import type { FunEmojiPickerProps } from './FunEmojiPicker';
import { FunEmojiPicker } from './FunEmojiPicker';
import { MOCK_RECENT_EMOJIS } from './mocks';
import { MOCK_RECENT_EMOJIS, MOCK_THIS_MESSAGE_EMOJIS } from './mocks';
import { FunProvider } from './FunProvider';
import { packs, recentStickers } from '../stickers/mocks';
import { EmojiSkinTone } from './data/emojis';
import { Select } from '../Select';
const { i18n } = window.SignalContext;
@@ -18,13 +19,27 @@ type TemplateProps = Omit<
'open' | 'onOpenChange' | 'children'
>;
const skinToneOptions = [
{ value: EmojiSkinTone.None, text: 'Default' },
{ value: EmojiSkinTone.Type1, text: 'Light Skin Tone' },
{ value: EmojiSkinTone.Type2, text: 'Medium-Light Skin Tone' },
{ value: EmojiSkinTone.Type3, text: 'Medium Skin Tone' },
{ value: EmojiSkinTone.Type4, text: 'Medium-Dark Skin Tone' },
{ value: EmojiSkinTone.Type5, text: 'Dark Skin Tone' },
];
function Template(props: TemplateProps): JSX.Element {
const [open, setOpen] = useState(true);
const [skinTone, setSkinTone] = useState(EmojiSkinTone.None);
const handleOpenChange = useCallback((openState: boolean) => {
setOpen(openState);
}, []);
const handleSkinToneChange = useCallback((value: string) => {
setSkinTone(value as EmojiSkinTone);
}, []);
return (
<FunProvider
i18n={i18n}
@@ -33,10 +48,10 @@ function Template(props: TemplateProps): JSX.Element {
recentStickers={recentStickers}
recentGifs={[]}
// Emojis
emojiSkinToneDefault={EmojiSkinTone.None}
onEmojiSkinToneDefaultChange={() => null}
emojiSkinToneDefault={skinTone}
onEmojiSkinToneDefaultChange={handleSkinToneChange}
onOpenCustomizePreferredReactionsModal={() => null}
onSelectEmoji={() => null}
onSelectEmoji={action('onSelectEmoji')}
// Stickers
installedStickerPacks={packs}
showStickerPickerHint={false}
@@ -48,9 +63,18 @@ function Template(props: TemplateProps): JSX.Element {
fetchGif={() => Promise.reject()}
onSelectGif={() => null}
>
<FunEmojiPicker {...props} open={open} onOpenChange={handleOpenChange}>
<Button>Open EmojiPicker</Button>
</FunEmojiPicker>
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
<Select
ariaLabel="Emoji skin tone"
options={skinToneOptions}
value={skinTone}
onChange={handleSkinToneChange}
moduleClassName="emoji-skin-tone-select"
/>
<FunEmojiPicker {...props} open={open} onOpenChange={handleOpenChange}>
<Button>Open EmojiPicker</Button>
</FunEmojiPicker>
</div>
</FunProvider>
);
}
@@ -64,9 +88,14 @@ export default {
onSelectEmoji: action('onSelectEmoji'),
showCustomizePreferredReactionsButton: false,
closeOnSelect: true,
messageEmojis: [],
},
} satisfies ComponentMeta<TemplateProps>;
export function Default(props: TemplateProps): JSX.Element {
return <Template {...props} />;
}
export function WithThisMessageReactions(props: TemplateProps): JSX.Element {
return <Template {...props} messageEmojis={MOCK_THIS_MESSAGE_EMOJIS} />;
}
+3
View File
@@ -10,6 +10,7 @@ import { FunPanelEmojis } from './panels/FunPanelEmojis';
import { useFunContext } from './FunProvider';
import type { ThemeType } from '../../types/Util';
import { FunErrorBoundary } from './base/FunErrorBoundary';
import type { EmojiVariantKey } from './data/emojis';
export type FunEmojiPickerProps = Readonly<{
open: boolean;
@@ -20,6 +21,7 @@ export type FunEmojiPickerProps = Readonly<{
showCustomizePreferredReactionsButton?: boolean;
closeOnSelect: boolean;
children: ReactNode;
messageEmojis?: ReadonlyArray<EmojiVariantKey>;
}>;
export const FunEmojiPicker = memo(function FunEmojiPicker(
@@ -53,6 +55,7 @@ export const FunEmojiPicker = memo(function FunEmojiPicker(
props.showCustomizePreferredReactionsButton ?? false
}
closeOnSelect={props.closeOnSelect}
messageEmojis={props.messageEmojis}
/>
</FunErrorBoundary>
</FunPopover>
+10 -2
View File
@@ -21,6 +21,10 @@ export enum FunGifsCategory {
Angry = 'Angry',
}
export enum FunEmojisBase {
ThisMessage = 'ThisMessage',
}
export enum FunSectionCommon {
SearchResults = 'SearchResults',
Recents = 'Recents',
@@ -48,7 +52,10 @@ export function toFunStickersPackSection(
return `StickerPack:${pack.id}` as FunStickersPackSection;
}
export type FunEmojisSection = FunSectionCommon | EmojiPickerCategory;
export type FunEmojisSection =
| FunSectionCommon
| EmojiPickerCategory
| FunEmojisBase;
export type FunStickersSection =
| FunSectionCommon
| FunStickersSectionBase
@@ -56,8 +63,9 @@ export type FunStickersSection =
export type FunGifsSection = FunSectionCommon | FunGifsCategory;
export const FunEmojisSectionOrder: ReadonlyArray<
FunSectionCommon.Recents | EmojiPickerCategory
FunSectionCommon.Recents | FunEmojisBase.ThisMessage | EmojiPickerCategory
> = [
FunEmojisBase.ThisMessage,
FunSectionCommon.Recents,
EmojiPickerCategory.SmileysAndPeople,
EmojiPickerCategory.AnimalsAndNature,
+22 -4
View File
@@ -247,6 +247,7 @@ type EmojiIndex = Readonly<{
variantByKey: Map<EmojiVariantKey, EmojiVariantData>;
variantKeysByValue: Map<EmojiVariantValue, EmojiVariantKey>;
variantKeysByValueNonQualified: Map<EmojiVariantValue, EmojiVariantKey>;
variantKeyToSkinTone: Map<EmojiVariantKey, EmojiSkinTone>;
unicodeCategories: Record<EmojiUnicodeCategory, Array<EmojiParentKey>>;
pickerCategories: Record<EmojiPickerCategory, Array<EmojiParentKey>>;
@@ -268,6 +269,7 @@ const EMOJI_INDEX: EmojiIndex = {
variantByKey: new Map(),
variantKeysByValue: new Map(),
variantKeysByValueNonQualified: new Map(),
variantKeyToSkinTone: new Map(),
unicodeCategories: {
[EmojiUnicodeCategory.SmileysAndEmotion]: [],
[EmojiUnicodeCategory.PeopleAndBody]: [],
@@ -397,6 +399,7 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
throw new Error(`Missing variant key ${parentKey} -> ${key} (${keys})`);
}
result[skinTone] = variantKey;
EMOJI_INDEX.variantKeyToSkinTone.set(variantKey, skinTone);
}
defaultSkinToneVariants = result as EmojiDefaultSkinToneVariants;
@@ -506,21 +509,36 @@ export function getEmojiPickerCategoryParentKeys(
/**
* Apply a skin tone (if possible) to any parent key.
*/
export function getEmojiVariantByParentKeyAndSkinTone(
export function getEmojiVariantKeyByParentKeyAndSkinTone(
key: EmojiParentKey,
skinTone: EmojiSkinTone
): EmojiVariantData {
): EmojiVariantKey {
const parent = getEmojiParentByKey(key);
const skinToneVariants = parent.defaultSkinToneVariants;
if (skinTone === EmojiSkinTone.None || skinToneVariants == null) {
return getEmojiVariantByKey(parent.defaultVariant);
return parent.defaultVariant;
}
const variantKey = skinToneVariants[skinTone];
strictAssert(variantKey, `Missing skin tone variant for ${skinTone}`);
return getEmojiVariantByKey(variantKey);
return variantKey;
}
export function getEmojiVariantByParentKeyAndSkinTone(
key: EmojiParentKey,
skinTone: EmojiSkinTone
): EmojiVariantData {
return getEmojiVariantByKey(
getEmojiVariantKeyByParentKeyAndSkinTone(key, skinTone)
);
}
export function getEmojiSkinToneByVariantKey(
variantKey: EmojiVariantKey
): EmojiSkinTone {
return EMOJI_INDEX.variantKeyToSkinTone.get(variantKey) ?? EmojiSkinTone.None;
}
/** @deprecated */
+24 -1
View File
@@ -2,9 +2,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { strictAssert } from '../../util/assert';
import type { EmojiParentKey } from './data/emojis';
import type { EmojiParentKey, EmojiVariantKey } from './data/emojis';
import {
EmojiSkinTone,
getEmojiParentKeyByEnglishShortName,
getEmojiVariantKeyByParentKeyAndSkinTone,
isEmojiEnglishShortName,
} from './data/emojis';
import type { GifsPaginated } from './data/gifs';
@@ -17,6 +19,27 @@ function getEmoji(input: string): EmojiParentKey {
return getEmojiParentKeyByEnglishShortName(input);
}
function getSkinToneEmoji(
input: string,
skinTone: EmojiSkinTone = EmojiSkinTone.None
): EmojiVariantKey {
strictAssert(
isEmojiEnglishShortName(input),
`Not an emoji short name ${input}`
);
const parentKey = getEmojiParentKeyByEnglishShortName(input);
return getEmojiVariantKeyByParentKeyAndSkinTone(parentKey, skinTone);
}
export const MOCK_THIS_MESSAGE_EMOJIS: ReadonlyArray<EmojiVariantKey> = [
getSkinToneEmoji('+1', EmojiSkinTone.None),
getSkinToneEmoji('+1', EmojiSkinTone.Type4),
getSkinToneEmoji('the_horns', EmojiSkinTone.Type1),
getSkinToneEmoji('the_horns', EmojiSkinTone.Type2),
getSkinToneEmoji('smile'),
getSkinToneEmoji('sweat_smile'),
];
export const MOCK_RECENT_EMOJIS: ReadonlyArray<EmojiParentKey> = [
getEmoji('grinning'),
getEmoji('grin'),
+80 -25
View File
@@ -15,7 +15,11 @@ import type { LocalizerType } from '../../../types/I18N';
import { strictAssert } from '../../../util/assert';
import { missingCaseError } from '../../../util/missingCaseError';
import type { FunEmojisSection } from '../constants';
import { FunEmojisSectionOrder, FunSectionCommon } from '../constants';
import {
FunEmojisBase,
FunEmojisSectionOrder,
FunSectionCommon,
} from '../constants';
import {
FunGridCell,
FunGridContainer,
@@ -53,8 +57,11 @@ import {
getEmojiParentByKey,
getEmojiPickerCategoryParentKeys,
getEmojiVariantByParentKeyAndSkinTone,
isEmojiParentKey,
normalizeShortNameCompletionDisplay,
isEmojiVariantKey,
getEmojiParentKeyByVariantKey,
getEmojiVariantByKey,
getEmojiSkinToneByVariantKey,
} from '../data/emojis';
import { useFunEmojiSearch } from '../useFunEmojiSearch';
import { FunKeyboard } from '../keyboard/FunKeyboard';
@@ -83,6 +90,9 @@ function getTitleForSection(
if (section === FunSectionCommon.Recents) {
return i18n('icu:FunPanelEmojis__SectionTitle--Recents');
}
if (section === FunEmojisBase.ThisMessage) {
return i18n('icu:FunPanelEmojis__SectionTitle--ThisMessage');
}
if (section === EmojiPickerCategory.SmileysAndPeople) {
return i18n('icu:FunPanelEmojis__SectionTitle--SmileysAndPeople');
}
@@ -120,7 +130,7 @@ const EMOJI_GRID_ROW_SIZE = EMOJI_GRID_CELL_HEIGHT;
function toGridSectionNode(
section: FunEmojisSection,
emojiParentKeys: ReadonlyArray<EmojiParentKey>
emojiKeys: ReadonlyArray<EmojiVariantKey>
): GridSectionNode {
return {
id: section,
@@ -128,10 +138,10 @@ function toGridSectionNode(
header: {
key: `header-${section}`,
},
cells: emojiParentKeys.map(emojiParentKey => {
cells: emojiKeys.map(emojiKey => {
return {
key: `cell-${section}-${emojiParentKey}`,
value: emojiParentKey,
key: `cell-${section}-${emojiKey}`,
value: emojiKey,
};
}),
};
@@ -149,6 +159,7 @@ export type FunPanelEmojisProps = Readonly<{
onClose: () => void;
showCustomizePreferredReactionsButton: boolean;
closeOnSelect: boolean;
messageEmojis?: ReadonlyArray<EmojiVariantKey>;
}>;
export function FunPanelEmojis({
@@ -156,6 +167,7 @@ export function FunPanelEmojis({
onClose,
showCustomizePreferredReactionsButton,
closeOnSelect,
messageEmojis: unstableMessageEmojis = [],
}: FunPanelEmojisProps): JSX.Element {
const fun = useFunContext();
const {
@@ -171,8 +183,9 @@ export function FunPanelEmojis({
const scrollerRef = useRef<HTMLDivElement>(null);
// Don't update recent emojis while the emoji panel is open
// Don't update recent emojis or this message emojis while the emoji panel is open
const [recentEmojis] = useState(unstableRecentEmojis);
const [messageEmojis] = useState(unstableMessageEmojis);
const [focusedCellKey, setFocusedCellKey] = useState<CellKey | null>(null);
const [skinTonePopoverOpen, setSkinTonePopoverOpen] = useState(false);
@@ -184,12 +197,17 @@ export function FunPanelEmojis({
const searchQuery = useMemo(() => fun.searchInput.trim(), [fun.searchInput]);
const sections = useMemo(() => {
const skinTone = fun.emojiSkinToneDefault ?? EmojiSkinTone.None;
if (searchQuery !== '') {
return [
toGridSectionNode(
FunSectionCommon.SearchResults,
searchEmojis(searchQuery).map(result => {
return result.parentKey;
return getEmojiVariantByParentKeyAndSkinTone(
result.parentKey,
skinTone
).key;
})
),
];
@@ -198,20 +216,50 @@ export function FunPanelEmojis({
const result: Array<GridSectionNode> = [];
for (const section of FunEmojisSectionOrder) {
if (section === FunEmojisBase.ThisMessage) {
if (messageEmojis.length > 0) {
result.push(
toGridSectionNode(FunEmojisBase.ThisMessage, messageEmojis)
);
}
continue;
}
if (section === FunSectionCommon.Recents) {
if (recentEmojis.length > 0) {
result.push(
toGridSectionNode(FunSectionCommon.Recents, recentEmojis)
toGridSectionNode(
FunSectionCommon.Recents,
recentEmojis.map(parentKey => {
return getEmojiVariantByParentKeyAndSkinTone(
parentKey,
skinTone
).key;
})
)
);
}
continue;
}
const emojiKeys = getEmojiPickerCategoryParentKeys(section);
result.push(toGridSectionNode(section, emojiKeys));
result.push(
toGridSectionNode(
section,
emojiKeys.map(parentKey => {
return getEmojiVariantByParentKeyAndSkinTone(parentKey, skinTone)
.key;
})
)
);
}
return result;
}, [recentEmojis, searchQuery, searchEmojis]);
}, [
fun.emojiSkinToneDefault,
searchQuery,
searchEmojis,
messageEmojis,
recentEmojis,
]);
const [virtualizer, layout] = useFunVirtualGrid({
scrollerRef,
@@ -557,24 +605,30 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
}, []);
const emojiParent = useMemo(() => {
strictAssert(
isEmojiParentKey(props.value),
'Cell value is not an emoji key'
);
return getEmojiParentByKey(props.value);
const isVariantKey = isEmojiVariantKey(props.value);
strictAssert(isVariantKey, 'Cell value is not a variant key');
const parentKey = getEmojiParentKeyByVariantKey(props.value);
return getEmojiParentByKey(parentKey);
}, [props.value]);
const emojiHasSkinToneVariants = useMemo(() => {
return emojiParent.defaultSkinToneVariants != null;
}, [emojiParent.defaultSkinToneVariants]);
const skinTone = useMemo(() => {
return emojiSkinToneDefault ?? EmojiSkinTone.None;
}, [emojiSkinToneDefault]);
const emojiVariant = useMemo(() => {
return getEmojiVariantByParentKeyAndSkinTone(emojiParent.key, skinTone);
}, [emojiParent, skinTone]);
const isVariantKey = isEmojiVariantKey(props.value);
strictAssert(isVariantKey, 'Cell value is not a variant key');
return getEmojiVariantByKey(props.value);
}, [props.value]);
const skinTone = useMemo(() => {
return getEmojiSkinToneByVariantKey(emojiVariant.key);
}, [emojiVariant.key]);
const handleClick = useCallback(
(event: PointerEvent) => {
@@ -596,10 +650,11 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
[
emojiHasSkinToneVariants,
emojiSkinToneDefault,
emojiVariant,
emojiParent,
onSelectEmoji,
emojiVariant.key,
emojiParent.key,
emojiParent.englishShortNameDefault,
skinTone,
onSelectEmoji,
]
);
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { groupBy, orderBy } from 'lodash';
import { useMemo } from 'react';
import type { Reaction } from '../components/conversation/ReactionViewer';
import {
isEmojiVariantValue,
getEmojiVariantKeyByValue,
getEmojiParentKeyByVariantKey,
getEmojiVariantByKey,
type EmojiVariantKey,
} from '../components/fun/data/emojis';
import { isNotNil } from './isNotNil';
import { useFunEmojiLocalizer } from '../components/fun/useFunEmojiLocalizer';
type ReactionWithEmojiData = Reaction & {
short_name: string | undefined;
short_names: Array<string>;
sheet_x: number;
sheet_y: number;
variantKey: EmojiVariantKey;
};
export function useGroupedAndOrderedReactions(
reactions: ReadonlyArray<Reaction> | undefined,
groupByKey: string = 'variantKey'
): Array<Array<ReactionWithEmojiData>> {
const emojiLocalization = useFunEmojiLocalizer();
return useMemo(() => {
if (!reactions || reactions.length === 0) {
return [];
}
const reactionsWithEmojiData: Array<ReactionWithEmojiData> = reactions
.map(reaction => {
if (!isEmojiVariantValue(reaction.emoji)) {
return undefined;
}
try {
const variantKey = getEmojiVariantKeyByValue(reaction.emoji);
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
const variant = getEmojiVariantByKey(variantKey);
const shortName = emojiLocalization.getLocaleShortName(variantKey);
return {
...reaction,
short_name: shortName,
short_names: [shortName].filter(isNotNil),
variantKey,
parentKey,
sheet_x: variant.sheetX,
sheet_y: variant.sheetY,
};
} catch {
return undefined;
}
})
.filter(isNotNil);
const groupedReactions = Object.values(
groupBy(reactionsWithEmojiData, groupByKey)
).map(groupedReaction =>
orderBy(
groupedReaction,
[reaction => reaction.from.isMe, 'timestamp'],
['desc', 'desc']
)
);
return orderBy(
groupedReactions,
['length', ([{ timestamp }]) => timestamp],
['desc', 'desc']
);
}, [reactions, groupByKey, emojiLocalization]);
}
+8
View File
@@ -1748,6 +1748,14 @@
"updated": "2023-06-30T22:12:49.259Z",
"reasonDetail": "Used for excluding the message metadata from triple-click selections."
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/Message.tsx",
"line": " const reactionsContainerRefMerger = useRef(createRefMerger());",
"reasonCategory": "usageTrusted",
"updated": "2025-06-05T12:55:51.245Z",
"reasonDetail": "Used for merging refs for reactions container"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/MessageDetail.tsx",