From 9325a2654ad14e91ff2294602f0d99ffdb652c0f Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Mon, 24 Mar 2025 18:07:47 -0300 Subject: [PATCH 1/5] reduce rerenders --- .../emoji-custom/client/lib/emojiCustom.ts | 5 ++ .../client/hooks/useEmojiOne.ts | 50 ++++++++------- apps/meteor/app/emoji/client/helpers.ts | 24 ++++++- apps/meteor/app/emoji/client/index.ts | 2 +- apps/meteor/app/emoji/client/lib.ts | 6 ++ apps/meteor/app/emoji/lib/rocketchat.ts | 1 + .../client/contexts/EmojiPickerContext.ts | 6 +- .../EmojiPickerProvider.tsx | 62 ++++++------------- .../composer/EmojiPicker/EmojiPicker.tsx | 4 +- 9 files changed, 89 insertions(+), 71 deletions(-) diff --git a/apps/meteor/app/emoji-custom/client/lib/emojiCustom.ts b/apps/meteor/app/emoji-custom/client/lib/emojiCustom.ts index 8b24cd0c29c76..bb76f7388c179 100644 --- a/apps/meteor/app/emoji-custom/client/lib/emojiCustom.ts +++ b/apps/meteor/app/emoji-custom/client/lib/emojiCustom.ts @@ -46,6 +46,7 @@ export const deleteEmojiCustom = (emojiData: IEmoji) => { } removeFromRecent(emojiData.name, emoji.packages.base.emojisByCategory.recent); + emoji.dispatchUpdate(); }; export const updateEmojiCustom = (emojiData: IEmoji) => { @@ -93,6 +94,8 @@ export const updateEmojiCustom = (emojiData: IEmoji) => { if (previousExists) { replaceEmojiInRecent({ oldEmoji: emojiData.previousName, newEmoji: emojiData.name }); } + + emoji.dispatchUpdate(); }; const customRender = (html: string) => { @@ -103,6 +106,7 @@ const customRender = (html: string) => { `]*>.*?<\/object>|]*>.*?<\/span>|<(?:object|embed|svg|img|div|span|p|a)[^>]*>|(${emojisMatchGroup})`, 'gi', ); + emoji.dispatchUpdate(); } html = html.replace(emoji.packages.emojiCustom._regexp!, (shortname) => { @@ -160,6 +164,7 @@ Meteor.startup(() => { }; } } + emoji.dispatchUpdate(); } catch (e) { console.error('Error getting custom emoji', e); } diff --git a/apps/meteor/app/emoji-emojione/client/hooks/useEmojiOne.ts b/apps/meteor/app/emoji-emojione/client/hooks/useEmojiOne.ts index 6dfd82c17cfb5..89b768bf8d934 100644 --- a/apps/meteor/app/emoji-emojione/client/hooks/useEmojiOne.ts +++ b/apps/meteor/app/emoji-emojione/client/hooks/useEmojiOne.ts @@ -1,6 +1,7 @@ import { useUserPreference } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; +import { queueMicrotask } from '../../../../client/lib/utils/queueMicrotask'; import { emoji } from '../../../emoji/client'; import { getEmojiConfig } from '../../lib/getEmojiConfig'; import { isSetNotNull } from '../../lib/isSetNotNull'; @@ -10,28 +11,34 @@ const config = getEmojiConfig(); export const useEmojiOne = () => { const convertAsciiToEmoji = useUserPreference('convertAsciiEmoji', true); - emoji.packages.emojione = config.emojione as any; - if (emoji.packages.emojione) { - emoji.packages.emojione.sprites = config.sprites; - emoji.packages.emojione.emojisByCategory = config.emojisByCategory; - emoji.packages.emojione.emojiCategories = config.emojiCategories as any; - emoji.packages.emojione.toneList = config.toneList; - - emoji.packages.emojione.render = config.render; - emoji.packages.emojione.renderPicker = config.renderPicker; - - // RocketChat.emoji.list is the collection of emojis from all emoji packages - for (const [key, currentEmoji] of Object.entries(config.emojione.emojioneList)) { - currentEmoji.emojiPackage = 'emojione'; - emoji.list[key] = currentEmoji; - - if (currentEmoji.shortnames) { - currentEmoji.shortnames.forEach((shortname: string) => { - emoji.list[shortname] = currentEmoji; - }); + // Waiting for another PR to be merged that adds this useEffect. + useEffect(() => { + queueMicrotask(() => { + emoji.packages.emojione = config.emojione as any; + if (emoji.packages.emojione) { + emoji.packages.emojione.sprites = config.sprites; + emoji.packages.emojione.emojisByCategory = config.emojisByCategory; + emoji.packages.emojione.emojiCategories = config.emojiCategories as any; + emoji.packages.emojione.toneList = config.toneList; + + emoji.packages.emojione.render = config.render; + emoji.packages.emojione.renderPicker = config.renderPicker; + + // RocketChat.emoji.list is the collection of emojis from all emoji packages + for (const [key, currentEmoji] of Object.entries(config.emojione.emojioneList)) { + currentEmoji.emojiPackage = 'emojione'; + emoji.list[key] = currentEmoji; + + if (currentEmoji.shortnames) { + currentEmoji.shortnames.forEach((shortname: string) => { + emoji.list[shortname] = currentEmoji; + }); + } + } } - } - } + }); + emoji.dispatchUpdate(); + }, []); useEffect(() => { if (emoji.packages.emojione) { // Additional settings -- ascii emojis @@ -46,6 +53,7 @@ export const useEmojiOne = () => { }; void ascii(); + emoji.dispatchUpdate(); } }, [convertAsciiToEmoji]); }; diff --git a/apps/meteor/app/emoji/client/helpers.ts b/apps/meteor/app/emoji/client/helpers.ts index a203216640f5e..206b6970f46cf 100644 --- a/apps/meteor/app/emoji/client/helpers.ts +++ b/apps/meteor/app/emoji/client/helpers.ts @@ -1,10 +1,29 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { EmojiCategory, EmojiItem } from '.'; -import { emoji } from './lib'; +import { emoji, emojiEmitter } from './lib'; export const CUSTOM_CATEGORY = 'rocket'; +export const createEmojiListByCategorySubscription = ( + customItemsLimit: number, + actualTone: number, + recentEmojis: string[], + setRecentEmojis: (emojis: string[]) => void, +): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ReturnType] => { + updateRecent(recentEmojis); + + let emojis = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); + + const sub = (cb: () => void) => + emojiEmitter.on('updated', () => { + emojis = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); + cb(); + }); + + return [sub, () => emojis]; +}; + export const createPickerEmojis = ( customItemsLimit: number, actualTone: number, @@ -149,6 +168,8 @@ export const removeFromRecent = (emoji: string, recentEmojis: string[], setRecen setRecentEmojis?.(recentEmojis); }; +// There's no need to dispatchUpdate here. This helper is called before the list is generated. +// This means that the recent list will always be up to date by the time it is used. export const updateRecent = (recentList: string[]) => { const recentPkgList: string[] = emoji.packages.base.emojisByCategory.recent; recentList?.forEach((_emoji) => { @@ -162,6 +183,7 @@ export const replaceEmojiInRecent = ({ oldEmoji, newEmoji }: { oldEmoji: string; if (pos !== -1) { recentPkgList[pos] = newEmoji; + emoji.dispatchUpdate(); } }; diff --git a/apps/meteor/app/emoji/client/index.ts b/apps/meteor/app/emoji/client/index.ts index 420abe27f211f..65e3b79cd5a6f 100644 --- a/apps/meteor/app/emoji/client/index.ts +++ b/apps/meteor/app/emoji/client/index.ts @@ -1,3 +1,3 @@ export * from './helpers'; export * from './types'; -export { emoji } from './lib'; +export { emoji, emojiEmitter } from './lib'; diff --git a/apps/meteor/app/emoji/client/lib.ts b/apps/meteor/app/emoji/client/lib.ts index 1d2397c9568d3..497e59833f60a 100644 --- a/apps/meteor/app/emoji/client/lib.ts +++ b/apps/meteor/app/emoji/client/lib.ts @@ -1,7 +1,10 @@ +import { Emitter } from '@rocket.chat/emitter'; import emojione from 'emojione'; import type { EmojiPackages } from '../lib/rocketchat'; +export const emojiEmitter = new Emitter<{ updated: void }>(); + export const emoji: EmojiPackages = { packages: { base: { @@ -23,4 +26,7 @@ export const emoji: EmojiPackages = { }, }, list: {}, + dispatchUpdate() { + emojiEmitter.emit('updated'); + }, }; diff --git a/apps/meteor/app/emoji/lib/rocketchat.ts b/apps/meteor/app/emoji/lib/rocketchat.ts index 1c5b515407ed8..2620d7c55a512 100644 --- a/apps/meteor/app/emoji/lib/rocketchat.ts +++ b/apps/meteor/app/emoji/lib/rocketchat.ts @@ -42,4 +42,5 @@ export type EmojiPackages = { etag?: string; }; }; + dispatchUpdate: () => void; }; diff --git a/apps/meteor/client/contexts/EmojiPickerContext.ts b/apps/meteor/client/contexts/EmojiPickerContext.ts index 77adbe419c1fa..83a9f01bec545 100644 --- a/apps/meteor/client/contexts/EmojiPickerContext.ts +++ b/apps/meteor/client/contexts/EmojiPickerContext.ts @@ -16,7 +16,7 @@ type EmojiPickerContextValue = { handlePreview: (emoji: string, name: string) => void; handleRemovePreview: () => void; addRecentEmoji: (emoji: string) => void; - getEmojiListsByCategory: () => EmojiByCategory[]; + emojiListByCategory: EmojiByCategory[]; recentEmojis: string[]; setRecentEmojis: (emoji: string[]) => void; actualTone: number; @@ -58,7 +58,7 @@ export const useEmojiPickerData = () => { currentCategory, categoriesPosition, customItemsLimit, - getEmojiListsByCategory, + emojiListByCategory, quickReactions, recentEmojis, setActualTone, @@ -69,7 +69,7 @@ export const useEmojiPickerData = () => { return { addRecentEmoji, - getEmojiListsByCategory, + emojiListByCategory, recentEmojis, setRecentEmojis, actualTone, diff --git a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx index b1db9e481df1a..a58f4d66523cc 100644 --- a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx +++ b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx @@ -1,31 +1,39 @@ import { useDebouncedState, useLocalStorage } from '@rocket.chat/fuselage-hooks'; import type { ReactNode, ReactElement, ContextType } from 'react'; -import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { useState, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; import { useUpdateCustomEmoji } from './useUpdateCustomEmoji'; -import type { EmojiByCategory } from '../../../app/emoji/client'; -import { emoji, getFrequentEmoji, updateRecent, createEmojiList, createPickerEmojis, CUSTOM_CATEGORY } from '../../../app/emoji/client'; +import { emoji, getFrequentEmoji, createEmojiListByCategorySubscription } from '../../../app/emoji/client'; import { EmojiPickerContext } from '../../contexts/EmojiPickerContext'; import EmojiPicker from '../../views/composer/EmojiPicker/EmojiPicker'; const DEFAULT_ITEMS_LIMIT = 90; +// limit recent emojis to 27 (3 rows of 9) +const RECENT_EMOJIS_LIMIT = 27; + const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElement => { const [emojiPicker, setEmojiPicker] = useState(null); const [emojiToPreview, setEmojiToPreview] = useDebouncedState<{ emoji: string; name: string } | null>(null, 100); const [recentEmojis, setRecentEmojis] = useLocalStorage('emoji.recent', []); + const [frequentEmojis, setFrequentEmojis] = useLocalStorage<[string, number][]>('emoji.frequent', []); + const [actualTone, setActualTone] = useLocalStorage('emoji.tone', 0); const [currentCategory, setCurrentCategory] = useState('recent'); const categoriesPosition = useRef([]); const [customItemsLimit, setCustomItemsLimit] = useState(DEFAULT_ITEMS_LIMIT); - const [frequentEmojis, setFrequentEmojis] = useLocalStorage<[string, number][]>('emoji.frequent', []); - const [quickReactions, setQuickReactions] = useState<{ emoji: string; image: string }[]>(() => getFrequentEmoji(frequentEmojis.map(([emoji]) => emoji)), ); + const [sub, getSnapshot] = useMemo(() => { + return createEmojiListByCategorySubscription(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); + }, [customItemsLimit, actualTone, recentEmojis, setRecentEmojis]); + + const emojiListByCategory = useSyncExternalStore(sub, getSnapshot); + useUpdateCustomEmoji(); const addFrequentEmojis = useCallback( @@ -44,37 +52,6 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen [frequentEmojis, setFrequentEmojis], ); - const [getEmojiListsByCategory, setEmojiListsByCategoryGetter] = useState<() => EmojiByCategory[]>(() => () => []); - - // TODO: improve this update - const updateEmojiListByCategory = useCallback( - (categoryKey: string, limit: number = DEFAULT_ITEMS_LIMIT) => { - setEmojiListsByCategoryGetter( - (getEmojiListsByCategory) => () => - getEmojiListsByCategory().map((category) => - categoryKey === category.key - ? { - ...category, - emojis: { - list: createEmojiList(category.key, null, recentEmojis, setRecentEmojis), - limit: category.key === CUSTOM_CATEGORY ? limit | customItemsLimit : null, - }, - } - : category, - ), - ); - }, - [customItemsLimit, recentEmojis, setRecentEmojis], - ); - - useEffect(() => { - if (recentEmojis?.length > 0) { - updateRecent(recentEmojis); - } - - setEmojiListsByCategoryGetter(() => () => createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis)); - }, [actualTone, recentEmojis, customItemsLimit, currentCategory, setRecentEmojis, frequentEmojis]); - const addRecentEmoji = useCallback( (_emoji: string) => { addFrequentEmojis(_emoji); @@ -88,14 +65,13 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen recent.unshift(_emoji); - // limit recent emojis to 27 (3 rows of 9) - recent.splice(27); + recent.splice(RECENT_EMOJIS_LIMIT); - setRecentEmojis(recent); + // If this value is not cloned, the recent list will not be updated + setRecentEmojis([...recent]); emoji.packages.base.emojisByCategory.recent = recent; - updateEmojiListByCategory('recent'); }, - [recentEmojis, setRecentEmojis, updateEmojiListByCategory, addFrequentEmojis], + [recentEmojis, setRecentEmojis, addFrequentEmojis], ); const open = useCallback((ref: Element, callback: (emoji: string) => void) => { @@ -115,7 +91,7 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen handlePreview, handleRemovePreview, addRecentEmoji, - getEmojiListsByCategory, + emojiListByCategory, recentEmojis, setRecentEmojis, actualTone, @@ -132,7 +108,7 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen open, emojiToPreview, addRecentEmoji, - getEmojiListsByCategory, + emojiListByCategory, recentEmojis, setRecentEmojis, actualTone, diff --git a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx index ad61d102a03ab..8618235454956 100644 --- a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx +++ b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx @@ -61,7 +61,7 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { actualTone, currentCategory, categoriesPosition, - getEmojiListsByCategory, + emojiListByCategory, customItemsLimit, setActualTone, setCustomItemsLimit, @@ -212,7 +212,7 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { {!searching && ( Date: Mon, 24 Mar 2025 18:10:47 -0300 Subject: [PATCH 2/5] add cs --- .changeset/thirty-cameras-warn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thirty-cameras-warn.md diff --git a/.changeset/thirty-cameras-warn.md b/.changeset/thirty-cameras-warn.md new file mode 100644 index 0000000000000..37c9c83761a2a --- /dev/null +++ b/.changeset/thirty-cameras-warn.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Improves the performance of the Emoji Picker. From 1b92190162c619b167f29906bf17bfa1052905e6 Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Mon, 24 Mar 2025 18:45:59 -0300 Subject: [PATCH 3/5] fix ts --- apps/meteor/app/emoji/client/lib.ts | 2 +- apps/meteor/app/emoji/lib/rocketchat.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/meteor/app/emoji/client/lib.ts b/apps/meteor/app/emoji/client/lib.ts index 497e59833f60a..12f50a2c888f0 100644 --- a/apps/meteor/app/emoji/client/lib.ts +++ b/apps/meteor/app/emoji/client/lib.ts @@ -5,7 +5,7 @@ import type { EmojiPackages } from '../lib/rocketchat'; export const emojiEmitter = new Emitter<{ updated: void }>(); -export const emoji: EmojiPackages = { +export const emoji: EmojiPackages & { dispatchUpdate: () => void } = { packages: { base: { emojiCategories: [{ key: 'recent', i18n: 'Frequently_Used' }], diff --git a/apps/meteor/app/emoji/lib/rocketchat.ts b/apps/meteor/app/emoji/lib/rocketchat.ts index 2620d7c55a512..1c5b515407ed8 100644 --- a/apps/meteor/app/emoji/lib/rocketchat.ts +++ b/apps/meteor/app/emoji/lib/rocketchat.ts @@ -42,5 +42,4 @@ export type EmojiPackages = { etag?: string; }; }; - dispatchUpdate: () => void; }; From 72c6b203f57a32043f0ea00777ea95d58f182cf6 Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Wed, 26 Mar 2025 19:02:56 -0300 Subject: [PATCH 4/5] Fix no emoji on refresh --- .../meteor/app/emoji-emojione/client/hooks/useEmojiOne.ts | 2 +- apps/meteor/app/emoji/client/helpers.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/meteor/app/emoji-emojione/client/hooks/useEmojiOne.ts b/apps/meteor/app/emoji-emojione/client/hooks/useEmojiOne.ts index 33f82069261fd..12e5496073e7a 100644 --- a/apps/meteor/app/emoji-emojione/client/hooks/useEmojiOne.ts +++ b/apps/meteor/app/emoji-emojione/client/hooks/useEmojiOne.ts @@ -35,8 +35,8 @@ export const useEmojiOne = () => { } } } + emoji.dispatchUpdate(); }); - emoji.dispatchUpdate(); }, []); useEffect(() => { diff --git a/apps/meteor/app/emoji/client/helpers.ts b/apps/meteor/app/emoji/client/helpers.ts index 206b6970f46cf..1ad8191f628c2 100644 --- a/apps/meteor/app/emoji/client/helpers.ts +++ b/apps/meteor/app/emoji/client/helpers.ts @@ -11,15 +11,17 @@ export const createEmojiListByCategorySubscription = ( recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ReturnType] => { + let emojis: ReturnType = []; updateRecent(recentEmojis); - let emojis = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); + const sub = (cb: () => void) => { + emojis = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); - const sub = (cb: () => void) => - emojiEmitter.on('updated', () => { + return emojiEmitter.on('updated', () => { emojis = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); cb(); }); + }; return [sub, () => emojis]; }; From 71a71caf569e894cc19d3864bd8320db2ea4a24a Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Thu, 27 Mar 2025 18:31:47 -0300 Subject: [PATCH 5/5] Improve virtuoso usage --- apps/meteor/app/emoji/client/helpers.ts | 59 ++++++++++----- .../client/contexts/EmojiPickerContext.ts | 16 ++-- .../EmojiPickerProvider.tsx | 8 +- .../composer/EmojiPicker/CategoriesResult.tsx | 23 +++--- .../composer/EmojiPicker/EmojiCategoryRow.tsx | 74 +++++++------------ .../composer/EmojiPicker/EmojiPicker.tsx | 38 +++++----- .../EmojiPicker/EmojiPickerCategoryItem.tsx | 7 +- 7 files changed, 113 insertions(+), 112 deletions(-) diff --git a/apps/meteor/app/emoji/client/helpers.ts b/apps/meteor/app/emoji/client/helpers.ts index 1ad8191f628c2..7a44ede46d19f 100644 --- a/apps/meteor/app/emoji/client/helpers.ts +++ b/apps/meteor/app/emoji/client/helpers.ts @@ -1,29 +1,40 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { EmojiCategory, EmojiItem } from '.'; import { emoji, emojiEmitter } from './lib'; export const CUSTOM_CATEGORY = 'rocket'; +type RowItem = Array; +type RowDivider = { category: string; i18n: TranslationKey }; +type LoadMoreItem = { loadMore: true }; +export type EmojiPickerItem = RowItem | RowDivider | LoadMoreItem; + +export type CategoriesIndexes = { key: string; index: number }[]; + +export const isRowDivider = (item: EmojiPickerItem): item is RowDivider => 'i18n' in item; +export const isLoadMore = (item: EmojiPickerItem): item is LoadMoreItem => 'loadMore' in item; + export const createEmojiListByCategorySubscription = ( customItemsLimit: number, actualTone: number, recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ReturnType] => { - let emojis: ReturnType = []; + let result: ReturnType = [[], []]; updateRecent(recentEmojis); const sub = (cb: () => void) => { - emojis = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); + result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); return emojiEmitter.on('updated', () => { - emojis = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); + result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); cb(); }); }; - return [sub, () => emojis]; + return [sub, () => result]; }; export const createPickerEmojis = ( @@ -31,28 +42,28 @@ export const createPickerEmojis = ( actualTone: number, recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, -) => { +): [EmojiPickerItem[], CategoriesIndexes] => { const categories = getCategoriesList(); + const categoriesIndexes: CategoriesIndexes = []; - const mappedCategories = categories.map((category) => ({ - key: category.key, - i18n: category.i18n, - emojis: { - list: createEmojiList(category.key, actualTone, recentEmojis, setRecentEmojis), - limit: category.key === CUSTOM_CATEGORY ? customItemsLimit : null, - }, - })); + const mappedCategories = categories.reduce((acc, category) => { + categoriesIndexes.push({ key: category.key, index: acc.length }); + acc.push({ category: category.key, i18n: category.i18n }); + acc.push(...createEmojiList(customItemsLimit, category.key, actualTone, recentEmojis, setRecentEmojis)); + return acc; + }, []); - return mappedCategories; + return [mappedCategories, categoriesIndexes]; }; export const createEmojiList = ( + customItemsLimit: number, category: string, actualTone: number | null, recentEmojis: string[], setRecentEmojis: (emojis: string[]) => void, -) => { - const emojiList: EmojiItem[] = []; +): (RowItem | LoadMoreItem)[] => { + const items: RowItem = []; const emojiPackages = Object.values(emoji.packages); emojiPackages.forEach((emojiPackage) => { @@ -78,11 +89,23 @@ export const createEmojiList = ( if (!image) { continue; } - emojiList.push({ emoji: current, image }); + items.push({ emoji: current, image, category }); } }); - return emojiList; + const rowCount = 9; + const rowList: Array = Array.from({ length: Math.ceil(items.length / rowCount) }).map(() => []); + + for (let i = 0; i < rowList.length; i++) { + const row = items.slice(i * rowCount, i * rowCount + rowCount); + rowList[i] = row; + } + + if (category === CUSTOM_CATEGORY && customItemsLimit < items.length) { + rowList.push({ loadMore: true }); + } + + return rowList; }; export const getCategoriesList = () => { diff --git a/apps/meteor/client/contexts/EmojiPickerContext.ts b/apps/meteor/client/contexts/EmojiPickerContext.ts index 83a9f01bec545..b79239441f188 100644 --- a/apps/meteor/client/contexts/EmojiPickerContext.ts +++ b/apps/meteor/client/contexts/EmojiPickerContext.ts @@ -1,12 +1,6 @@ -import type { MutableRefObject } from 'react'; import { createContext, useContext } from 'react'; -import type { EmojiByCategory } from '../../app/emoji/client'; - -type EmojiCategoryPosition = { - key: string; - top: number; -}; +import type { EmojiPickerItem, CategoriesIndexes } from '../../app/emoji/client'; type EmojiPickerContextValue = { open: (ref: Element, callback: (emoji: string) => void) => void; @@ -16,17 +10,17 @@ type EmojiPickerContextValue = { handlePreview: (emoji: string, name: string) => void; handleRemovePreview: () => void; addRecentEmoji: (emoji: string) => void; - emojiListByCategory: EmojiByCategory[]; + emojiListByCategory: EmojiPickerItem[]; recentEmojis: string[]; setRecentEmojis: (emoji: string[]) => void; actualTone: number; currentCategory: string; setCurrentCategory: (category: string) => void; - categoriesPosition: MutableRefObject; customItemsLimit: number; setCustomItemsLimit: (limit: number) => void; setActualTone: (tone: number) => void; quickReactions: { emoji: string; image: string }[]; + categoriesIndexes: CategoriesIndexes; }; export const EmojiPickerContext = createContext(undefined); @@ -56,7 +50,7 @@ export const useEmojiPickerData = () => { actualTone, addRecentEmoji, currentCategory, - categoriesPosition, + categoriesIndexes, customItemsLimit, emojiListByCategory, quickReactions, @@ -74,7 +68,7 @@ export const useEmojiPickerData = () => { setRecentEmojis, actualTone, currentCategory, - categoriesPosition, + categoriesIndexes, setCurrentCategory, customItemsLimit, setCustomItemsLimit, diff --git a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx index a58f4d66523cc..bf3207bbb8d1d 100644 --- a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx +++ b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx @@ -1,6 +1,6 @@ import { useDebouncedState, useLocalStorage } from '@rocket.chat/fuselage-hooks'; import type { ReactNode, ReactElement, ContextType } from 'react'; -import { useState, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; +import { useState, useCallback, useMemo, useSyncExternalStore } from 'react'; import { useUpdateCustomEmoji } from './useUpdateCustomEmoji'; import { emoji, getFrequentEmoji, createEmojiListByCategorySubscription } from '../../../app/emoji/client'; @@ -20,7 +20,6 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen const [actualTone, setActualTone] = useLocalStorage('emoji.tone', 0); const [currentCategory, setCurrentCategory] = useState('recent'); - const categoriesPosition = useRef([]); const [customItemsLimit, setCustomItemsLimit] = useState(DEFAULT_ITEMS_LIMIT); @@ -32,7 +31,7 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen return createEmojiListByCategorySubscription(customItemsLimit, actualTone, recentEmojis, setRecentEmojis); }, [customItemsLimit, actualTone, recentEmojis, setRecentEmojis]); - const emojiListByCategory = useSyncExternalStore(sub, getSnapshot); + const [emojiListByCategory, categoriesIndexes] = useSyncExternalStore(sub, getSnapshot); useUpdateCustomEmoji(); @@ -97,7 +96,7 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen actualTone, currentCategory, setCurrentCategory, - categoriesPosition, + categoriesIndexes, customItemsLimit, setCustomItemsLimit, setActualTone, @@ -109,6 +108,7 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen emojiToPreview, addRecentEmoji, emojiListByCategory, + categoriesIndexes, recentEmojis, setRecentEmojis, actualTone, diff --git a/apps/meteor/client/views/composer/EmojiPicker/CategoriesResult.tsx b/apps/meteor/client/views/composer/EmojiPicker/CategoriesResult.tsx index 57869f5aeafd9..68cb34dd27edf 100644 --- a/apps/meteor/client/views/composer/EmojiPicker/CategoriesResult.tsx +++ b/apps/meteor/client/views/composer/EmojiPicker/CategoriesResult.tsx @@ -1,24 +1,24 @@ import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; -import type { MouseEvent, UIEventHandler } from 'react'; +import type { MouseEvent } from 'react'; import { forwardRef, memo, useRef } from 'react'; -import type { VirtuosoHandle } from 'react-virtuoso'; +import type { ListRange, VirtuosoHandle } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; import EmojiCategoryRow from './EmojiCategoryRow'; -import type { EmojiByCategory } from '../../../../app/emoji/client'; +import type { EmojiPickerItem } from '../../../../app/emoji/client'; import { VirtualizedScrollbars } from '../../../components/CustomScrollbars'; type CategoriesResultProps = { - emojiListByCategory: EmojiByCategory[]; + items: EmojiPickerItem[]; customItemsLimit: number; handleLoadMore: () => void; handleSelectEmoji: (event: MouseEvent) => void; - handleScroll: UIEventHandler; + handleScroll: (range: ListRange) => void; }; const CategoriesResult = forwardRef(function CategoriesResult( - { emojiListByCategory, customItemsLimit, handleLoadMore, handleSelectEmoji, handleScroll }, + { items, customItemsLimit, handleLoadMore, handleSelectEmoji, handleScroll }, ref, ) { const wrapper = useRef(null); @@ -36,9 +36,9 @@ const CategoriesResult = forwardRef(funct { if (!wrapper.current) { return; @@ -50,13 +50,12 @@ const CategoriesResult = forwardRef(funct wrapper.current.classList.remove('pointer-none'); } }} - itemContent={(_, { key, ...data }) => ( + itemContent={(_, item) => ( )} /> diff --git a/apps/meteor/client/views/composer/EmojiPicker/EmojiCategoryRow.tsx b/apps/meteor/client/views/composer/EmojiPicker/EmojiCategoryRow.tsx index 3dea93f536aa4..76909ba1c78de 100644 --- a/apps/meteor/client/views/composer/EmojiPicker/EmojiCategoryRow.tsx +++ b/apps/meteor/client/views/composer/EmojiPicker/EmojiCategoryRow.tsx @@ -5,20 +5,18 @@ import { memo, type MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; import EmojiElement from './EmojiElement'; -import { CUSTOM_CATEGORY } from '../../../../app/emoji/client'; -import type { EmojiByCategory } from '../../../../app/emoji/client'; -import { useEmojiPickerData } from '../../../contexts/EmojiPickerContext'; +import { isRowDivider, isLoadMore } from '../../../../app/emoji/client'; +import type { EmojiPickerItem } from '../../../../app/emoji/client'; -type EmojiCategoryRowProps = Omit & { - categoryKey: EmojiByCategory['key']; +type EmojiCategoryRowProps = { customItemsLimit: number; handleLoadMore: () => void; handleSelectEmoji: (e: MouseEvent) => void; + item: EmojiPickerItem; }; -const EmojiCategoryRow = ({ categoryKey, i18n, emojis, customItemsLimit, handleLoadMore, handleSelectEmoji }: EmojiCategoryRowProps) => { +const EmojiCategoryRow = ({ item, handleLoadMore, handleSelectEmoji }: EmojiCategoryRowProps) => { const { t } = useTranslation(); - const { categoriesPosition } = useEmojiPickerData(); const categoryRowStyle = css` button { @@ -30,46 +28,30 @@ const EmojiCategoryRow = ({ categoryKey, i18n, emojis, customItemsLimit, handleL } `; - return ( - - { - if (categoriesPosition.current.find(({ key }) => key === categoryKey)) { - return; - } + if (isRowDivider(item)) { + return ( + <> + + {t(item.i18n)} + + + ); + } + + if (isLoadMore(item)) { + return {t('Load_more')}; + } - categoriesPosition.current.push({ key: categoryKey, top: element?.offsetTop }); - return element; - }} - > - {t(i18n)} - - {emojis.list.length > 0 && ( - - <> - {categoryKey === CUSTOM_CATEGORY && - emojis.list.map( - ({ emoji, image }, index = 1) => - index < customItemsLimit && ( - - ), - )} - {!(categoryKey === CUSTOM_CATEGORY) && - emojis.list.map(({ emoji, image }) => ( - - ))} - - - )} - {emojis.limit && emojis?.limit > 0 && emojis.list.length > emojis.limit && ( - {t('Load_more')} - )} - {emojis.list.length === 0 && {t('No_emojis_found')}} - + if (item.length === 0) { + return {t('No_emojis_found')}; + } + + return ( + + {item.map(({ emoji, image, category }) => ( + + ))} + ); }; diff --git a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx index 8618235454956..cef5d6c2fcd7c 100644 --- a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx +++ b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx @@ -10,9 +10,9 @@ import { EmojiPickerPreview, } from '@rocket.chat/ui-client'; import { useTranslation, usePermission, useRoute } from '@rocket.chat/ui-contexts'; -import type { ChangeEvent, KeyboardEvent, MouseEvent, RefObject, UIEvent } from 'react'; +import type { ChangeEvent, KeyboardEvent, MouseEvent, RefObject } from 'react'; import { useLayoutEffect, useState, useEffect, useRef } from 'react'; -import type { VirtuosoHandle } from 'react-virtuoso'; +import type { ListRange, VirtuosoHandle } from 'react-virtuoso'; import CategoriesResult from './CategoriesResult'; import EmojiPickerCategoryItem from './EmojiPickerCategoryItem'; @@ -60,7 +60,7 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { setRecentEmojis, actualTone, currentCategory, - categoriesPosition, + categoriesIndexes, emojiListByCategory, customItemsLimit, setActualTone, @@ -155,24 +155,29 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { setCustomItemsLimit(customItemsLimit + 90); }; - const handleScroll = (event: UIEvent) => { - const categoryMargin = 12; - const { scrollTop } = event.currentTarget; + const handleScroll = (range: ListRange) => { + const { startIndex } = range; - const lastCategory = categoriesPosition.current - ?.filter((category, index = 1) => category.top - categoryMargin * index <= scrollTop) - .pop(); + const category = categoriesIndexes.find( + (category, index) => category.index <= startIndex + 1 && categoriesIndexes[index + 1]?.index >= startIndex, + ); - if (!lastCategory) { + if (!category) { return; } - setCurrentCategory(lastCategory.key); + setCurrentCategory(category.key); }; - const handleGoToCategory = (categoryIndex: number) => { + const handleGoToCategory = (category: string) => { setSearching(false); - virtuosoRef.current?.scrollToIndex({ index: categoryIndex }); + const { index } = categoriesIndexes.find((item) => item.key === category) || {}; + + if (index === undefined) { + return; + } + + virtuosoRef.current?.scrollToIndex({ index: index > 0 ? index + 1 : 0 }); }; const handleGoToAddCustom = () => { @@ -196,13 +201,12 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { /> - {emojiCategories.map((category, index) => ( + {emojiCategories.map((category) => ( handleGoToCategory(category.key)} /> ))} @@ -212,7 +216,7 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { {!searching && ( void; + handleGoToCategory: () => void; } & Omit, 'is'>; const mapCategoryIcon = (category: string) => { @@ -45,7 +44,7 @@ const mapCategoryIcon = (category: string) => { } }; -const EmojiPickerCategoryItem = ({ category, index, active, handleGoToCategory, ...props }: EmojiPickerCategoryItemProps) => { +const EmojiPickerCategoryItem = ({ category, active, handleGoToCategory, ...props }: EmojiPickerCategoryItemProps) => { const { t } = useTranslation(); const icon = mapCategoryIcon(category.key); @@ -58,7 +57,7 @@ const EmojiPickerCategoryItem = ({ category, index, active, handleGoToCategory, className={category.key} small aria-label={t(category.i18n)} - onClick={() => handleGoToCategory(index)} + onClick={handleGoToCategory} icon={icon} {...props} />