mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-08 08:58:38 +01:00
Add "This Message" reactions
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user