From 95014eb44778f07159e451a14859cfb91b4d485d Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 26 Feb 2026 22:52:02 +0100 Subject: [PATCH] Initial commit --- examples/vite/src/App.tsx | 11 +- src/components/Reactions/ReactionSelector.tsx | 115 +++++++++++----- .../Reactions/ReactionSelectorWithButton.tsx | 1 + .../Reactions/hooks/useProcessReactions.tsx | 29 +++- src/components/Reactions/reactionOptions.tsx | 126 ++++++++++++++---- .../Reactions/styling/ReactionSelector.scss | 32 +++++ 6 files changed, 248 insertions(+), 66 deletions(-) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 032537e5f..d998881bb 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -29,10 +29,13 @@ import { ReactionsList, WithDragAndDropUpload, useChatContext, + defaultReactionOptions, + ReactionOptions, + mapEmojiMartData, } from 'stream-chat-react'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; import { init, SearchIndex } from 'emoji-mart'; -import data from '@emoji-mart/data'; +import data from '@emoji-mart/data/sets/14/native.json'; import { humanId } from 'human-id'; import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx'; import { useAppSettingsState } from './AppSettings'; @@ -69,6 +72,11 @@ const sort: ChannelSort = { last_message_at: -1, updated_at: -1 }; // @ts-ignore const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated; +const newReactionOptions: ReactionOptions = { + ...defaultReactionOptions, + extended: mapEmojiMartData(data), +}; + const useUser = () => { const userId = useMemo(() => { return ( @@ -184,6 +192,7 @@ const App = () => { emojiSearchIndex: SearchIndex, EmojiPicker, ReactionsList: CustomMessageReactions, + reactionOptions: newReactionOptions, }} > diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index 1ce9d519c..47dbee907 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import clsx from 'clsx'; import { useDialog } from '../Dialog'; @@ -24,6 +24,7 @@ const stableOwnReactions: ReactionResponse[] = []; const UnMemoizedReactionSelector = (props: ReactionSelectorProps) => { const { handleReaction: propHandleReaction, own_reactions: propOwnReactions } = props; + const [extendedListOpen, setExtendedListOpen] = useState(false); const { reactionOptions = defaultReactionOptions } = useComponentContext('ReactionSelector'); @@ -49,40 +50,88 @@ const UnMemoizedReactionSelector = (props: ReactionSelectorProps) => { return map; }, [ownReactions]); + const adjustedQuickReactionOptions = useMemo(() => { + if (Array.isArray(reactionOptions)) return reactionOptions; + + return Object.entries(reactionOptions.quick).map( + ([type, { Component, name, unicode }]) => ({ + Component, + name, + type, + unicode, + }), + ); + }, [reactionOptions]); + return (
-
    - {reactionOptions.map(({ Component, name: reactionName, type: reactionType }) => ( -
  • - -
  • - ))} -
- + {!extendedListOpen && ( + <> +
    + {adjustedQuickReactionOptions.map( + ({ Component, name: reactionName, type: reactionType }) => ( +
  • + +
  • + ), + )} +
+ + + )} + {extendedListOpen && + !Array.isArray(reactionOptions) && + reactionOptions.extended && ( +
+ {Object.entries(reactionOptions.extended).map( + ([reactionType, { Component, name: reactionName }]) => ( + + ), + )} +
+ )}
); }; diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx index 13f6e65ea..88e22c91e 100644 --- a/src/components/Reactions/ReactionSelectorWithButton.tsx +++ b/src/components/Reactions/ReactionSelectorWithButton.tsx @@ -41,6 +41,7 @@ export const ReactionSelectorWithButton = ({ placement={isMyMessage() ? 'top-end' : 'top-start'} referenceElement={buttonRef.current} trapFocus + updatePositionOnContentResize > diff --git a/src/components/Reactions/hooks/useProcessReactions.tsx b/src/components/Reactions/hooks/useProcessReactions.tsx index 47e8c8c3d..030c4283a 100644 --- a/src/components/Reactions/hooks/useProcessReactions.tsx +++ b/src/components/Reactions/hooks/useProcessReactions.tsx @@ -52,14 +52,35 @@ export const useProcessReactions = (params: UseProcessReactionsParams) => { ); const getEmojiByReactionType = useCallback( - (reactionType: string) => - reactionOptions.find(({ type }) => type === reactionType)?.Component ?? null, + (reactionType: string) => { + if (Array.isArray(reactionOptions)) { + return ( + reactionOptions.find(({ type }) => type === reactionType)?.Component ?? null + ); + } + + return ( + reactionOptions.quick[reactionType]?.Component ?? + reactionOptions.extended?.[reactionType]?.Component ?? + null + ); + }, [reactionOptions], ); const isSupportedReaction = useCallback( - (reactionType: string) => - reactionOptions.some((reactionOption) => reactionOption.type === reactionType), + (reactionType: string) => { + if (Array.isArray(reactionOptions)) { + return reactionOptions.some( + (reactionOption) => reactionOption.type === reactionType, + ); + } + + return ( + typeof reactionOptions.quick[reactionType] !== 'undefined' || + typeof reactionOptions.extended?.[reactionType] !== 'undefined' + ); + }, [reactionOptions], ); diff --git a/src/components/Reactions/reactionOptions.tsx b/src/components/Reactions/reactionOptions.tsx index a51c66964..3680073e8 100644 --- a/src/components/Reactions/reactionOptions.tsx +++ b/src/components/Reactions/reactionOptions.tsx @@ -2,37 +2,107 @@ import React from 'react'; -export type ReactionOptions = Array<{ +type LegacyReactionOptions = Array<{ Component: React.ComponentType; type: string; name?: string; }>; -export const defaultReactionOptions: ReactionOptions = [ - { - type: 'haha', - Component: () => <>😂, - name: 'Joy', - }, - { - type: 'like', - Component: () => <>👍, - name: 'Thumbs up', - }, - { - type: 'love', - Component: () => <>❤️, - name: 'Heart', - }, - { type: 'sad', Component: () => <>😔, name: 'Sad' }, - { - type: 'wow', - Component: () => <>😮, - name: 'Astonished', - }, - { - type: 'fire', - Component: () => <>🔥, - name: 'Fire', +type ReactionOptionData = { + Component: React.ComponentType; + name?: string; + unicode?: string; +}; + +export type ReactionOptions = + | LegacyReactionOptions + | { + quick: { + [key: string]: ReactionOptionData; + }; + extended?: { + [key: string]: ReactionOptionData; + }; + }; + +export const mapEmojiMartData = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + emojiMartData: any, +): NonNullable['extended']> => { + if (!emojiMartData || !emojiMartData.emojis) { + return {}; + } + + const newMap: ReturnType = {}; + + for (const emojiId in emojiMartData.emojis) { + const emojiData = emojiMartData.emojis[emojiId]; + const [firstEmoji] = emojiData.skins; + + if (!firstEmoji || !firstEmoji.native) continue; + + const nativeEmoji = firstEmoji.native as string; + + const unicode = emojiToUnicode(nativeEmoji); + + newMap[unicode] = { + Component: () => <>{nativeEmoji}, + name: emojiData.name, + }; + } + + return newMap; +}; + +export type AdvancedReactionOptions = { + quick: ReactionOptions; + extended: ReactionOptions; +}; + +export const emojiToUnicode = (emoji: string) => { + const unicodeStrings = []; + for (const c of emoji) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const codePoint = c.codePointAt(0)!; + unicodeStrings.push(`U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`); + } + + return unicodeStrings.join('-'); +}; + +export const unicodeToEmoji = (unicode: string) => + unicode + .split('-') + .map((code) => String.fromCodePoint(parseInt(code.replace('U+', ''), 16))) + .join(''); + +export const defaultReactionOptions: ReactionOptions = { + quick: { + haha: { + Component: () => <>😂, + name: 'Joy', + unicode: emojiToUnicode('😂'), + }, + like: { + Component: () => <>👍, + name: 'Thumbs up', + unicode: emojiToUnicode('👍'), + }, + love: { + Component: () => <>❤️, + name: 'Heart', + unicode: emojiToUnicode('❤️'), + }, + sad: { Component: () => <>😔, name: 'Sad', unicode: emojiToUnicode('😔') }, + wow: { + Component: () => <>😮, + name: 'Astonished', + unicode: emojiToUnicode('😮'), + }, + fire: { + Component: () => <>🔥, + name: 'Fire', + unicode: emojiToUnicode('🔥'), + }, }, -]; +}; diff --git a/src/components/Reactions/styling/ReactionSelector.scss b/src/components/Reactions/styling/ReactionSelector.scss index 0ec3c4d97..ccdb5d6f8 100644 --- a/src/components/Reactions/styling/ReactionSelector.scss +++ b/src/components/Reactions/styling/ReactionSelector.scss @@ -13,6 +13,15 @@ /* shadow/ios/light/elevation-3 */ box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.16); + &:has(.str-chat__reaction-selector-extended-list) { + padding: 0; + display: block; + overflow-y: auto; + scrollbar-width: none; + border-radius: var(--radius-lg); + max-height: 250px; + } + .str-chat__reaction-selector__add-button { width: 32px; aspect-ratio: 1/1; @@ -23,6 +32,29 @@ } } + .str-chat__reaction-selector-extended-list { + display: grid; + grid-template-columns: repeat(7, 1fr); + height: 100%; + overflow-y: auto; + padding-block: var(--spacing-md); + padding-inline: var(--spacing-sm); + + .str-chat__reaction-selector-extended-list__button { + .str-chat__reaction-icon { + height: 24px; + width: 24px; + font-size: 24px; + letter-spacing: 0; + line-height: 0; + font-family: system-ui; + display: flex; + justify-content: center; + align-items: center; + } + } + } + .str-chat__reaction-selector-list { list-style: none; margin: var(--spacing-none, 0);