diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b7ce211adf..4addb45638 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 35db747b59..9973bb3f73 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -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; + getPreferredBadge: PreferredBadgeSelectorType; + i18n: LocalizerType; + theme: ThemeType; + outgoing: boolean; + toggleReactionViewer: (onlyRemove?: boolean) => void; + reactionViewerRoot: HTMLDivElement | null; + popperPreventOverflowModifier: () => Partial; +}; + +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 ( + + + {({ ref: popperRef }) => ( +
{ + ev.stopPropagation(); + }} + > + {toRender.map((re, i) => { + const isLast = i === toRender.length - 1; + const isMore = isLast && someNotRendered; + const isMoreWithMe = isMore && notRenderedIsMe; + + return ( + + ); + })} +
+ )} +
+ {reactionViewerRoot && + createPortal( + + {({ ref, style }) => ( + + )} + , + reactionViewerRoot + )} +
+ ); +}); + export class Message extends React.PureComponent { public focusRef: React.RefObject = React.createRef(); @@ -458,8 +646,6 @@ export class Message extends React.PureComponent { #metadataRef: React.RefObject = 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 { ); } - #popperPreventOverflowModifier(): Partial { + #popperPreventOverflowModifier = (): Partial => { const { containerElementRef } = this.props; return { name: 'preventOverflow', @@ -2748,7 +2934,7 @@ export class Message extends React.PureComponent { }, }, }; - } + }; public toggleReactionViewer = (onlyRemove = false): void => { this.setState(oldState => { @@ -2796,185 +2982,22 @@ export class Message extends React.PureComponent { 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 ( - - - {({ ref: popperRef }) => ( -
{ - ev.stopPropagation(); - }} - > - {toRender.map((re, i) => { - const isLast = i === toRender.length - 1; - const isMore = isLast && someNotRendered; - const isMoreWithMe = isMore && notRenderedIsMe; - - return ( - - ); - })} -
- )} -
- {reactionViewerRoot && - createPortal( - - {({ ref, style }) => ( - - )} - , - reactionViewerRoot - )} -
+ { + this.toggleReactionViewer(); + }} + reactionViewerRoot={reactionViewerRoot} + popperPreventOverflowModifier={this.#popperPreventOverflowModifier} + ref={this.reactionsContainerRef} + /> ); } diff --git a/ts/components/conversation/ReactionPicker.tsx b/ts/components/conversation/ReactionPicker.tsx index e79885809b..205627c85c 100644 --- a/ts/components/conversation/ReactionPicker.tsx +++ b/ts/components/conversation/ReactionPicker.tsx @@ -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; renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement; theme?: ThemeType; + messageEmojis?: ReadonlyArray; }; export type Props = OwnProps & Pick, 'style'>; @@ -54,6 +55,7 @@ export const ReactionPicker = React.forwardRef( selected, style, theme, + messageEmojis, }, ref ) { @@ -156,6 +158,7 @@ export const ReactionPicker = React.forwardRef( theme={theme} showCustomizePreferredReactionsButton closeOnSelect + messageEmojis={messageEmojis} > - +
+