diff --git a/.prettierignore b/.prettierignore index 4b02ec540..7307d99ba 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,7 @@ examples/**/serviceWorker.ts .agents/ .claude/skills/ skills-lock.json + +# Vendored emoji dataset (snapshot of @emoji-mart/data — kept minified; regenerate +# via `node scripts/vendor-emoji-data.mjs`) +src/plugins/Emojis/data/emoji-data.json diff --git a/AI.md b/AI.md index 0fdb9ed29..88c873397 100644 --- a/AI.md +++ b/AI.md @@ -241,26 +241,89 @@ const CustomMessage = () => { **User intent**: "Add emoji picker and autocomplete" +Emoji support is built into the SDK — the **`StreamEmojiPicker`** needs no `emoji-mart` +packages or `init()` call. + +> **Deprecation:** the `EmojiPicker` export from `stream-chat-react/emojis` is the legacy +> **emoji-mart-backed** picker. It still works unchanged for backwards compatibility, but +> is **deprecated and will be removed in v15** and logs a one-time console warning. New +> integrations should use `StreamEmojiPicker`; existing emoji-mart integrations keep +> working until you migrate (see _Migrating from emoji-mart_ below). + **Steps**: -1. Install emoji packages: `npm install emoji-mart @emoji-mart/react @emoji-mart/data` -2. Initialize emoji data: `init({ data })` from `emoji-mart` -3. Import `EmojiPicker` from `stream-chat-react/emojis` -4. Pass `EmojiPicker` and `emojiSearchIndex={SearchIndex}` to `Channel` +1. Import `StreamEmojiPicker` from `stream-chat-react/emojis` and pass it to `Channel`. +2. Import the emoji picker's stylesheet: `import 'stream-chat-react/css/emoji-picker.css'`. + It is intentionally **not** part of `index.css` (so apps that don't use the picker + ship no emoji CSS); without it the picker panel renders unstyled. +3. (Optional) For `:shortcode` autocomplete + emoticon replacement, register the + emoji middleware on the message composer's text composer with + `createTextComposerEmojiMiddleware()` (no argument — it uses the built-in index). ```tsx -import { EmojiPicker } from 'stream-chat-react/emojis'; -import { init, SearchIndex } from 'emoji-mart'; -import data from '@emoji-mart/data'; +import { StreamEmojiPicker } from 'stream-chat-react/emojis'; +import 'stream-chat-react/css/emoji-picker.css'; -init({ data }); - - - {/* ... */} -; +{/* ... */}; ``` -**Note**: For React 19, may need package.json overrides for `@emoji-mart/react` +**Notes**: + +- Passing a custom `emojiSearchIndex` (including emoji-mart's `SearchIndex`) is + still supported for advanced use. +- On `StreamEmojiPicker`, skin tone and "frequently used" are integrator-managed props + (`skinTone`/`onSkinToneChange`, `frequentlyUsedEmoji`/`onFrequentlyUsedChange`); + the SDK does not persist them. See `examples/vite/` for a localStorage example. +- On `StreamEmojiPicker`, `pickerProps` accepts a curated set of emoji-mart-compatible `Picker` options (plus + `theme` and `style`): + - **Layout**: `navPosition` / `previewPosition` (`'top' | 'bottom' | 'none'`), + `searchPosition` (`'sticky' | 'static' | 'none'`), `skinTonePosition` + (`'preview' | 'search' | 'none'`). + - **Grid & content**: `perLine` (default `9`), `categories` (filter + reorder; + `'frequent'` always prepends), `maxFrequentRows` (default `1`). + - **Filtering & polish**: `exceptEmojis`, `emojiVersion`, `noCountryFlags`, + `previewEmoji`, `noResultsEmoji`, `autoFocus`, `onClickOutside`. + + Divergences from emoji-mart's defaults: `autoFocus` defaults to `true`; `emojiVersion` + is unfiltered by default (the bundled set 15); `previewEmoji` / `noResultsEmoji` default + to the SDK's placeholder / empty state; `noCountryFlags` is opt-in (no Windows + auto-detect); `categories` cannot reposition `'frequent'`. + + Not supported (rejected by the type; ignored with a console warning at runtime): image + sets (`set`, `getSpritesheetURL`), `custom` emoji, `data`, `i18n` / `locale`, + `dynamicWidth`, `icons`, `categoryIcons`. Sizing knobs (`emojiSize`, `emojiButtonSize`, + …) are `--str-chat__emoji-picker-*` CSS variables, not props. Skin tone uses the + first-class `skinTone` / `defaultSkinTone` props (not emoji-mart's `skin`). Try these + live in the "Emoji Picker" settings tab of `examples/vite/`. + +- To let users **react with any emoji** (the reaction selector's `+` button), fill + `reactionOptions.extended` with the full emoji set. It also gates display — a reaction + whose type isn't in `quick`/`extended` is not rendered. Load it lazily from the emojis + entry (the dataset is code-split, so this adds nothing to your initial bundle): + + ```tsx + import { defaultReactionOptions, type ReactionOptions } from 'stream-chat-react'; + import { loadDefaultExtendedReactionOptions } from 'stream-chat-react/emojis'; + + const [reactionOptions, setReactionOptions] = + useState(defaultReactionOptions); + useEffect(() => { + loadDefaultExtendedReactionOptions().then((extended) => + setReactionOptions({ ...defaultReactionOptions, extended }), + ); + }, []); + + {/* ... */}; + ``` + + For a curated subset instead, build the `extended` map yourself with `mapEmojiMartData`. + +- **Migrating from emoji-mart** (the deprecated `EmojiPicker`): swap `EmojiPicker` → + `StreamEmojiPicker`, then remove the `emoji-mart` / `@emoji-mart/react` / + `@emoji-mart/data` installs and any `init({ data })` call. The deprecated `EmojiPicker` + still accepts raw emoji-mart `pickerProps` (e.g. `set`, `emojiSize`); `StreamEmojiPicker` + uses the curated `pickerProps` above. Autocomplete (`createTextComposerEmojiMiddleware`) + and reactions (`mapEmojiMartData`) are engine-agnostic and need no changes. **Reference**: See `examples/tutorial/src/6-emoji-picker/` @@ -365,9 +428,11 @@ body { **Solution**: -- Ensure emoji packages are installed -- Initialize with `init({ data })` before rendering -- For React 19, add package.json overrides if needed +- Ensure `EmojiPicker` from `stream-chat-react/emojis` is passed to `Channel` (or set + via `ComponentContext`) +- Import the emoji picker CSS: `import 'stream-chat-react/css/emoji-picker.css'` +- For `:shortcode` autocomplete, register `createTextComposerEmojiMiddleware()` on + the composer's text composer ## Resources @@ -386,10 +451,8 @@ body { - `react`: ^19.0.0 || ^18.0.0 || ^17.0.0 - `react-dom`: ^19.0.0 || ^18.0.0 || ^17.0.0 - `stream-chat`: ^9.27.2 -- **Optional Dependencies** (for emoji support): - - `emoji-mart`: ^5.4.0 - - `@emoji-mart/react`: ^1.1.0 - - `@emoji-mart/data`: ^1.1.0 +- **Emoji support**: built in via the `stream-chat-react/emojis` entry point — no + `emoji-mart` packages required. ## Best Practices diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index 101fca5e7..4dfad3662 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -12,8 +12,6 @@ "hoistingLimits": "workspaces" }, "dependencies": { - "@emoji-mart/data": "^1.2.1", - "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", "stream-chat": "^9.49.0", diff --git a/examples/tutorial/src/6-emoji-picker/App.tsx b/examples/tutorial/src/6-emoji-picker/App.tsx index 5f339501b..315b636f3 100644 --- a/examples/tutorial/src/6-emoji-picker/App.tsx +++ b/examples/tutorial/src/6-emoji-picker/App.tsx @@ -12,10 +12,7 @@ import { Window, WithComponents, } from 'stream-chat-react'; -import { EmojiPicker } from 'stream-chat-react/emojis'; - -import { init, SearchIndex } from 'emoji-mart'; -import data from '@emoji-mart/data'; +import { StreamEmojiPicker } from 'stream-chat-react/emojis'; import './layout.css'; import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; @@ -32,8 +29,6 @@ const filters: ChannelFilters = { members: { $in: [userId] }, }; -init({ data }); - const App = () => { const [isReady, setIsReady] = useState(false); const client = useCreateChatClient({ @@ -66,13 +61,13 @@ const App = () => { return ( - + - + diff --git a/examples/tutorial/src/6-emoji-picker/layout.css b/examples/tutorial/src/6-emoji-picker/layout.css index 2fd790e68..fb1ab9794 100644 --- a/examples/tutorial/src/6-emoji-picker/layout.css +++ b/examples/tutorial/src/6-emoji-picker/layout.css @@ -1,5 +1,9 @@ @layer stream, stream-overrides; @import 'stream-chat-react/dist/css/index.css' layer(stream); +/* The emoji picker ships its own opt-in stylesheet (kept out of index.css so apps + that don't use it pay no CSS cost). This step renders , so it must + be imported or the picker panel renders unstyled. */ +@import 'stream-chat-react/dist/css/emoji-picker.css' layer(stream); @layer stream-overrides { .custom-theme { diff --git a/examples/tutorial/src/App.tsx b/examples/tutorial/src/App.tsx index 35a6b0092..6348377e5 100644 --- a/examples/tutorial/src/App.tsx +++ b/examples/tutorial/src/App.tsx @@ -57,7 +57,7 @@ const steps: TutorialStep[] = [ id: 'emoji-picker', title: '6. Emoji Picker', description: - 'Wire a custom EmojiPicker into MessageComposer with emoji-mart search support.', + 'Wire a custom EmojiPicker into MessageComposer with built-in emoji search support.', Component: EmojiPickerStep, }, { diff --git a/examples/vite/package.json b/examples/vite/package.json index 7289bf3ba..87b938f87 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", "human-id": "^4.1.3", diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index bdf11f91c..4049e02a6 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,4 +1,11 @@ -import { type CSSProperties, useCallback, useEffect, useMemo, useRef } from 'react'; +import { + type CSSProperties, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { ChannelFilters, ChannelOptions, @@ -22,7 +29,6 @@ import { ChatView, defaultReactionOptions, DialogManagerProvider, - mapEmojiMartData, MessageReactions, NotificationList, type NotificationListProps, @@ -32,9 +38,12 @@ import { useCreateChatClient, WithComponents, } from 'stream-chat-react'; -import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; -import { init, SearchIndex } from 'emoji-mart'; -import data from '@emoji-mart/data/sets/14/native.json'; +import { + createTextComposerEmojiMiddleware, + EmojiPicker, + loadDefaultExtendedReactionOptions, + StreamEmojiPicker, +} from 'stream-chat-react/emojis'; import { humanId } from 'human-id'; import { appSettingsStore, useAppSettingsSelector } from './AppSettings'; @@ -73,8 +82,6 @@ import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.t const PUBLIC_VITE_EXAMPLE_API_KEY = 'xzwhhgtazy6h'; -init({ data }); - const parseUserIdFromToken = (token: string): string | undefined => { try { const [, payload] = token.split('.'); @@ -109,11 +116,6 @@ const sort: ChannelSort = { last_message_at: -1, updated_at: -1 }; // @ts-expect-error ai_generated isn't on LocalMessage's public type yet const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated; -const newReactionOptions: ReactionOptions = { - ...defaultReactionOptions, - extended: mapEmojiMartData(data), -}; - const useUser = () => { const searchParams = useMemo(() => new URLSearchParams(window.location.search), []); @@ -180,18 +182,65 @@ const CustomMessageReactions = (props: React.ComponentProps ; +// Demonstrates integrator-owned persistence of the picker's skin tone and +// frequently-used emoji via localStorage. The SDK itself never touches storage — +// it exposes these as controlled props so apps own where the state lives. +const EMOJI_SKIN_TONE_KEY = 'vite-example/emoji-skin-tone'; +const EMOJI_FREQUENTLY_USED_KEY = 'vite-example/emoji-frequently-used'; + +const readStored = (key: string, fallback: T): T => { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +}; + +const writeStored = (key: string, value: unknown) => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // ignore storage failures (e.g. private browsing) + } +}; + const EmojiPickerWithCustomOptions = ( - props: React.ComponentProps, + props: React.ComponentProps, ) => { const { mode } = useAppSettingsSelector((state) => state.theme); + const { engine, ...pickerOptions } = useAppSettingsSelector( + (state) => state.emojiPicker, + ); + const [skinTone, setSkinTone] = useState(() => readStored(EMOJI_SKIN_TONE_KEY, 0)); + const [frequentlyUsedEmoji, setFrequentlyUsedEmoji] = useState(() => + readStored(EMOJI_FREQUENTLY_USED_KEY, []), + ); + + // The deprecated emoji-mart picker self-manages skin tone + frequently-used and + // accepts the same emoji-mart-compatible option names, so it only needs pickerProps. + if (engine === 'emoji-mart') { + return ; + } return ( - { + setFrequentlyUsedEmoji(ids); + writeStored(EMOJI_FREQUENTLY_USED_KEY, ids); + }} + onSkinToneChange={(tone) => { + setSkinTone(tone); + writeStored(EMOJI_SKIN_TONE_KEY, tone); + }} pickerProps={{ ...props.pickerProps, + ...pickerOptions, theme: mode, }} + skinTone={skinTone} /> ); }; @@ -226,6 +275,21 @@ const CustomAttachmentWithActions = (props: AttachmentProps) => ( const App = () => { const { tokenProvider, userId, userImage, userName } = useUser(); + // Restore "react with any emoji": lazily load the full emoji set and expose it as the + // extended reaction options (the reaction selector's "+" list). The dataset loads on + // demand via the same code-split chunk the picker uses; until it resolves only the + // quick reactions show. + const [reactionOptions, setReactionOptions] = + useState(defaultReactionOptions); + useEffect(() => { + let active = true; + loadDefaultExtendedReactionOptions().then((extended) => { + if (active) setReactionOptions({ ...defaultReactionOptions, extended }); + }); + return () => { + active = false; + }; + }, []); const chatView = useAppSettingsSelector((state) => state.chatView); const { mode: themeMode } = useAppSettingsSelector((state) => state.theme); const initialSearchParams = useMemo( @@ -357,9 +421,7 @@ const App = () => { }); composer.textComposer.middlewareExecutor.insert({ - middleware: [ - createTextComposerEmojiMiddleware(SearchIndex) as TextComposerMiddleware, - ], + middleware: [createTextComposerEmojiMiddleware() as TextComposerMiddleware], position: { before: 'stream-io/text-composer/mentions-middleware' }, unique: true, }); @@ -414,11 +476,10 @@ const App = () => { return ( >; + +type EmojiPickerTabProps = { + close: () => void; +}; + +const OPTION_BUTTON_CLASS = + 'app__settings-modal__option-button str-chat__button--outline str-chat__button--secondary str-chat__button--size-sm'; + +const update = (patch: Partial) => { + const current = appSettingsStore.getLatestValue().emojiPicker; + appSettingsStore.partialNext({ emojiPicker: { ...current, ...patch } }); +}; + +type FieldProps = { + label: string; + onSelect: (value: T) => void; + options: { label: string; value: T }[]; + value: T; +}; + +function Field({ + label, + onSelect, + options, + value, +}: FieldProps) { + return ( +
+
{label}
+
+ {options.map((option) => ( + + ))} +
+
+ ); +} + +const numberOptions = (values: number[]) => + values.map((value) => ({ label: String(value), value })); + +/** + * Always-open picker wired to the current settings, so tweaks show instantly without + * opening the composer. Skin tone and frequently-used are local to the preview — + * selecting an emoji here feeds the "frequently used" row so `maxFrequentRows` can be + * exercised too. + */ +const EmojiPickerPreview = ({ options }: { options: EmojiPickerSettingsState }) => { + const { mode } = useAppSettingsSelector((state) => state.theme); + const { engine, ...pickerOptions } = options; + const [skinTone, setSkinTone] = useState(0); + const [frequentlyUsedIds, setFrequentlyUsedIds] = useState([]); + + // The deprecated emoji-mart picker renders inline too and honors the same + // emoji-mart-compatible option names, so the same controls drive both engines. + if (engine === 'emoji-mart') { + return ( + (await import('@emoji-mart/data')).default} + onEmojiSelect={() => undefined} + theme={mode} + /> + ); + } + + return ( + + setFrequentlyUsedIds((ids) => [emoji.id, ...ids.filter((id) => id !== emoji.id)]) + } + onSkinToneChange={setSkinTone} + options={{ ...pickerOptions, exceptEmojis: [] }} + skinToneIndex={skinTone} + /> + ); +}; + +/** + * Playground for the built-in EmojiPicker's `pickerProps`. Each control writes to the + * app settings store; the live preview (and the composer's picker via + * `EmojiPickerWithCustomOptions`) reflect the change instantly. + */ +export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => { + const { emojiPicker } = useAppSettingsState(); + const atDefaults = ( + Object.keys(DEFAULT_EMOJI_PICKER_SETTINGS) as (keyof EmojiPickerSettingsState)[] + ).every((key) => emojiPicker[key] === DEFAULT_EMOJI_PICKER_SETTINGS[key]); + + return ( +
+ + + +
+
+
+ +
+ + label='Picker engine' + onSelect={(engine) => update({ engine })} + options={[ + { label: 'Stream (native)', value: 'stream' }, + { label: 'emoji-mart (deprecated)', value: 'emoji-mart' }, + ]} + value={emojiPicker.engine} + /> + + label='Navigation position' + onSelect={(navPosition) => update({ navPosition })} + options={[ + { label: 'Top', value: 'top' }, + { label: 'Bottom', value: 'bottom' }, + { label: 'None', value: 'none' }, + ]} + value={emojiPicker.navPosition} + /> + + label='Preview position' + onSelect={(previewPosition) => update({ previewPosition })} + options={[ + { label: 'Top', value: 'top' }, + { label: 'Bottom', value: 'bottom' }, + { label: 'None', value: 'none' }, + ]} + value={emojiPicker.previewPosition} + /> + + label='Search position' + onSelect={(searchPosition) => update({ searchPosition })} + options={[ + { label: 'Sticky', value: 'sticky' }, + { label: 'Static', value: 'static' }, + { label: 'None', value: 'none' }, + ]} + value={emojiPicker.searchPosition} + /> + + label='Skin-tone position' + onSelect={(skinTonePosition) => update({ skinTonePosition })} + options={[ + { label: 'Preview', value: 'preview' }, + { label: 'Search', value: 'search' }, + { label: 'None', value: 'none' }, + ]} + value={emojiPicker.skinTonePosition} + /> + + label='Emoji per line' + onSelect={(perLine) => update({ perLine })} + options={numberOptions([7, 8, 9, 10])} + value={emojiPicker.perLine} + /> + + label='Frequently-used rows' + onSelect={(maxFrequentRows) => update({ maxFrequentRows })} + options={numberOptions([1, 2, 3, 4])} + value={emojiPicker.maxFrequentRows} + /> + + label='Auto-focus search' + onSelect={(autoFocus) => update({ autoFocus })} + options={[ + { label: 'On', value: true }, + { label: 'Off', value: false }, + ]} + value={emojiPicker.autoFocus} + /> + + label='Country flags' + onSelect={(noCountryFlags) => update({ noCountryFlags })} + options={[ + { label: 'Show', value: false }, + { label: 'Hide', value: true }, + ]} + value={emojiPicker.noCountryFlags} + /> +
+
+
Live preview
+ +
+
+
+
+ ); +}; diff --git a/examples/vite/src/AppSettings/tabs/EmojiPicker/index.ts b/examples/vite/src/AppSettings/tabs/EmojiPicker/index.ts new file mode 100644 index 000000000..dc737187a --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/EmojiPicker/index.ts @@ -0,0 +1 @@ +export * from './EmojiPickerTab'; diff --git a/package.json b/package.json index 6f810565c..54c1701ab 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "@breezystack/lamejs": "^1.2.7", "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", - "@emoji-mart/data": "^1.2.1", + "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "^1.1.1", "@eslint/js": "^9.39.4", "@semantic-release/changelog": "^6.0.3", diff --git a/scripts/vendor-emoji-data.mjs b/scripts/vendor-emoji-data.mjs new file mode 100644 index 000000000..0619c741b --- /dev/null +++ b/scripts/vendor-emoji-data.mjs @@ -0,0 +1,49 @@ +// Regenerates the vendored emoji dataset used by the built-in emoji picker and +// search index (src/plugins/Emojis/data/emoji-data.json). +// +// We vendor a snapshot of @emoji-mart/data's *native* set (no spritesheet) so the +// SDK ships its own emoji data and does not depend on the unmaintained emoji-mart +// packages at runtime. @emoji-mart/data stays a pinned devDependency used ONLY by +// this script. +// +// Usage: node scripts/vendor-emoji-data.mjs +// +// The dataset is MIT-licensed (Copyright (c) Missive); its license is copied +// verbatim to src/plugins/Emojis/data/LICENSE. + +import { createRequire } from 'node:module'; +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +// @emoji-mart/data's "main" resolves to sets/15/native.json (its newest set), so +// pinning 15 matches a bare `import data from '@emoji-mart/data'`. +const SET = 15; + +const require = createRequire(import.meta.url); + +const sourcePath = require.resolve(`@emoji-mart/data/sets/${SET}/native.json`); +const pkgPath = require.resolve('@emoji-mart/data/package.json'); +const pkg = require('@emoji-mart/data/package.json'); +const licensePath = join(dirname(pkgPath), 'LICENSE'); + +const outDir = resolve(process.cwd(), 'src/plugins/Emojis/data'); +const outDataPath = join(outDir, 'emoji-data.json'); +const outLicensePath = join(outDir, 'LICENSE'); + +const data = JSON.parse(readFileSync(sourcePath, 'utf8')); + +// `sheet` only describes spritesheet geometry (cols/rows); we render native unicode +// exclusively, so it is dead weight. Everything else (categories, emojis, aliases) +// is preserved verbatim so mapEmojiMartData and the search index keep working. +delete data.sheet; + +mkdirSync(outDir, { recursive: true }); +writeFileSync(outDataPath, JSON.stringify(data)); +copyFileSync(licensePath, outLicensePath); + +const emojiCount = Object.keys(data.emojis).length; +console.log( + `Vendored @emoji-mart/data@${pkg.version} (set ${SET}, native): ` + + `${emojiCount} emoji, ${data.categories.length} categories -> ${outDataPath}`, +); +console.log(`Copied upstream MIT LICENSE -> ${outLicensePath}`); diff --git a/src/components/MessageComposer/MessageComposer.tsx b/src/components/MessageComposer/MessageComposer.tsx index 70888f629..4febe2c20 100644 --- a/src/components/MessageComposer/MessageComposer.tsx +++ b/src/components/MessageComposer/MessageComposer.tsx @@ -49,7 +49,7 @@ export type MessageComposerProps = { audioRecordingConfig?: CustomAudioRecordingConfig; /** Controls whether the users will be provided with the UI to record voice messages. */ audioRecordingEnabled?: boolean; - /** Mechanism to be used with autocomplete and text replace features of the `MessageComposer` component, see [emoji-mart `SearchIndex`](https://github.com/missive/emoji-mart#%EF%B8%8F%EF%B8%8F-headless-search) */ + /** Custom emoji search index for `MessageComposer` autocomplete and emoticon replacement. Optional — the SDK ships a built-in `defaultEmojiSearchIndex` (used by `createTextComposerEmojiMiddleware`); emoji-mart's `SearchIndex` also satisfies this interface. */ emojiSearchIndex?: ComponentContextValue['emojiSearchIndex']; /** If true, focuses the text input on component mount */ focus?: boolean; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index ae6c5fb70..c0b3b4632 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -139,7 +139,7 @@ export type ComponentContextValue = { EditedMessagePreview?: React.ComponentType; /** Custom UI component for rendering button with emoji picker in MessageComposer */ EmojiPicker?: React.ComponentType; - /** Mechanism to be used with autocomplete and text replace features of the `MessageComposer` component, see [emoji-mart `SearchIndex`](https://github.com/missive/emoji-mart#%EF%B8%8F%EF%B8%8F-headless-search) */ + /** Custom emoji search index for `MessageComposer` autocomplete and emoticon replacement. Optional — the SDK ships a built-in `defaultEmojiSearchIndex` (used by `createTextComposerEmojiMiddleware`); emoji-mart's `SearchIndex` also satisfies this interface. */ emojiSearchIndex?: EmojiSearchIndex; /** Custom UI component to be displayed when the `MessageList` is empty, defaults to and accepts same props as: [EmptyStateIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx) */ EmptyStateIndicator?: React.ComponentType; diff --git a/src/i18n/de.json b/src/i18n/de.json index ebe9c2644..53a87ab77 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -43,6 +43,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} hat abgestimmt: {{pollOptionText}}", "📍Shared location": "📍Geteilter Standort", "Actions": "Actions", + "Activities": "Aktivitäten", "Add": "Hinzufügen", "Add {{ count }} members_one": "{{ count }} Mitglied hinzufügen", "Add {{ count }} members_other": "{{ count }} Mitglieder hinzufügen", @@ -65,6 +66,7 @@ "Also sent in channel": "Auch im Kanal gesendet", "An error has occurred during recording": "Ein Fehler ist während der Aufnahme aufgetreten", "An error has occurred during the recording processing": "Ein Fehler ist während der Aufnahmeverarbeitung aufgetreten", + "Animals & Nature": "Tiere & Natur", "Anonymous": "Anonym", "Anonymous poll": "Anonyme Umfrage", "Archive": "Archivieren", @@ -100,8 +102,12 @@ "aria/Channel Actions": "Kanalaktionen", "aria/Channel details": "Kanaldetails", "aria/Channel list": "Kanalliste", + "aria/Channel search results": "Kanalsuchergebnisse", "aria/Chat view controls": "Chat-Ansicht-Steuerelemente", + "aria/Chat view tabs": "Chat-Ansicht Tabs", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", + "aria/Choose default skin tone": "Standard-Hautton wählen", + "aria/Clear emoji search": "Emoji-Suche löschen", "aria/Clear search": "Suche leeren", "aria/Close callout dialog": "Hinweisdialog schließen", "aria/Close thread": "Thread schließen", @@ -270,6 +276,8 @@ "Create a question, add options, and configure poll settings": "Erstelle eine Frage, füge Optionen hinzu und konfiguriere die Umfrageeinstellungen", "Create poll": "Umfrage erstellen", "Current location": "Aktueller Standort", + "Dark": "Dunkel", + "Default": "Standard", "Delete": "Löschen", "Delete chat": "Chat löschen", "Delete for me": "Für mich löschen", @@ -340,6 +348,7 @@ "Failed to jump to the first unread message": "Fehler beim Springen zur ersten ungelesenen Nachricht", "Failed to leave channel": "Kanal konnte nicht verlassen werden", "Failed to load channels": "Kanäle konnten nicht geladen werden", + "Failed to load emojis": "Emojis konnten nicht geladen werden", "Failed to load more channels": "Weitere Kanäle konnten nicht geladen werden", "Failed to mark channel as read": "Fehler beim Markieren des Kanals als gelesen", "Failed to play the recording": "Wiedergabe der Aufnahme fehlgeschlagen", @@ -357,6 +366,9 @@ "fileCount_other": "{{ count }} dateien", "Files": "Dateien", "Flag": "Melden", + "Flags": "Flaggen", + "Food & Drink": "Essen & Trinken", + "Frequently used": "Häufig verwendet", "Generating...": "Generieren...", "giphy-command-args": "[Text]", "giphy-command-description": "Poste ein zufälliges Gif in den Kanal", @@ -430,6 +442,7 @@ "Leave chat": "Kanal verlassen", "Left channel": "Kanal verlassen", "Let others add options": "Andere Optionen hinzufügen lassen", + "Light": "Hell", "Limit votes per person": "Stimmen pro Person begrenzen", "Link": "Link", "linkCount_one": "Link", @@ -448,6 +461,9 @@ "Mark as unread": "Als ungelesen markieren", "Maximum number of votes (from 2 to 10)": "Maximale Anzahl der Stimmen (von 2 bis 10)", "Maximum votes per person": "Maximale Stimmen pro Person", + "Medium": "Mittel", + "Medium-Dark": "Mitteldunkel", + "Medium-Light": "Mittelhell", "Member detail": "Mitgliederdetails", "mention/Channel": "Kanal", "mention/Channel Description": "Alle in diesem Kanal benachrichtigen", @@ -478,6 +494,7 @@ "Next image": "Nächstes Bild", "No chats here yet…": "Noch keine Chats hier...", "No conversations yet": "Noch keine Unterhaltungen", + "No emoji found": "Keine Emojis gefunden", "No files": "Keine Dateien", "No items exist": "Keine Elemente vorhanden", "No member found": "Kein Mitglied gefunden", @@ -489,6 +506,7 @@ "Nobody will be able to vote in this poll anymore.": "Niemand kann mehr in dieser Umfrage abstimmen.", "Nothing yet...": "Noch nichts...", "Notify all {{ role }} members": "Alle Mitglieder mit Rolle {{ role }} benachrichtigen", + "Objects": "Objekte", "Offline": "Offline", "Ok": "OK", "Online": "Online", @@ -508,6 +526,7 @@ "People matching": "Passende Personen", "Photo": "Foto", "Photos & videos": "Fotos & Videos", + "Pick an emoji…": "Emoji auswählen…", "Pin": "Anheften", "Pin a message to see it here": "Hefte eine Nachricht an, um sie hier zu sehen", "Pinned by {{ name }}": "Angeheftet von {{ name }}", @@ -553,6 +572,7 @@ "replyCount_one": "1 Antwort", "replyCount_other": "{{ count }} Antworten", "Resend": "Erneut senden", + "Retry": "Erneut versuchen", "Retry upload": "Upload erneut versuchen", "Review all options available in this poll": "Überprüfe alle verfügbaren Optionen in dieser Umfrage", "Review comments submitted with poll answers": "Überprüfe Kommentare, die mit Umfrageantworten eingereicht wurden", @@ -563,6 +583,7 @@ "Save for later": "Für später speichern", "Saved for later": "Für später gespeichert", "Search": "Suche", + "Search emoji": "Emoji suchen", "Search GIFs": "GIFs suchen", "search-results-header-filter-source-button-label--channels": "Kanäle", "search-results-header-filter-source-button-label--messages": "Nachrichten", @@ -598,12 +619,14 @@ "size limit": "Größenbeschränkung", "Slow Mode ON": "Langsamer Modus EIN", "Slow mode, wait {{ seconds }}s...": "Langsamer Modus, warte {{ seconds }}s...", + "Smileys & People": "Smileys & Personen", "Some of the files will not be accepted": "Einige der Dateien werden nicht akzeptiert", "Start typing to search": "Tippen Sie, um zu suchen", "Stop sharing": "Teilen beenden", "Submit": "Absenden", "Suggest a new option to add to this poll": "Schlage eine neue Option vor, die zu dieser Umfrage hinzugefügt werden soll", "Suggest an option": "Eine Option vorschlagen", + "Symbols": "Symbole", "Tap to remove": "Tippen zum Entfernen", "Tap to remove: {{ reactionName }}": "Tippen zum Entfernen: {{ reactionName }}", "Thinking...": "Denken...", @@ -642,6 +665,7 @@ "Translated": "Übersetzt", "Translated from {{ language }}": "Übersetzung aus {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Reisen & Orte", "Type a number from 2 to 10": "Geben Sie eine Zahl von 2 bis 10 ein", "Unarchive": "Archivierung aufheben", "unban-command-args": "[@Benutzername]", diff --git a/src/i18n/en.json b/src/i18n/en.json index 4d2d536dd..3eea668af 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -43,6 +43,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} voted: {{pollOptionText}}", "📍Shared location": "📍Shared location", "Actions": "Actions", + "Activities": "Activities", "Add": "Add", "Add {{ count }} members_one": "Add {{ count }} member", "Add {{ count }} members_other": "Add {{ count }} members", @@ -65,6 +66,7 @@ "Also sent in channel": "Also sent in channel", "An error has occurred during recording": "An error has occurred during recording", "An error has occurred during the recording processing": "An error has occurred during the recording processing", + "Animals & Nature": "Animals & Nature", "Anonymous": "Anonymous", "Anonymous poll": "Anonymous Poll", "Archive": "Archive", @@ -100,8 +102,12 @@ "aria/Channel Actions": "Channel Actions", "aria/Channel details": "Channel details", "aria/Channel list": "Channel list", + "aria/Channel search results": "Channel search results", "aria/Chat view controls": "Chat view controls", + "aria/Chat view tabs": "Chat view tabs", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", + "aria/Choose default skin tone": "aria/Choose default skin tone", + "aria/Clear emoji search": "aria/Clear emoji search", "aria/Clear search": "Clear search", "aria/Close callout dialog": "Close callout dialog", "aria/Close thread": "Close thread", @@ -270,6 +276,8 @@ "Create a question, add options, and configure poll settings": "Create a question, add options, and configure poll settings", "Create poll": "Create Poll", "Current location": "Current location", + "Dark": "Dark", + "Default": "Default", "Delete": "Delete", "Delete chat": "Delete chat", "Delete for me": "Delete for me", @@ -340,6 +348,7 @@ "Failed to jump to the first unread message": "Failed to jump to the first unread message", "Failed to leave channel": "Failed to leave channel", "Failed to load channels": "Failed to load channels", + "Failed to load emojis": "Failed to load emojis", "Failed to load more channels": "Failed to load more channels", "Failed to mark channel as read": "Failed to mark channel as read", "Failed to play the recording": "Failed to play the recording", @@ -357,6 +366,9 @@ "fileCount_other": "{{ count }} files", "Files": "Files", "Flag": "Flag", + "Flags": "Flags", + "Food & Drink": "Food & Drink", + "Frequently used": "Frequently used", "Generating...": "Generating...", "giphy-command-args": "[text]", "giphy-command-description": "Post a random gif to the channel", @@ -430,6 +442,7 @@ "Leave chat": "Leave chat", "Left channel": "Left channel", "Let others add options": "Let Others Add Options", + "Light": "Light", "Limit votes per person": "Limit Votes per Person", "Link": "Link", "linkCount_one": "Link", @@ -448,6 +461,9 @@ "Mark as unread": "Mark as unread", "Maximum number of votes (from 2 to 10)": "Maximum number of votes (from 2 to 10)", "Maximum votes per person": "Maximum votes per person", + "Medium": "Medium", + "Medium-Dark": "Medium-Dark", + "Medium-Light": "Medium-Light", "Member detail": "Member detail", "mention/Channel": "Channel", "mention/Channel Description": "Notify everyone in this channel", @@ -478,6 +494,7 @@ "Next image": "Next image", "No chats here yet…": "No chats here yet…", "No conversations yet": "No conversations yet", + "No emoji found": "No emoji found", "No files": "No files", "No items exist": "No items exist", "No member found": "No member found", @@ -489,6 +506,7 @@ "Nobody will be able to vote in this poll anymore.": "Nobody will be able to vote in this poll anymore.", "Nothing yet...": "Nothing yet...", "Notify all {{ role }} members": "Notify all {{ role }} members", + "Objects": "Objects", "Offline": "Offline", "Ok": "Ok", "Online": "Online", @@ -508,6 +526,7 @@ "People matching": "People matching", "Photo": "Photo", "Photos & videos": "Photos & videos", + "Pick an emoji…": "Pick an emoji…", "Pin": "Pin", "Pin a message to see it here": "Pin a message to see it here", "Pinned by {{ name }}": "Pinned by {{ name }}", @@ -553,6 +572,7 @@ "replyCount_one": "1 reply", "replyCount_other": "{{ count }} replies", "Resend": "Resend", + "Retry": "Retry", "Retry upload": "Retry upload", "Review all options available in this poll": "Review all options available in this poll", "Review comments submitted with poll answers": "Review comments submitted with poll answers", @@ -563,6 +583,7 @@ "Save for later": "Save for later", "Saved for later": "Saved for later", "Search": "Search", + "Search emoji": "Search emoji", "Search GIFs": "Search GIFs", "search-results-header-filter-source-button-label--channels": "channels", "search-results-header-filter-source-button-label--messages": "messages", @@ -598,12 +619,14 @@ "size limit": "size limit", "Slow Mode ON": "Slow Mode ON", "Slow mode, wait {{ seconds }}s...": "Slow mode, wait {{ seconds }}s...", + "Smileys & People": "Smileys & People", "Some of the files will not be accepted": "Some of the files will not be accepted", "Start typing to search": "Start typing to search", "Stop sharing": "Stop sharing", "Submit": "Submit", "Suggest a new option to add to this poll": "Suggest a new option to add to this poll", "Suggest an option": "Suggest an Option", + "Symbols": "Symbols", "Tap to remove": "Tap to remove", "Tap to remove: {{ reactionName }}": "Tap to remove: {{ reactionName }}", "Thinking...": "Thinking...", @@ -642,6 +665,7 @@ "Translated": "Translated", "Translated from {{ language }}": "Translated from {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Travel & Places", "Type a number from 2 to 10": "Type a number from 2 to 10", "Unarchive": "Unarchive", "unban-command-args": "[@username]", diff --git a/src/i18n/es.json b/src/i18n/es.json index f0a743d48..3981a33e8 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -53,6 +53,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votó: {{pollOptionText}}", "📍Shared location": "📍Ubicación compartida", "Actions": "Actions", + "Activities": "Actividades", "Add": "Añadir", "Add {{ count }} members_one": "Añadir {{ count }} miembro", "Add {{ count }} members_many": "Añadir {{ count }} miembros", @@ -76,6 +77,7 @@ "Also sent in channel": "También enviado en el canal", "An error has occurred during recording": "Se ha producido un error durante la grabación", "An error has occurred during the recording processing": "Se ha producido un error durante el procesamiento de la grabación", + "Animals & Nature": "Animales y naturaleza", "Anonymous": "Anónimo", "Anonymous poll": "Encuesta anónima", "Archive": "Archivo", @@ -116,8 +118,12 @@ "aria/Channel Actions": "Acciones del canal", "aria/Channel details": "Detalles del canal", "aria/Channel list": "Lista de canales", + "aria/Channel search results": "Resultados de búsqueda de canales", "aria/Chat view controls": "Controles de la vista del chat", + "aria/Chat view tabs": "Pestañas de vista del chat", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", + "aria/Choose default skin tone": "Elegir tono de piel predeterminado", + "aria/Clear emoji search": "Borrar búsqueda de emojis", "aria/Clear search": "Borrar búsqueda", "aria/Close callout dialog": "Cerrar diálogo de aviso", "aria/Close thread": "Cerrar hilo", @@ -287,6 +293,8 @@ "Create a question, add options, and configure poll settings": "Crea una pregunta, añade opciones y configura los ajustes de la encuesta", "Create poll": "Crear encuesta", "Current location": "Ubicación actual", + "Dark": "Oscuro", + "Default": "Predeterminado", "Delete": "Borrar", "Delete chat": "Eliminar chat", "Delete for me": "Eliminar para mí", @@ -357,6 +365,7 @@ "Failed to jump to the first unread message": "Error al saltar al primer mensaje no leído", "Failed to leave channel": "No se pudo salir del canal", "Failed to load channels": "No se pudieron cargar los canales", + "Failed to load emojis": "No se pudieron cargar los emojis", "Failed to load more channels": "No se pudieron cargar más canales", "Failed to mark channel as read": "Error al marcar el canal como leído", "Failed to play the recording": "No se pudo reproducir la grabación", @@ -375,6 +384,9 @@ "fileCount_other": "{{ count }} archivos", "Files": "Archivos", "Flag": "Marcar", + "Flags": "Banderas", + "Food & Drink": "Comida y bebida", + "Frequently used": "Usados frecuentemente", "Generating...": "Generando...", "giphy-command-args": "[texto]", "giphy-command-description": "Publicar un gif aleatorio en el canal", @@ -449,6 +461,7 @@ "Leave chat": "Abandonar canal", "Left channel": "Canal abandonado", "Let others add options": "Permitir que otros añadan opciones", + "Light": "Claro", "Limit votes per person": "Limitar votos por persona", "Link": "Enlace", "linkCount_one": "Enlace", @@ -468,6 +481,9 @@ "Mark as unread": "Marcar como no leído", "Maximum number of votes (from 2 to 10)": "Número máximo de votos (de 2 a 10)", "Maximum votes per person": "Máximo de votos por persona", + "Medium": "Medio", + "Medium-Dark": "Medio oscuro", + "Medium-Light": "Medio claro", "Member detail": "Detalle del miembro", "mention/Channel": "Canal", "mention/Channel Description": "Notificar a todos en este canal", @@ -498,6 +514,7 @@ "Next image": "Siguiente imagen", "No chats here yet…": "Aún no hay mensajes aquí...", "No conversations yet": "Aún no hay conversaciones", + "No emoji found": "No se encontraron emojis", "No files": "No hay archivos", "No items exist": "No existen elementos", "No member found": "No se encontró ningún miembro", @@ -509,6 +526,7 @@ "Nobody will be able to vote in this poll anymore.": "Nadie podrá votar en esta encuesta.", "Nothing yet...": "Nada aún...", "Notify all {{ role }} members": "Notificar a todos los miembros con rol {{ role }}", + "Objects": "Objetos", "Offline": "Desconectado", "Ok": "Aceptar", "Online": "En línea", @@ -528,6 +546,7 @@ "People matching": "Personas que coinciden", "Photo": "Foto", "Photos & videos": "Fotos y videos", + "Pick an emoji…": "Elige un emoji…", "Pin": "Fijar", "Pin a message to see it here": "Fija un mensaje para verlo aquí", "Pinned by {{ name }}": "Fijado por {{ name }}", @@ -576,6 +595,7 @@ "replyCount_many": "{{ count }} respuestas", "replyCount_other": "{{ count }} respuestas", "Resend": "Reenviar", + "Retry": "Reintentar", "Retry upload": "Reintentar la carga", "Review all options available in this poll": "Revisa todas las opciones disponibles en esta encuesta", "Review comments submitted with poll answers": "Revisa los comentarios enviados con las respuestas de la encuesta", @@ -586,6 +606,7 @@ "Save for later": "Guardar para más tarde", "Saved for later": "Guardado para más tarde", "Search": "Buscar", + "Search emoji": "Buscar emoji", "Search GIFs": "Buscar GIFs", "search-results-header-filter-source-button-label--channels": "canales", "search-results-header-filter-source-button-label--messages": "mensajes", @@ -623,12 +644,14 @@ "size limit": "límite de tamaño", "Slow Mode ON": "Modo lento activado", "Slow mode, wait {{ seconds }}s...": "Modo lento, espera {{ seconds }} s...", + "Smileys & People": "Emoticonos y personas", "Some of the files will not be accepted": "Algunos archivos no serán aceptados", "Start typing to search": "Empieza a escribir para buscar", "Stop sharing": "Dejar de compartir", "Submit": "Enviar", "Suggest a new option to add to this poll": "Sugiere una nueva opción para añadir a esta encuesta", "Suggest an option": "Sugerir una opción", + "Symbols": "Símbolos", "Tap to remove": "Toca para quitar", "Tap to remove: {{ reactionName }}": "Toca para quitar: {{ reactionName }}", "Thinking...": "Pensando...", @@ -669,6 +692,7 @@ "Translated": "Traducido", "Translated from {{ language }}": "Traducido de {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Viajes y lugares", "Type a number from 2 to 10": "Escribe un número del 2 al 10", "Unarchive": "Desarchivar", "unban-command-args": "[@usuario]", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 838567394..ce5476a7e 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -53,6 +53,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} a voté : {{pollOptionText}}", "📍Shared location": "📍Emplacement partagé", "Actions": "Actions", + "Activities": "Activités", "Add": "Ajouter", "Add {{ count }} members_one": "Ajouter {{ count }} membre", "Add {{ count }} members_many": "Ajouter {{ count }} membres", @@ -76,6 +77,7 @@ "Also sent in channel": "Également envoyé dans le canal", "An error has occurred during recording": "Une erreur s'est produite pendant l'enregistrement", "An error has occurred during the recording processing": "Une erreur s'est produite pendant le traitement de l'enregistrement", + "Animals & Nature": "Animaux et nature", "Anonymous": "Anonyme", "Anonymous poll": "Sondage anonyme", "Archive": "Archiver", @@ -116,8 +118,12 @@ "aria/Channel Actions": "Actions du canal", "aria/Channel details": "Détails du canal", "aria/Channel list": "Liste des canaux", + "aria/Channel search results": "Résultats de recherche de canaux", "aria/Chat view controls": "Commandes de la vue du chat", + "aria/Chat view tabs": "Onglets de la vue de chat", "aria/Chat: {{ channelName }}": "Chat : {{ channelName }}", + "aria/Choose default skin tone": "Choisir le teint par défaut", + "aria/Clear emoji search": "Effacer la recherche d'emoji", "aria/Clear search": "Effacer la recherche", "aria/Close callout dialog": "Fermer la boîte de dialogue d'information", "aria/Close thread": "Fermer le fil", @@ -287,6 +293,8 @@ "Create a question, add options, and configure poll settings": "Créez une question, ajoutez des options et configurez les paramètres du sondage", "Create poll": "Créer un sondage", "Current location": "Emplacement actuel", + "Dark": "Foncé", + "Default": "Par défaut", "Delete": "Supprimer", "Delete chat": "Supprimer le chat", "Delete for me": "Supprimer pour moi", @@ -357,6 +365,7 @@ "Failed to jump to the first unread message": "Échec du saut vers le premier message non lu", "Failed to leave channel": "Impossible de quitter le canal", "Failed to load channels": "Impossible de charger les canaux", + "Failed to load emojis": "Échec du chargement des emojis", "Failed to load more channels": "Impossible de charger davantage de canaux", "Failed to mark channel as read": "Échec du marquage du canal comme lu", "Failed to play the recording": "Impossible de lire l'enregistrement", @@ -375,6 +384,9 @@ "fileCount_other": "{{ count }} fichiers", "Files": "Fichiers", "Flag": "Signaler", + "Flags": "Drapeaux", + "Food & Drink": "Nourriture et boissons", + "Frequently used": "Fréquemment utilisés", "Generating...": "Génération...", "giphy-command-args": "[texte]", "giphy-command-description": "Poster un GIF aléatoire dans le canal", @@ -449,6 +461,7 @@ "Leave chat": "Quitter le canal", "Left channel": "Canal quitté", "Let others add options": "Permettre à d'autres d'ajouter des options", + "Light": "Clair", "Limit votes per person": "Limiter les votes par personne", "Link": "Lien", "linkCount_one": "Lien", @@ -468,6 +481,9 @@ "Mark as unread": "Marquer comme non lu", "Maximum number of votes (from 2 to 10)": "Nombre maximum de votes (de 2 à 10)", "Maximum votes per person": "Nombre maximal de votes par personne", + "Medium": "Moyen", + "Medium-Dark": "Moyennement foncé", + "Medium-Light": "Moyennement clair", "Member detail": "Détails du membre", "mention/Channel": "Canal", "mention/Channel Description": "Notifier tout le monde dans ce canal", @@ -498,6 +514,7 @@ "Next image": "Image suivante", "No chats here yet…": "Pas encore de messages ici...", "No conversations yet": "Aucune conversation pour le moment", + "No emoji found": "Aucun emoji trouvé", "No files": "Aucun fichier", "No items exist": "Aucun élément", "No member found": "Aucun membre trouvé", @@ -509,6 +526,7 @@ "Nobody will be able to vote in this poll anymore.": "Personne ne pourra plus voter dans ce sondage.", "Nothing yet...": "Rien pour l'instant...", "Notify all {{ role }} members": "Notifier tous les membres ayant le rôle {{ role }}", + "Objects": "Objets", "Offline": "Hors ligne", "Ok": "D'accord", "Online": "En ligne", @@ -528,6 +546,7 @@ "People matching": "Correspondance de personnes", "Photo": "Photo", "Photos & videos": "Photos et vidéos", + "Pick an emoji…": "Choisissez un emoji…", "Pin": "Épingler", "Pin a message to see it here": "Épinglez un message pour le voir ici", "Pinned by {{ name }}": "Épinglé par {{ name }}", @@ -576,6 +595,7 @@ "replyCount_many": "{{ count }} réponses", "replyCount_other": "{{ count }} réponses", "Resend": "Renvoyer", + "Retry": "Réessayer", "Retry upload": "Réessayer le téléchargement", "Review all options available in this poll": "Consultez toutes les options disponibles dans ce sondage", "Review comments submitted with poll answers": "Consultez les commentaires envoyés avec les réponses au sondage", @@ -586,6 +606,7 @@ "Save for later": "Enregistrer pour plus tard", "Saved for later": "Enregistré pour plus tard", "Search": "Rechercher", + "Search emoji": "Rechercher un emoji", "Search GIFs": "Rechercher des GIFs", "search-results-header-filter-source-button-label--channels": "canaux", "search-results-header-filter-source-button-label--messages": "messages", @@ -623,12 +644,14 @@ "size limit": "limite de taille", "Slow Mode ON": "Mode lent activé", "Slow mode, wait {{ seconds }}s...": "Mode lent, attendez {{ seconds }} s...", + "Smileys & People": "Émoticônes et personnes", "Some of the files will not be accepted": "Certains fichiers ne seront pas acceptés", "Start typing to search": "Commencez à taper pour rechercher", "Stop sharing": "Arrêter de partager", "Submit": "Envoyer", "Suggest a new option to add to this poll": "Suggérez une nouvelle option à ajouter à ce sondage", "Suggest an option": "Suggérer une option", + "Symbols": "Symboles", "Tap to remove": "Appuyez pour retirer", "Tap to remove: {{ reactionName }}": "Appuyez pour retirer: {{ reactionName }}", "Thinking...": "Réflexion...", @@ -669,6 +692,7 @@ "Translated": "Traduit", "Translated from {{ language }}": "Traduit du {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Voyages et lieux", "Type a number from 2 to 10": "Tapez un nombre de 2 à 10", "Unarchive": "Désarchiver", "unban-command-args": "[@nomdutilisateur]", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 920881d77..c9e18310d 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -43,6 +43,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ने वोट दिया: {{pollOptionText}}", "📍Shared location": "📍साझा किया गया स्थान", "Actions": "Actions", + "Activities": "गतिविधियाँ", "Add": "जोड़ें", "Add {{ count }} members_one": "{{ count }} सदस्य जोड़ें", "Add {{ count }} members_other": "{{ count }} सदस्य जोड़ें", @@ -65,6 +66,7 @@ "Also sent in channel": "चैनल में भी भेजा गया", "An error has occurred during recording": "रेकॉर्डिंग के दौरान एक त्रुटि आ गई है", "An error has occurred during the recording processing": "रेकॉर्डिंग प्रोसेसिंग के दौरान एक त्रुटि आ गई है", + "Animals & Nature": "जानवर और प्रकृति", "Anonymous": "गुमनाम", "Anonymous poll": "गुमनाम मतदान", "Archive": "आर्काइव", @@ -100,8 +102,12 @@ "aria/Channel Actions": "चैनल क्रियाएँ", "aria/Channel details": "चैनल विवरण", "aria/Channel list": "चैनल सूची", + "aria/Channel search results": "चैनल खोज परिणाम", "aria/Chat view controls": "चैट व्यू नियंत्रण", + "aria/Chat view tabs": "चैट व्यू टैब", "aria/Chat: {{ channelName }}": "चैट: {{ channelName }}", + "aria/Choose default skin tone": "डिफ़ॉल्ट त्वचा टोन चुनें", + "aria/Clear emoji search": "इमोजी खोज साफ़ करें", "aria/Clear search": "खोज साफ़ करें", "aria/Close callout dialog": "कॉलआउट संवाद बंद करें", "aria/Close thread": "थ्रेड बंद करें", @@ -270,6 +276,8 @@ "Create a question, add options, and configure poll settings": "एक प्रश्न बनाएं, विकल्प जोड़ें और पोल सेटिंग्स कॉन्फ़िगर करें", "Create poll": "मतदान बनाएँ", "Current location": "वर्तमान स्थान", + "Dark": "गहरा", + "Default": "डिफ़ॉल्ट", "Delete": "डिलीट", "Delete chat": "चैट हटाएं", "Delete for me": "मेरे लिए डिलीट करें", @@ -341,6 +349,7 @@ "Failed to jump to the first unread message": "पहले अपठित संदेश पर जाने में विफल", "Failed to leave channel": "चैनल छोड़ने में विफल", "Failed to load channels": "चैनल लोड करने में विफल", + "Failed to load emojis": "इमोजी लोड नहीं हो सके", "Failed to load more channels": "और चैनल लोड करने में विफल", "Failed to mark channel as read": "चैनल को पढ़ा हुआ चिह्नित करने में विफल।", "Failed to play the recording": "रेकॉर्डिंग प्ले करने में विफल", @@ -358,6 +367,9 @@ "fileCount_other": "{{ count }} फ़ाइलें", "Files": "फ़ाइलें", "Flag": "फ्लैग करे", + "Flags": "झंडे", + "Food & Drink": "खाना और पेय", + "Frequently used": "अक्सर उपयोग किए गए", "Generating...": "बना रहा है...", "giphy-command-args": "[पाठ]", "giphy-command-description": "चैनल पर एक क्रॉफिल जीआइएफ पोस्ट करें", @@ -431,6 +443,7 @@ "Leave chat": "चैनल छोड़ें", "Left channel": "चैनल छोड़ दिया गया", "Let others add options": "दूसरों को विकल्प जोड़ने दें", + "Light": "हल्का", "Limit votes per person": "प्रति व्यक्ति वोट सीमित करें", "Link": "लिंक", "linkCount_one": "1 लिंक", @@ -449,6 +462,9 @@ "Mark as unread": "अपठित चिह्नित करें", "Maximum number of votes (from 2 to 10)": "अधिकतम वोटों की संख्या (2 से 10)", "Maximum votes per person": "प्रति व्यक्ति अधिकतम वोट", + "Medium": "मध्यम", + "Medium-Dark": "मध्यम-गहरा", + "Medium-Light": "मध्यम-हल्का", "Member detail": "सदस्य विवरण", "mention/Channel": "चैनल", "mention/Channel Description": "इस चैनल में सभी को सूचित करें", @@ -479,6 +495,7 @@ "Next image": "अगली छवि", "No chats here yet…": "यहां अभी तक कोई चैट नहीं...", "No conversations yet": "अभी तक कोई बातचीत नहीं है", + "No emoji found": "कोई इमोजी नहीं मिला", "No files": "कोई फ़ाइल नहीं", "No items exist": "कोई आइटम मौजूद नहीं है", "No member found": "कोई सदस्य नहीं मिला", @@ -490,6 +507,7 @@ "Nobody will be able to vote in this poll anymore.": "अब कोई भी इस मतदान में मतदान नहीं कर सकेगा।", "Nothing yet...": "कोई मैसेज नहीं है", "Notify all {{ role }} members": "{{ role }} भूमिका वाले सभी सदस्यों को सूचित करें", + "Objects": "वस्तुएँ", "Offline": "ऑफलाइन", "Ok": "ठीक है", "Online": "ऑनलाइन", @@ -509,6 +527,7 @@ "People matching": "मेल खाते लोग", "Photo": "फ़ोटो", "Photos & videos": "फ़ोटो और वीडियो", + "Pick an emoji…": "इमोजी चुनें…", "Pin": "पिन", "Pin a message to see it here": "इसे यहाँ देखने के लिए संदेश पिन करें", "Pinned by {{ name }}": "{{ name }} द्वारा पिन किया गया", @@ -554,6 +573,7 @@ "replyCount_one": "1 रिप्लाई", "replyCount_other": "{{ count }} रिप्लाई", "Resend": "फिर से भेजें", + "Retry": "पुनः प्रयास करें", "Retry upload": "अपलोड फिर से करें", "Review all options available in this poll": "इस पोल में उपलब्ध सभी विकल्पों की समीक्षा करें", "Review comments submitted with poll answers": "पोल उत्तरों के साथ भेजी गई टिप्पणियों की समीक्षा करें", @@ -564,6 +584,7 @@ "Save for later": "बाद के लिए सहेजें", "Saved for later": "बाद के लिए सहेजा गया", "Search": "खोज", + "Search emoji": "इमोजी खोजें", "Search GIFs": "GIF खोजें", "search-results-header-filter-source-button-label--channels": "चैनल्स", "search-results-header-filter-source-button-label--messages": "संदेश", @@ -599,12 +620,14 @@ "size limit": "आकार सीमा", "Slow Mode ON": "स्लो मोड ऑन", "Slow mode, wait {{ seconds }}s...": "स्लो मोड, {{ seconds }} सेकंड प्रतीक्षा करें...", + "Smileys & People": "स्माइली और लोग", "Some of the files will not be accepted": "कुछ फ़ाइलें स्वीकार नहीं की जाएंगी", "Start typing to search": "खोजने के लिए टाइप करना शुरू करें", "Stop sharing": "साझा करना बंद करें", "Submit": "जमा करें", "Suggest a new option to add to this poll": "इस पोल में जोड़ने के लिए एक नया विकल्प सुझाएं", "Suggest an option": "एक विकल्प सुझाव दें", + "Symbols": "प्रतीक", "Tap to remove": "हटाने के लिए टैप करें", "Tap to remove: {{ reactionName }}": "हटाने के लिए टैप करें: {{ reactionName }}", "Thinking...": "सोच रहा है...", @@ -643,6 +666,7 @@ "Translated": "अनुवादित", "Translated from {{ language }}": "{{ language }} से अनुवादित", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "यात्रा और स्थान", "Type a number from 2 to 10": "2 से 10 तक का एक नंबर टाइप करें", "Unarchive": "अनआर्काइव", "unban-command-args": "[@उपयोगकर्तनाम]", diff --git a/src/i18n/it.json b/src/i18n/it.json index 3f23847bf..de794792e 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -53,6 +53,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ha votato: {{pollOptionText}}", "📍Shared location": "📍Posizione condivisa", "Actions": "Actions", + "Activities": "Attività", "Add": "Aggiungi", "Add {{ count }} members_one": "Aggiungi {{ count }} membro", "Add {{ count }} members_many": "Aggiungi {{ count }} membri", @@ -76,6 +77,7 @@ "Also sent in channel": "Inviato anche nel canale", "An error has occurred during recording": "Si è verificato un errore durante la registrazione", "An error has occurred during the recording processing": "Si è verificato un errore durante l'elaborazione della registrazione", + "Animals & Nature": "Animali e natura", "Anonymous": "Anonimo", "Anonymous poll": "Sondaggio anonimo", "Archive": "Archivia", @@ -116,8 +118,12 @@ "aria/Channel Actions": "Azioni canale", "aria/Channel details": "Dettagli canale", "aria/Channel list": "Elenco dei canali", + "aria/Channel search results": "Risultati della ricerca dei canali", "aria/Chat view controls": "Controlli visualizzazione chat", + "aria/Chat view tabs": "Schede visualizzazione chat", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", + "aria/Choose default skin tone": "Scegli la tonalità della pelle predefinita", + "aria/Clear emoji search": "Cancella ricerca emoji", "aria/Clear search": "Cancella ricerca", "aria/Close callout dialog": "Chiudi finestra informativa", "aria/Close thread": "Chiudi discussione", @@ -287,6 +293,8 @@ "Create a question, add options, and configure poll settings": "Crea una domanda, aggiungi opzioni e configura le impostazioni del sondaggio", "Create poll": "Crea sondaggio", "Current location": "Posizione attuale", + "Dark": "Scuro", + "Default": "Predefinito", "Delete": "Elimina", "Delete chat": "Elimina chat", "Delete for me": "Elimina per me", @@ -357,6 +365,7 @@ "Failed to jump to the first unread message": "Impossibile passare al primo messaggio non letto", "Failed to leave channel": "Impossibile lasciare il canale", "Failed to load channels": "Impossibile caricare i canali", + "Failed to load emojis": "Impossibile caricare gli emoji", "Failed to load more channels": "Impossibile caricare altri canali", "Failed to mark channel as read": "Impossibile contrassegnare il canale come letto", "Failed to play the recording": "Impossibile riprodurre la registrazione", @@ -375,6 +384,9 @@ "fileCount_other": "{{ count }} file", "Files": "File", "Flag": "Segnala", + "Flags": "Bandiere", + "Food & Drink": "Cibo e bevande", + "Frequently used": "Usati di frequente", "Generating...": "Generando...", "giphy-command-args": "[testo]", "giphy-command-description": "Pubblica un gif casuale sul canale", @@ -449,6 +461,7 @@ "Leave chat": "Lascia il canale", "Left channel": "Canale lasciato", "Let others add options": "Lascia che altri aggiungano opzioni", + "Light": "Chiaro", "Limit votes per person": "Limita i voti per persona", "Link": "Collegamento", "linkCount_one": "Link", @@ -468,6 +481,9 @@ "Mark as unread": "Contrassegna come non letto", "Maximum number of votes (from 2 to 10)": "Numero massimo di voti (da 2 a 10)", "Maximum votes per person": "Voti massimi per persona", + "Medium": "Medio", + "Medium-Dark": "Medio scuro", + "Medium-Light": "Medio chiaro", "Member detail": "Dettagli membro", "mention/Channel": "Canale", "mention/Channel Description": "Notifica tutti in questo canale", @@ -498,6 +514,7 @@ "Next image": "Immagine successiva", "No chats here yet…": "Non ci sono ancora messaggi qui...", "No conversations yet": "Ancora nessuna conversazione", + "No emoji found": "Nessun emoji trovato", "No files": "Nessun file", "No items exist": "Nessun elemento presente", "No member found": "Nessun membro trovato", @@ -509,6 +526,7 @@ "Nobody will be able to vote in this poll anymore.": "Nessuno potrà più votare in questo sondaggio.", "Nothing yet...": "Ancora niente...", "Notify all {{ role }} members": "Notifica tutti i membri con ruolo {{ role }}", + "Objects": "Oggetti", "Offline": "Offline", "Ok": "OK", "Online": "Online", @@ -528,6 +546,7 @@ "People matching": "Persone che corrispondono", "Photo": "Foto", "Photos & videos": "Foto e video", + "Pick an emoji…": "Scegli un emoji…", "Pin": "Appunta", "Pin a message to see it here": "Appunta un messaggio per vederlo qui", "Pinned by {{ name }}": "Appuntato da {{ name }}", @@ -576,6 +595,7 @@ "replyCount_many": "{{ count }} risposte", "replyCount_other": "{{ count }} risposte", "Resend": "Invia di nuovo", + "Retry": "Riprova", "Retry upload": "Riprova caricamento", "Review all options available in this poll": "Rivedi tutte le opzioni disponibili in questo sondaggio", "Review comments submitted with poll answers": "Rivedi i commenti inviati con le risposte al sondaggio", @@ -586,6 +606,7 @@ "Save for later": "Salva per dopo", "Saved for later": "Salvato per dopo", "Search": "Cerca", + "Search emoji": "Cerca emoji", "Search GIFs": "Cerca GIF", "search-results-header-filter-source-button-label--channels": "canali", "search-results-header-filter-source-button-label--messages": "messaggi", @@ -623,12 +644,14 @@ "size limit": "limite di dimensione", "Slow Mode ON": "Modalità lenta attivata", "Slow mode, wait {{ seconds }}s...": "Modalità lenta, attendi {{ seconds }} s...", + "Smileys & People": "Faccine e persone", "Some of the files will not be accepted": "Alcuni dei file non saranno accettati", "Start typing to search": "Inizia a digitare per cercare", "Stop sharing": "Ferma condivisione", "Submit": "Invia", "Suggest a new option to add to this poll": "Suggerisci una nuova opzione da aggiungere a questo sondaggio", "Suggest an option": "Suggerisci un'opzione", + "Symbols": "Simboli", "Tap to remove": "Tocca per rimuovere", "Tap to remove: {{ reactionName }}": "Tocca per rimuovere: {{ reactionName }}", "Thinking...": "Pensando...", @@ -669,6 +692,7 @@ "Translated": "Tradotto", "Translated from {{ language }}": "Tradotto da {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Viaggi e luoghi", "Type a number from 2 to 10": "Digita un numero da 2 a 10", "Unarchive": "Ripristina", "unban-command-args": "[@nomeutente]", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 6770d1e19..c627eec70 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -40,6 +40,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} が投票: {{pollOptionText}}", "📍Shared location": "📍共有された位置情報", "Actions": "Actions", + "Activities": "アクティビティ", "Add": "追加", "Add {{ count }} members_other": "{{ count }}人のメンバーを追加", "Add a comment": "コメントを追加", @@ -61,6 +62,7 @@ "Also sent in channel": "チャンネルにも送信済み", "An error has occurred during recording": "録音中にエラーが発生しました", "An error has occurred during the recording processing": "録音処理中にエラーが発生しました", + "Animals & Nature": "動物と自然", "Anonymous": "匿名", "Anonymous poll": "匿名投票", "Archive": "アーカイブ", @@ -91,8 +93,12 @@ "aria/Channel Actions": "チャンネル操作", "aria/Channel details": "チャンネル詳細", "aria/Channel list": "チャンネル一覧", + "aria/Channel search results": "チャンネル検索結果", "aria/Chat view controls": "チャットビューのコントロール", + "aria/Chat view tabs": "チャットビューのタブ", "aria/Chat: {{ channelName }}": "チャット: {{ channelName }}", + "aria/Choose default skin tone": "デフォルトの肌の色を選択", + "aria/Clear emoji search": "絵文字検索をクリア", "aria/Clear search": "検索をクリア", "aria/Close callout dialog": "吹き出しダイアログを閉じる", "aria/Close thread": "スレッドを閉じる", @@ -261,6 +267,8 @@ "Create a question, add options, and configure poll settings": "質問を作成し、選択肢を追加して投票設定を構成", "Create poll": "投票を作成", "Current location": "現在の位置", + "Dark": "暗い", + "Default": "デフォルト", "Delete": "消去", "Delete chat": "チャットを削除", "Delete for me": "自分用に削除", @@ -331,6 +339,7 @@ "Failed to jump to the first unread message": "最初の未読メッセージにジャンプできませんでした", "Failed to leave channel": "チャンネルの退出に失敗しました", "Failed to load channels": "チャンネルの読み込みに失敗しました", + "Failed to load emojis": "絵文字を読み込めませんでした", "Failed to load more channels": "さらにチャンネルを読み込めませんでした", "Failed to mark channel as read": "チャンネルを既読にすることができませんでした", "Failed to play the recording": "録音の再生に失敗しました", @@ -347,6 +356,9 @@ "fileCount_other": "{{ count }}件のファイル", "Files": "ファイル", "Flag": "フラグ", + "Flags": "旗", + "Food & Drink": "食べ物と飲み物", + "Frequently used": "よく使う", "Generating...": "生成中...", "giphy-command-args": "[テキスト]", "giphy-command-description": "チャンネルにランダムなGIFを投稿する", @@ -419,6 +431,7 @@ "Leave chat": "チャンネルを退出", "Left channel": "チャンネルを退出しました", "Let others add options": "他の人が選択肢を追加できるようにする", + "Light": "明るい", "Limit votes per person": "1人あたりの投票数を制限する", "Link": "リンク", "linkCount_other": "{{ count }}件のリンク", @@ -436,6 +449,9 @@ "Mark as unread": "未読としてマーク", "Maximum number of votes (from 2 to 10)": "最大投票数(2から10まで)", "Maximum votes per person": "1人あたりの最大投票数", + "Medium": "普通", + "Medium-Dark": "やや暗い", + "Medium-Light": "やや明るい", "Member detail": "メンバー詳細", "mention/Channel": "チャンネル", "mention/Channel Description": "このチャンネルの全員に通知", @@ -466,6 +482,7 @@ "Next image": "次の画像", "No chats here yet…": "ここにはまだチャットはありません…", "No conversations yet": "まだ会話はありません", + "No emoji found": "絵文字が見つかりません", "No files": "ファイルはありません", "No items exist": "項目がありません", "No member found": "メンバーが見つかりません", @@ -477,6 +494,7 @@ "Nobody will be able to vote in this poll anymore.": "この投票では、誰も投票できなくなります。", "Nothing yet...": "まだ何もありません...", "Notify all {{ role }} members": "{{ role }} メンバー全員に通知", + "Objects": "物", "Offline": "オフライン", "Ok": "OK", "Online": "オンライン", @@ -496,6 +514,7 @@ "People matching": "一致する人", "Photo": "写真", "Photos & videos": "写真と動画", + "Pick an emoji…": "絵文字を選択…", "Pin": "ピン", "Pin a message to see it here": "ここに表示するにはメッセージをピン留めしてください", "Pinned by {{ name }}": "{{ name }}がピンしました", @@ -539,6 +558,7 @@ "replyCount_one": "1件の返信", "replyCount_other": "{{ count }} 返信", "Resend": "再送信", + "Retry": "再試行", "Retry upload": "アップロードを再試行", "Review all options available in this poll": "この投票で利用可能なすべての選択肢を確認", "Review comments submitted with poll answers": "投票回答とともに送信されたコメントを確認", @@ -549,6 +569,7 @@ "Save for later": "後で保存", "Saved for later": "後で保存済み", "Search": "探す", + "Search emoji": "絵文字を検索", "Search GIFs": "GIFを検索", "search-results-header-filter-source-button-label--channels": "チャンネル", "search-results-header-filter-source-button-label--messages": "メッセージ", @@ -584,12 +605,14 @@ "size limit": "サイズ制限", "Slow Mode ON": "スローモードオン", "Slow mode, wait {{ seconds }}s...": "スローモード、{{ seconds }}秒お待ちください...", + "Smileys & People": "スマイリーと人々", "Some of the files will not be accepted": "一部のファイルは受け付けられません", "Start typing to search": "検索するには入力を開始してください", "Stop sharing": "共有を停止", "Submit": "送信", "Suggest a new option to add to this poll": "この投票に追加する新しい選択肢を提案", "Suggest an option": "オプションを提案", + "Symbols": "記号", "Tap to remove": "タップして削除", "Tap to remove: {{ reactionName }}": "タップして削除: {{ reactionName }}", "Thinking...": "考え中...", @@ -626,6 +649,7 @@ "Translated": "翻訳済み", "Translated from {{ language }}": "{{ language }}から翻訳", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "旅行と場所", "Type a number from 2 to 10": "2から10までの数字を入力してください", "Unarchive": "アーカイブ解除", "unban-command-args": "[@ユーザ名]", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 85159367b..a4f1230ea 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -40,6 +40,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}}이(가) 투표함: {{pollOptionText}}", "📍Shared location": "📍공유된 위치", "Actions": "Actions", + "Activities": "활동", "Add": "추가", "Add {{ count }} members_other": "{{ count }}명 멤버 추가", "Add a comment": "댓글 추가", @@ -61,6 +62,7 @@ "Also sent in channel": "채널에도 전송됨", "An error has occurred during recording": "녹음 중 오류가 발생했습니다", "An error has occurred during the recording processing": "녹음 처리 중 오류가 발생했습니다", + "Animals & Nature": "동물 & 자연", "Anonymous": "익명", "Anonymous poll": "익명 투표", "Archive": "아카이브", @@ -91,8 +93,12 @@ "aria/Channel Actions": "채널 작업", "aria/Channel details": "채널 세부 정보", "aria/Channel list": "채널 목록", + "aria/Channel search results": "채널 검색 결과", "aria/Chat view controls": "채팅 보기 컨트롤", + "aria/Chat view tabs": "채팅 보기 탭", "aria/Chat: {{ channelName }}": "채팅: {{ channelName }}", + "aria/Choose default skin tone": "기본 피부색 선택", + "aria/Clear emoji search": "이모지 검색 지우기", "aria/Clear search": "검색 지우기", "aria/Close callout dialog": "콜아웃 대화 상자 닫기", "aria/Close thread": "스레드 닫기", @@ -261,6 +267,8 @@ "Create a question, add options, and configure poll settings": "질문을 만들고 옵션을 추가한 뒤 투표 설정 구성", "Create poll": "투표 생성", "Current location": "현재 위치", + "Dark": "어두움", + "Default": "기본", "Delete": "삭제", "Delete chat": "채팅 삭제", "Delete for me": "나만 삭제", @@ -331,6 +339,7 @@ "Failed to jump to the first unread message": "첫 번째 읽지 않은 메시지로 이동하지 못했습니다", "Failed to leave channel": "채널 나가기에 실패했습니다", "Failed to load channels": "채널을 불러오지 못했습니다", + "Failed to load emojis": "이모지를 불러오지 못했습니다", "Failed to load more channels": "채널을 더 불러오지 못했습니다", "Failed to mark channel as read": "채널을 읽음으로 표시하는 데 실패했습니다", "Failed to play the recording": "녹음을 재생하지 못했습니다", @@ -347,6 +356,9 @@ "fileCount_other": "파일 {{ count }}개", "Files": "파일", "Flag": "플래그", + "Flags": "깃발", + "Food & Drink": "음식 & 음료", + "Frequently used": "자주 사용함", "Generating...": "생성 중...", "giphy-command-args": "[텍스트]", "giphy-command-description": "채널에 무작위 GIF 게시", @@ -419,6 +431,7 @@ "Leave chat": "채널 나가기", "Left channel": "채널을 나갔습니다", "Let others add options": "다른 사람이 선택지를 추가할 수 있도록 허용", + "Light": "밝음", "Limit votes per person": "1인당 투표 수 제한", "Link": "링크", "linkCount_other": "링크 {{ count }}개", @@ -436,6 +449,9 @@ "Mark as unread": "읽지 않음으로 표시", "Maximum number of votes (from 2 to 10)": "최대 투표 수 (2에서 10까지)", "Maximum votes per person": "1인당 최대 투표 수", + "Medium": "중간", + "Medium-Dark": "중간 어두움", + "Medium-Light": "중간 밝음", "Member detail": "멤버 상세 정보", "mention/Channel": "채널", "mention/Channel Description": "이 채널의 모두에게 알림", @@ -466,6 +482,7 @@ "Next image": "다음 이미지", "No chats here yet…": "아직 채팅이 없습니다...", "No conversations yet": "아직 대화가 없습니다.", + "No emoji found": "이모지를 찾을 수 없습니다", "No files": "파일이 없습니다", "No items exist": "항목이 없습니다.", "No member found": "멤버를 찾을 수 없습니다", @@ -477,6 +494,7 @@ "Nobody will be able to vote in this poll anymore.": "이 투표에 더 이상 아무도 투표할 수 없습니다.", "Nothing yet...": "아직 아무것도...", "Notify all {{ role }} members": "{{ role }} 역할의 모든 멤버에게 알림", + "Objects": "사물", "Offline": "오프라인", "Ok": "확인", "Online": "온라인", @@ -496,6 +514,7 @@ "People matching": "일치하는 사람", "Photo": "사진", "Photos & videos": "사진 및 동영상", + "Pick an emoji…": "이모지 선택…", "Pin": "핀", "Pin a message to see it here": "여기에서 보려면 메시지를 고정하세요", "Pinned by {{ name }}": "{{ name }}님이 핀함", @@ -539,6 +558,7 @@ "replyCount_one": "답장 1개", "replyCount_other": "{{ count }} 답장", "Resend": "다시 보내기", + "Retry": "다시 시도", "Retry upload": "업로드 다시 시도", "Review all options available in this poll": "이 투표에서 사용 가능한 모든 옵션 검토", "Review comments submitted with poll answers": "투표 답변과 함께 제출된 댓글 검토", @@ -549,6 +569,7 @@ "Save for later": "나중에 저장", "Saved for later": "나중에 저장됨", "Search": "찾다", + "Search emoji": "이모지 검색", "Search GIFs": "GIF 검색", "search-results-header-filter-source-button-label--channels": "채널", "search-results-header-filter-source-button-label--messages": "메시지", @@ -584,12 +605,14 @@ "size limit": "크기 제한", "Slow Mode ON": "슬로우 모드 켜짐", "Slow mode, wait {{ seconds }}s...": "슬로우 모드, {{ seconds }}초 기다려 주세요...", + "Smileys & People": "스마일리 & 사람", "Some of the files will not be accepted": "일부 파일은 허용되지 않을 수 있습니다", "Start typing to search": "검색하려면 입력을 시작하세요", "Stop sharing": "공유 중지", "Submit": "제출", "Suggest a new option to add to this poll": "이 투표에 추가할 새 옵션 제안", "Suggest an option": "옵션 제안", + "Symbols": "기호", "Tap to remove": "제거하려면 탭하세요", "Tap to remove: {{ reactionName }}": "제거하려면 탭하세요: {{ reactionName }}", "Thinking...": "생각 중...", @@ -626,6 +649,7 @@ "Translated": "번역됨", "Translated from {{ language }}": "{{ language }}(으)로 번역됨", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "여행 & 장소", "Type a number from 2 to 10": "2에서 10 사이의 숫자를 입력하세요", "Unarchive": "아카이브 해제", "unban-command-args": "[@사용자이름]", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index f83f5ad22..97ff04548 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -43,6 +43,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} heeft gestemd: {{pollOptionText}}", "📍Shared location": "📍Gedeelde locatie", "Actions": "Actions", + "Activities": "Activiteiten", "Add": "Toevoegen", "Add {{ count }} members_one": "{{ count }} lid toevoegen", "Add {{ count }} members_other": "{{ count }} leden toevoegen", @@ -65,6 +66,7 @@ "Also sent in channel": "Ook in kanaal verzonden", "An error has occurred during recording": "Er is een fout opgetreden tijdens het opnemen", "An error has occurred during the recording processing": "Er is een fout opgetreden tijdens de verwerking van de opname", + "Animals & Nature": "Dieren en natuur", "Anonymous": "Anoniem", "Anonymous poll": "Anonieme peiling", "Archive": "Archief", @@ -100,8 +102,12 @@ "aria/Channel Actions": "Kanaalacties", "aria/Channel details": "Kanaaldetails", "aria/Channel list": "Kanaallijst", + "aria/Channel search results": "Zoekresultaten voor kanalen", "aria/Chat view controls": "Bedieningselementen chatweergave", + "aria/Chat view tabs": "Tabbladen chatweergave", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", + "aria/Choose default skin tone": "Standaard huidskleur kiezen", + "aria/Clear emoji search": "Emoji zoekopdracht wissen", "aria/Clear search": "Zoekopdracht wissen", "aria/Close callout dialog": "Calloutdialoog sluiten", "aria/Close thread": "Draad sluiten", @@ -270,6 +276,8 @@ "Create a question, add options, and configure poll settings": "Maak een vraag, voeg opties toe en stel de pollinstellingen in", "Create poll": "Maak peiling", "Current location": "Huidige locatie", + "Dark": "Donker", + "Default": "Standaard", "Delete": "Verwijder", "Delete chat": "Chat verwijderen", "Delete for me": "Voor mij verwijderen", @@ -340,6 +348,7 @@ "Failed to jump to the first unread message": "Niet gelukt om naar het eerste ongelezen bericht te springen", "Failed to leave channel": "Kanaal verlaten mislukt", "Failed to load channels": "Kanalen konden niet worden geladen", + "Failed to load emojis": "Emoji's konden niet worden geladen", "Failed to load more channels": "Meer kanalen konden niet worden geladen", "Failed to mark channel as read": "Kanaal kon niet als gelezen worden gemarkeerd", "Failed to play the recording": "Kan de opname niet afspelen", @@ -357,6 +366,9 @@ "fileCount_other": "{{ count }} bestanden", "Files": "Bestanden", "Flag": "Markeer", + "Flags": "Vlaggen", + "Food & Drink": "Eten en drinken", + "Frequently used": "Veelgebruikt", "Generating...": "Genereren...", "giphy-command-args": "[tekst]", "giphy-command-description": "Plaats een willekeurige gif in het kanaal", @@ -430,6 +442,7 @@ "Leave chat": "Kanaal verlaten", "Left channel": "Kanaal verlaten", "Let others add options": "Laat anderen opties toevoegen", + "Light": "Licht", "Limit votes per person": "Stemmen per persoon beperken", "Link": "Link", "linkCount_one": "Link", @@ -448,6 +461,9 @@ "Mark as unread": "Markeren als ongelezen", "Maximum number of votes (from 2 to 10)": "Maximaal aantal stemmen (van 2 tot 10)", "Maximum votes per person": "Maximum aantal stemmen per persoon", + "Medium": "Middel", + "Medium-Dark": "Middeldonker", + "Medium-Light": "Middellicht", "Member detail": "Lidgegevens", "mention/Channel": "Kanaal", "mention/Channel Description": "Iedereen in dit kanaal informeren", @@ -478,6 +494,7 @@ "Next image": "Volgende afbeelding", "No chats here yet…": "Nog geen chats hier...", "No conversations yet": "Nog geen gesprekken", + "No emoji found": "Geen emoji gevonden", "No files": "Geen bestanden", "No items exist": "Er zijn geen items", "No member found": "Geen lid gevonden", @@ -489,6 +506,7 @@ "Nobody will be able to vote in this poll anymore.": "Niemand kan meer stemmen in deze peiling.", "Nothing yet...": "Nog niets ...", "Notify all {{ role }} members": "Alle leden met rol {{ role }} informeren", + "Objects": "Objecten", "Offline": "Offline", "Ok": "Oké", "Online": "Online", @@ -508,6 +526,7 @@ "People matching": "Mensen die matchen", "Photo": "Foto", "Photos & videos": "Foto's en video's", + "Pick an emoji…": "Kies een emoji…", "Pin": "Vastmaken", "Pin a message to see it here": "Maak een bericht vast om het hier te zien", "Pinned by {{ name }}": "Vastgemaakt door {{ name }}", @@ -553,6 +572,7 @@ "replyCount_one": "1 antwoord", "replyCount_other": "{{ count }} antwoorden", "Resend": "Opnieuw verzenden", + "Retry": "Opnieuw proberen", "Retry upload": "Upload opnieuw proberen", "Review all options available in this poll": "Bekijk alle beschikbare opties in deze poll", "Review comments submitted with poll answers": "Bekijk reacties die met pollantwoorden zijn ingediend", @@ -563,6 +583,7 @@ "Save for later": "Bewaren voor later", "Saved for later": "Bewaard voor later", "Search": "Zoeken", + "Search emoji": "Emoji zoeken", "Search GIFs": "GIF's zoeken", "search-results-header-filter-source-button-label--channels": "kanalen", "search-results-header-filter-source-button-label--messages": "berichten", @@ -600,12 +621,14 @@ "Slow mode, wait {{ seconds }}s...": "Langzame modus, wacht {{ seconds }}s...", "Slow wait, wait {{ seconds }}s": "Langzame modus, wacht {{ seconds }}s", "Slow wait, wait {{ seconds }}s...": "Langzame modus, wacht {{ seconds }}s...", + "Smileys & People": "Smileys en mensen", "Some of the files will not be accepted": "Sommige bestanden zullen niet worden geaccepteerd", "Start typing to search": "Begin met typen om te zoeken", "Stop sharing": "Delen stoppen", "Submit": "Versturen", "Suggest a new option to add to this poll": "Stel een nieuwe optie voor om aan deze poll toe te voegen", "Suggest an option": "Stel een optie voor", + "Symbols": "Symbolen", "Tap to remove": "Tik om te verwijderen", "Tap to remove: {{ reactionName }}": "Tik om te verwijderen: {{ reactionName }}", "Thinking...": "Denken...", @@ -644,6 +667,7 @@ "Translated": "Vertaald", "Translated from {{ language }}": "Vertaald uit {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Reizen en plaatsen", "Type a number from 2 to 10": "Typ een getal van 2 tot 10", "Unarchive": "Uit archief halen", "unban-command-args": "[@gebruikersnaam]", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 5cff8a41a..e1803f46c 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -53,6 +53,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votou: {{pollOptionText}}", "📍Shared location": "📍Localização compartilhada", "Actions": "Actions", + "Activities": "Atividades", "Add": "Adicionar", "Add {{ count }} members_one": "Adicionar {{ count }} membro", "Add {{ count }} members_many": "Adicionar {{ count }} membros", @@ -76,6 +77,7 @@ "Also sent in channel": "Também enviado no canal", "An error has occurred during recording": "Ocorreu um erro durante a gravação", "An error has occurred during the recording processing": "Ocorreu um erro durante o processamento da gravação", + "Animals & Nature": "Animais e natureza", "Anonymous": "Anônimo", "Anonymous poll": "Enquete anônima", "Archive": "Arquivar", @@ -116,8 +118,12 @@ "aria/Channel Actions": "Ações do canal", "aria/Channel details": "Detalhes do canal", "aria/Channel list": "Lista de canais", + "aria/Channel search results": "Resultados de pesquisa de canais", "aria/Chat view controls": "Controles da visualização do chat", + "aria/Chat view tabs": "Abas da visualização do chat", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", + "aria/Choose default skin tone": "Escolher tom de pele padrão", + "aria/Clear emoji search": "Limpar pesquisa de emoji", "aria/Clear search": "Limpar pesquisa", "aria/Close callout dialog": "Fechar diálogo de destaque", "aria/Close thread": "Fechar tópico", @@ -287,6 +293,8 @@ "Create a question, add options, and configure poll settings": "Crie uma pergunta, adicione opções e configure as definições da enquete", "Create poll": "Criar enquete", "Current location": "Localização atual", + "Dark": "Escuro", + "Default": "Padrão", "Delete": "Excluir", "Delete chat": "Excluir chat", "Delete for me": "Excluir para mim", @@ -357,6 +365,7 @@ "Failed to jump to the first unread message": "Falha ao pular para a primeira mensagem não lida", "Failed to leave channel": "Falha ao sair do canal", "Failed to load channels": "Falha ao carregar os canais", + "Failed to load emojis": "Falha ao carregar os emojis", "Failed to load more channels": "Falha ao carregar mais canais", "Failed to mark channel as read": "Falha ao marcar o canal como lido", "Failed to play the recording": "Falha ao reproduzir a gravação", @@ -375,6 +384,9 @@ "fileCount_other": "{{ count }} arquivos", "Files": "Arquivos", "Flag": "Reportar", + "Flags": "Bandeiras", + "Food & Drink": "Comida e bebida", + "Frequently used": "Usados com frequência", "Generating...": "Gerando...", "giphy-command-args": "[texto]", "giphy-command-description": "Postar um gif aleatório no canal", @@ -449,6 +461,7 @@ "Leave chat": "Sair do canal", "Left channel": "Canal abandonado", "Let others add options": "Permitir que outros adicionem opções", + "Light": "Claro", "Limit votes per person": "Limitar votos por pessoa", "Link": "Link", "linkCount_one": "Link", @@ -468,6 +481,9 @@ "Mark as unread": "Marcar como não lida", "Maximum number of votes (from 2 to 10)": "Número máximo de votos (de 2 a 10)", "Maximum votes per person": "Máximo de votos por pessoa", + "Medium": "Médio", + "Medium-Dark": "Médio escuro", + "Medium-Light": "Médio claro", "Member detail": "Detalhes do membro", "mention/Channel": "Canal", "mention/Channel Description": "Notificar todos neste canal", @@ -498,6 +514,7 @@ "Next image": "Próxima imagem", "No chats here yet…": "Ainda não há conversas aqui...", "No conversations yet": "Ainda não há conversas", + "No emoji found": "Nenhum emoji encontrado", "No files": "Nenhum arquivo", "No items exist": "Não existem itens", "No member found": "Nenhum membro encontrado", @@ -509,6 +526,7 @@ "Nobody will be able to vote in this poll anymore.": "Ninguém mais poderá votar nesta pesquisa.", "Nothing yet...": "Nada ainda...", "Notify all {{ role }} members": "Notificar todos os membros com a função {{ role }}", + "Objects": "Objetos", "Offline": "Offline", "Ok": "OK", "Online": "Online", @@ -528,6 +546,7 @@ "People matching": "Pessoas correspondentes", "Photo": "Foto", "Photos & videos": "Fotos e vídeos", + "Pick an emoji…": "Escolha um emoji…", "Pin": "Fixar", "Pin a message to see it here": "Fixe uma mensagem para vê-la aqui", "Pinned by {{ name }}": "Fixado por {{ name }}", @@ -576,6 +595,7 @@ "replyCount_many": "{{ count }} respostas", "replyCount_other": "{{ count }} respostas", "Resend": "Reenviar", + "Retry": "Tentar novamente", "Retry upload": "Tentar enviar novamente", "Review all options available in this poll": "Revise todas as opções disponíveis nesta enquete", "Review comments submitted with poll answers": "Revise comentários enviados com respostas da enquete", @@ -586,6 +606,7 @@ "Save for later": "Salvar para depois", "Saved for later": "Salvo para depois", "Search": "Buscar", + "Search emoji": "Pesquisar emoji", "Search GIFs": "Pesquisar GIFs", "search-results-header-filter-source-button-label--channels": "canais", "search-results-header-filter-source-button-label--messages": "mensagens", @@ -623,12 +644,14 @@ "size limit": "limite de tamanho", "Slow Mode ON": "Modo lento LIGADO", "Slow mode, wait {{ seconds }}s...": "Modo lento, aguarde {{ seconds }} s...", + "Smileys & People": "Smileys e pessoas", "Some of the files will not be accepted": "Alguns arquivos não serão aceitos", "Start typing to search": "Comece a digitar para pesquisar", "Stop sharing": "Parar de compartilhar", "Submit": "Enviar", "Suggest a new option to add to this poll": "Sugira uma nova opção para adicionar a esta enquete", "Suggest an option": "Sugerir uma opção", + "Symbols": "Símbolos", "Tap to remove": "Toque para remover", "Tap to remove: {{ reactionName }}": "Toque para remover: {{ reactionName }}", "Thinking...": "Pensando...", @@ -669,6 +692,7 @@ "Translated": "Traduzido", "Translated from {{ language }}": "Traduzido de {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Viagens e lugares", "Type a number from 2 to 10": "Digite um número de 2 a 10", "Unarchive": "Desarquivar", "unban-command-args": "[@nomedeusuário]", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index f13f1c58f..8f76b9a8a 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -64,6 +64,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} проголосовал(а): {{pollOptionText}}", "📍Shared location": "📍Общее местоположение", "Actions": "Actions", + "Activities": "Активности", "Add": "Добавить", "Add {{ count }} members_one": "Добавить {{ count }} участника", "Add {{ count }} members_few": "Добавить {{ count }} участников", @@ -88,6 +89,7 @@ "Also sent in channel": "Также отправлено в канал", "An error has occurred during recording": "Произошла ошибка во время записи", "An error has occurred during the recording processing": "Произошла ошибка во время обработки записи", + "Animals & Nature": "Животные и природа", "Anonymous": "Аноним", "Anonymous poll": "Анонимный опрос", "Archive": "Aрхивировать", @@ -133,8 +135,12 @@ "aria/Channel Actions": "Действия канала", "aria/Channel details": "Сведения о канале", "aria/Channel list": "Список каналов", + "aria/Channel search results": "Результаты поиска по каналам", "aria/Chat view controls": "Элементы управления видом чата", + "aria/Chat view tabs": "Вкладки вида чата", "aria/Chat: {{ channelName }}": "Чат: {{ channelName }}", + "aria/Choose default skin tone": "Выбрать тон кожи по умолчанию", + "aria/Clear emoji search": "Очистить поиск эмодзи", "aria/Clear search": "Очистить поиск", "aria/Close callout dialog": "Закрыть диалог выноски", "aria/Close thread": "Закрыть тему", @@ -305,6 +311,8 @@ "Create a question, add options, and configure poll settings": "Создайте вопрос, добавьте варианты и настройте параметры опроса", "Create poll": "Создать опрос", "Current location": "Текущее местоположение", + "Dark": "Тёмный", + "Default": "По умолчанию", "Delete": "Удалить", "Delete chat": "Удалить чат", "Delete for me": "Удалить для меня", @@ -375,6 +383,7 @@ "Failed to jump to the first unread message": "Не удалось перейти к первому непрочитанному сообщению", "Failed to leave channel": "Не удалось покинуть канал", "Failed to load channels": "Не удалось загрузить каналы", + "Failed to load emojis": "Не удалось загрузить эмодзи", "Failed to load more channels": "Не удалось загрузить больше каналов", "Failed to mark channel as read": "Не удалось пометить канал как прочитанный", "Failed to play the recording": "Не удалось воспроизвести запись", @@ -397,6 +406,9 @@ "fileCount_three": "{{ count }} файла", "Files": "Файлы", "Flag": "Пожаловаться", + "Flags": "Флаги", + "Food & Drink": "Еда и напитки", + "Frequently used": "Часто используемые", "Generating...": "Генерирую...", "giphy-command-args": "[текст]", "giphy-command-description": "Опубликовать случайную GIF-анимацию в канале", @@ -472,6 +484,7 @@ "Leave chat": "Покинуть канал", "Left channel": "Канал покинут", "Let others add options": "Разрешить другим добавлять варианты", + "Light": "Светлый", "Limit votes per person": "Ограничить голоса на человека", "Link": "Линк", "linkCount_one": "{{ count }} линк", @@ -492,6 +505,9 @@ "Mark as unread": "Отметить как непрочитанное", "Maximum number of votes (from 2 to 10)": "Максимальное количество голосов (от 2 до 10)", "Maximum votes per person": "Максимум голосов на человека", + "Medium": "Средний", + "Medium-Dark": "Умеренно-тёмный", + "Medium-Light": "Умеренно-светлый", "Member detail": "Сведения об участнике", "mention/Channel": "Канал", "mention/Channel Description": "Уведомить всех в этом канале", @@ -522,6 +538,7 @@ "Next image": "Следующее изображение", "No chats here yet…": "Здесь еще нет чатов...", "No conversations yet": "Пока нет бесед", + "No emoji found": "Эмодзи не найдены", "No files": "Нет файлов", "No items exist": "Элементов нет", "No member found": "Участник не найден", @@ -533,6 +550,7 @@ "Nobody will be able to vote in this poll anymore.": "Никто больше не сможет голосовать в этом опросе.", "Nothing yet...": "Пока ничего нет...", "Notify all {{ role }} members": "Уведомить всех участников с ролью {{ role }}", + "Objects": "Объекты", "Offline": "Не в сети", "Ok": "Ок", "Online": "В сети", @@ -552,6 +570,7 @@ "People matching": "Совпадающие люди", "Photo": "Фото", "Photos & videos": "Фото и видео", + "Pick an emoji…": "Выберите эмодзи…", "Pin": "Закрепить", "Pin a message to see it here": "Закрепите сообщение, чтобы увидеть его здесь", "Pinned by {{ name }}": "Закреплено: {{ name }}", @@ -603,6 +622,7 @@ "replyCount_many": "{{ count }} ответов", "replyCount_other": "{{ count }} ответов", "Resend": "Отправить повторно", + "Retry": "Повторить", "Retry upload": "Повторить загрузку", "Review all options available in this poll": "Просмотрите все варианты, доступные в этом опросе", "Review comments submitted with poll answers": "Просмотрите комментарии, отправленные вместе с ответами в опросе", @@ -613,6 +633,7 @@ "Save for later": "Сохранить на потом", "Saved for later": "Сохранено на потом", "Search": "Поиск", + "Search emoji": "Поиск эмодзи", "Search GIFs": "Поиск GIF", "search-results-header-filter-source-button-label--channels": "каналы", "search-results-header-filter-source-button-label--messages": "сообщения", @@ -652,12 +673,14 @@ "size limit": "ограничение размера", "Slow Mode ON": "Медленный режим включен", "Slow mode, wait {{ seconds }}s...": "Медленный режим: подождите {{ seconds }} с...", + "Smileys & People": "Смайлики и люди", "Some of the files will not be accepted": "Некоторые файлы не будут приняты", "Start typing to search": "Начните вводить для поиска", "Stop sharing": "Прекратить делиться", "Submit": "Отправить", "Suggest a new option to add to this poll": "Предложите новый вариант для добавления в этот опрос", "Suggest an option": "Предложить вариант", + "Symbols": "Символы", "Tap to remove": "Нажмите, чтобы удалить", "Tap to remove: {{ reactionName }}": "Нажмите, чтобы удалить: {{ reactionName }}", "Thinking...": "Думаю...", @@ -700,6 +723,7 @@ "Translated": "Переведено", "Translated from {{ language }}": "Переведено с {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Путешествия и места", "Type a number from 2 to 10": "Введите число от 2 до 10", "Unarchive": "Удалить из архива", "unban-command-args": "[@имяпользователя]", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 962b95ad4..46b53593c 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -43,6 +43,7 @@ "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} oy verdi: {{pollOptionText}}", "📍Shared location": "📍Paylaşılan konum", "Actions": "Actions", + "Activities": "Etkinlikler", "Add": "Ekle", "Add {{ count }} members_one": "{{ count }} üye ekle", "Add {{ count }} members_other": "{{ count }} üye ekle", @@ -65,6 +66,7 @@ "Also sent in channel": "Kanala da gönderildi", "An error has occurred during recording": "Kayıt sırasında bir hata oluştu", "An error has occurred during the recording processing": "Kayıt işlemi sırasında bir hata oluştu", + "Animals & Nature": "Hayvanlar ve Doğa", "Anonymous": "Anonim", "Anonymous poll": "Anonim anket", "Archive": "Arşivle", @@ -100,8 +102,12 @@ "aria/Channel Actions": "Kanal işlemleri", "aria/Channel details": "Kanal ayrıntıları", "aria/Channel list": "Kanal listesi", + "aria/Channel search results": "Kanal arama sonuçları", "aria/Chat view controls": "Sohbet görünümü kontrolleri", + "aria/Chat view tabs": "Sohbet görünümü sekmeleri", "aria/Chat: {{ channelName }}": "Sohbet: {{ channelName }}", + "aria/Choose default skin tone": "Varsayılan ten rengini seç", + "aria/Clear emoji search": "Emoji aramasını temizle", "aria/Clear search": "Aramayı temizle", "aria/Close callout dialog": "Bilgi balonu iletişim kutusunu kapat", "aria/Close thread": "Konuyu kapat", @@ -270,6 +276,8 @@ "Create a question, add options, and configure poll settings": "Bir soru oluşturun, seçenekler ekleyin ve anket ayarlarını yapılandırın", "Create poll": "Anket oluştur", "Current location": "Mevcut konum", + "Dark": "Koyu", + "Default": "Varsayılan", "Delete": "Sil", "Delete chat": "Sohbeti sil", "Delete for me": "Benim için sil", @@ -340,6 +348,7 @@ "Failed to jump to the first unread message": "İlk okunmamış mesaja atlamada hata oluştu", "Failed to leave channel": "Kanaldan çıkılamadı", "Failed to load channels": "Kanallar yüklenemedi", + "Failed to load emojis": "Emojiler yüklenemedi", "Failed to load more channels": "Daha fazla kanal yüklenemedi", "Failed to mark channel as read": "Kanalı okundu olarak işaretleme başarısız oldu", "Failed to play the recording": "Kayıt oynatılamadı", @@ -357,6 +366,9 @@ "fileCount_other": "{{ count }} dosya", "Files": "Dosyalar", "Flag": "Bayrak", + "Flags": "Bayraklar", + "Food & Drink": "Yiyecek ve İçecek", + "Frequently used": "Sık kullanılanlar", "Generating...": "Oluşturuluyor...", "giphy-command-args": "[metin]", "giphy-command-description": "Rastgele bir gif'i kanala gönder", @@ -430,6 +442,7 @@ "Leave chat": "Kanaldan ayrıl", "Left channel": "Kanaldan ayrıldınız", "Let others add options": "Başkalarının seçenek eklemesine izin ver", + "Light": "Açık", "Limit votes per person": "Kişi başına oy sınırı", "Link": "Bağlantı", "linkCount_one": "Bağlantı", @@ -448,6 +461,9 @@ "Mark as unread": "Okunmamış olarak işaretle", "Maximum number of votes (from 2 to 10)": "Maksimum oy sayısı (2 ile 10 arası)", "Maximum votes per person": "Kişi başına maksimum oy", + "Medium": "Orta", + "Medium-Dark": "Orta koyu", + "Medium-Light": "Orta açık", "Member detail": "Üye detayı", "mention/Channel": "Kanal", "mention/Channel Description": "Bu kanaldaki herkesi bildir", @@ -478,6 +494,7 @@ "Next image": "Sonraki görsel", "No chats here yet…": "Henüz burada sohbet yok...", "No conversations yet": "Henüz konuşma yok", + "No emoji found": "Emoji bulunamadı", "No files": "Dosya yok", "No items exist": "Hiç öğe yok", "No member found": "Üye bulunamadı", @@ -489,6 +506,7 @@ "Nobody will be able to vote in this poll anymore.": "Artık bu ankette kimse oy kullanamayacak.", "Nothing yet...": "Şimdilik hiçbir şey...", "Notify all {{ role }} members": "{{ role }} rolündeki tüm üyelere bildir", + "Objects": "Nesneler", "Offline": "Çevrimdışı", "Ok": "Tamam", "Online": "Çevrimiçi", @@ -508,6 +526,7 @@ "People matching": "Eşleşen kişiler", "Photo": "Fotoğraf", "Photos & videos": "Fotoğraflar ve videolar", + "Pick an emoji…": "Emoji seç…", "Pin": "Sabitle", "Pin a message to see it here": "Burada görmek için bir mesaj sabitle", "Pinned by {{ name }}": "{{ name }} sabitledi", @@ -553,6 +572,7 @@ "replyCount_one": "1 cevap", "replyCount_other": "{{ count }} cevap", "Resend": "Tekrar gönder", + "Retry": "Yeniden dene", "Retry upload": "Yüklemeyi yeniden dene", "Review all options available in this poll": "Bu anketteki tüm mevcut seçenekleri inceleyin", "Review comments submitted with poll answers": "Anket yanıtlarıyla gönderilen yorumları inceleyin", @@ -563,6 +583,7 @@ "Save for later": "Daha sonra kaydet", "Saved for later": "Daha sonra kaydedildi", "Search": "Arama", + "Search emoji": "Emoji ara", "Search GIFs": "GIF ara", "search-results-header-filter-source-button-label--channels": "kanallar", "search-results-header-filter-source-button-label--messages": "mesajlar", @@ -598,12 +619,14 @@ "size limit": "boyut sınırı", "Slow Mode ON": "Yavaş Mod Açık", "Slow mode, wait {{ seconds }}s...": "Yavaş mod, {{ seconds }} sn bekleyin...", + "Smileys & People": "Suratlar ve İnsanlar", "Some of the files will not be accepted": "Bazı dosyalar kabul edilmeyecek", "Start typing to search": "Aramak için yazmaya başlayın", "Stop sharing": "Paylaşımı durdur", "Submit": "Gönder", "Suggest a new option to add to this poll": "Bu ankete eklenecek yeni bir seçenek önerin", "Suggest an option": "Bir seçenek önerin", + "Symbols": "Semboller", "Tap to remove": "Kaldırmak için dokunun", "Tap to remove: {{ reactionName }}": "Kaldırmak için dokunun: {{ reactionName }}", "Thinking...": "Düşünüyor...", @@ -642,6 +665,7 @@ "Translated": "Çevrildi", "Translated from {{ language }}": "{{ language }} dilinden çevrildi", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Seyahat ve Yerler", "Type a number from 2 to 10": "2 ile 10 arasında bir sayı yazın", "Unarchive": "Arşivden çıkar", "unban-command-args": "[@kullanıcıadı]", diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 4972da647..b9f6931c8 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -21,6 +21,9 @@ const Picker = const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; +// Warn at most once per session that this engine is going away. +let hasWarnedEmojiMartDeprecation = false; + export type EmojiPickerProps = { ButtonIconComponent?: React.ComponentType; buttonClassName?: string; @@ -28,8 +31,8 @@ export type EmojiPickerProps = { wrapperClassName?: string; closeOnEmojiSelect?: boolean; /** - * Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.5.2#options--props) to be - * passed down to the [emoji-mart `Picker`](https://github.com/missive/emoji-mart/tree/v5.5.2#-picker) component + * Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.6.0#options--props) to be + * passed down to the [emoji-mart `Picker`](https://github.com/missive/emoji-mart/tree/v5.6.0#-picker) component */ pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & Record>; /** @@ -48,6 +51,11 @@ const classNames: Pick< wrapperClassName: 'str-chat__message-textarea-emoji-picker', }; +/** + * @deprecated The emoji-mart-based `EmojiPicker` will be removed in v15. Switch to + * `StreamEmojiPicker` from `stream-chat-react/emojis`, which needs no emoji-mart + * packages. See the emoji section of `AI.md` for migration notes. + */ export const EmojiPicker = (props: EmojiPickerProps) => { const { t } = useTranslationContext('EmojiPicker'); const { textareaRef } = useMessageComposerContext('EmojiPicker'); @@ -63,6 +71,15 @@ export const EmojiPicker = (props: EmojiPickerProps) => { placement: props.placement ?? 'top-end', }); + useEffect(() => { + if (hasWarnedEmojiMartDeprecation) return; + hasWarnedEmojiMartDeprecation = true; + console.warn( + '[stream-chat-react] The `emoji-mart`-based `EmojiPicker` is deprecated and will be removed in the next major version. ' + + 'Switch to `StreamEmojiPicker` from `stream-chat-react/emojis` as it offers the same functionality without 3rd party dependencies.', + ); + }, []); + useEffect(() => { refs.setReference(referenceElement); }, [referenceElement, refs]); diff --git a/src/plugins/Emojis/StreamEmojiPicker.tsx b/src/plugins/Emojis/StreamEmojiPicker.tsx new file mode 100644 index 000000000..61e250e25 --- /dev/null +++ b/src/plugins/Emojis/StreamEmojiPicker.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { useMessageComposerContext, useTranslationContext } from '../../context'; +import { + Button, + IconEmoji, + type PopperLikePlacement, + useMessageComposerController, +} from '../../components'; +import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition'; +import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useIsCooldownActive'; +import { EmojiPickerPanel } from './components'; +import { useFrequentlyUsedEmoji } from './hooks/useFrequentlyUsedEmoji'; +import { useSkinTone } from './hooks/useSkinTone'; +import { + type EmojiPickerPassthroughProps, + resolveEmojiPickerOptions, + warnUnsupportedPickerProps, +} from './options'; + +const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; + +export type { + EmojiPickerNavPosition, + EmojiPickerPassthroughProps, + EmojiPickerPreviewPosition, + EmojiPickerSearchPosition, + EmojiPickerSkinTonePosition, +} from './options'; + +export type StreamEmojiPickerProps = { + ButtonIconComponent?: React.ComponentType; + buttonClassName?: string; + pickerContainerClassName?: string; + wrapperClassName?: string; + closeOnEmojiSelect?: boolean; + /** + * Presentation + curated layout/behavior options for the picker panel, using + * emoji-mart-compatible names (`theme`, `style`, `perLine`, `navPosition`, + * `previewPosition`, `searchPosition`, `skinTonePosition`, `categories`, + * `exceptEmojis`, `emojiVersion`, `maxFrequentRows`, `noCountryFlags`, + * `previewEmoji`, `noResultsEmoji`, `autoFocus`, `onClickOutside`). + * + * Not every emoji-mart `Picker` option is supported: image sets (`set`, + * `getSpritesheetURL`), `custom` emoji, `data`, `i18n`/`locale`, `dynamicWidth`, + * `icons`, and `categoryIcons` are rejected by the type and ignored (with a console + * warning) at runtime; sizing knobs (`emojiSize`, …) are CSS tokens instead. See the + * emoji migration notes in `AI.md`. + */ + pickerProps?: EmojiPickerPassthroughProps; + /** + * Floating UI placement (default: 'top-end') for the picker popover + */ + placement?: PopperLikePlacement; + /** Uncontrolled initial skin tone index (0 = default, 1–5 = light → dark). */ + defaultSkinTone?: number; + /** + * Controlled ordered list of recently used emoji ids (most recent first). The SDK + * does not persist this — provide it (and `onFrequentlyUsedChange`) to control the + * "frequently used" section; otherwise it is tracked in memory for the session. + */ + frequentlyUsedEmoji?: string[]; + /** Called with the updated recently-used list when an emoji is selected. */ + onFrequentlyUsedChange?: (emojiIds: string[]) => void; + /** Called with the new skin tone index when it changes. */ + onSkinToneChange?: (skinTone: number) => void; + /** Controlled skin tone index (0 = default, 1–5 = light → dark). */ + skinTone?: number; +}; + +const defaultButtonClassName = 'str-chat__emoji-picker-button'; + +const classNames: Pick< + StreamEmojiPickerProps, + 'pickerContainerClassName' | 'wrapperClassName' +> = { + pickerContainerClassName: 'str-chat__message-textarea-emoji-picker-container', + wrapperClassName: 'str-chat__message-textarea-emoji-picker', +}; + +export const StreamEmojiPicker = (props: StreamEmojiPickerProps) => { + const { t } = useTranslationContext('EmojiPicker'); + const { textareaRef } = useMessageComposerContext('EmojiPicker'); + const { textComposer } = useMessageComposerController(); + const isCooldownActive = useIsCooldownActive(); + // Skin tone and frequently-used live here (not in the panel) so they survive the + // picker opening/closing — the panel below is mounted only while open. + const [skinTone, setSkinTone] = useSkinTone({ + defaultSkinTone: props.defaultSkinTone, + onSkinToneChange: props.onSkinToneChange, + skinTone: props.skinTone, + }); + const { frequentlyUsedIds, recordUse } = useFrequentlyUsedEmoji({ + frequentlyUsedEmoji: props.frequentlyUsedEmoji, + onFrequentlyUsedChange: props.onFrequentlyUsedChange, + }); + const [displayPicker, setDisplayPicker] = useState(false); + const [referenceElement, setReferenceElement] = useState( + null, + ); + const [popperElement, setPopperElement] = useState(null); + const { refs, strategy, x, y } = usePopoverPosition({ + offset: 8, + placement: props.placement ?? 'top-end', + }); + + useEffect(() => { + refs.setReference(referenceElement); + }, [referenceElement, refs]); + useEffect(() => { + refs.setFloating(popperElement); + }, [popperElement, refs]); + + const { pickerContainerClassName, wrapperClassName } = classNames; + + const { ButtonIconComponent = IconEmoji } = props; + const pickerStyle = props.pickerProps?.style; + const options = resolveEmojiPickerOptions(props.pickerProps); + // Latest-ref so the click-outside listener isn't re-attached when the callback identity + // changes between renders. + const onClickOutsideRef = useRef(props.pickerProps?.onClickOutside); + onClickOutsideRef.current = props.pickerProps?.onClickOutside; + + const pickerPropsKeys = Object.keys(props.pickerProps ?? {}); + useEffect(() => { + warnUnsupportedPickerProps(props.pickerProps); + // Re-check only when the set of provided keys changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pickerPropsKeys.join(',')]); + + useEffect(() => { + if (!popperElement || !referenceElement) return; + + const handlePointerDown = (e: PointerEvent) => { + const target = e.target as HTMLElement; + + const rootNode = target.getRootNode(); + + if ( + popperElement.contains(isShadowRoot(rootNode) ? rootNode.host : target) || + referenceElement.contains(target) + ) { + return; + } + + onClickOutsideRef.current?.(); + setDisplayPicker(false); + }; + + window.addEventListener('pointerdown', handlePointerDown); + return () => window.removeEventListener('pointerdown', handlePointerDown); + }, [referenceElement, popperElement]); + + return ( +
+ {displayPicker && ( +
+ { + setDisplayPicker(false); + referenceElement?.focus(); + }} + onEmojiSelect={(emoji) => { + const textarea = textareaRef.current; + if (!textarea) return; + // Record only once we know the emoji is actually being inserted. + recordUse(emoji.id); + textComposer.insertText({ text: emoji.native }); + textarea.focus(); + if (props.closeOnEmojiSelect) { + setDisplayPicker(false); + } + }} + onSkinToneChange={setSkinTone} + options={options} + skinToneIndex={skinTone} + style={pickerStyle} + theme={props.pickerProps?.theme} + /> +
+ )} + +
+ ); +}; diff --git a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx new file mode 100644 index 000000000..85e7088cc --- /dev/null +++ b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx @@ -0,0 +1,86 @@ +import type { MouseEventHandler, ReactNode } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +// Mock emoji-mart's Picker so jsdom doesn't render the real web component. +vi.mock('@emoji-mart/react', () => ({ + default: ({ onEmojiSelect }: { onEmojiSelect: (e: { native: string }) => void }) => ( +
+ +
+ ), +})); +vi.mock('@emoji-mart/data', () => ({ default: {} })); + +const { textareaRef } = vi.hoisted(() => ({ + textareaRef: { + current: document.createElement('textarea') as HTMLTextAreaElement | null, + }, +})); +const insertText = vi.hoisted(() => vi.fn()); + +vi.mock('../../../context', () => ({ + useMessageComposerContext: () => ({ textareaRef }), + useTranslationContext: () => ({ t: (key: string) => key }), +})); +vi.mock('../../../components', async () => { + const { forwardRef } = await import('react'); + return { + Button: forwardRef>( + function Button(props, ref) { + return ( + + ); + }, + ), + IconEmoji: () => emoji, + useMessageComposerController: () => ({ textComposer: { insertText } }), + }; +}); +vi.mock('../../../components/Dialog/hooks/usePopoverPosition', () => ({ + usePopoverPosition: () => ({ + refs: { setFloating: vi.fn(), setReference: vi.fn() }, + strategy: 'absolute', + x: 0, + y: 0, + }), +})); +vi.mock('../../../components/MessageComposer/hooks/useIsCooldownActive', () => ({ + useIsCooldownActive: () => false, +})); + +import { EmojiPicker } from '../EmojiPicker'; + +describe('EmojiPicker (deprecated emoji-mart)', () => { + it('warns once about deprecation, naming the successor', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { unmount } = render(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/deprecated/i); + expect(warn.mock.calls[0][0]).toMatch(/StreamEmojiPicker/); + expect(warn.mock.calls[0][0]).toMatch(/next major version/i); + unmount(); + + warn.mockClear(); + render(); // module-level flag → no second warning + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('renders emoji-mart Picker when opened and inserts the chosen emoji', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + fireEvent.click(screen.getByLabelText('aria/Emoji picker')); + expect(screen.getByTestId('em-picker')).toBeInTheDocument(); + fireEvent.click(screen.getByText('pick')); + expect(insertText).toHaveBeenCalledWith({ text: '🚀' }); + }); +}); diff --git a/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx b/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx new file mode 100644 index 000000000..f3c270fe4 --- /dev/null +++ b/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx @@ -0,0 +1,173 @@ +import type { AriaAttributes, MouseEventHandler, ReactNode } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +// The panel is mounted only while the picker is open, so mock it to a controllable +// stub. This lets us assert that the owner (EmojiPicker) preserves skin tone and +// frequently-used across open/close — without depending on the real panel's +// virtualized grid + async data load, which don't render reliably in jsdom. +vi.mock('../components', () => ({ + EmojiPickerPanel: ({ + frequentlyUsedIds = [], + onEmojiSelect, + onSkinToneChange, + skinToneIndex = 0, + }: { + frequentlyUsedIds?: string[]; + onEmojiSelect: (emoji: { id: string; name: string; native: string }) => void; + onSkinToneChange?: (skinTone: number) => void; + skinToneIndex?: number; + }) => ( +
+ {skinToneIndex} + {frequentlyUsedIds.join(',')} + + +
+ ), +})); + +// Mutable so a test can simulate "no textarea to insert into" (textareaRef.current null). +const { textareaRef } = vi.hoisted(() => ({ + textareaRef: { current: null as HTMLTextAreaElement | null }, +})); + +vi.mock('../../../context', () => ({ + useMessageComposerContext: () => ({ textareaRef }), + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +vi.mock('../../../components', async () => { + const { forwardRef } = await import('react'); + return { + Button: forwardRef>( + function Button(props, ref) { + // Forward only the DOM-valid props the test needs; styling props are dropped. + return ( + + ); + }, + ), + IconEmoji: () => emoji, + useMessageComposerController: () => ({ textComposer: { insertText: vi.fn() } }), + }; +}); + +vi.mock('../../../components/Dialog/hooks/usePopoverPosition', () => ({ + usePopoverPosition: () => ({ + refs: { setFloating: vi.fn(), setReference: vi.fn() }, + strategy: 'absolute', + x: 0, + y: 0, + }), +})); + +vi.mock('../../../components/MessageComposer/hooks/useIsCooldownActive', () => ({ + useIsCooldownActive: () => false, +})); + +// Imported after the mocks so the mocked dependencies are in place. +import { StreamEmojiPicker } from '../StreamEmojiPicker'; + +const openPicker = () => fireEvent.click(screen.getByLabelText('aria/Emoji picker')); + +beforeEach(() => { + textareaRef.current = document.createElement('textarea'); +}); + +describe('StreamEmojiPicker session state', () => { + it('keeps skin tone and frequently-used across close and reopen (incl. closeOnEmojiSelect)', () => { + render(); + + openPicker(); + expect(screen.getByTestId('skin-tone')).toHaveTextContent('0'); + expect(screen.getByTestId('frequently-used')).toHaveTextContent(''); + + // Change skin tone, then select an emoji — selecting closes the picker. + fireEvent.click(screen.getByText('set-skin')); + expect(screen.getByTestId('skin-tone')).toHaveTextContent('4'); + fireEvent.click(screen.getByText('select-rocket')); + + // Panel (and any state it might have held) is unmounted. + expect(screen.queryByTestId('panel')).not.toBeInTheDocument(); + + // Reopening shows the retained skin tone and the just-used emoji. + openPicker(); + expect(screen.getByTestId('skin-tone')).toHaveTextContent('4'); + expect(screen.getByTestId('frequently-used')).toHaveTextContent('rocket'); + }); + + it('does not record a frequently-used emoji when there is no textarea to insert into', () => { + textareaRef.current = null; + render(); + + openPicker(); + expect(screen.getByTestId('frequently-used').textContent).toBe(''); + + // Selecting can't insert (no textarea), so it must not be recorded as "used". + fireEvent.click(screen.getByText('select-rocket')); + expect(screen.getByTestId('frequently-used').textContent).toBe(''); + }); + + it('marks the toggle button as opening a dialog popup', () => { + render(); + expect(screen.getByLabelText('aria/Emoji picker')).toHaveAttribute( + 'aria-haspopup', + 'dialog', + ); + }); +}); + +describe('StreamEmojiPicker pickerProps', () => { + it('warns about unsupported (emoji-mart) pickerProps, but not about theme/style', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { unmount } = render( + , + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('set')); + unmount(); + + warn.mockClear(); + render(); + expect(warn).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); + + it('does not warn when only supported (curated) picker options are passed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + , + ); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('calls onClickOutside when a pointer press lands outside the open picker', () => { + const onClickOutside = vi.fn(); + render(); + openPicker(); + fireEvent.pointerDown(document.body); + expect(onClickOutside).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/Emojis/__tests__/entry-exports.test.ts b/src/plugins/Emojis/__tests__/entry-exports.test.ts new file mode 100644 index 000000000..509670a8a --- /dev/null +++ b/src/plugins/Emojis/__tests__/entry-exports.test.ts @@ -0,0 +1,8 @@ +import * as emojis from '../index'; + +describe('emojis entry exports', () => { + it('exports both the deprecated EmojiPicker and the StreamEmojiPicker successor', () => { + expect(typeof emojis.EmojiPicker).toBe('function'); + expect(typeof emojis.StreamEmojiPicker).toBe('function'); + }); +}); diff --git a/src/plugins/Emojis/__tests__/memoizeAsyncWithReset.test.ts b/src/plugins/Emojis/__tests__/memoizeAsyncWithReset.test.ts new file mode 100644 index 000000000..c4cd49c45 --- /dev/null +++ b/src/plugins/Emojis/__tests__/memoizeAsyncWithReset.test.ts @@ -0,0 +1,41 @@ +import { memoizeAsyncWithReset } from '../memoizeAsyncWithReset'; + +describe('memoizeAsyncWithReset', () => { + it('memoizes a successful result so the factory runs once', async () => { + let calls = 0; + const memoized = memoizeAsyncWithReset(() => { + calls += 1; + return Promise.resolve('data'); + }); + + await expect(memoized()).resolves.toBe('data'); + await expect(memoized()).resolves.toBe('data'); + expect(calls).toBe(1); + }); + + it('shares a single in-flight promise between concurrent callers', async () => { + let calls = 0; + const memoized = memoizeAsyncWithReset(() => { + calls += 1; + return Promise.resolve('data'); + }); + + await Promise.all([memoized(), memoized()]); + expect(calls).toBe(1); + }); + + it('resets after a rejection so the next call retries instead of replaying the failure', async () => { + let calls = 0; + const memoized = memoizeAsyncWithReset(() => { + calls += 1; + return calls === 1 + ? Promise.reject(new Error('chunk load failed')) + : Promise.resolve('data'); + }); + + await expect(memoized()).rejects.toThrow('chunk load failed'); + // A memoized rejection would replay forever; the reset must let the retry succeed. + await expect(memoized()).resolves.toBe('data'); + expect(calls).toBe(2); + }); +}); diff --git a/src/plugins/Emojis/__tests__/options.test.ts b/src/plugins/Emojis/__tests__/options.test.ts new file mode 100644 index 000000000..0cc3e66ae --- /dev/null +++ b/src/plugins/Emojis/__tests__/options.test.ts @@ -0,0 +1,47 @@ +import { + DEFAULT_EMOJI_PICKER_OPTIONS, + resolveEmojiPickerOptions, + warnUnsupportedPickerProps, +} from '../options'; + +describe('resolveEmojiPickerOptions', () => { + it('returns the documented defaults when nothing is passed', () => { + expect(resolveEmojiPickerOptions()).toEqual(DEFAULT_EMOJI_PICKER_OPTIONS); + expect(DEFAULT_EMOJI_PICKER_OPTIONS.autoFocus).toBe(true); + expect(DEFAULT_EMOJI_PICKER_OPTIONS.perLine).toBe(9); + expect(DEFAULT_EMOJI_PICKER_OPTIONS.maxFrequentRows).toBe(1); + expect(DEFAULT_EMOJI_PICKER_OPTIONS.navPosition).toBe('top'); + }); + + it('overrides only the provided keys and clamps perLine/maxFrequentRows', () => { + const resolved = resolveEmojiPickerOptions({ + maxFrequentRows: -3, + navPosition: 'bottom', + perLine: 0, + }); + expect(resolved.navPosition).toBe('bottom'); + expect(resolved.perLine).toBe(1); // clamped to >= 1 + expect(resolved.maxFrequentRows).toBe(0); // clamped to >= 0 + expect(resolved.previewPosition).toBe('bottom'); // untouched default + }); +}); + +describe('warnUnsupportedPickerProps', () => { + it('warns about emoji-mart-only keys but not supported ones', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + warnUnsupportedPickerProps({ perLine: 9, set: 'apple', theme: 'dark' }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('set')); + expect(warn.mock.calls[0][0]).not.toContain('perLine'); + warn.mockRestore(); + }); + + it('points styling knobs at the CSS token instead of ignoring them', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + warnUnsupportedPickerProps({ emojiSize: 20 }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('--str-chat__emoji-picker-emoji-size'), + ); + warn.mockRestore(); + }); +}); diff --git a/src/plugins/Emojis/__tests__/reactions.test.tsx b/src/plugins/Emojis/__tests__/reactions.test.tsx new file mode 100644 index 000000000..42a21d584 --- /dev/null +++ b/src/plugins/Emojis/__tests__/reactions.test.tsx @@ -0,0 +1,32 @@ +import { render } from '@testing-library/react'; +import { loadDefaultExtendedReactionOptions } from '../reactions'; +import { emojiToUnicode } from '../../../components/Reactions/reactionOptions'; + +describe('loadDefaultExtendedReactionOptions', () => { + it('builds an extended reaction map covering the full emoji dataset', async () => { + const extended = await loadDefaultExtendedReactionOptions(); + + // The full vendored set (~1,870 emoji) — far beyond the handful of quick reactions, + // so the picker's "+" list lets you react with essentially any emoji again. + expect(Object.keys(extended).length).toBeGreaterThan(1000); + }); + + it('keys entries by unicode and renders the native glyph', async () => { + const extended = await loadDefaultExtendedReactionOptions(); + const grinningUnicode = emojiToUnicode('😀'); // U+1F600 + + const entry = extended[grinningUnicode]; + expect(entry).toBeDefined(); + expect(entry.unicode).toBe(grinningUnicode); + + const { container } = render(); + expect(container).toHaveTextContent('😀'); + }); + + it('memoizes: repeated calls resolve the same map (no rebuild per reaction render)', async () => { + const first = await loadDefaultExtendedReactionOptions(); + const second = await loadDefaultExtendedReactionOptions(); + + expect(first).toBe(second); + }); +}); diff --git a/src/plugins/Emojis/compat.ts b/src/plugins/Emojis/compat.ts new file mode 100644 index 000000000..2f270ceed --- /dev/null +++ b/src/plugins/Emojis/compat.ts @@ -0,0 +1,9 @@ +/** + * @deprecated No longer required. The built-in emoji search index self-initializes, + * so this is a no-op kept only so existing `init({ data })` calls keep compiling and + * running while migrating away from `emoji-mart`. Safe to remove from your app. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const init = (_options?: unknown): void => { + // intentionally empty — kept for backwards compatibility only +}; diff --git a/src/plugins/Emojis/components/CategoryNav.tsx b/src/plugins/Emojis/components/CategoryNav.tsx new file mode 100644 index 000000000..cea8cd3be --- /dev/null +++ b/src/plugins/Emojis/components/CategoryNav.tsx @@ -0,0 +1,70 @@ +import { type KeyboardEvent, useRef } from 'react'; +import clsx from 'clsx'; +import { EMOJI_CATEGORY_META } from './categories'; +import type { EmojiPickerCategory } from './EmojiGrid'; + +export type CategoryNavProps = { + categories: EmojiPickerCategory[]; + onNavigate: (categoryId: string) => void; + activeCategoryId?: string; +}; + +const NAV_KEYS = ['ArrowRight', 'ArrowLeft', 'Home', 'End']; + +/** + * Top navigation bar with one tab per category (role="tablist"). Clicking a tab + * scrolls its section into view; Left/Right/Home/End move focus between tabs with a + * roving tabindex; the active tab reflects the currently visible section. + */ +export const CategoryNav = ({ + activeCategoryId, + categories, + onNavigate, +}: CategoryNavProps) => { + const navRef = useRef(null); + const rovingId = activeCategoryId ?? categories[0]?.id; + + const onKeyDown = (event: KeyboardEvent) => { + if (!NAV_KEYS.includes(event.key)) return; + const tabs = Array.from( + navRef.current?.querySelectorAll('[role="tab"]') ?? [], + ); + const index = tabs.findIndex((tab) => tab === document.activeElement); + if (index === -1) return; + event.preventDefault(); + const lastIndex = tabs.length - 1; + let next = index; + if (event.key === 'ArrowRight') next = Math.min(index + 1, lastIndex); + else if (event.key === 'ArrowLeft') next = Math.max(index - 1, 0); + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = lastIndex; + tabs[next]?.focus(); + }; + + return ( +
+ {categories.map(({ id, label }) => ( + + ))} +
+ ); +}; diff --git a/src/plugins/Emojis/components/EmojiButton.tsx b/src/plugins/Emojis/components/EmojiButton.tsx new file mode 100644 index 000000000..38b9381db --- /dev/null +++ b/src/plugins/Emojis/components/EmojiButton.tsx @@ -0,0 +1,35 @@ +import { memo } from 'react'; +import { useEmojiPickerContext } from '../context/EmojiPickerContext'; +import type { EmojiDataEmoji } from '../data'; + +export type EmojiButtonProps = { + emoji: EmojiDataEmoji; +}; + +/** + * A single selectable emoji cell rendering the native unicode glyph for the active + * skin tone. Memoized because the grid can render ~1800 of these. + */ +export const EmojiButton = memo(function EmojiButton({ emoji }: EmojiButtonProps) { + const { onSelectEmoji, setPreviewedEmoji, skinToneIndex } = + useEmojiPickerContext('EmojiButton'); + const native = emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? ''; + + return ( + + ); +}); diff --git a/src/plugins/Emojis/components/EmojiGrid.tsx b/src/plugins/Emojis/components/EmojiGrid.tsx new file mode 100644 index 000000000..1f2d242f4 --- /dev/null +++ b/src/plugins/Emojis/components/EmojiGrid.tsx @@ -0,0 +1,86 @@ +import { forwardRef, useImperativeHandle, useRef } from 'react'; +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; +import { EmojiButton } from './EmojiButton'; +import type { EmojiDataEmoji } from '../data'; + +export type EmojiPickerCategory = { + emojis: EmojiDataEmoji[]; + id: string; + label: string; +}; + +export type EmojiGridHandle = { + scrollToCategory: (categoryId: string) => void; +}; + +export type EmojiGridProps = { + categories: EmojiPickerCategory[]; + onActiveCategoryChange?: (categoryId: string) => void; +}; + +const CategorySection = ({ category }: { category: EmojiPickerCategory }) => ( +
+
+ {category.label} +
+
+ {category.emojis.map((emoji) => ( + + ))} +
+
+); + +/** + * The category-grouped emoji grid. Virtualized at the category level with + * react-virtuoso: only the categories in (and near) the viewport are mounted, which + * keeps opening the picker fast without giving up section headers, scroll-spy, or + * per-category scrolling. `scrollToCategory` is exposed imperatively for the nav. + */ +export const EmojiGrid = forwardRef(function EmojiGrid( + { categories, onActiveCategoryChange }, + ref, +) { + const virtuosoRef = useRef(null); + const atBottomRef = useRef(false); + + useImperativeHandle( + ref, + () => ({ + scrollToCategory: (categoryId: string) => { + const index = categories.findIndex((category) => category.id === categoryId); + if (index >= 0) virtuosoRef.current?.scrollToIndex({ align: 'start', index }); + }, + }), + [categories], + ); + + return ( + { + atBottomRef.current = atBottom; + // The last category is often short and never reaches the top of the viewport, + // so scroll-spy alone would never mark it active — pin it while at the bottom. + if (atBottom) { + const last = categories[categories.length - 1]; + if (last) onActiveCategoryChange?.(last.id); + } + }} + className='str-chat__emoji-picker__grid' + data={categories} + itemContent={(_index, category) => } + rangeChanged={({ startIndex }) => { + // While pinned at the bottom, atBottomStateChange owns the active category. + if (atBottomRef.current) return; + const category = categories[startIndex]; + if (category) onActiveCategoryChange?.(category.id); + }} + ref={virtuosoRef} + style={{ height: '100%' }} + /> + ); +}); diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx new file mode 100644 index 000000000..39b9dfe45 --- /dev/null +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -0,0 +1,345 @@ +import { type CSSProperties, useCallback, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; +import { CategoryNav } from './CategoryNav'; +import { EmojiButton } from './EmojiButton'; +import { EmojiGrid, type EmojiGridHandle, type EmojiPickerCategory } from './EmojiGrid'; +import { EMOJI_CATEGORY_META } from './categories'; +import { resolveFrequentlyUsedEmoji } from './frequentlyUsed'; +import { EmptyResults } from './EmptyResults'; +import { PreviewPane } from './PreviewPane'; +import { SearchInput } from './SearchInput'; +import { SkinToneSelector } from './SkinToneSelector'; +import { + type EmojiPickerContextValue, + EmojiPickerProvider, +} from '../context/EmojiPickerContext'; +import { useDebouncedValue } from '../hooks/useDebouncedValue'; +import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; +import { useGridKeyboardNav } from '../hooks/useGridKeyboardNav'; +import { resolvePickerLayout } from '../hooks/pickerLayout'; +import { buildEmojiSearchData, runSearch } from '../search'; +import type { EmojiDataEmoji } from '../data'; +import { filterEmojiData } from '../data/filterEmojiData'; +import { + DEFAULT_EMOJI_PICKER_OPTIONS, + type ResolvedEmojiPickerOptions, +} from '../options'; +import { useTranslationContext } from '../../../context'; + +export type EmojiSelection = { + id: string; + name: string; + native: string; +}; + +export type EmojiPickerPanelProps = { + onEmojiSelect: (emoji: EmojiSelection) => void; + className?: string; + /** + * Ordered list of recently used emoji ids (most recent first), rendered as the + * "frequently used" section. This is a controlled value — the owner (EmojiPicker) + * holds it above the panel's mount so it survives the picker opening/closing. + */ + frequentlyUsedIds?: string[]; + /** Called when the panel requests to close (e.g. the Escape key). */ + onClose?: () => void; + /** Called with the new skin tone index when the user changes it. */ + onSkinToneChange?: (skinTone: number) => void; + /** Resolved (fully-defaulted) picker options — layout, filtering, and grid config. */ + options?: ResolvedEmojiPickerOptions; + /** Active skin tone index (0 = default, 1–5 = light → dark). Controlled by the owner. */ + skinToneIndex?: number; + style?: CSSProperties; + theme?: 'auto' | 'light' | 'dark'; +}; + +const noop = () => undefined; + +/** + * Maps the `theme` prop to the class applied on the panel root. `light`/`dark` force + * an absolute theme (the forced-light case is backed by a full light-variable override + * in EmojiPicker.scss, since the SDK has no `.str-chat__theme-light` variable set); + * `auto`/undefined applies no class and inherits the ancestor `.str-chat__theme-*`. + * Exported for testing the class contract the theme CSS relies on. + */ +export const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { + if (theme === 'light') return 'str-chat__theme-light'; + if (theme === 'dark') return 'str-chat__theme-dark'; + // 'auto' (default): inherit the ancestor `.str-chat__theme-*`. + return undefined; +}; + +/** + * The native React emoji picker panel that replaces emoji-mart's `` + * web component. Loads the vendored dataset, renders search, category navigation, + * the emoji grid, a preview and a skin-tone selector, and emits the resolved native + * emoji on selection. + * + * Skin tone and frequently-used are fully controlled (`skinToneIndex`, + * `frequentlyUsedIds`, `onSkinToneChange`): the panel is mounted only while the + * picker is open, so this session state is owned by the always-mounted `EmojiPicker` + * shell rather than held here. + */ +export const EmojiPickerPanel = ({ + className, + frequentlyUsedIds = [], + onClose, + onEmojiSelect, + onSkinToneChange, + options = DEFAULT_EMOJI_PICKER_OPTIONS, + skinToneIndex = 0, + style, + theme, +}: EmojiPickerPanelProps) => { + const { t } = useTranslationContext('EmojiPickerPanel'); + const { data, error, retry } = useEmojiPickerState(); + // One filtered view of the dataset feeds both the grid and the search index so + // exclusions apply consistently. Returns the same reference when no filter is set. + const filteredData = useMemo( + () => + data + ? filterEmojiData(data, { + emojiVersion: options.emojiVersion, + exceptEmojis: options.exceptEmojis, + noCountryFlags: options.noCountryFlags, + }) + : data, + [data, options.emojiVersion, options.exceptEmojis, options.noCountryFlags], + ); + const [previewedEmoji, setPreviewedEmoji] = useState(null); + const [activeCategoryId, setActiveCategoryId] = useState(undefined); + const [query, setQuery] = useState(''); + // Debounce the query that drives the search so typing doesn't re-scan the index and + // re-render up to 90 result cells on every keystroke (clearing still exits at once). + const debouncedQuery = useDebouncedValue(query, 120); + const emojiGridRef = useRef(null); + const bodyRef = useRef(null); + + const baseCategories = useMemo(() => { + if (!filteredData) return []; + const built = filteredData.categories.map((category) => ({ + emojis: category.emojis.map((id) => filteredData.emojis[id]).filter(Boolean), + id: category.id, + label: t(EMOJI_CATEGORY_META[category.id]?.labelKey ?? category.id), + })); + // `categories` option: keep only the requested ids, in the requested order. + if (!options.categories) return built; + const byId = new Map(built.map((category) => [category.id, category])); + return options.categories + .map((id) => { + if (!byId.has(id)) { + console.warn( + `[stream-chat-react] EmojiPicker: unknown category id "${id}" ignored.`, + ); + } + return byId.get(id); + }) + .filter((category): category is EmojiPickerCategory => Boolean(category)); + }, [filteredData, options.categories, t]); + + const categories = useMemo(() => { + if (!filteredData || !frequentlyUsedIds.length) return baseCategories; + const frequent: EmojiPickerCategory = { + // Capped to `perLine × maxFrequentRows` items (default one row) so the section + // grows to at most the configured number of rows as more emoji are used. + emojis: resolveFrequentlyUsedEmoji( + filteredData, + frequentlyUsedIds, + options.perLine * options.maxFrequentRows, + ), + id: 'frequent', + label: t(EMOJI_CATEGORY_META.frequent.labelKey), + }; + return frequent.emojis.length ? [frequent, ...baseCategories] : baseCategories; + }, [ + baseCategories, + filteredData, + frequentlyUsedIds, + options.maxFrequentRows, + options.perLine, + t, + ]); + + const scrollToCategory = useCallback((categoryId: string) => { + emojiGridRef.current?.scrollToCategory(categoryId); + }, []); + + // Keyboard nav can target a category that virtualization has unmounted; give it the + // category order + a way to scroll one into view so focus can traverse the whole set. + const { focusFirst, onKeyDown: onGridKeyDown } = useGridKeyboardNav(bodyRef, { + categories, + scrollToCategory, + }); + + const searchIndex = useMemo( + () => (filteredData ? buildEmojiSearchData(filteredData) : []), + [filteredData], + ); + + // `null` when not searching; otherwise the (possibly empty) list of matches. Clearing + // the field (empty live `query`) exits search immediately; a non-empty query is taken + // from the debounced value. + const searchedEmojis = useMemo(() => { + const trimmed = query.trim() && debouncedQuery.trim(); + if (!trimmed || !filteredData) return null; + return (runSearch(searchIndex, trimmed) ?? []) + .map((result) => filteredData.emojis[result.id]) + .filter(Boolean); + }, [debouncedQuery, filteredData, query, searchIndex]); + + const onSelectEmoji = useCallback( + (emoji: EmojiDataEmoji) => { + const native = emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? ''; + if (!native) return; + onEmojiSelect({ id: emoji.id, name: emoji.name, native }); + }, + [onEmojiSelect, skinToneIndex], + ); + + const contextValue = useMemo( + () => ({ onSelectEmoji, setPreviewedEmoji, skinToneIndex }), + [onSelectEmoji, skinToneIndex], + ); + + const handleNavigate = useCallback((categoryId: string) => { + setQuery(''); // navigating a category exits search + setActiveCategoryId(categoryId); + // Defer so the (virtualized) category view has mounted before we scroll to it. + requestAnimationFrame(() => { + emojiGridRef.current?.scrollToCategory(categoryId); + }); + }, []); + + const isSearching = searchedEmojis !== null; + + // Region placement + skin-tone fallbacks are resolved by a pure helper. + const pickerLayout = resolvePickerLayout(options); + const idlePreviewEmoji = options.previewEmoji + ? (filteredData?.emojis[options.previewEmoji] ?? null) + : null; + const noResultsGlyph = options.noResultsEmoji + ? (filteredData?.emojis[options.noResultsEmoji]?.skins[0]?.native ?? undefined) + : undefined; + + const nav = ( + + ); + const skinToneSelector = ( + + ); + const searchInput = pickerLayout.search ? ( + + ) : null; + // The skin-tone selector shares the search row only when it belongs there; otherwise + // the bare input renders (unchanged default), avoiding an extra wrapper. + const searchRow = + searchInput && pickerLayout.skinTone === 'search' ? ( +
+ {searchInput} + {skinToneSelector} +
+ ) : ( + searchInput + ); + const previewRow = (position: 'top' | 'bottom') => + pickerLayout.preview === position ? ( +
+ + {pickerLayout.skinTone === 'preview' ? skinToneSelector : null} +
+ ) : null; + const footerSkinRow = + pickerLayout.skinTone === 'footer' ? ( +
{skinToneSelector}
+ ) : null; + + return ( + +
{ + if (event.key === 'Escape') { + event.stopPropagation(); + onClose?.(); + } + }} + role='dialog' + // `--*` custom properties aren't in React 17/18's CSSProperties, so cast (the + // vite example uses the same pattern for its layout CSS variables). + style={ + { + ...style, + '--str-chat__emoji-picker-per-line': options.perLine, + } as CSSProperties + } + > + {data ? ( + <> + {pickerLayout.nav === 'top' ? nav : null} + {previewRow('top')} + {searchRow} +
+ {isSearching ? ( + searchedEmojis.length ? ( +
+
+
+ {searchedEmojis.map((emoji) => ( + + ))} +
+
+
+ ) : ( + + ) + ) : ( + + )} +
+ {previewRow('bottom')} + {footerSkinRow} + {pickerLayout.nav === 'bottom' ? nav : null} + + ) : error ? ( +
+

+ {t('Failed to load emojis')} +

+ +
+ ) : ( +
+ )} +
+ + ); +}; diff --git a/src/plugins/Emojis/components/EmptyResults.tsx b/src/plugins/Emojis/components/EmptyResults.tsx new file mode 100644 index 000000000..707d34755 --- /dev/null +++ b/src/plugins/Emojis/components/EmptyResults.tsx @@ -0,0 +1,24 @@ +import { useTranslationContext } from '../../../context'; + +export type EmptyResultsProps = { + /** Native glyph shown above the "no results" text (from the `noResultsEmoji` option). */ + emoji?: string; +}; + +/** + * Shown in place of the emoji grid when a search yields no matches. + */ +export const EmptyResults = ({ emoji }: EmptyResultsProps) => { + const { t } = useTranslationContext('EmojiPicker'); + + return ( +
+ {emoji ? ( + + ) : null} + {t('No emoji found')} +
+ ); +}; diff --git a/src/plugins/Emojis/components/PreviewPane.tsx b/src/plugins/Emojis/components/PreviewPane.tsx new file mode 100644 index 000000000..6186d9785 --- /dev/null +++ b/src/plugins/Emojis/components/PreviewPane.tsx @@ -0,0 +1,42 @@ +import { useEmojiPickerContext } from '../context/EmojiPickerContext'; +import { useTranslationContext } from '../../../context'; +import type { EmojiDataEmoji } from '../data'; + +export type PreviewPaneProps = { + emoji: EmojiDataEmoji | null; + /** Emoji shown at rest (nothing hovered/focused) instead of the text placeholder. */ + placeholderEmoji?: EmojiDataEmoji | null; +}; + +/** + * Footer preview of the hovered/focused emoji: a large glyph, its name, and its + * `:shortcode:` (so users learn the token that drives `:` autocomplete). Falls back to + * a "Pick an emoji…" placeholder (like emoji-mart) so the footer is never empty. + * Receives the previewed emoji as a prop (kept out of context) so hovering does not + * re-render the emoji grid. + */ +export const PreviewPane = ({ emoji, placeholderEmoji }: PreviewPaneProps) => { + const { t } = useTranslationContext('EmojiPickerPreview'); + const { skinToneIndex } = useEmojiPickerContext('PreviewPane'); + // Prefer the hovered/focused emoji, then a configured resting emoji, then the text + // placeholder. + const shown = emoji ?? placeholderEmoji ?? null; + + return ( +
+ + + + {shown ? shown.name : t('Pick an emoji…')} + + {shown ? ( + {`:${shown.id}:`} + ) : null} + +
+ ); +}; diff --git a/src/plugins/Emojis/components/SearchInput.tsx b/src/plugins/Emojis/components/SearchInput.tsx new file mode 100644 index 000000000..461bbce6e --- /dev/null +++ b/src/plugins/Emojis/components/SearchInput.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; +import { Button, IconSearch, IconXCircle, VisuallyHidden } from '../../../components'; +import { useStableId } from '../../../components/UtilityComponents/useStableId'; +import { useTranslationContext } from '../../../context'; + +export type SearchInputProps = { + onChange: (value: string) => void; + value: string; + /** Focus the input on mount when the picker opens (default `true`). */ + autoFocus?: boolean; + /** Called when ArrowDown is pressed, to move focus into the emoji grid. */ + onArrowDown?: () => void; +}; + +/** + * Search box for the emoji picker. Mirrors the SDK's SearchBar structure (icon + + * labelled input + clear button) and receives focus when the picker opens. + */ +export const SearchInput = ({ + autoFocus = true, + onArrowDown, + onChange, + value, +}: SearchInputProps) => { + const { t } = useTranslationContext('EmojiPickerSearchInput'); + const inputId = useStableId(); + const inputRef = useRef(null); + + useEffect(() => { + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); + + return ( +
+ + + onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + onArrowDown?.(); + } + }} + placeholder={t('Search emoji')} + ref={inputRef} + type='text' + value={value} + /> + {value ? ( + + ) : null} +
+ ); +}; diff --git a/src/plugins/Emojis/components/SkinToneSelector.tsx b/src/plugins/Emojis/components/SkinToneSelector.tsx new file mode 100644 index 000000000..e584c36da --- /dev/null +++ b/src/plugins/Emojis/components/SkinToneSelector.tsx @@ -0,0 +1,117 @@ +import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; +import { MAX_SKIN_TONE_INDEX, SKIN_TONES } from './skinTones'; +import { useTranslationContext } from '../../../context'; + +export type SkinToneSelectorProps = { + onSelect: (skinToneIndex: number) => void; + skinToneIndex: number; +}; + +const ARROW_KEYS = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End']; + +/** + * Skin-tone picker rendered in the footer. Collapsed to the active tone; expands to a + * WAI-ARIA radiogroup: focus moves onto the checked tone on open, a roving tabindex keeps + * one tone tab-reachable, arrow/Home/End keys move the selection (selection follows + * focus), and Escape collapses back to the toggle without closing the whole picker. + */ +export const SkinToneSelector = ({ onSelect, skinToneIndex }: SkinToneSelectorProps) => { + const { t } = useTranslationContext('EmojiPickerSkinTone'); + const [expanded, setExpanded] = useState(false); + const toggleRef = useRef(null); + const groupRef = useRef(null); + const returnFocusToToggle = useRef(false); + const activeTone = SKIN_TONES[skinToneIndex] ?? SKIN_TONES[0]; + + const radios = () => + Array.from( + groupRef.current?.querySelectorAll('[role="radio"]') ?? [], + ); + + useEffect(() => { + if (expanded) { + // Move focus into the group, onto the checked tone, when it opens. + radios()[skinToneIndex]?.focus(); + } else if (returnFocusToToggle.current) { + returnFocusToToggle.current = false; + toggleRef.current?.focus(); + } + // Focus is managed on open/close only; arrow navigation focuses the target directly. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expanded]); + + if (!expanded) { + return ( + + ); + } + + const collapse = (returnFocus: boolean) => { + returnFocusToToggle.current = returnFocus; + setExpanded(false); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.stopPropagation(); // collapse the group, not the whole picker + collapse(true); + return; + } + if (!ARROW_KEYS.includes(event.key)) return; + event.preventDefault(); + + let next = skinToneIndex; + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + next = skinToneIndex >= MAX_SKIN_TONE_INDEX ? 0 : skinToneIndex + 1; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + next = skinToneIndex <= 0 ? MAX_SKIN_TONE_INDEX : skinToneIndex - 1; + } else if (event.key === 'Home') { + next = 0; + } else if (event.key === 'End') { + next = MAX_SKIN_TONE_INDEX; + } + + // All tones are mounted, so focus the target now; selection follows focus. + radios()[next]?.focus(); + onSelect(next); + }; + + return ( +
+ {SKIN_TONES.map((tone, index) => ( + + ))} +
+ ); +}; diff --git a/src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx new file mode 100644 index 000000000..f7284bd9d --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react'; +import { EmojiButton } from '../EmojiButton'; +import { EmojiPickerProvider } from '../../context/EmojiPickerContext'; +import type { EmojiDataEmoji } from '../../data'; + +// A skin-tone-capable emoji (6 skins) and a plain one (1 skin) — mirrors the dataset, +// where only ~305 of ~1870 emoji have skin variants. +const wave: EmojiDataEmoji = { + id: 'wave', + keywords: ['hand'], + name: 'Waving Hand', + skins: [ + { native: '👋', unified: '1f44b' }, + { native: '👋🏻', unified: '1f44b-1f3fb' }, + { native: '👋🏼', unified: '1f44b-1f3fc' }, + { native: '👋🏽', unified: '1f44b-1f3fd' }, + { native: '👋🏾', unified: '1f44b-1f3fe' }, + { native: '👋🏿', unified: '1f44b-1f3ff' }, + ], + version: 1, +}; + +const grinning: EmojiDataEmoji = { + id: 'grinning', + keywords: ['face'], + name: 'Grinning', + skins: [{ native: '😀', unified: '1f600' }], + version: 1, +}; + +const renderButton = (emoji: EmojiDataEmoji, skinToneIndex: number) => + render( + + + , + ); + +describe('EmojiButton skin tone', () => { + it('renders the default glyph at skin tone 0', () => { + renderButton(wave, 0); + expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('👋'); + }); + + it('renders the toned glyph for a skin-tone-capable emoji', () => { + renderButton(wave, 5); + expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('👋🏿'); + }); + + it('updates the glyph when the active skin tone changes (memo does not block context)', () => { + const { rerender } = renderButton(wave, 0); + expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('👋'); + + rerender( + + + , + ); + expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('👋🏽'); + }); + + it('leaves an emoji with no skin variants unchanged at any tone (why smileys never change)', () => { + renderButton(grinning, 5); + // Falls back to skins[0] — this is why changing skin tone has no visible effect on + // the faces shown by default; only hand/person emoji carry skin variants. + expect(screen.getByRole('button', { name: 'Grinning' })).toHaveTextContent('😀'); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx new file mode 100644 index 000000000..255a04a7a --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { EmojiGrid, type EmojiPickerCategory } from '../EmojiGrid'; +import { EmojiPickerProvider } from '../../context/EmojiPickerContext'; + +// Capture the Virtuoso scroll callbacks so the scroll-spy behavior is testable, and +// render every item synchronously so the non-search view's a11y tree is assertable. +const { virtuoso } = vi.hoisted(() => ({ + virtuoso: {} as { + atBottomStateChange?: (atBottom: boolean) => void; + rangeChanged?: (range: { endIndex: number; startIndex: number }) => void; + }, +})); + +vi.mock('react-virtuoso', () => ({ + Virtuoso: ({ + atBottomStateChange, + data = [], + itemContent, + rangeChanged, + }: { + atBottomStateChange?: (atBottom: boolean) => void; + data?: EmojiPickerCategory[]; + itemContent?: (index: number, item: EmojiPickerCategory) => React.ReactNode; + rangeChanged?: (range: { endIndex: number; startIndex: number }) => void; + }) => { + virtuoso.atBottomStateChange = atBottomStateChange; + virtuoso.rangeChanged = rangeChanged; + return ( +
+ {data.map((item, index) => ( + {itemContent?.(index, item)} + ))} +
+ ); + }, +})); + +const emoji = (id: string, native: string) => ({ + id, + keywords: [], + name: id, + skins: [{ native, unified: '' }], + version: 1, +}); + +const categories: EmojiPickerCategory[] = [ + { + emojis: [ + { + id: 'grinning', + keywords: ['face', 'smile'], + name: 'Grinning', + skins: [{ native: '😀', unified: '1f600' }], + version: 1, + }, + ], + id: 'people', + label: 'Smileys & People', + }, +]; + +const renderGrid = () => + render( + + + , + ); + +describe('EmojiGrid accessibility (non-search view)', () => { + it('renders emojis as accessible buttons with no orphaned grid/row/gridcell roles', () => { + renderGrid(); + + // The review flagged row/gridcell elements with no owning grid in the virtualized + // view. Native button semantics avoid the invalid ARIA entirely. + expect(screen.queryAllByRole('grid')).toHaveLength(0); + expect(screen.queryAllByRole('row')).toHaveLength(0); + expect(screen.queryAllByRole('gridcell')).toHaveLength(0); + + expect(screen.getByRole('button', { name: 'Grinning' })).toBeInTheDocument(); + }); + + it('keeps emojis grouped under a labeled category region', () => { + renderGrid(); + + expect(screen.getByRole('region', { name: 'Smileys & People' })).toBeInTheDocument(); + }); +}); + +describe('EmojiGrid scroll-spy (active category tracking)', () => { + const twoCategories: EmojiPickerCategory[] = [ + { emojis: [emoji('grinning', '😀')], id: 'people', label: 'People' }, + { emojis: [emoji('checkered_flag', '🏁')], id: 'flags', label: 'Flags' }, + ]; + + const renderSpy = (onActiveCategoryChange: (id: string) => void) => + render( + + + , + ); + + it('marks the category at the top of the viewport active as the list scrolls', () => { + const onActive = vi.fn(); + renderSpy(onActive); + + virtuoso.rangeChanged?.({ endIndex: 1, startIndex: 1 }); + + expect(onActive).toHaveBeenLastCalledWith('flags'); + }); + + it('marks the last category active at the bottom, even when a short final category never reaches the top', () => { + const onActive = vi.fn(); + renderSpy(onActive); + + // Top of the viewport is still the first category… + virtuoso.rangeChanged?.({ endIndex: 1, startIndex: 0 }); + // …but the list is scrolled to the very bottom, so the last category is active. + virtuoso.atBottomStateChange?.(true); + expect(onActive).toHaveBeenLastCalledWith('flags'); + + // Range changes while pinned at the bottom must not steal the active category back. + virtuoso.rangeChanged?.({ endIndex: 1, startIndex: 0 }); + expect(onActive).toHaveBeenLastCalledWith('flags'); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx new file mode 100644 index 000000000..15987dca7 --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx @@ -0,0 +1,218 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +const { hookState } = vi.hoisted(() => ({ + hookState: { + data: null as unknown, + error: false, + retry: vi.fn(), + }, +})); + +vi.mock('../../hooks/useEmojiPickerState', () => ({ + useEmojiPickerState: () => hookState, +})); + +vi.mock('../../../../context', () => ({ + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +// The real grid uses react-virtuoso, which renders no items without layout (jsdom). +// Mock it to expose the categories it receives so we can assert what the panel feeds it. +vi.mock('../EmojiGrid', async () => { + const { forwardRef } = await import('react'); + return { + EmojiGrid: forwardRef(function EmojiGrid({ + categories, + }: { + categories: { emojis: { id: string; name: string }[]; id: string }[]; + }) { + return ( +
+ {categories + .flatMap((category) => category.emojis) + .map((emoji) => ( + {emoji.name} + ))} +
+ ); + }), + }; +}); + +import { EmojiPickerPanel, themeClassName } from '../EmojiPickerPanel'; +import { DEFAULT_EMOJI_PICKER_OPTIONS } from '../../options'; + +const DATA = { + aliases: {}, + categories: [{ emojis: ['grinning', 'smile'], id: 'people' }], + emojis: { + grinning: { + id: 'grinning', + keywords: [], + name: 'Grinning', + skins: [{ native: '😀', unified: '1f600' }], + version: 1, + }, + smile: { + id: 'smile', + keywords: [], + name: 'Smile', + skins: [{ native: '😄', unified: '1f604' }], + version: 1, + }, + }, +}; + +// The picker's theme CSS keys off the class this maps to: a forced `light`/`dark` +// must emit an SDK theme class on the panel root so the forced-light variable override +// in EmojiPicker.scss can win over an ancestor `.str-chat__theme-dark`. If these strings +// or the mapping drift, forced theming silently stops overriding the ancestor theme. +describe('themeClassName', () => { + it('maps a forced theme to the matching SDK theme class', () => { + expect(themeClassName('light')).toBe('str-chat__theme-light'); + expect(themeClassName('dark')).toBe('str-chat__theme-dark'); + }); + + it('emits no class for auto/undefined so the panel inherits the ancestor theme', () => { + expect(themeClassName('auto')).toBeUndefined(); + expect(themeClassName(undefined)).toBeUndefined(); + }); +}); + +describe('EmojiPickerPanel dataset loading', () => { + beforeEach(() => { + hookState.data = null; + hookState.error = false; + hookState.retry.mockClear(); + }); + + it('renders a recoverable error (not a permanent spinner) when the dataset fails to load', () => { + hookState.error = true; + render( {}} />); + + // No stuck aria-busy loading region… + expect(document.querySelector('[aria-busy="true"]')).toBeNull(); + // …an announced error with a working retry instead. + expect(screen.getByRole('alert')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(hookState.retry).toHaveBeenCalledTimes(1); + }); + + it('shows the loading region while the dataset is still loading', () => { + render( {}} />); + + expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); + +describe('EmojiPickerPanel option: exceptEmojis', () => { + beforeEach(() => { + hookState.data = DATA; + hookState.error = false; + }); + + it('removes excluded emoji from the grid, keeping the rest', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, exceptEmojis: ['smile'] }} + />, + ); + expect(screen.getByText('Grinning')).toBeInTheDocument(); + expect(screen.queryByText('Smile')).not.toBeInTheDocument(); + }); +}); + +const TWO_CATS = { + aliases: {}, + categories: [ + { emojis: ['grinning'], id: 'people' }, + { emojis: ['dog'], id: 'nature' }, + ], + emojis: { + dog: { + id: 'dog', + keywords: [], + name: 'Dog', + skins: [{ native: '🐶', unified: '1f436' }], + version: 1, + }, + grinning: { + id: 'grinning', + keywords: [], + name: 'Grinning', + skins: [{ native: '😀', unified: '1f600' }], + version: 1, + }, + }, +}; + +describe('EmojiPickerPanel option: categories', () => { + beforeEach(() => { + hookState.data = TWO_CATS; + hookState.error = false; + }); + + it('shows only the requested categories, in the requested order', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, categories: ['nature'] }} + />, + ); + expect(screen.getAllByRole('tab')).toHaveLength(1); + expect(screen.getByText('Dog')).toBeInTheDocument(); + expect(screen.queryByText('Grinning')).not.toBeInTheDocument(); + }); +}); + +describe('EmojiPickerPanel layout positions', () => { + beforeEach(() => { + hookState.data = DATA; + hookState.error = false; + }); + + it('omits the search input when searchPosition is none', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, searchPosition: 'none' }} + />, + ); + expect(screen.queryByPlaceholderText('Search emoji')).not.toBeInTheDocument(); + }); + + it('omits the category nav when navPosition is none', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, navPosition: 'none' }} + />, + ); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + }); + + it('omits the skin-tone selector when skinTonePosition is none', () => { + render( + {}} + onSkinToneChange={() => {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, skinTonePosition: 'none' }} + />, + ); + expect( + screen.queryByRole('button', { name: 'aria/Choose default skin tone' }), + ).not.toBeInTheDocument(); + }); + + it('does not focus the search input when autoFocus is false', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, autoFocus: false }} + />, + ); + expect(screen.getByPlaceholderText('Search emoji')).not.toHaveFocus(); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx b/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx new file mode 100644 index 000000000..dd22890ff --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react'; + +vi.mock('../../../../context', () => ({ + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +import { PreviewPane } from '../PreviewPane'; +import { EmojiPickerProvider } from '../../context/EmojiPickerContext'; +import type { EmojiDataEmoji } from '../../data'; + +const smile: EmojiDataEmoji = { + id: 'smile', + keywords: ['happy'], + name: 'Smiling Face', + skins: [{ native: '😄', unified: '1f604' }], + version: 1, +}; + +const renderPreview = (emoji: EmojiDataEmoji | null, skinToneIndex = 0) => + render( + + + , + ); + +describe('PreviewPane', () => { + it('shows the emoji name and its shortcode when an emoji is previewed', () => { + renderPreview(smile); + + expect(screen.getByText('Smiling Face')).toBeInTheDocument(); + // The shortcode (`:id:`) must be visible so users learn the autocomplete token. + expect(screen.getByText(':smile:')).toBeInTheDocument(); + }); + + it('shows the placeholder and no shortcode when nothing is previewed', () => { + renderPreview(null); + + expect(screen.getByText('Pick an emoji…')).toBeInTheDocument(); + expect(screen.queryByText(/^:.+:$/)).not.toBeInTheDocument(); + }); + + it('shows the configured resting emoji (previewEmoji) when nothing is hovered', () => { + render( + + + , + ); + + expect(screen.getByText('Smiling Face')).toBeInTheDocument(); + expect(screen.getByText(':smile:')).toBeInTheDocument(); + expect(screen.queryByText('Pick an emoji…')).not.toBeInTheDocument(); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx b/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx new file mode 100644 index 000000000..1ba3db6d9 --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('../../../../context', () => ({ + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +import { SkinToneSelector } from '../SkinToneSelector'; + +const openGroup = () => + fireEvent.click(screen.getByRole('button', { name: 'aria/Choose default skin tone' })); + +describe('SkinToneSelector radiogroup keyboard navigation', () => { + it('moves focus to the checked tone when the group opens, with roving tabindex', () => { + render(); + openGroup(); + + const radios = screen.getAllByRole('radio'); + expect(radios[2]).toHaveFocus(); + expect(radios[2]).toHaveAttribute('tabindex', '0'); + expect(radios[0]).toHaveAttribute('tabindex', '-1'); + }); + + it('selects the next/previous tone with arrow keys (selection follows focus)', () => { + const onSelect = vi.fn(); + render(); + openGroup(); + + const group = screen.getByRole('radiogroup'); + fireEvent.keyDown(group, { key: 'ArrowRight' }); + expect(onSelect).toHaveBeenLastCalledWith(1); + fireEvent.keyDown(group, { key: 'ArrowLeft' }); + expect(onSelect).toHaveBeenLastCalledWith(5); // wraps from 0 to the last tone + }); + + it('jumps to the first/last tone with Home/End', () => { + const onSelect = vi.fn(); + render(); + openGroup(); + + const group = screen.getByRole('radiogroup'); + fireEvent.keyDown(group, { key: 'End' }); + expect(onSelect).toHaveBeenLastCalledWith(5); + fireEvent.keyDown(group, { key: 'Home' }); + expect(onSelect).toHaveBeenLastCalledWith(0); + }); + + it('collapses on Escape without bubbling to close the whole picker', () => { + const onOuterKeyDown = vi.fn(); + render( +
+ +
, + ); + openGroup(); + + fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'Escape' }); + + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'aria/Choose default skin tone' }), + ).toBeInTheDocument(); + // Escape stayed inside the selector — it must not reach the picker's Escape handler. + expect(onOuterKeyDown).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts b/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts new file mode 100644 index 000000000..46e4bbf21 --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts @@ -0,0 +1,45 @@ +import { FREQUENTLY_USED_LIMIT, resolveFrequentlyUsedEmoji } from '../frequentlyUsed'; +import type { EmojiData, EmojiDataEmoji } from '../../data'; + +const makeEmoji = (id: string): EmojiDataEmoji => ({ + id, + keywords: [], + name: id, + skins: [{ native: id, unified: id }], + version: 1, +}); + +const data = { + aliases: {}, + categories: [], + emojis: Object.fromEntries( + Array.from({ length: 30 }, (_, i) => `e${i}`).map((id) => [id, makeEmoji(id)]), + ), +} as unknown as EmojiData; + +describe('resolveFrequentlyUsedEmoji', () => { + it('caps the frequently-used list to a single row so it never wraps', () => { + const ids = Array.from({ length: 30 }, (_, i) => `e${i}`); + const result = resolveFrequentlyUsedEmoji(data, ids); + + expect(result).toHaveLength(FREQUENTLY_USED_LIMIT); + }); + + it('preserves most-recent-first order', () => { + const result = resolveFrequentlyUsedEmoji(data, ['e2', 'e0', 'e1']); + + expect(result.map((emoji) => emoji.id)).toEqual(['e2', 'e0', 'e1']); + }); + + it('drops ids missing from the dataset', () => { + const result = resolveFrequentlyUsedEmoji(data, ['e0', 'does-not-exist', 'e1']); + + expect(result.map((emoji) => emoji.id)).toEqual(['e0', 'e1']); + }); + + it('honors an explicit limit (perLine × maxFrequentRows)', () => { + const ids = Array.from({ length: 30 }, (_, i) => `e${i}`); + + expect(resolveFrequentlyUsedEmoji(data, ids, 14)).toHaveLength(14); + }); +}); diff --git a/src/plugins/Emojis/components/categories.ts b/src/plugins/Emojis/components/categories.ts new file mode 100644 index 000000000..b006a47dd --- /dev/null +++ b/src/plugins/Emojis/components/categories.ts @@ -0,0 +1,15 @@ +// Category metadata for the emoji picker navigation. We use representative native +// emoji glyphs for the nav tabs (avoids shipping ~8 additional SVG icon assets) +// plus an i18n label key per category. Keys match the vendored dataset's category +// ids, with `frequent` reserved for the synthetic "frequently used" section. +export const EMOJI_CATEGORY_META: Record = { + activity: { glyph: '⚽', labelKey: 'Activities' }, + flags: { glyph: '🏁', labelKey: 'Flags' }, + foods: { glyph: '🍎', labelKey: 'Food & Drink' }, + frequent: { glyph: '🕐', labelKey: 'Frequently used' }, + nature: { glyph: '🌸', labelKey: 'Animals & Nature' }, + objects: { glyph: '💡', labelKey: 'Objects' }, + people: { glyph: '😀', labelKey: 'Smileys & People' }, + places: { glyph: '✈️', labelKey: 'Travel & Places' }, + symbols: { glyph: '🔣', labelKey: 'Symbols' }, +}; diff --git a/src/plugins/Emojis/components/frequentlyUsed.ts b/src/plugins/Emojis/components/frequentlyUsed.ts new file mode 100644 index 000000000..ab37a9456 --- /dev/null +++ b/src/plugins/Emojis/components/frequentlyUsed.ts @@ -0,0 +1,27 @@ +import type { EmojiData, EmojiDataEmoji } from '../data'; + +/** + * How many recently-used emoji the "frequently used" section shows. Matches the + * default grid's column count, so the section stays a single row. + * + * Kept in sync with the fixed column count in EmojiPicker.scss + * (`[data-category-id='frequent'] .str-chat__emoji-picker__category-emojis`): the + * slice caps the item count and the CSS pins the columns, together guaranteeing one + * row regardless of how many emoji have been used. + */ +export const FREQUENTLY_USED_LIMIT = 9; + +/** + * Resolves an ordered (most-recent-first) list of emoji ids to dataset entries for the + * "frequently used" section: drops ids missing from the dataset and caps the result to + * a single row (`FREQUENTLY_USED_LIMIT`). + */ +export const resolveFrequentlyUsedEmoji = ( + data: EmojiData, + frequentlyUsedIds: string[], + limit = FREQUENTLY_USED_LIMIT, +): EmojiDataEmoji[] => + frequentlyUsedIds + .map((id) => data.emojis[id]) + .filter(Boolean) + .slice(0, limit); diff --git a/src/plugins/Emojis/components/index.ts b/src/plugins/Emojis/components/index.ts new file mode 100644 index 000000000..a8bf208ec --- /dev/null +++ b/src/plugins/Emojis/components/index.ts @@ -0,0 +1 @@ +export * from './EmojiPickerPanel'; diff --git a/src/plugins/Emojis/components/skinTones.ts b/src/plugins/Emojis/components/skinTones.ts new file mode 100644 index 000000000..68838fd24 --- /dev/null +++ b/src/plugins/Emojis/components/skinTones.ts @@ -0,0 +1,13 @@ +// Skin-tone swatches for the picker. Index 0 is the default (no modifier); 1–5 are +// light → dark. The glyph is a hand emoji carrying the matching Fitzpatrick skin +// tone modifier, mirroring emoji-mart's selector. `labelKey` is translated via t(). +export const SKIN_TONES: Array<{ glyph: string; labelKey: string }> = [ + { glyph: '✋', labelKey: 'Default' }, + { glyph: '✋🏻', labelKey: 'Light' }, + { glyph: '✋🏼', labelKey: 'Medium-Light' }, + { glyph: '✋🏽', labelKey: 'Medium' }, + { glyph: '✋🏾', labelKey: 'Medium-Dark' }, + { glyph: '✋🏿', labelKey: 'Dark' }, +]; + +export const MAX_SKIN_TONE_INDEX = SKIN_TONES.length - 1; diff --git a/src/plugins/Emojis/context/EmojiPickerContext.tsx b/src/plugins/Emojis/context/EmojiPickerContext.tsx new file mode 100644 index 000000000..b74b683b3 --- /dev/null +++ b/src/plugins/Emojis/context/EmojiPickerContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; +import type { EmojiDataEmoji } from '../data'; + +export type EmojiPickerContextValue = { + onSelectEmoji: (emoji: EmojiDataEmoji) => void; + setPreviewedEmoji: (emoji: EmojiDataEmoji | null) => void; + skinToneIndex: number; +}; + +const EmojiPickerContext = createContext(undefined); + +export const EmojiPickerProvider = EmojiPickerContext.Provider; + +/** + * Shares the stable picker callbacks and the active skin tone with the picker's + * child components. Deliberately excludes transient state (like the previewed + * emoji) so consuming the context does not re-render the whole emoji grid. + */ +export const useEmojiPickerContext = (componentName = 'EmojiPicker') => { + const context = useContext(EmojiPickerContext); + if (!context) { + throw new Error( + `The ${componentName} component must be rendered within an EmojiPickerPanel.`, + ); + } + return context; +}; diff --git a/src/plugins/Emojis/data/LICENSE b/src/plugins/Emojis/data/LICENSE new file mode 100644 index 000000000..a82512e98 --- /dev/null +++ b/src/plugins/Emojis/data/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Missive. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/plugins/Emojis/data/__tests__/emoji-data.test.ts b/src/plugins/Emojis/data/__tests__/emoji-data.test.ts new file mode 100644 index 000000000..202f9c40e --- /dev/null +++ b/src/plugins/Emojis/data/__tests__/emoji-data.test.ts @@ -0,0 +1,34 @@ +import emojiData from '../emoji-data.json'; +import type { EmojiData } from '../types'; + +const data = emojiData as unknown as EmojiData; + +describe('vendored emoji-data.json', () => { + it('has exactly the aliases/categories/emojis top-level keys (no spritesheet)', () => { + expect(Object.keys(data).sort()).toEqual(['aliases', 'categories', 'emojis']); + expect((data as Record).sheet).toBeUndefined(); + }); + + it('has the 8 standard categories in the expected order', () => { + expect(data.categories.map((category) => category.id)).toEqual([ + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', + ]); + }); + + it('every emoji has an id, a name and at least one native skin', () => { + const emojis = Object.values(data.emojis); + expect(emojis.length).toBeGreaterThan(1800); + for (const emoji of emojis) { + expect(typeof emoji.id).toBe('string'); + expect(typeof emoji.name).toBe('string'); + expect(typeof emoji.skins?.[0]?.native).toBe('string'); + } + }); +}); diff --git a/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts b/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts new file mode 100644 index 000000000..391e686dc --- /dev/null +++ b/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts @@ -0,0 +1,89 @@ +import type { EmojiData } from '../types'; +import { filterEmojiData, isCountryFlag } from '../filterEmojiData'; + +const DATA: EmojiData = { + aliases: {}, + categories: [ + { emojis: ['grinning', 'smile'], id: 'people' }, + { emojis: ['us', 'fr', 'checkered_flag'], id: 'flags' }, + ], + emojis: { + checkered_flag: { + id: 'checkered_flag', + keywords: [], + name: 'Checkered Flag', + skins: [{ native: '🏁', unified: '1f3c1' }], + version: 1, + }, + fr: { + id: 'fr', + keywords: [], + name: 'France', + skins: [{ native: '🇫🇷', unified: '1f1eb-1f1f7' }], + version: 1, + }, + grinning: { + id: 'grinning', + keywords: [], + name: 'Grinning', + skins: [{ native: '😀', unified: '1f600' }], + version: 1, + }, + smile: { + id: 'smile', + keywords: [], + name: 'Smile', + skins: [{ native: '😄', unified: '1f604' }], + version: 13, + }, + us: { + id: 'us', + keywords: [], + name: 'United States', + skins: [{ native: '🇺🇸', unified: '1f1fa-1f1f8' }], + version: 1, + }, + }, +}; + +describe('isCountryFlag', () => { + it('is true only for regional-indicator pairs', () => { + expect(isCountryFlag(DATA.emojis.us)).toBe(true); + expect(isCountryFlag(DATA.emojis.fr)).toBe(true); + expect(isCountryFlag(DATA.emojis.checkered_flag)).toBe(false); + expect(isCountryFlag(DATA.emojis.grinning)).toBe(false); + }); +}); + +describe('filterEmojiData', () => { + it('returns the same reference when no filters apply', () => { + expect(filterEmojiData(DATA, {})).toBe(DATA); + }); + + it('drops exceptEmojis and prunes them from categories', () => { + const out = filterEmojiData(DATA, { exceptEmojis: ['smile'] }); + expect(out.emojis.smile).toBeUndefined(); + expect(out.categories.find((c) => c.id === 'people')?.emojis).toEqual(['grinning']); + }); + + it('drops emoji newer than emojiVersion', () => { + const out = filterEmojiData(DATA, { emojiVersion: 1 }); + expect(out.emojis.smile).toBeUndefined(); // version 13 > 1 + expect(out.emojis.grinning).toBeDefined(); + }); + + it('drops country flags but keeps non-country flags when noCountryFlags', () => { + const out = filterEmojiData(DATA, { noCountryFlags: true }); + expect(out.emojis.us).toBeUndefined(); + expect(out.emojis.fr).toBeUndefined(); + expect(out.emojis.checkered_flag).toBeDefined(); + }); + + it('removes a category entirely when all its emoji are filtered out', () => { + const out = filterEmojiData(DATA, { + exceptEmojis: ['checkered_flag'], + noCountryFlags: true, + }); + expect(out.categories.find((c) => c.id === 'flags')).toBeUndefined(); + }); +}); diff --git a/src/plugins/Emojis/data/emoji-data.json b/src/plugins/Emojis/data/emoji-data.json new file mode 100644 index 000000000..ce24d7519 --- /dev/null +++ b/src/plugins/Emojis/data/emoji-data.json @@ -0,0 +1 @@ +{"categories":[{"id":"people","emojis":["grinning","smiley","smile","grin","laughing","sweat_smile","rolling_on_the_floor_laughing","joy","slightly_smiling_face","upside_down_face","melting_face","wink","blush","innocent","smiling_face_with_3_hearts","heart_eyes","star-struck","kissing_heart","kissing","relaxed","kissing_closed_eyes","kissing_smiling_eyes","smiling_face_with_tear","yum","stuck_out_tongue","stuck_out_tongue_winking_eye","zany_face","stuck_out_tongue_closed_eyes","money_mouth_face","hugging_face","face_with_hand_over_mouth","face_with_open_eyes_and_hand_over_mouth","face_with_peeking_eye","shushing_face","thinking_face","saluting_face","zipper_mouth_face","face_with_raised_eyebrow","neutral_face","expressionless","no_mouth","dotted_line_face","face_in_clouds","smirk","unamused","face_with_rolling_eyes","grimacing","face_exhaling","lying_face","shaking_face","relieved","pensive","sleepy","drooling_face","sleeping","mask","face_with_thermometer","face_with_head_bandage","nauseated_face","face_vomiting","sneezing_face","hot_face","cold_face","woozy_face","dizzy_face","face_with_spiral_eyes","exploding_head","face_with_cowboy_hat","partying_face","disguised_face","sunglasses","nerd_face","face_with_monocle","confused","face_with_diagonal_mouth","worried","slightly_frowning_face","white_frowning_face","open_mouth","hushed","astonished","flushed","pleading_face","face_holding_back_tears","frowning","anguished","fearful","cold_sweat","disappointed_relieved","cry","sob","scream","confounded","persevere","disappointed","sweat","weary","tired_face","yawning_face","triumph","rage","angry","face_with_symbols_on_mouth","smiling_imp","imp","skull","skull_and_crossbones","hankey","clown_face","japanese_ogre","japanese_goblin","ghost","alien","space_invader","wave","raised_back_of_hand","raised_hand_with_fingers_splayed","hand","spock-hand","rightwards_hand","leftwards_hand","palm_down_hand","palm_up_hand","leftwards_pushing_hand","rightwards_pushing_hand","ok_hand","pinched_fingers","pinching_hand","v","crossed_fingers","hand_with_index_finger_and_thumb_crossed","i_love_you_hand_sign","the_horns","call_me_hand","point_left","point_right","point_up_2","middle_finger","point_down","point_up","index_pointing_at_the_viewer","+1","-1","fist","facepunch","left-facing_fist","right-facing_fist","clap","raised_hands","heart_hands","open_hands","palms_up_together","handshake","pray","writing_hand","nail_care","selfie","muscle","mechanical_arm","mechanical_leg","leg","foot","ear","ear_with_hearing_aid","nose","brain","anatomical_heart","lungs","tooth","bone","eyes","eye","tongue","lips","biting_lip","baby","child","boy","girl","adult","person_with_blond_hair","man","bearded_person","man_with_beard","woman_with_beard","red_haired_man","curly_haired_man","white_haired_man","bald_man","woman","red_haired_woman","red_haired_person","curly_haired_woman","curly_haired_person","white_haired_woman","white_haired_person","bald_woman","bald_person","blond-haired-woman","blond-haired-man","older_adult","older_man","older_woman","person_frowning","man-frowning","woman-frowning","person_with_pouting_face","man-pouting","woman-pouting","no_good","man-gesturing-no","woman-gesturing-no","ok_woman","man-gesturing-ok","woman-gesturing-ok","information_desk_person","man-tipping-hand","woman-tipping-hand","raising_hand","man-raising-hand","woman-raising-hand","deaf_person","deaf_man","deaf_woman","bow","man-bowing","woman-bowing","face_palm","man-facepalming","woman-facepalming","shrug","man-shrugging","woman-shrugging","health_worker","male-doctor","female-doctor","student","male-student","female-student","teacher","male-teacher","female-teacher","judge","male-judge","female-judge","farmer","male-farmer","female-farmer","cook","male-cook","female-cook","mechanic","male-mechanic","female-mechanic","factory_worker","male-factory-worker","female-factory-worker","office_worker","male-office-worker","female-office-worker","scientist","male-scientist","female-scientist","technologist","male-technologist","female-technologist","singer","male-singer","female-singer","artist","male-artist","female-artist","pilot","male-pilot","female-pilot","astronaut","male-astronaut","female-astronaut","firefighter","male-firefighter","female-firefighter","cop","male-police-officer","female-police-officer","sleuth_or_spy","male-detective","female-detective","guardsman","male-guard","female-guard","ninja","construction_worker","male-construction-worker","female-construction-worker","person_with_crown","prince","princess","man_with_turban","man-wearing-turban","woman-wearing-turban","man_with_gua_pi_mao","person_with_headscarf","person_in_tuxedo","man_in_tuxedo","woman_in_tuxedo","bride_with_veil","man_with_veil","woman_with_veil","pregnant_woman","pregnant_man","pregnant_person","breast-feeding","woman_feeding_baby","man_feeding_baby","person_feeding_baby","angel","santa","mrs_claus","mx_claus","superhero","male_superhero","female_superhero","supervillain","male_supervillain","female_supervillain","mage","male_mage","female_mage","fairy","male_fairy","female_fairy","vampire","male_vampire","female_vampire","merperson","merman","mermaid","elf","male_elf","female_elf","genie","male_genie","female_genie","zombie","male_zombie","female_zombie","troll","massage","man-getting-massage","woman-getting-massage","haircut","man-getting-haircut","woman-getting-haircut","walking","man-walking","woman-walking","standing_person","man_standing","woman_standing","kneeling_person","man_kneeling","woman_kneeling","person_with_probing_cane","man_with_probing_cane","woman_with_probing_cane","person_in_motorized_wheelchair","man_in_motorized_wheelchair","woman_in_motorized_wheelchair","person_in_manual_wheelchair","man_in_manual_wheelchair","woman_in_manual_wheelchair","runner","man-running","woman-running","dancer","man_dancing","man_in_business_suit_levitating","dancers","men-with-bunny-ears-partying","women-with-bunny-ears-partying","person_in_steamy_room","man_in_steamy_room","woman_in_steamy_room","person_climbing","man_climbing","woman_climbing","fencer","horse_racing","skier","snowboarder","golfer","man-golfing","woman-golfing","surfer","man-surfing","woman-surfing","rowboat","man-rowing-boat","woman-rowing-boat","swimmer","man-swimming","woman-swimming","person_with_ball","man-bouncing-ball","woman-bouncing-ball","weight_lifter","man-lifting-weights","woman-lifting-weights","bicyclist","man-biking","woman-biking","mountain_bicyclist","man-mountain-biking","woman-mountain-biking","person_doing_cartwheel","man-cartwheeling","woman-cartwheeling","wrestlers","man-wrestling","woman-wrestling","water_polo","man-playing-water-polo","woman-playing-water-polo","handball","man-playing-handball","woman-playing-handball","juggling","man-juggling","woman-juggling","person_in_lotus_position","man_in_lotus_position","woman_in_lotus_position","bath","sleeping_accommodation","people_holding_hands","two_women_holding_hands","man_and_woman_holding_hands","two_men_holding_hands","couplekiss","woman-kiss-man","man-kiss-man","woman-kiss-woman","couple_with_heart","woman-heart-man","man-heart-man","woman-heart-woman","family","man-woman-boy","man-woman-girl","man-woman-girl-boy","man-woman-boy-boy","man-woman-girl-girl","man-man-boy","man-man-girl","man-man-girl-boy","man-man-boy-boy","man-man-girl-girl","woman-woman-boy","woman-woman-girl","woman-woman-girl-boy","woman-woman-boy-boy","woman-woman-girl-girl","man-boy","man-boy-boy","man-girl","man-girl-boy","man-girl-girl","woman-boy","woman-boy-boy","woman-girl","woman-girl-boy","woman-girl-girl","speaking_head_in_silhouette","bust_in_silhouette","busts_in_silhouette","people_hugging","footprints","robot_face","smiley_cat","smile_cat","joy_cat","heart_eyes_cat","smirk_cat","kissing_cat","scream_cat","crying_cat_face","pouting_cat","see_no_evil","hear_no_evil","speak_no_evil","love_letter","cupid","gift_heart","sparkling_heart","heartpulse","heartbeat","revolving_hearts","two_hearts","heart_decoration","heavy_heart_exclamation_mark_ornament","broken_heart","heart_on_fire","mending_heart","heart","pink_heart","orange_heart","yellow_heart","green_heart","blue_heart","light_blue_heart","purple_heart","brown_heart","black_heart","grey_heart","white_heart","kiss","100","anger","boom","dizzy","sweat_drops","dash","hole","speech_balloon","eye-in-speech-bubble","left_speech_bubble","right_anger_bubble","thought_balloon","zzz"]},{"id":"nature","emojis":["monkey_face","monkey","gorilla","orangutan","dog","dog2","guide_dog","service_dog","poodle","wolf","fox_face","raccoon","cat","cat2","black_cat","lion_face","tiger","tiger2","leopard","horse","moose","donkey","racehorse","unicorn_face","zebra_face","deer","bison","cow","ox","water_buffalo","cow2","pig","pig2","boar","pig_nose","ram","sheep","goat","dromedary_camel","camel","llama","giraffe_face","elephant","mammoth","rhinoceros","hippopotamus","mouse","mouse2","rat","hamster","rabbit","rabbit2","chipmunk","beaver","hedgehog","bat","bear","polar_bear","koala","panda_face","sloth","otter","skunk","kangaroo","badger","feet","turkey","chicken","rooster","hatching_chick","baby_chick","hatched_chick","bird","penguin","dove_of_peace","eagle","duck","swan","owl","dodo","feather","flamingo","peacock","parrot","wing","black_bird","goose","frog","crocodile","turtle","lizard","snake","dragon_face","dragon","sauropod","t-rex","whale","whale2","dolphin","seal","fish","tropical_fish","blowfish","shark","octopus","shell","coral","jellyfish","snail","butterfly","bug","ant","bee","beetle","ladybug","cricket","cockroach","spider","spider_web","scorpion","mosquito","fly","worm","microbe","bouquet","cherry_blossom","white_flower","lotus","rosette","rose","wilted_flower","hibiscus","sunflower","blossom","tulip","hyacinth","seedling","potted_plant","evergreen_tree","deciduous_tree","palm_tree","cactus","ear_of_rice","herb","shamrock","four_leaf_clover","maple_leaf","fallen_leaf","leaves","empty_nest","nest_with_eggs","mushroom"]},{"id":"foods","emojis":["grapes","melon","watermelon","tangerine","lemon","banana","pineapple","mango","apple","green_apple","pear","peach","cherries","strawberry","blueberries","kiwifruit","tomato","olive","coconut","avocado","eggplant","potato","carrot","corn","hot_pepper","bell_pepper","cucumber","leafy_green","broccoli","garlic","onion","peanuts","beans","chestnut","ginger_root","pea_pod","bread","croissant","baguette_bread","flatbread","pretzel","bagel","pancakes","waffle","cheese_wedge","meat_on_bone","poultry_leg","cut_of_meat","bacon","hamburger","fries","pizza","hotdog","sandwich","taco","burrito","tamale","stuffed_flatbread","falafel","egg","fried_egg","shallow_pan_of_food","stew","fondue","bowl_with_spoon","green_salad","popcorn","butter","salt","canned_food","bento","rice_cracker","rice_ball","rice","curry","ramen","spaghetti","sweet_potato","oden","sushi","fried_shrimp","fish_cake","moon_cake","dango","dumpling","fortune_cookie","takeout_box","crab","lobster","shrimp","squid","oyster","icecream","shaved_ice","ice_cream","doughnut","cookie","birthday","cake","cupcake","pie","chocolate_bar","candy","lollipop","custard","honey_pot","baby_bottle","glass_of_milk","coffee","teapot","tea","sake","champagne","wine_glass","cocktail","tropical_drink","beer","beers","clinking_glasses","tumbler_glass","pouring_liquid","cup_with_straw","bubble_tea","beverage_box","mate_drink","ice_cube","chopsticks","knife_fork_plate","fork_and_knife","spoon","hocho","jar","amphora"]},{"id":"activity","emojis":["jack_o_lantern","christmas_tree","fireworks","sparkler","firecracker","sparkles","balloon","tada","confetti_ball","tanabata_tree","bamboo","dolls","flags","wind_chime","rice_scene","red_envelope","ribbon","gift","reminder_ribbon","admission_tickets","ticket","medal","trophy","sports_medal","first_place_medal","second_place_medal","third_place_medal","soccer","baseball","softball","basketball","volleyball","football","rugby_football","tennis","flying_disc","bowling","cricket_bat_and_ball","field_hockey_stick_and_ball","ice_hockey_stick_and_puck","lacrosse","table_tennis_paddle_and_ball","badminton_racquet_and_shuttlecock","boxing_glove","martial_arts_uniform","goal_net","golf","ice_skate","fishing_pole_and_fish","diving_mask","running_shirt_with_sash","ski","sled","curling_stone","dart","yo-yo","kite","gun","8ball","crystal_ball","magic_wand","video_game","joystick","slot_machine","game_die","jigsaw","teddy_bear","pinata","mirror_ball","nesting_dolls","spades","hearts","diamonds","clubs","chess_pawn","black_joker","mahjong","flower_playing_cards","performing_arts","frame_with_picture","art","thread","sewing_needle","yarn","knot"]},{"id":"places","emojis":["earth_africa","earth_americas","earth_asia","globe_with_meridians","world_map","japan","compass","snow_capped_mountain","mountain","volcano","mount_fuji","camping","beach_with_umbrella","desert","desert_island","national_park","stadium","classical_building","building_construction","bricks","rock","wood","hut","house_buildings","derelict_house_building","house","house_with_garden","office","post_office","european_post_office","hospital","bank","hotel","love_hotel","convenience_store","school","department_store","factory","japanese_castle","european_castle","wedding","tokyo_tower","statue_of_liberty","church","mosque","hindu_temple","synagogue","shinto_shrine","kaaba","fountain","tent","foggy","night_with_stars","cityscape","sunrise_over_mountains","sunrise","city_sunset","city_sunrise","bridge_at_night","hotsprings","carousel_horse","playground_slide","ferris_wheel","roller_coaster","barber","circus_tent","steam_locomotive","railway_car","bullettrain_side","bullettrain_front","train2","metro","light_rail","station","tram","monorail","mountain_railway","train","bus","oncoming_bus","trolleybus","minibus","ambulance","fire_engine","police_car","oncoming_police_car","taxi","oncoming_taxi","car","oncoming_automobile","blue_car","pickup_truck","truck","articulated_lorry","tractor","racing_car","racing_motorcycle","motor_scooter","manual_wheelchair","motorized_wheelchair","auto_rickshaw","bike","scooter","skateboard","roller_skate","busstop","motorway","railway_track","oil_drum","fuelpump","wheel","rotating_light","traffic_light","vertical_traffic_light","octagonal_sign","construction","anchor","ring_buoy","boat","canoe","speedboat","passenger_ship","ferry","motor_boat","ship","airplane","small_airplane","airplane_departure","airplane_arriving","parachute","seat","helicopter","suspension_railway","mountain_cableway","aerial_tramway","satellite","rocket","flying_saucer","bellhop_bell","luggage","hourglass","hourglass_flowing_sand","watch","alarm_clock","stopwatch","timer_clock","mantelpiece_clock","clock12","clock1230","clock1","clock130","clock2","clock230","clock3","clock330","clock4","clock430","clock5","clock530","clock6","clock630","clock7","clock730","clock8","clock830","clock9","clock930","clock10","clock1030","clock11","clock1130","new_moon","waxing_crescent_moon","first_quarter_moon","moon","full_moon","waning_gibbous_moon","last_quarter_moon","waning_crescent_moon","crescent_moon","new_moon_with_face","first_quarter_moon_with_face","last_quarter_moon_with_face","thermometer","sunny","full_moon_with_face","sun_with_face","ringed_planet","star","star2","stars","milky_way","cloud","partly_sunny","thunder_cloud_and_rain","mostly_sunny","barely_sunny","partly_sunny_rain","rain_cloud","snow_cloud","lightning","tornado","fog","wind_blowing_face","cyclone","rainbow","closed_umbrella","umbrella","umbrella_with_rain_drops","umbrella_on_ground","zap","snowflake","snowman","snowman_without_snow","comet","fire","droplet","ocean"]},{"id":"objects","emojis":["eyeglasses","dark_sunglasses","goggles","lab_coat","safety_vest","necktie","shirt","jeans","scarf","gloves","coat","socks","dress","kimono","sari","one-piece_swimsuit","briefs","shorts","bikini","womans_clothes","folding_hand_fan","purse","handbag","pouch","shopping_bags","school_satchel","thong_sandal","mans_shoe","athletic_shoe","hiking_boot","womans_flat_shoe","high_heel","sandal","ballet_shoes","boot","hair_pick","crown","womans_hat","tophat","mortar_board","billed_cap","military_helmet","helmet_with_white_cross","prayer_beads","lipstick","ring","gem","mute","speaker","sound","loud_sound","loudspeaker","mega","postal_horn","bell","no_bell","musical_score","musical_note","notes","studio_microphone","level_slider","control_knobs","microphone","headphones","radio","saxophone","accordion","guitar","musical_keyboard","trumpet","violin","banjo","drum_with_drumsticks","long_drum","maracas","flute","iphone","calling","phone","telephone_receiver","pager","fax","battery","low_battery","electric_plug","computer","desktop_computer","printer","keyboard","three_button_mouse","trackball","minidisc","floppy_disk","cd","dvd","abacus","movie_camera","film_frames","film_projector","clapper","tv","camera","camera_with_flash","video_camera","vhs","mag","mag_right","candle","bulb","flashlight","izakaya_lantern","diya_lamp","notebook_with_decorative_cover","closed_book","book","green_book","blue_book","orange_book","books","notebook","ledger","page_with_curl","scroll","page_facing_up","newspaper","rolled_up_newspaper","bookmark_tabs","bookmark","label","moneybag","coin","yen","dollar","euro","pound","money_with_wings","credit_card","receipt","chart","email","e-mail","incoming_envelope","envelope_with_arrow","outbox_tray","inbox_tray","package","mailbox","mailbox_closed","mailbox_with_mail","mailbox_with_no_mail","postbox","ballot_box_with_ballot","pencil2","black_nib","lower_left_fountain_pen","lower_left_ballpoint_pen","lower_left_paintbrush","lower_left_crayon","memo","briefcase","file_folder","open_file_folder","card_index_dividers","date","calendar","spiral_note_pad","spiral_calendar_pad","card_index","chart_with_upwards_trend","chart_with_downwards_trend","bar_chart","clipboard","pushpin","round_pushpin","paperclip","linked_paperclips","straight_ruler","triangular_ruler","scissors","card_file_box","file_cabinet","wastebasket","lock","unlock","lock_with_ink_pen","closed_lock_with_key","key","old_key","hammer","axe","pick","hammer_and_pick","hammer_and_wrench","dagger_knife","crossed_swords","bomb","boomerang","bow_and_arrow","shield","carpentry_saw","wrench","screwdriver","nut_and_bolt","gear","compression","scales","probing_cane","link","chains","hook","toolbox","magnet","ladder","alembic","test_tube","petri_dish","dna","microscope","telescope","satellite_antenna","syringe","drop_of_blood","pill","adhesive_bandage","crutch","stethoscope","x-ray","door","elevator","mirror","window","bed","couch_and_lamp","chair","toilet","plunger","shower","bathtub","mouse_trap","razor","lotion_bottle","safety_pin","broom","basket","roll_of_paper","bucket","soap","bubbles","toothbrush","sponge","fire_extinguisher","shopping_trolley","smoking","coffin","headstone","funeral_urn","nazar_amulet","hamsa","moyai","placard","identification_card"]},{"id":"symbols","emojis":["atm","put_litter_in_its_place","potable_water","wheelchair","mens","womens","restroom","baby_symbol","wc","passport_control","customs","baggage_claim","left_luggage","warning","children_crossing","no_entry","no_entry_sign","no_bicycles","no_smoking","do_not_litter","non-potable_water","no_pedestrians","no_mobile_phones","underage","radioactive_sign","biohazard_sign","arrow_up","arrow_upper_right","arrow_right","arrow_lower_right","arrow_down","arrow_lower_left","arrow_left","arrow_upper_left","arrow_up_down","left_right_arrow","leftwards_arrow_with_hook","arrow_right_hook","arrow_heading_up","arrow_heading_down","arrows_clockwise","arrows_counterclockwise","back","end","on","soon","top","place_of_worship","atom_symbol","om_symbol","star_of_david","wheel_of_dharma","yin_yang","latin_cross","orthodox_cross","star_and_crescent","peace_symbol","menorah_with_nine_branches","six_pointed_star","khanda","aries","taurus","gemini","cancer","leo","virgo","libra","scorpius","sagittarius","capricorn","aquarius","pisces","ophiuchus","twisted_rightwards_arrows","repeat","repeat_one","arrow_forward","fast_forward","black_right_pointing_double_triangle_with_vertical_bar","black_right_pointing_triangle_with_double_vertical_bar","arrow_backward","rewind","black_left_pointing_double_triangle_with_vertical_bar","arrow_up_small","arrow_double_up","arrow_down_small","arrow_double_down","double_vertical_bar","black_square_for_stop","black_circle_for_record","eject","cinema","low_brightness","high_brightness","signal_strength","wireless","vibration_mode","mobile_phone_off","female_sign","male_sign","transgender_symbol","heavy_multiplication_x","heavy_plus_sign","heavy_minus_sign","heavy_division_sign","heavy_equals_sign","infinity","bangbang","interrobang","question","grey_question","grey_exclamation","exclamation","wavy_dash","currency_exchange","heavy_dollar_sign","medical_symbol","recycle","fleur_de_lis","trident","name_badge","beginner","o","white_check_mark","ballot_box_with_check","heavy_check_mark","x","negative_squared_cross_mark","curly_loop","loop","part_alternation_mark","eight_spoked_asterisk","eight_pointed_black_star","sparkle","copyright","registered","tm","hash","keycap_star","zero","one","two","three","four","five","six","seven","eight","nine","keycap_ten","capital_abcd","abcd","1234","symbols","abc","a","ab","b","cl","cool","free","information_source","id","m","new","ng","o2","ok","parking","sos","up","vs","koko","sa","u6708","u6709","u6307","ideograph_advantage","u5272","u7121","u7981","accept","u7533","u5408","u7a7a","congratulations","secret","u55b6","u6e80","red_circle","large_orange_circle","large_yellow_circle","large_green_circle","large_blue_circle","large_purple_circle","large_brown_circle","black_circle","white_circle","large_red_square","large_orange_square","large_yellow_square","large_green_square","large_blue_square","large_purple_square","large_brown_square","black_large_square","white_large_square","black_medium_square","white_medium_square","black_medium_small_square","white_medium_small_square","black_small_square","white_small_square","large_orange_diamond","large_blue_diamond","small_orange_diamond","small_blue_diamond","small_red_triangle","small_red_triangle_down","diamond_shape_with_a_dot_inside","radio_button","white_square_button","black_square_button"]},{"id":"flags","emojis":["checkered_flag","cn","crossed_flags","de","es","flag-ac","flag-ad","flag-ae","flag-af","flag-ag","flag-ai","flag-al","flag-am","flag-ao","flag-aq","flag-ar","flag-as","flag-at","flag-au","flag-aw","flag-ax","flag-az","flag-ba","flag-bb","flag-bd","flag-be","flag-bf","flag-bg","flag-bh","flag-bi","flag-bj","flag-bl","flag-bm","flag-bn","flag-bo","flag-bq","flag-br","flag-bs","flag-bt","flag-bv","flag-bw","flag-by","flag-bz","flag-ca","flag-cc","flag-cd","flag-cf","flag-cg","flag-ch","flag-ci","flag-ck","flag-cl","flag-cm","flag-co","flag-cp","flag-cr","flag-cu","flag-cv","flag-cw","flag-cx","flag-cy","flag-cz","flag-dg","flag-dj","flag-dk","flag-dm","flag-do","flag-dz","flag-ea","flag-ec","flag-ee","flag-eg","flag-eh","flag-england","flag-er","flag-et","flag-eu","flag-fi","flag-fj","flag-fk","flag-fm","flag-fo","flag-ga","flag-gd","flag-ge","flag-gf","flag-gg","flag-gh","flag-gi","flag-gl","flag-gm","flag-gn","flag-gp","flag-gq","flag-gr","flag-gs","flag-gt","flag-gu","flag-gw","flag-gy","flag-hk","flag-hm","flag-hn","flag-hr","flag-ht","flag-hu","flag-ic","flag-id","flag-ie","flag-il","flag-im","flag-in","flag-io","flag-iq","flag-ir","flag-is","flag-je","flag-jm","flag-jo","flag-ke","flag-kg","flag-kh","flag-ki","flag-km","flag-kn","flag-kp","flag-kw","flag-ky","flag-kz","flag-la","flag-lb","flag-lc","flag-li","flag-lk","flag-lr","flag-ls","flag-lt","flag-lu","flag-lv","flag-ly","flag-ma","flag-mc","flag-md","flag-me","flag-mf","flag-mg","flag-mh","flag-mk","flag-ml","flag-mm","flag-mn","flag-mo","flag-mp","flag-mq","flag-mr","flag-ms","flag-mt","flag-mu","flag-mv","flag-mw","flag-mx","flag-my","flag-mz","flag-na","flag-nc","flag-ne","flag-nf","flag-ng","flag-ni","flag-nl","flag-no","flag-np","flag-nr","flag-nu","flag-nz","flag-om","flag-pa","flag-pe","flag-pf","flag-pg","flag-ph","flag-pk","flag-pl","flag-pm","flag-pn","flag-pr","flag-ps","flag-pt","flag-pw","flag-py","flag-qa","flag-re","flag-ro","flag-rs","flag-rw","flag-sa","flag-sb","flag-sc","flag-scotland","flag-sd","flag-se","flag-sg","flag-sh","flag-si","flag-sj","flag-sk","flag-sl","flag-sm","flag-sn","flag-so","flag-sr","flag-ss","flag-st","flag-sv","flag-sx","flag-sy","flag-sz","flag-ta","flag-tc","flag-td","flag-tf","flag-tg","flag-th","flag-tj","flag-tk","flag-tl","flag-tm","flag-tn","flag-to","flag-tr","flag-tt","flag-tv","flag-tw","flag-tz","flag-ua","flag-ug","flag-um","flag-un","flag-uy","flag-uz","flag-va","flag-vc","flag-ve","flag-vg","flag-vi","flag-vn","flag-vu","flag-wales","flag-wf","flag-ws","flag-xk","flag-ye","flag-yt","flag-za","flag-zm","flag-zw","fr","gb","it","jp","kr","pirate_flag","rainbow-flag","ru","transgender_flag","triangular_flag_on_post","us","waving_black_flag","waving_white_flag"]}],"emojis":{"100":{"id":"100","name":"Hundred Points","keywords":["100","score","perfect","numbers","century","exam","quiz","test","pass"],"skins":[{"unified":"1f4af","native":"💯"}],"version":1},"1234":{"id":"1234","name":"Input Numbers","keywords":["1234","blue","square","1","2","3","4"],"skins":[{"unified":"1f522","native":"🔢"}],"version":1},"grinning":{"id":"grinning","name":"Grinning Face","emoticons":[":D"],"keywords":["smile","happy","joy",":D","grin"],"skins":[{"unified":"1f600","native":"😀"}],"version":1},"smiley":{"id":"smiley","name":"Grinning Face with Big Eyes","emoticons":[":)","=)","=-)"],"keywords":["smiley","happy","joy","haha",":D",":)","smile","funny"],"skins":[{"unified":"1f603","native":"😃"}],"version":1},"smile":{"id":"smile","name":"Grinning Face with Smiling Eyes","emoticons":[":)","C:","c:",":D",":-D"],"keywords":["smile","happy","joy","funny","haha","laugh","like",":D",":)"],"skins":[{"unified":"1f604","native":"😄"}],"version":1},"grin":{"id":"grin","name":"Beaming Face with Smiling Eyes","keywords":["grin","happy","smile","joy","kawaii"],"skins":[{"unified":"1f601","native":"😁"}],"version":1},"laughing":{"id":"laughing","name":"Grinning Squinting Face","emoticons":[":>",":->"],"keywords":["laughing","satisfied","happy","joy","lol","haha","glad","XD","laugh"],"skins":[{"unified":"1f606","native":"😆"}],"version":1},"sweat_smile":{"id":"sweat_smile","name":"Grinning Face with Sweat","keywords":["smile","hot","happy","laugh","relief"],"skins":[{"unified":"1f605","native":"😅"}],"version":1},"rolling_on_the_floor_laughing":{"id":"rolling_on_the_floor_laughing","name":"Rolling on the Floor Laughing","keywords":["face","lol","haha","rofl"],"skins":[{"unified":"1f923","native":"🤣"}],"version":3},"joy":{"id":"joy","name":"Face with Tears of Joy","keywords":["cry","weep","happy","happytears","haha"],"skins":[{"unified":"1f602","native":"😂"}],"version":1},"slightly_smiling_face":{"id":"slightly_smiling_face","name":"Slightly Smiling Face","emoticons":[":)","(:",":-)"],"keywords":["smile"],"skins":[{"unified":"1f642","native":"🙂"}],"version":1},"upside_down_face":{"id":"upside_down_face","name":"Upside-Down Face","keywords":["upside","down","flipped","silly","smile"],"skins":[{"unified":"1f643","native":"🙃"}],"version":1},"melting_face":{"id":"melting_face","name":"Melting Face","keywords":["hot","heat"],"skins":[{"unified":"1fae0","native":"🫠"}],"version":14},"wink":{"id":"wink","name":"Winking Face","emoticons":[";)",";-)"],"keywords":["wink","happy","mischievous","secret",";)","smile","eye"],"skins":[{"unified":"1f609","native":"😉"}],"version":1},"blush":{"id":"blush","name":"Smiling Face with Smiling Eyes","emoticons":[":)"],"keywords":["blush","smile","happy","flushed","crush","embarrassed","shy","joy"],"skins":[{"unified":"1f60a","native":"😊"}],"version":1},"innocent":{"id":"innocent","name":"Smiling Face with Halo","keywords":["innocent","angel","heaven"],"skins":[{"unified":"1f607","native":"😇"}],"version":1},"smiling_face_with_3_hearts":{"id":"smiling_face_with_3_hearts","name":"Smiling Face with Hearts","keywords":["3","love","like","affection","valentines","infatuation","crush","adore"],"skins":[{"unified":"1f970","native":"🥰"}],"version":11},"heart_eyes":{"id":"heart_eyes","name":"Smiling Face with Heart-Eyes","keywords":["heart","eyes","love","like","affection","valentines","infatuation","crush"],"skins":[{"unified":"1f60d","native":"😍"}],"version":1},"star-struck":{"id":"star-struck","name":"Star-Struck","keywords":["star","struck","grinning","face","with","eyes","smile","starry"],"skins":[{"unified":"1f929","native":"🤩"}],"version":5},"kissing_heart":{"id":"kissing_heart","name":"Face Blowing a Kiss","emoticons":[":*",":-*"],"keywords":["kissing","heart","love","like","affection","valentines","infatuation"],"skins":[{"unified":"1f618","native":"😘"}],"version":1},"kissing":{"id":"kissing","name":"Kissing Face","keywords":["love","like","3","valentines","infatuation","kiss"],"skins":[{"unified":"1f617","native":"😗"}],"version":1},"relaxed":{"id":"relaxed","name":"Smiling Face","keywords":["relaxed","blush","massage","happiness"],"skins":[{"unified":"263a-fe0f","native":"☺️"}],"version":1},"kissing_closed_eyes":{"id":"kissing_closed_eyes","name":"Kissing Face with Closed Eyes","keywords":["love","like","affection","valentines","infatuation","kiss"],"skins":[{"unified":"1f61a","native":"😚"}],"version":1},"kissing_smiling_eyes":{"id":"kissing_smiling_eyes","name":"Kissing Face with Smiling Eyes","keywords":["affection","valentines","infatuation","kiss"],"skins":[{"unified":"1f619","native":"😙"}],"version":1},"smiling_face_with_tear":{"id":"smiling_face_with_tear","name":"Smiling Face with Tear","keywords":["sad","cry","pretend"],"skins":[{"unified":"1f972","native":"🥲"}],"version":13},"yum":{"id":"yum","name":"Face Savoring Food","keywords":["yum","happy","joy","tongue","smile","silly","yummy","nom","delicious","savouring"],"skins":[{"unified":"1f60b","native":"😋"}],"version":1},"stuck_out_tongue":{"id":"stuck_out_tongue","name":"Face with Tongue","emoticons":[":p",":-p",":P",":-P",":b",":-b"],"keywords":["stuck","out","prank","childish","playful","mischievous","smile"],"skins":[{"unified":"1f61b","native":"😛"}],"version":1},"stuck_out_tongue_winking_eye":{"id":"stuck_out_tongue_winking_eye","name":"Winking Face with Tongue","emoticons":[";p",";-p",";b",";-b",";P",";-P"],"keywords":["stuck","out","eye","prank","childish","playful","mischievous","smile","wink"],"skins":[{"unified":"1f61c","native":"😜"}],"version":1},"zany_face":{"id":"zany_face","name":"Zany Face","keywords":["grinning","with","one","large","and","small","eye","goofy","crazy"],"skins":[{"unified":"1f92a","native":"🤪"}],"version":5},"stuck_out_tongue_closed_eyes":{"id":"stuck_out_tongue_closed_eyes","name":"Squinting Face with Tongue","keywords":["stuck","out","closed","eyes","prank","playful","mischievous","smile"],"skins":[{"unified":"1f61d","native":"😝"}],"version":1},"money_mouth_face":{"id":"money_mouth_face","name":"Money-Mouth Face","keywords":["money","mouth","rich","dollar"],"skins":[{"unified":"1f911","native":"🤑"}],"version":1},"hugging_face":{"id":"hugging_face","name":"Hugging Face","keywords":["smile","hug"],"skins":[{"unified":"1f917","native":"🤗"}],"version":1},"face_with_hand_over_mouth":{"id":"face_with_hand_over_mouth","name":"Face with Hand over Mouth","keywords":["smiling","eyes","and","covering","whoops","shock","surprise"],"skins":[{"unified":"1f92d","native":"🤭"}],"version":5},"face_with_open_eyes_and_hand_over_mouth":{"id":"face_with_open_eyes_and_hand_over_mouth","name":"Face with Open Eyes and Hand over Mouth","keywords":["silence","secret","shock","surprise"],"skins":[{"unified":"1fae2","native":"🫢"}],"version":14},"face_with_peeking_eye":{"id":"face_with_peeking_eye","name":"Face with Peeking Eye","keywords":["scared","frightening","embarrassing","shy"],"skins":[{"unified":"1fae3","native":"🫣"}],"version":14},"shushing_face":{"id":"shushing_face","name":"Shushing Face","keywords":["with","finger","covering","closed","lips","quiet","shhh"],"skins":[{"unified":"1f92b","native":"🤫"}],"version":5},"thinking_face":{"id":"thinking_face","name":"Thinking Face","keywords":["hmmm","think","consider"],"skins":[{"unified":"1f914","native":"🤔"}],"version":1},"saluting_face":{"id":"saluting_face","name":"Saluting Face","keywords":["respect","salute"],"skins":[{"unified":"1fae1","native":"🫡"}],"version":14},"zipper_mouth_face":{"id":"zipper_mouth_face","name":"Zipper-Mouth Face","keywords":["zipper","mouth","sealed","secret"],"skins":[{"unified":"1f910","native":"🤐"}],"version":1},"face_with_raised_eyebrow":{"id":"face_with_raised_eyebrow","name":"Face with Raised Eyebrow","keywords":["one","distrust","scepticism","disapproval","disbelief","surprise"],"skins":[{"unified":"1f928","native":"🤨"}],"version":5},"neutral_face":{"id":"neutral_face","name":"Neutral Face","emoticons":[":|",":-|"],"keywords":["indifference","meh",":",""],"skins":[{"unified":"1f610","native":"😐"}],"version":1},"expressionless":{"id":"expressionless","name":"Expressionless Face","emoticons":["-_-"],"keywords":["indifferent","-","","meh","deadpan"],"skins":[{"unified":"1f611","native":"😑"}],"version":1},"no_mouth":{"id":"no_mouth","name":"Face Without Mouth","keywords":["no","hellokitty"],"skins":[{"unified":"1f636","native":"😶"}],"version":1},"dotted_line_face":{"id":"dotted_line_face","name":"Dotted Line Face","keywords":["invisible","lonely","isolation","depression"],"skins":[{"unified":"1fae5","native":"🫥"}],"version":14},"face_in_clouds":{"id":"face_in_clouds","name":"Face in Clouds","keywords":["shower","steam","dream"],"skins":[{"unified":"1f636-200d-1f32b-fe0f","native":"😶‍🌫️"}],"version":13.1},"smirk":{"id":"smirk","name":"Smirking Face","keywords":["smirk","smile","mean","prank","smug","sarcasm"],"skins":[{"unified":"1f60f","native":"😏"}],"version":1},"unamused":{"id":"unamused","name":"Unamused Face","emoticons":[":("],"keywords":["indifference","bored","straight","serious","sarcasm","unimpressed","skeptical","dubious","side","eye"],"skins":[{"unified":"1f612","native":"😒"}],"version":1},"face_with_rolling_eyes":{"id":"face_with_rolling_eyes","name":"Face with Rolling Eyes","keywords":["eyeroll","frustrated"],"skins":[{"unified":"1f644","native":"🙄"}],"version":1},"grimacing":{"id":"grimacing","name":"Grimacing Face","keywords":["grimace","teeth"],"skins":[{"unified":"1f62c","native":"😬"}],"version":1},"face_exhaling":{"id":"face_exhaling","name":"Face Exhaling","keywords":["relieve","relief","tired","sigh"],"skins":[{"unified":"1f62e-200d-1f4a8","native":"😮‍💨"}],"version":13.1},"lying_face":{"id":"lying_face","name":"Lying Face","keywords":["lie","pinocchio"],"skins":[{"unified":"1f925","native":"🤥"}],"version":3},"shaking_face":{"id":"shaking_face","name":"Shaking Face","keywords":["dizzy","shock","blurry","earthquake"],"skins":[{"unified":"1fae8","native":"🫨"}],"version":15},"relieved":{"id":"relieved","name":"Relieved Face","keywords":["relaxed","phew","massage","happiness"],"skins":[{"unified":"1f60c","native":"😌"}],"version":1},"pensive":{"id":"pensive","name":"Pensive Face","keywords":["sad","depressed","upset"],"skins":[{"unified":"1f614","native":"😔"}],"version":1},"sleepy":{"id":"sleepy","name":"Sleepy Face","keywords":["tired","rest","nap"],"skins":[{"unified":"1f62a","native":"😪"}],"version":1},"drooling_face":{"id":"drooling_face","name":"Drooling Face","keywords":[],"skins":[{"unified":"1f924","native":"🤤"}],"version":3},"sleeping":{"id":"sleeping","name":"Sleeping Face","keywords":["tired","sleepy","night","zzz"],"skins":[{"unified":"1f634","native":"😴"}],"version":1},"mask":{"id":"mask","name":"Face with Medical Mask","keywords":["sick","ill","disease","covid"],"skins":[{"unified":"1f637","native":"😷"}],"version":1},"face_with_thermometer":{"id":"face_with_thermometer","name":"Face with Thermometer","keywords":["sick","temperature","cold","fever","covid"],"skins":[{"unified":"1f912","native":"🤒"}],"version":1},"face_with_head_bandage":{"id":"face_with_head_bandage","name":"Face with Head-Bandage","keywords":["head","bandage","injured","clumsy","hurt"],"skins":[{"unified":"1f915","native":"🤕"}],"version":1},"nauseated_face":{"id":"nauseated_face","name":"Nauseated Face","keywords":["vomit","gross","green","sick","throw","up","ill"],"skins":[{"unified":"1f922","native":"🤢"}],"version":3},"face_vomiting":{"id":"face_vomiting","name":"Face Vomiting","keywords":["with","open","mouth","sick"],"skins":[{"unified":"1f92e","native":"🤮"}],"version":5},"sneezing_face":{"id":"sneezing_face","name":"Sneezing Face","keywords":["gesundheit","sneeze","sick","allergy"],"skins":[{"unified":"1f927","native":"🤧"}],"version":3},"hot_face":{"id":"hot_face","name":"Hot Face","keywords":["feverish","heat","red","sweating"],"skins":[{"unified":"1f975","native":"🥵"}],"version":11},"cold_face":{"id":"cold_face","name":"Cold Face","keywords":["blue","freezing","frozen","frostbite","icicles"],"skins":[{"unified":"1f976","native":"🥶"}],"version":11},"woozy_face":{"id":"woozy_face","name":"Woozy Face","keywords":["dizzy","intoxicated","tipsy","wavy"],"skins":[{"unified":"1f974","native":"🥴"}],"version":11},"dizzy_face":{"id":"dizzy_face","name":"Dizzy Face","keywords":["spent","unconscious","xox"],"skins":[{"unified":"1f635","native":"😵"}],"version":1},"face_with_spiral_eyes":{"id":"face_with_spiral_eyes","name":"Face with Spiral Eyes","keywords":["sick","ill","confused","nauseous","nausea"],"skins":[{"unified":"1f635-200d-1f4ab","native":"😵‍💫"}],"version":13.1},"exploding_head":{"id":"exploding_head","name":"Exploding Head","keywords":["shocked","face","with","mind","blown"],"skins":[{"unified":"1f92f","native":"🤯"}],"version":5},"face_with_cowboy_hat":{"id":"face_with_cowboy_hat","name":"Cowboy Hat Face","keywords":["with","cowgirl"],"skins":[{"unified":"1f920","native":"🤠"}],"version":3},"partying_face":{"id":"partying_face","name":"Partying Face","keywords":["celebration","woohoo"],"skins":[{"unified":"1f973","native":"🥳"}],"version":11},"disguised_face":{"id":"disguised_face","name":"Disguised Face","keywords":["pretent","brows","glasses","moustache"],"skins":[{"unified":"1f978","native":"🥸"}],"version":13},"sunglasses":{"id":"sunglasses","name":"Smiling Face with Sunglasses","emoticons":["8)"],"keywords":["cool","smile","summer","beach","sunglass"],"skins":[{"unified":"1f60e","native":"😎"}],"version":1},"nerd_face":{"id":"nerd_face","name":"Nerd Face","keywords":["nerdy","geek","dork"],"skins":[{"unified":"1f913","native":"🤓"}],"version":1},"face_with_monocle":{"id":"face_with_monocle","name":"Face with Monocle","keywords":["stuffy","wealthy"],"skins":[{"unified":"1f9d0","native":"🧐"}],"version":5},"confused":{"id":"confused","name":"Confused Face","emoticons":[":\\",":-\\",":/",":-/"],"keywords":["indifference","huh","weird","hmmm",":/"],"skins":[{"unified":"1f615","native":"😕"}],"version":1},"face_with_diagonal_mouth":{"id":"face_with_diagonal_mouth","name":"Face with Diagonal Mouth","keywords":["skeptic","confuse","frustrated","indifferent"],"skins":[{"unified":"1fae4","native":"🫤"}],"version":14},"worried":{"id":"worried","name":"Worried Face","keywords":["concern","nervous",":("],"skins":[{"unified":"1f61f","native":"😟"}],"version":1},"slightly_frowning_face":{"id":"slightly_frowning_face","name":"Slightly Frowning Face","keywords":["disappointed","sad","upset"],"skins":[{"unified":"1f641","native":"🙁"}],"version":1},"white_frowning_face":{"id":"white_frowning_face","name":"Frowning Face","keywords":["white","sad","upset","frown"],"skins":[{"unified":"2639-fe0f","native":"☹️"}],"version":1},"open_mouth":{"id":"open_mouth","name":"Face with Open Mouth","emoticons":[":o",":-o",":O",":-O"],"keywords":["surprise","impressed","wow","whoa",":O"],"skins":[{"unified":"1f62e","native":"😮"}],"version":1},"hushed":{"id":"hushed","name":"Hushed Face","keywords":["woo","shh"],"skins":[{"unified":"1f62f","native":"😯"}],"version":1},"astonished":{"id":"astonished","name":"Astonished Face","keywords":["xox","surprised","poisoned"],"skins":[{"unified":"1f632","native":"😲"}],"version":1},"flushed":{"id":"flushed","name":"Flushed Face","keywords":["blush","shy","flattered"],"skins":[{"unified":"1f633","native":"😳"}],"version":1},"pleading_face":{"id":"pleading_face","name":"Pleading Face","keywords":["begging","mercy","cry","tears","sad","grievance"],"skins":[{"unified":"1f97a","native":"🥺"}],"version":11},"face_holding_back_tears":{"id":"face_holding_back_tears","name":"Face Holding Back Tears","keywords":["touched","gratitude","cry"],"skins":[{"unified":"1f979","native":"🥹"}],"version":14},"frowning":{"id":"frowning","name":"Frowning Face with Open Mouth","keywords":["aw","what"],"skins":[{"unified":"1f626","native":"😦"}],"version":1},"anguished":{"id":"anguished","name":"Anguished Face","emoticons":["D:"],"keywords":["stunned","nervous"],"skins":[{"unified":"1f627","native":"😧"}],"version":1},"fearful":{"id":"fearful","name":"Fearful Face","keywords":["scared","terrified","nervous"],"skins":[{"unified":"1f628","native":"😨"}],"version":1},"cold_sweat":{"id":"cold_sweat","name":"Anxious Face with Sweat","keywords":["cold","nervous"],"skins":[{"unified":"1f630","native":"😰"}],"version":1},"disappointed_relieved":{"id":"disappointed_relieved","name":"Sad but Relieved Face","keywords":["disappointed","phew","sweat","nervous"],"skins":[{"unified":"1f625","native":"😥"}],"version":1},"cry":{"id":"cry","name":"Crying Face","emoticons":[":'("],"keywords":["cry","tears","sad","depressed","upset",":'("],"skins":[{"unified":"1f622","native":"😢"}],"version":1},"sob":{"id":"sob","name":"Loudly Crying Face","emoticons":[":'("],"keywords":["sob","cry","tears","sad","upset","depressed"],"skins":[{"unified":"1f62d","native":"😭"}],"version":1},"scream":{"id":"scream","name":"Face Screaming in Fear","keywords":["scream","munch","scared","omg"],"skins":[{"unified":"1f631","native":"😱"}],"version":1},"confounded":{"id":"confounded","name":"Confounded Face","keywords":["confused","sick","unwell","oops",":S"],"skins":[{"unified":"1f616","native":"😖"}],"version":1},"persevere":{"id":"persevere","name":"Persevering Face","keywords":["persevere","sick","no","upset","oops"],"skins":[{"unified":"1f623","native":"😣"}],"version":1},"disappointed":{"id":"disappointed","name":"Disappointed Face","emoticons":["):",":(",":-("],"keywords":["sad","upset","depressed",":("],"skins":[{"unified":"1f61e","native":"😞"}],"version":1},"sweat":{"id":"sweat","name":"Face with Cold Sweat","keywords":["downcast","hot","sad","tired","exercise"],"skins":[{"unified":"1f613","native":"😓"}],"version":1},"weary":{"id":"weary","name":"Weary Face","keywords":["tired","sleepy","sad","frustrated","upset"],"skins":[{"unified":"1f629","native":"😩"}],"version":1},"tired_face":{"id":"tired_face","name":"Tired Face","keywords":["sick","whine","upset","frustrated"],"skins":[{"unified":"1f62b","native":"😫"}],"version":1},"yawning_face":{"id":"yawning_face","name":"Yawning Face","keywords":["tired","sleepy"],"skins":[{"unified":"1f971","native":"🥱"}],"version":12},"triumph":{"id":"triumph","name":"Face with Look of Triumph","keywords":["steam","from","nose","gas","phew","proud","pride"],"skins":[{"unified":"1f624","native":"😤"}],"version":1},"rage":{"id":"rage","name":"Pouting Face","keywords":["rage","angry","mad","hate","despise"],"skins":[{"unified":"1f621","native":"😡"}],"version":1},"angry":{"id":"angry","name":"Angry Face","emoticons":[">:(",">:-("],"keywords":["mad","annoyed","frustrated"],"skins":[{"unified":"1f620","native":"😠"}],"version":1},"face_with_symbols_on_mouth":{"id":"face_with_symbols_on_mouth","name":"Face with Symbols on Mouth","keywords":["serious","covering","swearing","cursing","cussing","profanity","expletive"],"skins":[{"unified":"1f92c","native":"🤬"}],"version":5},"smiling_imp":{"id":"smiling_imp","name":"Smiling Face with Horns","keywords":["imp","devil"],"skins":[{"unified":"1f608","native":"😈"}],"version":1},"imp":{"id":"imp","name":"Imp","keywords":["angry","face","with","horns","devil"],"skins":[{"unified":"1f47f","native":"👿"}],"version":1},"skull":{"id":"skull","name":"Skull","keywords":["dead","skeleton","creepy","death"],"skins":[{"unified":"1f480","native":"💀"}],"version":1},"skull_and_crossbones":{"id":"skull_and_crossbones","name":"Skull and Crossbones","keywords":["poison","danger","deadly","scary","death","pirate","evil"],"skins":[{"unified":"2620-fe0f","native":"☠️"}],"version":1},"hankey":{"id":"hankey","name":"Pile of Poo","keywords":["hankey","poop","shit","shitface","fail","turd"],"skins":[{"unified":"1f4a9","native":"💩"}],"version":1},"clown_face":{"id":"clown_face","name":"Clown Face","keywords":[],"skins":[{"unified":"1f921","native":"🤡"}],"version":3},"japanese_ogre":{"id":"japanese_ogre","name":"Ogre","keywords":["japanese","monster","red","mask","halloween","scary","creepy","devil","demon"],"skins":[{"unified":"1f479","native":"👹"}],"version":1},"japanese_goblin":{"id":"japanese_goblin","name":"Goblin","keywords":["japanese","red","evil","mask","monster","scary","creepy"],"skins":[{"unified":"1f47a","native":"👺"}],"version":1},"ghost":{"id":"ghost","name":"Ghost","keywords":["halloween","spooky","scary"],"skins":[{"unified":"1f47b","native":"👻"}],"version":1},"alien":{"id":"alien","name":"Alien","keywords":["UFO","paul","weird","outer","space"],"skins":[{"unified":"1f47d","native":"👽"}],"version":1},"space_invader":{"id":"space_invader","name":"Alien Monster","keywords":["space","invader","game","arcade","play"],"skins":[{"unified":"1f47e","native":"👾"}],"version":1},"robot_face":{"id":"robot_face","name":"Robot","keywords":["face","computer","machine","bot"],"skins":[{"unified":"1f916","native":"🤖"}],"version":1},"smiley_cat":{"id":"smiley_cat","name":"Grinning Cat","keywords":["smiley","animal","cats","happy","smile"],"skins":[{"unified":"1f63a","native":"😺"}],"version":1},"smile_cat":{"id":"smile_cat","name":"Grinning Cat with Smiling Eyes","keywords":["smile","animal","cats"],"skins":[{"unified":"1f638","native":"😸"}],"version":1},"joy_cat":{"id":"joy_cat","name":"Cat with Tears of Joy","keywords":["animal","cats","haha","happy"],"skins":[{"unified":"1f639","native":"😹"}],"version":1},"heart_eyes_cat":{"id":"heart_eyes_cat","name":"Smiling Cat with Heart-Eyes","keywords":["heart","eyes","animal","love","like","affection","cats","valentines"],"skins":[{"unified":"1f63b","native":"😻"}],"version":1},"smirk_cat":{"id":"smirk_cat","name":"Cat with Wry Smile","keywords":["smirk","animal","cats"],"skins":[{"unified":"1f63c","native":"😼"}],"version":1},"kissing_cat":{"id":"kissing_cat","name":"Kissing Cat","keywords":["animal","cats","kiss"],"skins":[{"unified":"1f63d","native":"😽"}],"version":1},"scream_cat":{"id":"scream_cat","name":"Weary Cat","keywords":["scream","animal","cats","munch","scared"],"skins":[{"unified":"1f640","native":"🙀"}],"version":1},"crying_cat_face":{"id":"crying_cat_face","name":"Crying Cat","keywords":["face","animal","tears","weep","sad","cats","upset","cry"],"skins":[{"unified":"1f63f","native":"😿"}],"version":1},"pouting_cat":{"id":"pouting_cat","name":"Pouting Cat","keywords":["animal","cats"],"skins":[{"unified":"1f63e","native":"😾"}],"version":1},"see_no_evil":{"id":"see_no_evil","name":"See-No-Evil Monkey","keywords":["see","no","evil","animal","nature","haha"],"skins":[{"unified":"1f648","native":"🙈"}],"version":1},"hear_no_evil":{"id":"hear_no_evil","name":"Hear-No-Evil Monkey","keywords":["hear","no","evil","animal","nature"],"skins":[{"unified":"1f649","native":"🙉"}],"version":1},"speak_no_evil":{"id":"speak_no_evil","name":"Speak-No-Evil Monkey","keywords":["speak","no","evil","animal","nature","omg"],"skins":[{"unified":"1f64a","native":"🙊"}],"version":1},"love_letter":{"id":"love_letter","name":"Love Letter","keywords":["email","like","affection","envelope","valentines"],"skins":[{"unified":"1f48c","native":"💌"}],"version":1},"cupid":{"id":"cupid","name":"Heart with Arrow","keywords":["cupid","love","like","affection","valentines"],"skins":[{"unified":"1f498","native":"💘"}],"version":1},"gift_heart":{"id":"gift_heart","name":"Heart with Ribbon","keywords":["gift","love","valentines"],"skins":[{"unified":"1f49d","native":"💝"}],"version":1},"sparkling_heart":{"id":"sparkling_heart","name":"Sparkling Heart","keywords":["love","like","affection","valentines"],"skins":[{"unified":"1f496","native":"💖"}],"version":1},"heartpulse":{"id":"heartpulse","name":"Growing Heart","keywords":["heartpulse","like","love","affection","valentines","pink"],"skins":[{"unified":"1f497","native":"💗"}],"version":1},"heartbeat":{"id":"heartbeat","name":"Beating Heart","keywords":["heartbeat","love","like","affection","valentines","pink"],"skins":[{"unified":"1f493","native":"💓"}],"version":1},"revolving_hearts":{"id":"revolving_hearts","name":"Revolving Hearts","keywords":["love","like","affection","valentines"],"skins":[{"unified":"1f49e","native":"💞"}],"version":1},"two_hearts":{"id":"two_hearts","name":"Two Hearts","keywords":["love","like","affection","valentines","heart"],"skins":[{"unified":"1f495","native":"💕"}],"version":1},"heart_decoration":{"id":"heart_decoration","name":"Heart Decoration","keywords":["purple","square","love","like"],"skins":[{"unified":"1f49f","native":"💟"}],"version":1},"heavy_heart_exclamation_mark_ornament":{"id":"heavy_heart_exclamation_mark_ornament","name":"Heart Exclamation","keywords":["heavy","mark","ornament","decoration","love"],"skins":[{"unified":"2763-fe0f","native":"❣️"}],"version":1},"broken_heart":{"id":"broken_heart","name":"Broken Heart","emoticons":[" { + const unified = emoji.skins[0]?.unified ?? ''; + const points = unified.split('-').map((part) => parseInt(part, 16)); + return ( + points.length > 0 && + points.every((cp) => cp >= REGIONAL_INDICATOR_START && cp <= REGIONAL_INDICATOR_END) + ); +}; + +type FilterEmojiDataOptions = { + emojiVersion?: number; + exceptEmojis?: string[]; + noCountryFlags?: boolean; +}; + +/** + * Returns a dataset with emoji removed per the options, pruning any category left empty. + * Returns the SAME reference when no filter applies, so callers memoize cheaply and the + * picker renders identically to the unfiltered dataset. + */ +export const filterEmojiData = ( + data: EmojiData, + { emojiVersion, exceptEmojis = [], noCountryFlags = false }: FilterEmojiDataOptions, +): EmojiData => { + if (!exceptEmojis.length && emojiVersion == null && !noCountryFlags) return data; + + const excluded = new Set(exceptEmojis); + const keep = (emoji: EmojiDataEmoji) => { + if (excluded.has(emoji.id)) return false; + if (emojiVersion != null && emoji.version > emojiVersion) return false; + if (noCountryFlags && isCountryFlag(emoji)) return false; + return true; + }; + + const emojis: EmojiData['emojis'] = {}; + for (const id of Object.keys(data.emojis)) { + if (keep(data.emojis[id])) emojis[id] = data.emojis[id]; + } + const categories = data.categories + .map((category) => ({ + ...category, + emojis: category.emojis.filter((id) => emojis[id]), + })) + .filter((category) => category.emojis.length > 0); + + return { ...data, categories, emojis }; +}; diff --git a/src/plugins/Emojis/data/index.ts b/src/plugins/Emojis/data/index.ts new file mode 100644 index 000000000..d31c9443f --- /dev/null +++ b/src/plugins/Emojis/data/index.ts @@ -0,0 +1,24 @@ +import { memoizeAsyncWithReset } from '../memoizeAsyncWithReset'; +import type { EmojiData } from './types'; + +export type { + EmojiData, + EmojiDataCategory, + EmojiDataEmoji, + EmojiDataSkin, +} from './types'; + +/** + * Lazily loads the vendored emoji dataset. The JSON is imported dynamically so + * bundlers emit it as a separate async chunk — it is fetched only when the emoji + * picker or search actually runs, and never enters the main `stream-chat-react` + * bundle. The result is memoized, so repeated calls share a single load; a failed + * load (offline, stale chunk after a deploy) is not cached, so a later call retries. + */ +export const loadEmojiData = memoizeAsyncWithReset(() => + import('./emoji-data.json').then( + (mod) => + ((mod as unknown as { default?: EmojiData }).default ?? + mod) as unknown as EmojiData, + ), +); diff --git a/src/plugins/Emojis/data/types.ts b/src/plugins/Emojis/data/types.ts new file mode 100644 index 000000000..f4f4d2d3b --- /dev/null +++ b/src/plugins/Emojis/data/types.ts @@ -0,0 +1,28 @@ +// Shape of the vendored emoji dataset (a snapshot of `@emoji-mart/data`'s native +// set). Kept structurally identical to the upstream data so that `mapEmojiMartData` +// and any consumer relying on the emoji-mart data shape keep working unchanged. + +export type EmojiDataSkin = { + native: string; + unified: string; +}; + +export type EmojiDataEmoji = { + id: string; + keywords: string[]; + name: string; + skins: EmojiDataSkin[]; + version: number; + emoticons?: string[]; +}; + +export type EmojiDataCategory = { + emojis: string[]; + id: string; +}; + +export type EmojiData = { + aliases: Record; + categories: EmojiDataCategory[]; + emojis: Record; +}; diff --git a/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts b/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts new file mode 100644 index 000000000..95672be90 --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts @@ -0,0 +1,126 @@ +import { navigateGrid } from '../gridNavigation'; + +// A "grid" is a vertical stack of sections (categories); each section is laid out as a +// `columns`-wide grid filled in reading order. navigateGrid is pure: given the section +// lengths, the active (section, index), a key, and the column count, it returns the +// target position (or null to stay put at a grid edge). + +describe('navigateGrid — single section (search view)', () => { + const single = [7]; // rows (cols=3): [0,1,2] [3,4,5] [6] + + it('moves right/left within a row', () => { + expect(navigateGrid(single, { index: 0, section: 0 }, 'ArrowRight', 3)).toEqual({ + index: 1, + section: 0, + }); + expect(navigateGrid(single, { index: 1, section: 0 }, 'ArrowLeft', 3)).toEqual({ + index: 0, + section: 0, + }); + }); + + it('clamps (null) at the last cell going right and the first going left', () => { + expect(navigateGrid(single, { index: 6, section: 0 }, 'ArrowRight', 3)).toBeNull(); + expect(navigateGrid(single, { index: 0, section: 0 }, 'ArrowLeft', 3)).toBeNull(); + }); + + it('moves down/up by one row, same column', () => { + expect(navigateGrid(single, { index: 1, section: 0 }, 'ArrowDown', 3)).toEqual({ + index: 4, + section: 0, + }); + expect(navigateGrid(single, { index: 4, section: 0 }, 'ArrowUp', 3)).toEqual({ + index: 1, + section: 0, + }); + }); + + it('down into a partial last row clamps to that row’s last cell', () => { + // index 5 (row 1, col 2) down: row 2 has only index 6 → clamp to 6. + expect(navigateGrid(single, { index: 5, section: 0 }, 'ArrowDown', 3)).toEqual({ + index: 6, + section: 0, + }); + }); + + it('clamps (null) going down from the last row and up from the first', () => { + expect(navigateGrid(single, { index: 6, section: 0 }, 'ArrowDown', 3)).toBeNull(); + expect(navigateGrid(single, { index: 2, section: 0 }, 'ArrowUp', 3)).toBeNull(); + }); + + it('Home/End jump to the first/last cell of the whole grid', () => { + expect(navigateGrid(single, { index: 5, section: 0 }, 'Home', 3)).toEqual({ + index: 0, + section: 0, + }); + expect(navigateGrid(single, { index: 0, section: 0 }, 'End', 3)).toEqual({ + index: 6, + section: 0, + }); + }); +}); + +describe('navigateGrid — multiple sections (category view)', () => { + const multi = [5, 6]; // section 0 rows (cols=3): [0,1,2] [3,4]; section 1: [0,1,2] [3,4,5] + + it('crosses to the next section going right off a section’s last cell', () => { + expect(navigateGrid(multi, { index: 4, section: 0 }, 'ArrowRight', 3)).toEqual({ + index: 0, + section: 1, + }); + }); + + it('crosses to the previous section going left off a section’s first cell', () => { + expect(navigateGrid(multi, { index: 0, section: 1 }, 'ArrowLeft', 3)).toEqual({ + index: 4, + section: 0, + }); + }); + + it('crosses down into the next section preserving the column', () => { + expect(navigateGrid(multi, { index: 3, section: 0 }, 'ArrowDown', 3)).toEqual({ + index: 0, + section: 1, + }); + expect(navigateGrid(multi, { index: 4, section: 0 }, 'ArrowDown', 3)).toEqual({ + index: 1, + section: 1, + }); + }); + + it('crosses up into the previous section’s last row, clamping the column', () => { + expect(navigateGrid(multi, { index: 1, section: 1 }, 'ArrowUp', 3)).toEqual({ + index: 4, + section: 0, + }); + // Column 2 has no cell in section 0's last row ([3,4]) → clamp to its last cell. + expect(navigateGrid(multi, { index: 2, section: 1 }, 'ArrowUp', 3)).toEqual({ + index: 4, + section: 0, + }); + }); + + it('clamps (null) going right off the very last cell of the last section', () => { + expect(navigateGrid(multi, { index: 5, section: 1 }, 'ArrowRight', 3)).toBeNull(); + }); + + it('End jumps to the last cell of the last section', () => { + expect(navigateGrid(multi, { index: 0, section: 0 }, 'End', 3)).toEqual({ + index: 5, + section: 1, + }); + }); +}); + +// Column count is a parameter, so a non-default `perLine` navigates by that many cells +// per row — the hook measures columns from layout at runtime and passes them here, which +// is why the perLine option needs no keyboard-nav change. +describe('navigateGrid — honors the column count (perLine parity)', () => { + it('ArrowDown moves by `columns` cells within a section', () => { + // 14 cells at 7 columns: row 0 = [0..6], row 1 = [7..13]; down from 0 → 7. + expect(navigateGrid([14], { index: 0, section: 0 }, 'ArrowDown', 7)).toEqual({ + index: 7, + section: 0, + }); + }); +}); diff --git a/src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts b/src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts new file mode 100644 index 000000000..1d374f15c --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts @@ -0,0 +1,45 @@ +import { DEFAULT_EMOJI_PICKER_OPTIONS } from '../../options'; +import { resolvePickerLayout } from '../pickerLayout'; + +const layout = (over: Partial = {}) => + resolvePickerLayout({ ...DEFAULT_EMOJI_PICKER_OPTIONS, ...over }); + +describe('resolvePickerLayout', () => { + it('maps the defaults (nav top, preview bottom, search sticky, skin in preview)', () => { + expect(layout()).toEqual({ + nav: 'top', + preview: 'bottom', + search: 'sticky', + skinTone: 'preview', + }); + }); + + it('maps `none` to null for each region', () => { + expect(layout({ navPosition: 'none' }).nav).toBeNull(); + expect(layout({ previewPosition: 'none' }).preview).toBeNull(); + expect(layout({ searchPosition: 'none' }).search).toBeNull(); + expect(layout({ skinTonePosition: 'none' }).skinTone).toBeNull(); + }); + + it('falls back skin-tone to the preview row when search is hidden', () => { + expect(layout({ searchPosition: 'none', skinTonePosition: 'search' }).skinTone).toBe( + 'preview', + ); + }); + + it('falls back skin-tone to a standalone footer when preview is hidden', () => { + expect( + layout({ previewPosition: 'none', skinTonePosition: 'preview' }).skinTone, + ).toBe('footer'); + }); + + it('falls back to footer when both search and preview are hidden', () => { + expect( + layout({ + previewPosition: 'none', + searchPosition: 'none', + skinTonePosition: 'search', + }).skinTone, + ).toBe('footer'); + }); +}); diff --git a/src/plugins/Emojis/hooks/__tests__/useDebouncedValue.test.ts b/src/plugins/Emojis/hooks/__tests__/useDebouncedValue.test.ts new file mode 100644 index 000000000..8ea4b443a --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/useDebouncedValue.test.ts @@ -0,0 +1,39 @@ +import { act, renderHook } from '@testing-library/react'; +import { useDebouncedValue } from '../useDebouncedValue'; + +describe('useDebouncedValue', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('returns the initial value immediately', () => { + const { result } = renderHook(() => useDebouncedValue('a', 100)); + expect(result.current).toBe('a'); + }); + + it('updates to the latest value only after the delay elapses', () => { + const { rerender, result } = renderHook(({ v }) => useDebouncedValue(v, 100), { + initialProps: { v: 'a' }, + }); + + rerender({ v: 'b' }); + expect(result.current).toBe('a'); // not yet + + act(() => vi.advanceTimersByTime(100)); + expect(result.current).toBe('b'); + }); + + it('collapses rapid changes, emitting only the final value', () => { + const { rerender, result } = renderHook(({ v }) => useDebouncedValue(v, 100), { + initialProps: { v: 'a' }, + }); + + rerender({ v: 'ab' }); + act(() => vi.advanceTimersByTime(50)); + rerender({ v: 'abc' }); + act(() => vi.advanceTimersByTime(50)); // only 50ms since 'abc' → still stale + expect(result.current).toBe('a'); + + act(() => vi.advanceTimersByTime(50)); // now 100ms since 'abc' + expect(result.current).toBe('abc'); // intermediate 'ab' was skipped + }); +}); diff --git a/src/plugins/Emojis/hooks/__tests__/useEmojiPickerState.test.ts b/src/plugins/Emojis/hooks/__tests__/useEmojiPickerState.test.ts new file mode 100644 index 000000000..1ef4c4689 --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/useEmojiPickerState.test.ts @@ -0,0 +1,37 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; + +const { loadState } = vi.hoisted(() => ({ loadState: { attempts: 0 } })); + +// Reject the first dataset load, then succeed — the offline / stale-chunk scenario the +// picker must recover from without a full page reload. +vi.mock('../../data', () => ({ + loadEmojiData: vi.fn(() => { + loadState.attempts += 1; + return loadState.attempts === 1 + ? Promise.reject(new Error('chunk load failed')) + : Promise.resolve({ aliases: {}, categories: [], emojis: {} }); + }), +})); + +import { useEmojiPickerState } from '../useEmojiPickerState'; + +describe('useEmojiPickerState', () => { + beforeEach(() => { + loadState.attempts = 0; + }); + + it('surfaces an error when the dataset fails to load, then recovers on retry', async () => { + const { result } = renderHook(() => useEmojiPickerState()); + + await waitFor(() => expect(result.current.error).toBe(true)); + expect(result.current.data).toBeNull(); + + act(() => { + result.current.retry(); + }); + + await waitFor(() => expect(result.current.data).not.toBeNull()); + expect(result.current.error).toBe(false); + expect(loadState.attempts).toBe(2); + }); +}); diff --git a/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts b/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts new file mode 100644 index 000000000..2ab949e0a --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts @@ -0,0 +1,49 @@ +import { act, renderHook } from '@testing-library/react'; +import { useFrequentlyUsedEmoji } from '../useFrequentlyUsedEmoji'; + +describe('useFrequentlyUsedEmoji', () => { + it('tracks recents most-recent-first and de-duplicates (uncontrolled)', () => { + const { result } = renderHook(() => useFrequentlyUsedEmoji({})); + act(() => result.current.recordUse('a')); + act(() => result.current.recordUse('b')); + act(() => result.current.recordUse('a')); + expect(result.current.frequentlyUsedIds).toEqual(['a', 'b']); + }); + + it('notifies via onFrequentlyUsedChange and stays controlled', () => { + const onFrequentlyUsedChange = vi.fn(); + const { rerender, result } = renderHook( + ({ frequentlyUsedEmoji }) => + useFrequentlyUsedEmoji({ frequentlyUsedEmoji, onFrequentlyUsedChange }), + { initialProps: { frequentlyUsedEmoji: ['x'] } }, + ); + act(() => result.current.recordUse('y')); + expect(onFrequentlyUsedChange).toHaveBeenCalledWith(['y', 'x']); + expect(result.current.frequentlyUsedIds).toEqual(['x']); // controlled: reflects prop only + rerender({ frequentlyUsedEmoji: ['y', 'x'] }); + expect(result.current.frequentlyUsedIds).toEqual(['y', 'x']); + }); + + it('keeps both when two emoji are recorded before a re-render (uncontrolled)', () => { + const { result } = renderHook(() => useFrequentlyUsedEmoji({})); + // Two selects in the same tick close over the same list — the second must still + // build on the first, not overwrite it. + act(() => { + result.current.recordUse('a'); + result.current.recordUse('b'); + }); + expect(result.current.frequentlyUsedIds).toEqual(['b', 'a']); + }); + + it('accumulates both when two emoji are recorded before a re-render (controlled)', () => { + const onFrequentlyUsedChange = vi.fn(); + const { result } = renderHook(() => + useFrequentlyUsedEmoji({ frequentlyUsedEmoji: [], onFrequentlyUsedChange }), + ); + act(() => { + result.current.recordUse('a'); + result.current.recordUse('b'); + }); + expect(onFrequentlyUsedChange).toHaveBeenLastCalledWith(['b', 'a']); + }); +}); diff --git a/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx b/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx new file mode 100644 index 000000000..5ae64e7dc --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx @@ -0,0 +1,124 @@ +import { useRef, useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useGridKeyboardNav } from '../useGridKeyboardNav'; + +// jsdom doesn't implement scrollIntoView; the hook calls it after focusing a cell. +beforeAll(() => { + Element.prototype.scrollIntoView = () => undefined; +}); + +type Category = { emojis: { id: string }[]; id: string }; + +/** + * Mimics the virtualized grid: only `initialMounted` categories are in the DOM, and + * asking to scroll to a category "mounts" it (as Virtuoso would). This lets us prove + * navigation can cross into a category that virtualization has not yet rendered. + */ +const Harness = ({ + categories, + initialMounted, +}: { + categories: Category[]; + initialMounted: string[]; +}) => { + const ref = useRef(null); + const [mounted, setMounted] = useState(initialMounted); + const scrollToCategory = (id: string) => + setMounted((current) => (current.includes(id) ? current : [...current, id])); + const { onKeyDown } = useGridKeyboardNav(ref, { categories, scrollToCategory }); + + return ( +
+ {categories + .filter((category) => mounted.includes(category.id)) + .map((category) => ( +
+ {category.emojis.map(({ id }) => ( + + ))} +
+ ))} +
+ ); +}; + +const categories: Category[] = [ + { emojis: [{ id: 'a1' }, { id: 'a2' }, { id: 'a3' }], id: 'a' }, + { emojis: [{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }], id: 'b' }, +]; + +describe('useGridKeyboardNav across virtualization boundaries', () => { + it('ArrowRight past the last mounted cell mounts the next category and focuses its first cell', async () => { + render(); + // The next category isn't mounted yet (virtualization). + expect(screen.queryByText('b1')).not.toBeInTheDocument(); + + screen.getByText('a3').focus(); + fireEvent.keyDown(screen.getByText('a3'), { key: 'ArrowRight' }); + + await waitFor(() => expect(screen.getByText('b1')).toHaveFocus()); + }); + + it('ArrowLeft before the first mounted cell mounts the previous category and focuses its last cell', async () => { + render(); + expect(screen.queryByText('a3')).not.toBeInTheDocument(); + + screen.getByText('b1').focus(); + fireEvent.keyDown(screen.getByText('b1'), { key: 'ArrowLeft' }); + + await waitFor(() => expect(screen.getByText('a3')).toHaveFocus()); + }); + + it('ArrowRight still moves within the mounted set without scrolling', () => { + render(); + + screen.getByText('a1').focus(); + fireEvent.keyDown(screen.getByText('a1'), { key: 'ArrowRight' }); + + expect(screen.getByText('a2')).toHaveFocus(); + }); + + it('does not try to cross categories in the search view (flat results, no category sections)', () => { + const scrollToCategory = vi.fn(); + const SearchHarness = () => { + const ref = useRef(null); + const { onKeyDown } = useGridKeyboardNav(ref, { categories, scrollToCategory }); + // Search results render as a flat grid with no [data-category-id] ancestor. + return ( +
+ {['r1', 'r2'].map((id) => ( + + ))} +
+ ); + }; + render(); + + // ArrowRight at the last result clamps (no category to scroll to)… + screen.getByText('r2').focus(); + fireEvent.keyDown(screen.getByText('r2'), { key: 'ArrowRight' }); + expect(screen.getByText('r2')).toHaveFocus(); + + // …and Home goes to the first result rather than scrolling to a category. + fireEvent.keyDown(screen.getByText('r2'), { key: 'Home' }); + expect(screen.getByText('r1')).toHaveFocus(); + + expect(scrollToCategory).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/Emojis/hooks/__tests__/useSkinTone.test.ts b/src/plugins/Emojis/hooks/__tests__/useSkinTone.test.ts new file mode 100644 index 000000000..adc4f510e --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/useSkinTone.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react'; +import { useSkinTone } from '../useSkinTone'; + +describe('useSkinTone', () => { + it('is uncontrolled from defaultSkinTone and updates internally', () => { + const { result } = renderHook(() => useSkinTone({ defaultSkinTone: 2 })); + expect(result.current[0]).toBe(2); + act(() => result.current[1](4)); + expect(result.current[0]).toBe(4); + }); + + it('clamps out-of-range values to 0..5', () => { + const { result } = renderHook(() => useSkinTone({ defaultSkinTone: 99 })); + expect(result.current[0]).toBe(5); + act(() => result.current[1](-3)); + expect(result.current[0]).toBe(0); + }); + + it('is controlled when skinTone is provided and does not self-update', () => { + const onSkinToneChange = vi.fn(); + const { rerender, result } = renderHook( + ({ skinTone }) => useSkinTone({ onSkinToneChange, skinTone }), + { initialProps: { skinTone: 1 } }, + ); + expect(result.current[0]).toBe(1); + act(() => result.current[1](3)); + expect(onSkinToneChange).toHaveBeenCalledWith(3); + expect(result.current[0]).toBe(1); // stays until the controlled prop changes + rerender({ skinTone: 3 }); + expect(result.current[0]).toBe(3); + }); +}); diff --git a/src/plugins/Emojis/hooks/gridNavigation.ts b/src/plugins/Emojis/hooks/gridNavigation.ts new file mode 100644 index 000000000..b079b2c3b --- /dev/null +++ b/src/plugins/Emojis/hooks/gridNavigation.ts @@ -0,0 +1,81 @@ +export type GridPosition = { index: number; section: number }; + +/** + * Pure keyboard navigation over a sectioned grid: the emoji picker is a vertical stack + * of sections (categories), each laid out as a `columns`-wide grid filled in reading + * order. Given the section lengths, the active `(section, index)`, the pressed key, and + * the current column count, it returns the target position — or `null` to stay put at a + * grid edge. + * + * Kept free of the DOM so the (otherwise layout-dependent, untestable) navigation logic + * can be unit-tested exhaustively. The hook maps the DOM position in/out and measures + * `columns` from layout. + */ +export const navigateGrid = ( + sectionLengths: number[], + pos: GridPosition, + key: string, + columns: number, +): GridPosition | null => { + const cols = Math.max(1, columns); + const len = sectionLengths[pos.section] ?? 0; + if (len === 0) return null; + + const col = pos.index % cols; + const row = Math.floor(pos.index / cols); + const lastRow = Math.floor((len - 1) / cols); + + switch (key) { + case 'Home': + return { index: 0, section: 0 }; + + case 'End': { + const last = sectionLengths.length - 1; + return { index: Math.max(0, (sectionLengths[last] ?? 0) - 1), section: last }; + } + + case 'ArrowRight': + if (pos.index < len - 1) return { index: pos.index + 1, section: pos.section }; + if (pos.section < sectionLengths.length - 1) { + return { index: 0, section: pos.section + 1 }; + } + return null; + + case 'ArrowLeft': + if (pos.index > 0) return { index: pos.index - 1, section: pos.section }; + if (pos.section > 0) { + return { + index: Math.max(0, (sectionLengths[pos.section - 1] ?? 0) - 1), + section: pos.section - 1, + }; + } + return null; + + case 'ArrowDown': + if (row < lastRow) { + return { index: Math.min(pos.index + cols, len - 1), section: pos.section }; + } + if (pos.section < sectionLengths.length - 1) { + const nextLen = sectionLengths[pos.section + 1] ?? 0; + if (nextLen === 0) return null; + return { index: Math.min(col, nextLen - 1), section: pos.section + 1 }; + } + return null; + + case 'ArrowUp': + if (row > 0) return { index: pos.index - cols, section: pos.section }; + if (pos.section > 0) { + const prevLen = sectionLengths[pos.section - 1] ?? 0; + if (prevLen === 0) return null; + const prevLastRow = Math.floor((prevLen - 1) / cols); + return { + index: Math.min(prevLastRow * cols + col, prevLen - 1), + section: pos.section - 1, + }; + } + return null; + + default: + return null; + } +}; diff --git a/src/plugins/Emojis/hooks/pickerLayout.ts b/src/plugins/Emojis/hooks/pickerLayout.ts new file mode 100644 index 000000000..36c05a9c2 --- /dev/null +++ b/src/plugins/Emojis/hooks/pickerLayout.ts @@ -0,0 +1,42 @@ +import type { ResolvedEmojiPickerOptions } from '../options'; + +export type PickerLayout = { + nav: 'top' | 'bottom' | null; + preview: 'top' | 'bottom' | null; + search: 'sticky' | 'static' | null; + skinTone: 'search' | 'preview' | 'footer' | null; +}; + +/** + * Pure resolver deciding where each picker region renders. `'none'` positions become + * `null` (omit the region). The skin-tone selector normally lives with the search row + * (`skinTonePosition: 'search'`) or the preview row (`'preview'`); when its host region + * is hidden it falls back — to the preview row, then to a standalone footer — so the + * selector never silently disappears when the integrator hides search or preview. + */ +export const resolvePickerLayout = ({ + navPosition, + previewPosition, + searchPosition, + skinTonePosition, +}: Pick< + ResolvedEmojiPickerOptions, + 'navPosition' | 'previewPosition' | 'searchPosition' | 'skinTonePosition' +>): PickerLayout => { + const nav = navPosition === 'none' ? null : navPosition; + const preview = previewPosition === 'none' ? null : previewPosition; + const search = searchPosition === 'none' ? null : searchPosition; + + let skinTone: PickerLayout['skinTone']; + if (skinTonePosition === 'none') { + skinTone = null; + } else if (skinTonePosition === 'search') { + if (search) skinTone = 'search'; + else if (preview) skinTone = 'preview'; + else skinTone = 'footer'; + } else { + skinTone = preview ? 'preview' : 'footer'; + } + + return { nav, preview, search, skinTone }; +}; diff --git a/src/plugins/Emojis/hooks/useDebouncedValue.ts b/src/plugins/Emojis/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..0ff16ee63 --- /dev/null +++ b/src/plugins/Emojis/hooks/useDebouncedValue.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +/** + * Returns `value` debounced by `delayMs`: the result only catches up to the latest value + * once `delayMs` has elapsed without a further change. Version-safe across React 17–19 + * (unlike `useDeferredValue`, which is React 18+). + */ +export const useDebouncedValue = (value: T, delayMs: number): T => { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs]); + + return debounced; +}; diff --git a/src/plugins/Emojis/hooks/useEmojiPickerState.ts b/src/plugins/Emojis/hooks/useEmojiPickerState.ts new file mode 100644 index 000000000..4ecfc2572 --- /dev/null +++ b/src/plugins/Emojis/hooks/useEmojiPickerState.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from 'react'; +import { type EmojiData, loadEmojiData } from '../data'; + +/** + * Loads the vendored emoji dataset lazily (via the code-split dynamic import) and + * exposes it once resolved. + * + * `data` is `null` while loading. If the load fails (offline, or a stale chunk after a + * deploy) `error` becomes `true`; `retry()` re-attempts it. Because `loadEmojiData` + * drops its memo on failure, the retry actually re-imports rather than replaying the + * rejected promise — so the picker can recover without a full page reload. + */ +export const useEmojiPickerState = () => { + const [data, setData] = useState(null); + const [error, setError] = useState(false); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + let active = true; + setError(false); + loadEmojiData() + .then((loaded) => { + if (active) setData(loaded); + }) + .catch(() => { + if (active) setError(true); + }); + return () => { + active = false; + }; + }, [attempt]); + + const retry = useCallback(() => setAttempt((current) => current + 1), []); + + return { data, error, retry }; +}; diff --git a/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts b/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts new file mode 100644 index 000000000..7efb681b2 --- /dev/null +++ b/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts @@ -0,0 +1,46 @@ +import { useCallback, useRef, useState } from 'react'; + +export type UseFrequentlyUsedEmojiParams = { + /** Controlled ordered list of recently used emoji ids (most recent first). */ + frequentlyUsedEmoji?: string[]; + /** Called with the updated ordered list whenever an emoji is used. */ + onFrequentlyUsedChange?: (emojiIds: string[]) => void; +}; + +const MAX_FREQUENTLY_USED = 24; + +/** + * Tracks recently used emoji as an ordered, most-recent-first list. Controlled via + * `frequentlyUsedEmoji`/`onFrequentlyUsedChange`; otherwise kept ephemerally in + * memory for the current mount (reset on reload). The SDK never persists — see the + * vite example for a localStorage-backed integrator pattern. + */ +export const useFrequentlyUsedEmoji = ({ + frequentlyUsedEmoji, + onFrequentlyUsedChange, +}: UseFrequentlyUsedEmojiParams) => { + const [internal, setInternal] = useState([]); + const isControlled = Array.isArray(frequentlyUsedEmoji); + const frequentlyUsedIds = isControlled ? frequentlyUsedEmoji : internal; + + // Mirror the current list into a ref so several recordUse calls fired before a + // re-render each build on the previous result rather than a stale closure/prop + // (otherwise the second synchronous select would drop the first). + const latestRef = useRef(frequentlyUsedIds); + latestRef.current = frequentlyUsedIds; + + const recordUse = useCallback( + (emojiId: string) => { + const next = [ + emojiId, + ...latestRef.current.filter((existing) => existing !== emojiId), + ].slice(0, MAX_FREQUENTLY_USED); + latestRef.current = next; + if (!isControlled) setInternal(next); + onFrequentlyUsedChange?.(next); + }, + [isControlled, onFrequentlyUsedChange], + ); + + return { frequentlyUsedIds, recordUse }; +}; diff --git a/src/plugins/Emojis/hooks/useGridKeyboardNav.ts b/src/plugins/Emojis/hooks/useGridKeyboardNav.ts new file mode 100644 index 000000000..33977b2d4 --- /dev/null +++ b/src/plugins/Emojis/hooks/useGridKeyboardNav.ts @@ -0,0 +1,189 @@ +import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react'; +import { navigateGrid } from './gridNavigation'; + +type GridRef = { readonly current: HTMLElement | null }; + +export type GridKeyboardNavOptions = { + /** + * Ordered categories with their emoji ids. Navigation runs over this model (not live + * DOM geometry), so it can target a cell in a category that virtualization has not + * mounted yet — and stays correct regardless of layout. + */ + categories?: readonly { emojis: readonly { id: string }[]; id: string }[]; + /** + * Scrolls a category into view so virtualization mounts it (e.g. Virtuoso's + * `scrollToIndex`). Navigation focuses the target cell once that category appears. + */ + scrollToCategory?: (categoryId: string) => void; +}; + +const EMOJI_SELECTOR = '.str-chat__emoji-picker__emoji'; +const CATEGORY_SELECTOR = '[data-category-id]'; +const NAV_KEYS = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End']; +// Stop waiting for an offscreen category to mount after this long (avoids a dangling +// observer if a scroll never mounts the target). +const MOUNT_TIMEOUT_MS = 1000; + +const cellsIn = (root: Element | null) => + root ? Array.from(root.querySelectorAll(EMOJI_SELECTOR)) : []; + +// Cells are laid out row-major, so the first row is every cell sharing the first cell's +// offsetTop — its size is the current column count. Uses offsetTop (no forced reflow) +// and is read once per keypress. +const measureColumns = (cells: HTMLButtonElement[]) => { + if (cells.length <= 1) return Math.max(1, cells.length); + const top = cells[0].offsetTop; + return Math.max(1, cells.filter((cell) => cell.offsetTop === top).length); +}; + +/** + * Roving-tabindex keyboard navigation for the emoji grid. Navigation is computed by the + * pure `navigateGrid` over the known category model (see gridNavigation.ts) — only the + * current column count is read from layout — then the resolved cell is focused by id. + * + * Because the grid is virtualized, a move can target a category that is not currently + * mounted; navigation then asks `scrollToCategory` to mount it and focuses the cell as + * soon as it appears, so keyboard users can traverse the whole set. In the search view + * (no categories) it navigates the flat, fully-mounted result list the same way. + */ +export const useGridKeyboardNav = ( + gridRef: GridRef, + { categories = [], scrollToCategory }: GridKeyboardNavOptions = {}, +) => { + const getButtons = useCallback(() => cellsIn(gridRef.current), [gridRef]); + + // The single tab-reachable ("roving") cell. Tracked in a ref so moving focus only + // flips two cells' tabIndex instead of sweeping every mounted cell. + const rovingRef = useRef(null); + + const focusCell = useCallback((cell: HTMLButtonElement | null | undefined) => { + if (!cell) return; + if (rovingRef.current && rovingRef.current !== cell) { + rovingRef.current.tabIndex = -1; + } + cell.tabIndex = 0; + rovingRef.current = cell; + cell.focus(); + cell.scrollIntoView({ block: 'nearest' }); + }, []); + + // Keep one tab-reachable cell as virtualization mounts/unmounts. Cheap: only touches + // the DOM when the roving cell has detached (e.g. scrolled out and unmounted). + useEffect(() => { + if (rovingRef.current && gridRef.current?.contains(rovingRef.current)) return; + const first = getButtons()[0]; + if (first) { + first.tabIndex = 0; + rovingRef.current = first; + } + }); + + const focusFirst = useCallback( + () => focusCell(getButtons()[0]), + [focusCell, getButtons], + ); + + // Teardown for a pending "focus once the category mounts" watch. Replaced whenever a + // new focus-by-id starts; invoked on a superseding keypress and on unmount. + const cancelPending = useRef<() => void>(() => undefined); + useEffect(() => () => cancelPending.current(), []); + + // Focus an emoji by id within a specific category (ids repeat across the "frequent" + // section and their home category, so the lookup must be category-scoped). If that + // category isn't mounted, scroll it into view and focus once it appears. + const focusEmojiInCategory = useCallback( + (categoryId: string, emojiId: string) => { + const find = () => { + const section = gridRef.current?.querySelector( + `${CATEGORY_SELECTOR}[data-category-id="${categoryId}"]`, + ); + return ( + section?.querySelector( + `${EMOJI_SELECTOR}[data-emoji-id="${emojiId}"]`, + ) ?? null + ); + }; + + cancelPending.current(); + + const mounted = find(); + if (mounted) { + focusCell(mounted); + return; + } + + scrollToCategory?.(categoryId); + const grid = gridRef.current; + if (!grid) return; + + const observer = new MutationObserver(() => { + const cell = find(); + if (cell) { + cancelPending.current(); + focusCell(cell); + } + }); + observer.observe(grid, { childList: true, subtree: true }); + const timeout = setTimeout(() => cancelPending.current(), MOUNT_TIMEOUT_MS); + cancelPending.current = () => { + observer.disconnect(); + clearTimeout(timeout); + cancelPending.current = () => undefined; + }; + }, + [focusCell, gridRef, scrollToCategory], + ); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!NAV_KEYS.includes(event.key)) return; + const buttons = getButtons(); + const activeIndex = buttons.findIndex( + (button) => button === document.activeElement, + ); + if (activeIndex === -1) return; + event.preventDefault(); + // A fresh keypress supersedes any in-flight wait for an offscreen category. + cancelPending.current(); + + const active = buttons[activeIndex]; + const sectionEl = active.closest(CATEGORY_SELECTOR); + const categoryId = sectionEl?.getAttribute('data-category-id'); + const sectionIndex = categories.findIndex((entry) => entry.id === categoryId); + + if (sectionIndex >= 0) { + // Category (virtualized) view — navigate the known model. + const emojiId = active.getAttribute('data-emoji-id'); + const indexInSection = categories[sectionIndex].emojis.findIndex( + (emoji) => emoji.id === emojiId, + ); + if (indexInSection === -1) return; + + const target = navigateGrid( + categories.map((category) => category.emojis.length), + { index: indexInSection, section: sectionIndex }, + event.key, + measureColumns(cellsIn(sectionEl)), + ); + if (!target) return; // grid edge — stay put + + const targetCategory = categories[target.section]; + const targetEmoji = targetCategory.emojis[target.index]; + if (targetEmoji) focusEmojiInCategory(targetCategory.id, targetEmoji.id); + return; + } + + // Search (non-virtualized) view — navigate the flat, fully-mounted result list. + const target = navigateGrid( + [buttons.length], + { index: activeIndex, section: 0 }, + event.key, + measureColumns(buttons), + ); + if (target) focusCell(buttons[target.index]); + }, + [categories, focusCell, focusEmojiInCategory, getButtons], + ); + + return { focusFirst, onKeyDown }; +}; diff --git a/src/plugins/Emojis/hooks/useSkinTone.ts b/src/plugins/Emojis/hooks/useSkinTone.ts new file mode 100644 index 000000000..b06b2c7e2 --- /dev/null +++ b/src/plugins/Emojis/hooks/useSkinTone.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from 'react'; +import { MAX_SKIN_TONE_INDEX } from '../components/skinTones'; + +export type UseSkinToneParams = { + /** Uncontrolled initial skin tone index (0 = default, 1–5 = light → dark). */ + defaultSkinTone?: number; + /** Called with the new skin tone index whenever it changes. */ + onSkinToneChange?: (skinTone: number) => void; + /** Controlled skin tone index. When provided, the picker does not hold its own. */ + skinTone?: number; +}; + +const clamp = (value: number) => + Math.min(MAX_SKIN_TONE_INDEX, Math.max(0, Math.floor(value))); + +/** + * Controlled-or-uncontrolled skin tone selection. The SDK never persists the value + * — integrators own persistence by controlling `skinTone`/`onSkinToneChange`. + */ +export const useSkinTone = ({ + defaultSkinTone, + onSkinToneChange, + skinTone, +}: UseSkinToneParams) => { + const [internal, setInternal] = useState(() => clamp(defaultSkinTone ?? 0)); + const isControlled = typeof skinTone === 'number'; + const value = clamp(isControlled ? skinTone : internal); + + const setSkinTone = useCallback( + (next: number) => { + const clamped = clamp(next); + if (!isControlled) setInternal(clamped); + onSkinToneChange?.(clamped); + }, + [isControlled, onSkinToneChange], + ); + + return [value, setSkinTone] as const; +}; diff --git a/src/plugins/Emojis/index.ts b/src/plugins/Emojis/index.ts index 17d0119d3..75470a3e9 100644 --- a/src/plugins/Emojis/index.ts +++ b/src/plugins/Emojis/index.ts @@ -1,2 +1,8 @@ +export * from './compat'; +export * from './components'; +export * from './data'; export * from './EmojiPicker'; export * from './middleware'; +export * from './reactions'; +export * from './search'; +export * from './StreamEmojiPicker'; diff --git a/src/plugins/Emojis/memoizeAsyncWithReset.ts b/src/plugins/Emojis/memoizeAsyncWithReset.ts new file mode 100644 index 000000000..72721b44f --- /dev/null +++ b/src/plugins/Emojis/memoizeAsyncWithReset.ts @@ -0,0 +1,19 @@ +/** + * Memoizes an async factory, sharing one in-flight/resolved promise across callers — + * but drops the memo if the promise rejects, so a later call retries instead of + * replaying the failure forever. Used for the lazily code-split emoji dataset (and the + * search index derived from it): a transient chunk-load failure (offline, stale deploy) + * must not permanently disable the picker or poison the shared search index. + */ +export const memoizeAsyncWithReset = (factory: () => Promise) => { + let promise: Promise | null = null; + return (): Promise => { + if (!promise) { + promise = factory().catch((error) => { + promise = null; + throw error; + }); + } + return promise; + }; +}; diff --git a/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts b/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts new file mode 100644 index 000000000..db9027819 --- /dev/null +++ b/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts @@ -0,0 +1,102 @@ +import { createTextComposerEmojiMiddleware } from '../textComposerEmojiMiddleware'; + +// Minimal onChange harness: capture whatever state the handler completes/nexts with. +const runOnChange = async ( + middleware: ReturnType, + + state: any, +) => { + let output: any; + + const complete = (next: any) => { + output = next; + return { state: next, status: 'complete' }; + }; + const forward = vi.fn(() => ({ status: 'forward' })); + + const next = (nextState: any) => { + output = nextState; + return { state: nextState, status: 'next' }; + }; + await middleware.handlers.onChange({ + complete, + forward, + next, + state, + } as any); + return { forward, output }; +}; + +describe('createTextComposerEmojiMiddleware', () => { + it('returns a middleware with the expected id and handlers when called with no arguments', () => { + const middleware = createTextComposerEmojiMiddleware(); + expect(middleware.id).toBe('stream-io/emoji-middleware'); + expect(typeof middleware.handlers.onChange).toBe('function'); + expect(typeof middleware.handlers.onSuggestionItemSelect).toBe('function'); + }); + + it('drives ":" shortcode autocomplete from the built-in index (no emoji-mart)', async () => { + const middleware = createTextComposerEmojiMiddleware(); + const { output } = await runOnChange(middleware, { + selection: { end: 4, start: 4 }, + suggestions: undefined, + text: ':smi', + }); + + expect(output?.suggestions?.trigger).toBe(':'); + expect(output?.suggestions?.query).toBe('smi'); + + const { items } = await output.suggestions.searchSource.query('smi'); + expect(items.length).toBeGreaterThan(0); + // e.g. "smile" / "smiley" — proves the default search index is wired in + expect(items.some((item: { id: string }) => item.id.startsWith('smi'))).toBe(true); + }); + + it('forwards when there is no selection', async () => { + const middleware = createTextComposerEmojiMiddleware(); + const { forward } = await runOnChange(middleware, { + selection: null, + suggestions: undefined, + text: '', + }); + expect(forward).toHaveBeenCalled(); + }); + + it('accepts a custom EmojiSearchIndex override', async () => { + const search = vi.fn().mockResolvedValue([ + { + emoticons: [], + id: 'custom', + name: 'Custom', + native: '🦄', + skins: [{ native: '🦄' }], + }, + ]); + const middleware = createTextComposerEmojiMiddleware({ search }); + const { output } = await runOnChange(middleware, { + selection: { end: 4, start: 4 }, + suggestions: undefined, + text: ':uni', + }); + const { items } = await output.suggestions.searchSource.query('uni'); + expect(search).toHaveBeenCalled(); + expect(items[0]?.id).toBe('custom'); + }); + + it('does not leak options between calls (shared defaults are not mutated)', async () => { + // A call that overrides options must not change the defaults a later no-arg call + // sees. Previously `mergeWith(DEFAULT_OPTIONS, options)` mutated the shared constant. + createTextComposerEmojiMiddleware(undefined, { minChars: 3, trigger: ';' }); + + const withDefaults = createTextComposerEmojiMiddleware(); + const { output } = await runOnChange(withDefaults, { + selection: { end: 4, start: 4 }, + suggestions: undefined, + text: ':smi', + }); + + // Still the default ':' trigger — not the ';' leaked from the earlier call. + expect(output?.suggestions?.trigger).toBe(':'); + expect(output?.suggestions?.query).toBe('smi'); + }); +}); diff --git a/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts b/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts index 2a8ce8c00..36367d1c0 100644 --- a/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts +++ b/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts @@ -1,4 +1,3 @@ -import mergeWith from 'lodash.mergewith'; import type { Middleware, SearchSourceOptions, @@ -18,6 +17,7 @@ import type { EmojiSearchIndex, EmojiSearchIndexResult, } from '../../../components/MessageComposer'; +import { defaultEmojiSearchIndex } from '../search'; export type EmojiSuggestion = TextComposerSuggestion; @@ -78,27 +78,30 @@ export type EmojiMiddleware; /** - * TextComposer middleware for mentions + * TextComposer middleware providing `:shortcode` emoji autocomplete and + * emoticon-to-emoji replacement (e.g. `:)` → 🙂). + * * Usage: * - * const textComposer = new TextComposer(options); + * // uses the SDK's built-in emoji search index (no `emoji-mart` required) + * textComposer.middlewareExecutor.insert({ + * middleware: [createTextComposerEmojiMiddleware()], + * }); * - * textComposer.use(new createTextComposerEmojiMiddleware(emojiSearchIndex, { - * minChars: 2 - * })); + * // or provide a custom EmojiSearchIndex / options + * createTextComposerEmojiMiddleware(customEmojiSearchIndex, { minChars: 2 }); * - * @param emojiSearchIndex - * @param {{ - * minChars: number; - * trigger: string; - * }} options + * @param emojiSearchIndex Defaults to the SDK's built-in `defaultEmojiSearchIndex`. + * @param options `minChars` and `trigger` overrides. * @returns */ export const createTextComposerEmojiMiddleware = ( - emojiSearchIndex: EmojiSearchIndex, + emojiSearchIndex: EmojiSearchIndex = defaultEmojiSearchIndex, options?: Partial, ): EmojiMiddleware => { - const finalOptions = mergeWith(DEFAULT_OPTIONS, options ?? {}); + // Spread into a fresh object — never mutate the shared module-level DEFAULT_OPTIONS + // (options are flat, so a shallow merge is exact). + const finalOptions = { ...DEFAULT_OPTIONS, ...options }; const emojiSearchSource = new EmojiSearchSource(emojiSearchIndex); emojiSearchSource.activate(); diff --git a/src/plugins/Emojis/options.ts b/src/plugins/Emojis/options.ts new file mode 100644 index 000000000..a386f8610 --- /dev/null +++ b/src/plugins/Emojis/options.ts @@ -0,0 +1,141 @@ +import type { CSSProperties } from 'react'; + +export type EmojiPickerNavPosition = 'top' | 'bottom' | 'none'; +export type EmojiPickerPreviewPosition = 'top' | 'bottom' | 'none'; +export type EmojiPickerSearchPosition = 'sticky' | 'static' | 'none'; +export type EmojiPickerSkinTonePosition = 'preview' | 'search' | 'none'; + +export type EmojiPickerPassthroughProps = { + /** Focus the search input when the picker opens (default `true`). */ + autoFocus?: boolean; + /** Category ids to show, in order. Defaults to the dataset order. `frequent` always prepends. */ + categories?: string[]; + /** Hide emoji introduced after this Unicode emoji version. */ + emojiVersion?: number; + /** Emoji ids to exclude from the grid and search. */ + exceptEmojis?: string[]; + /** Max rows in the "frequently used" section (default `1`). */ + maxFrequentRows?: number; + /** Category navigation placement (default `'top'`). */ + navPosition?: EmojiPickerNavPosition; + /** Hide country-flag emoji (default `false`). */ + noCountryFlags?: boolean; + /** Emoji id shown in the empty-search state. */ + noResultsEmoji?: string; + /** Called when a pointer press lands outside the picker. */ + onClickOutside?: () => void; + /** Emoji per row (default `9`). */ + perLine?: number; + /** Emoji id shown in the preview when nothing is hovered/focused. */ + previewEmoji?: string; + /** Preview placement (default `'bottom'`). */ + previewPosition?: EmojiPickerPreviewPosition; + /** Search input placement (default `'sticky'`). */ + searchPosition?: EmojiPickerSearchPosition; + /** Skin-tone selector placement (default `'preview'`). */ + skinTonePosition?: EmojiPickerSkinTonePosition; + /** Inline styles applied to the picker panel root. */ + style?: CSSProperties; + /** Color theme. 'auto' (default) inherits the ancestor SDK theme; 'light'/'dark' force it. */ + theme?: 'auto' | 'light' | 'dark'; +}; + +export type ResolvedEmojiPickerOptions = { + autoFocus: boolean; + categories?: string[]; + emojiVersion?: number; + exceptEmojis: string[]; + maxFrequentRows: number; + navPosition: EmojiPickerNavPosition; + noCountryFlags: boolean; + noResultsEmoji?: string; + perLine: number; + previewEmoji?: string; + previewPosition: EmojiPickerPreviewPosition; + searchPosition: EmojiPickerSearchPosition; + skinTonePosition: EmojiPickerSkinTonePosition; +}; + +export const DEFAULT_EMOJI_PICKER_OPTIONS: ResolvedEmojiPickerOptions = { + autoFocus: true, + exceptEmojis: [], + maxFrequentRows: 1, + navPosition: 'top', + noCountryFlags: false, + perLine: 9, + previewPosition: 'bottom', + searchPosition: 'sticky', + skinTonePosition: 'preview', +}; + +export const resolveEmojiPickerOptions = ( + pickerProps?: EmojiPickerPassthroughProps, +): ResolvedEmojiPickerOptions => { + const p = pickerProps ?? {}; + const d = DEFAULT_EMOJI_PICKER_OPTIONS; + return { + autoFocus: p.autoFocus ?? d.autoFocus, + categories: p.categories, + emojiVersion: p.emojiVersion, + exceptEmojis: p.exceptEmojis ?? d.exceptEmojis, + maxFrequentRows: Math.max(0, Math.floor(p.maxFrequentRows ?? d.maxFrequentRows)), + navPosition: p.navPosition ?? d.navPosition, + noCountryFlags: p.noCountryFlags ?? d.noCountryFlags, + noResultsEmoji: p.noResultsEmoji, + perLine: Math.max(1, Math.floor(p.perLine ?? d.perLine)), + previewEmoji: p.previewEmoji, + previewPosition: p.previewPosition ?? d.previewPosition, + searchPosition: p.searchPosition ?? d.searchPosition, + skinTonePosition: p.skinTonePosition ?? d.skinTonePosition, + }; +}; + +const SUPPORTED_PICKER_PROP_KEYS = [ + 'autoFocus', + 'categories', + 'emojiVersion', + 'exceptEmojis', + 'maxFrequentRows', + 'navPosition', + 'noCountryFlags', + 'noResultsEmoji', + 'onClickOutside', + 'perLine', + 'previewEmoji', + 'previewPosition', + 'searchPosition', + 'skinTonePosition', + 'style', + 'theme', +]; + +/** emoji-mart styling knobs → the CSS token that replaces each. */ +const STYLING_KNOB_TOKENS: Record = { + emojiButtonColors: '--str-chat__emoji-picker-hover-background-color', + emojiButtonRadius: '--str-chat__radius-4 (on .str-chat__emoji-picker__emoji)', + emojiButtonSize: '--str-chat__emoji-picker-emoji-size', + emojiSize: '--str-chat__emoji-picker-emoji-size', +}; + +export const warnUnsupportedPickerProps = ( + pickerProps?: Record, +): void => { + if (!pickerProps) return; + const keys = Object.keys(pickerProps); + const styling = keys.filter((key) => key in STYLING_KNOB_TOKENS); + const ignored = keys.filter( + (key) => !SUPPORTED_PICKER_PROP_KEYS.includes(key) && !(key in STYLING_KNOB_TOKENS), + ); + if (styling.length) { + console.warn( + `[stream-chat-react] EmojiPicker: ${styling.join(', ')} are emoji-mart styling props. ` + + `Use the matching CSS token instead (e.g. ${STYLING_KNOB_TOKENS[styling[0]]}).`, + ); + } + if (ignored.length) { + console.warn( + `[stream-chat-react] EmojiPicker ignored unsupported pickerProps: ${ignored.join(', ')}. ` + + 'These emoji-mart Picker options are not available in the built-in picker.', + ); + } +}; diff --git a/src/plugins/Emojis/reactions.ts b/src/plugins/Emojis/reactions.ts new file mode 100644 index 000000000..dcf659c1a --- /dev/null +++ b/src/plugins/Emojis/reactions.ts @@ -0,0 +1,24 @@ +import { mapEmojiMartData } from '../../components/Reactions/reactionOptions'; +import { loadEmojiData } from './data'; +import { memoizeAsyncWithReset } from './memoizeAsyncWithReset'; + +/** + * Loads the full set of "extended" reaction options — every vendored emoji, keyed by + * unicode — for use as `reactionOptions.extended`. This restores reacting with (almost) + * any emoji via the reaction selector's "+" button, which previously came from feeding + * the whole `@emoji-mart/data` object into `mapEmojiMartData`. + * + * Both halves of that feature read `reactionOptions.extended`: the selector only shows + * "+" when it is non-empty, and `useProcessReactions` renders a reaction only when its + * type is a known option — so without this map an arbitrary-emoji reaction can be sent + * but not displayed. + * + * The dataset is fetched through the same lazily code-split dynamic import the picker + * uses (and the result is memoized), so importing this loader costs nothing until it is + * actually called and the ~340 KB JSON never lands in an app's initial bundle. + * Integrators call it once — e.g. in an effect — and merge the result into their + * reaction options; see the emoji migration notes in `AI.md`. + */ +export const loadDefaultExtendedReactionOptions = memoizeAsyncWithReset(async () => + mapEmojiMartData(await loadEmojiData()), +); diff --git a/src/plugins/Emojis/search/EmojiSearchIndex.ts b/src/plugins/Emojis/search/EmojiSearchIndex.ts new file mode 100644 index 000000000..ee9382a56 --- /dev/null +++ b/src/plugins/Emojis/search/EmojiSearchIndex.ts @@ -0,0 +1,37 @@ +import type { + EmojiSearchIndex, + EmojiSearchIndexResult, +} from '../../../components/MessageComposer'; +import { loadEmojiData } from '../data'; +import { memoizeAsyncWithReset } from '../memoizeAsyncWithReset'; +import { buildEmojiSearchData, type SearchableEmoji } from './buildEmojiSearchData'; +import { runSearch } from './search'; + +// Memoized, but dropped on failure so a transient dataset-load error does not +// permanently poison the shared search index used by the composer middleware. +const getIndex = memoizeAsyncWithReset(() => + loadEmojiData().then(buildEmojiSearchData), +); + +const toResult = (emoji: SearchableEmoji): EmojiSearchIndexResult => ({ + emoticons: emoji.emoticons, + id: emoji.id, + name: emoji.name, + native: emoji.native, + skins: emoji.skins, +}); + +/** + * The built-in, `emoji-mart`-free implementation of the {@link EmojiSearchIndex} + * interface consumed by `createTextComposerEmojiMiddleware` and the emoji picker. + * It self-initializes: the vendored dataset is loaded and the search index built + * lazily on first `search()` call, then memoized. An empty query resolves to `[]` + * (functionally equivalent to emoji-mart's `null`, which the middleware coerces). + */ +export const defaultEmojiSearchIndex: EmojiSearchIndex = { + search: async (query: string) => { + if (!query || !query.trim()) return []; + const results = runSearch(await getIndex(), query); + return results ? results.map(toResult) : []; + }, +}; diff --git a/src/plugins/Emojis/search/__tests__/EmojiSearchIndex.test.ts b/src/plugins/Emojis/search/__tests__/EmojiSearchIndex.test.ts new file mode 100644 index 000000000..b7be9f36a --- /dev/null +++ b/src/plugins/Emojis/search/__tests__/EmojiSearchIndex.test.ts @@ -0,0 +1,34 @@ +import { defaultEmojiSearchIndex } from '../EmojiSearchIndex'; + +describe('defaultEmojiSearchIndex', () => { + it('satisfies the EmojiSearchIndex interface (async search)', () => { + expect(typeof defaultEmojiSearchIndex.search).toBe('function'); + }); + + it('resolves to [] for an empty query', async () => { + await expect(defaultEmojiSearchIndex.search('')).resolves.toEqual([]); + await expect(defaultEmojiSearchIndex.search(' ')).resolves.toEqual([]); + }); + + it('lazily builds the index and returns EmojiSearchIndexResult-shaped items', async () => { + const results = (await defaultEmojiSearchIndex.search('fire')) ?? []; + expect(results.length).toBeGreaterThan(0); + const [first] = results; + expect(first).toEqual( + expect.objectContaining({ + id: 'fire', + name: 'Fire', + native: '🔥', + }), + ); + expect(Array.isArray(first.skins)).toBe(true); + expect(first.skins[0]).toHaveProperty('native'); + }); + + it('reuses the memoized index across calls', async () => { + const first = (await defaultEmojiSearchIndex.search('smile')) ?? []; + const second = (await defaultEmojiSearchIndex.search('smile')) ?? []; + expect(second.map((emoji) => emoji.id)).toEqual(first.map((emoji) => emoji.id)); + expect(first[0]?.id).toBe('smile'); + }); +}); diff --git a/src/plugins/Emojis/search/__tests__/buildEmojiSearchData.test.ts b/src/plugins/Emojis/search/__tests__/buildEmojiSearchData.test.ts new file mode 100644 index 000000000..3a3c286c8 --- /dev/null +++ b/src/plugins/Emojis/search/__tests__/buildEmojiSearchData.test.ts @@ -0,0 +1,35 @@ +import { buildEmojiSearchData } from '../buildEmojiSearchData'; +import type { EmojiData } from '../../data/types'; + +const makeData = (): EmojiData => + ({ + aliases: {}, + categories: [], + emojis: { + smile: { + id: 'smile', + keywords: ['happy'], + name: 'Smile', + skins: [{ native: '😄', unified: '1f604' }], + version: 1, + }, + }, + }) as unknown as EmojiData; + +describe('buildEmojiSearchData', () => { + it('memoizes by data object so the panel and middleware share a single build', () => { + const data = makeData(); + expect(buildEmojiSearchData(data)).toBe(buildEmojiSearchData(data)); + }); + + it('builds a distinct index for a different data object', () => { + expect(buildEmojiSearchData(makeData())).not.toBe(buildEmojiSearchData(makeData())); + }); + + it('produces a comma-anchored haystack and resolves the native glyph', () => { + const [entry] = buildEmojiSearchData(makeData()); + expect(entry.id).toBe('smile'); + expect(entry.search).toContain(',smile'); + expect(entry.native).toBe('😄'); + }); +}); diff --git a/src/plugins/Emojis/search/__tests__/search.test.ts b/src/plugins/Emojis/search/__tests__/search.test.ts new file mode 100644 index 000000000..7d89df922 --- /dev/null +++ b/src/plugins/Emojis/search/__tests__/search.test.ts @@ -0,0 +1,108 @@ +import emojiData from '../../data/emoji-data.json'; +import type { EmojiData } from '../../data/types'; +import { buildEmojiSearchData, type SearchableEmoji } from '../buildEmojiSearchData'; +import { runSearch } from '../search'; + +const index = buildEmojiSearchData(emojiData as unknown as EmojiData); +const ids = (results: SearchableEmoji[] | null) => + (results ?? []).map((emoji) => emoji.id); + +describe('runSearch (against the vendored dataset)', () => { + it('returns null for an empty or whitespace-only query', () => { + expect(runSearch(index, '')).toBeNull(); + expect(runSearch(index, ' ')).toBeNull(); + }); + + it('ranks an exact id match first', () => { + expect(ids(runSearch(index, 'fire'))[0]).toBe('fire'); + expect(ids(runSearch(index, 'smile'))[0]).toBe('smile'); + }); + + it('matches keywords by comma-anchored token prefix', () => { + const thumbs = ids(runSearch(index, 'thumb')); + expect(thumbs).toContain('+1'); // "Thumbs Up" via the "thumbsup" keyword + expect(thumbs).toContain('-1'); // "Thumbs Down" via the "thumbsdown" keyword + }); + + it('matches (lowercased) emoticons', () => { + const results = runSearch(index, ':)') ?? []; + expect(results.length).toBeGreaterThan(0); + expect(results.some((emoji) => emoji.emoticons?.includes(':)'))).toBe(true); + }); + + it('applies AND semantics across multiple words', () => { + const results = runSearch(index, 'red heart') ?? []; + expect(results.map((emoji) => emoji.id)).toContain('heart'); // "Red Heart" + // Every result matched BOTH words as comma-anchored tokens — mirroring runSearch's + // own `indexOf(",word")`, not a loose substring that a match like "tired" would pass. + expect( + results.every( + (emoji) => emoji.search.includes(',red') && emoji.search.includes(',heart'), + ), + ).toBe(true); + }); + + it('resolves the native character on each result', () => { + const [first] = runSearch(index, 'fire') ?? []; + expect(first.native).toBe('🔥'); + }); + + it('respects the maxResults cap', () => { + const capped = runSearch(index, 'face', { maxResults: 5 }) ?? []; + expect(capped.length).toBeLessThanOrEqual(5); + expect(capped.length).toBeGreaterThan(0); + }); +}); + +describe('runSearch (ranking details, synthetic data)', () => { + const tiny = buildEmojiSearchData({ + aliases: {}, + categories: [], + emojis: { + aaa: { + id: 'aaa', + keywords: [], + name: 'First', + skins: [{ native: '①', unified: '' }], + version: 1, + }, + bbb: { + id: 'bbb', + keywords: ['aaa'], + name: 'Second', + skins: [{ native: '②', unified: '' }], + version: 1, + }, + // Equal-length names (and ids) so the shared "tie" keyword lands at the same + // haystack offset in both — giving an actual score tie that the id.localeCompare + // tie-break must resolve (otherwise score alone would decide the order). + ccc: { + id: 'ccc', + keywords: ['tie'], + name: 'Cat', + skins: [{ native: '③', unified: '' }], + version: 1, + }, + ddd: { + id: 'ddd', + keywords: ['tie'], + name: 'Dog', + skins: [{ native: '④', unified: '' }], + version: 1, + }, + }, + } as EmojiData); + + it('returns a single match unsorted when fewer than two results', () => { + expect(runSearch(tiny, 'first')?.map((emoji) => emoji.id)).toEqual(['aaa']); + }); + + it('scores an exact id match as 0 so it ranks before keyword matches', () => { + // "aaa" is the id of emoji aaa (score 0) and a keyword of emoji bbb (score > 0) + expect(runSearch(tiny, 'aaa')?.map((emoji) => emoji.id)).toEqual(['aaa', 'bbb']); + }); + + it('breaks score ties with id.localeCompare', () => { + expect(runSearch(tiny, 'tie')?.map((emoji) => emoji.id)).toEqual(['ccc', 'ddd']); + }); +}); diff --git a/src/plugins/Emojis/search/buildEmojiSearchData.ts b/src/plugins/Emojis/search/buildEmojiSearchData.ts new file mode 100644 index 000000000..bba983b23 --- /dev/null +++ b/src/plugins/Emojis/search/buildEmojiSearchData.ts @@ -0,0 +1,70 @@ +import type { EmojiData, EmojiDataEmoji, EmojiDataSkin } from '../data'; + +export type SearchableEmoji = { + id: string; + name: string; + native: string; + /** Comma-prefixed, lowercased haystack — mirrors emoji-mart's `emoji.search`. */ + search: string; + skins: EmojiDataSkin[]; + emoticons?: string[]; +}; + +// Mirrors emoji-mart's SearchIndex haystack construction (module.js): id (not +// tokenized), name (tokenized on /[-|_|\s]+/), keywords and emoticons (not +// tokenized) — all lowercased and comma-joined — followed by each skin's native. +const buildHaystack = (emoji: EmojiDataEmoji): string => { + const fields: Array<[string | string[] | undefined, boolean]> = [ + [emoji.id, false], + [emoji.name, true], + [emoji.keywords, false], + [emoji.emoticons, false], + ]; + + const tokens = fields + .map(([strings, split]) => { + if (!strings) return []; + return (Array.isArray(strings) ? strings : [strings]) + .map((string) => + (split ? string.split(/[-|_|\s]+/) : [string]).map((part) => + part.toLowerCase(), + ), + ) + .flat(); + }) + .flat() + .filter((token) => token && token.trim()); + + let haystack = `,${tokens.join(',')}`; + for (const skin of emoji.skins) { + if (skin?.native) haystack += `,${skin.native}`; + } + return haystack; +}; + +// Cache the built index per data object. The dataset is loaded once (a memoized dynamic +// import), so the picker panel and the composer middleware pass the same object here and +// share a single ~1,900-entry build instead of computing it twice. +const cache = new WeakMap(); + +/** + * Transforms the vendored emoji dataset into a flat, search-ready index (memoized by the + * data object). Pure and side-effect free — the produced `search` haystack matches + * emoji-mart's format so that ranking parity with `emoji-mart`'s `SearchIndex` is + * preserved. + */ +export const buildEmojiSearchData = (data: EmojiData): SearchableEmoji[] => { + const cached = cache.get(data); + if (cached) return cached; + + const built = Object.values(data.emojis).map((emoji) => ({ + emoticons: emoji.emoticons, + id: emoji.id, + name: emoji.name, + native: emoji.skins[0]?.native ?? '', + search: buildHaystack(emoji), + skins: emoji.skins, + })); + cache.set(data, built); + return built; +}; diff --git a/src/plugins/Emojis/search/index.ts b/src/plugins/Emojis/search/index.ts new file mode 100644 index 000000000..e59a35752 --- /dev/null +++ b/src/plugins/Emojis/search/index.ts @@ -0,0 +1,3 @@ +export * from './buildEmojiSearchData'; +export * from './EmojiSearchIndex'; +export * from './search'; diff --git a/src/plugins/Emojis/search/search.ts b/src/plugins/Emojis/search/search.ts new file mode 100644 index 000000000..11be95dd4 --- /dev/null +++ b/src/plugins/Emojis/search/search.ts @@ -0,0 +1,61 @@ +import type { SearchableEmoji } from './buildEmojiSearchData'; + +export type RunSearchOptions = { + maxResults?: number; +}; + +/** + * Ranked emoji search replicating emoji-mart's `SearchIndex.search` (module.js): + * lowercase the query, turn the first `word-` into `word ` (space), split on + * whitespace/`|`/`,`, dedupe, then AND-intersect the pool across words — scoring + * each emoji by the position of `,` in its haystack. Lower score (earlier + * match) ranks first; an exact id match scores 0; ties break by `id.localeCompare`. + * + * Returns `null` for an empty query (parity with emoji-mart), an empty array when + * the query yields no usable words, otherwise the ranked matches capped at + * `maxResults` (default 90). + */ +export const runSearch = ( + index: SearchableEmoji[], + value: string, + { maxResults = 90 }: RunSearchOptions = {}, +): SearchableEmoji[] | null => { + if (!value || !value.trim().length) return null; + + const words = value + .toLowerCase() + .replace(/(\w)-/, '$1 ') + .split(/[\s|,]+/) + .filter((word, position, all) => word.trim() && all.indexOf(word) === position); + + if (!words.length) return []; + + let pool = index; + let results: SearchableEmoji[] = []; + let scores: Record = {}; + + for (const word of words) { + if (!pool.length) break; + results = []; + scores = {}; + for (const emoji of pool) { + if (!emoji.search) continue; + const score = emoji.search.indexOf(`,${word}`); + if (score === -1) continue; + results.push(emoji); + scores[emoji.id] = (scores[emoji.id] ?? 0) + (emoji.id === word ? 0 : score + 1); + } + pool = results; + } + + if (results.length < 2) return results; + + results.sort((a, b) => { + const aScore = scores[a.id]; + const bScore = scores[b.id]; + if (aScore === bScore) return a.id.localeCompare(b.id); + return aScore - bScore; + }); + + return results.length > maxResults ? results.slice(0, maxResults) : results; +}; diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index 2bd0aadea..a5678361a 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -1,11 +1,389 @@ -$emoji-picker-border-radius: 10px; +@use '../../../styling/utils'; +@use '../../../styling/light' as light; -.str-chat__message-textarea-emoji-picker-container { - border-radius: $emoji-picker-border-radius; - box-shadow: var(--str-chat__box-shadow-3); - overflow: hidden; +$emoji-picker-border-radius: 12px; - em-emoji-picker { - --border-radius: #{$emoji-picker-border-radius}; +.str-chat { + .str-chat__message-textarea-emoji-picker-container { + border-radius: $emoji-picker-border-radius; + box-shadow: var(--str-chat__box-shadow-3); + overflow: hidden; } + + .str-chat__emoji-picker { + // Component tokens — resolved from semantic tokens. With `theme='auto'` these + // inherit the ancestor `.str-chat__theme-*`; with a forced `theme` the semantic + // tokens are re-asserted on the panel below so the override wins regardless of + // the ancestor theme. + --str-chat__emoji-picker-background-color: var( + --str-chat__background-core-surface-card + ); + --str-chat__emoji-picker-text-color: var(--str-chat__text-primary); + --str-chat__emoji-picker-secondary-text-color: var(--str-chat__text-secondary); + --str-chat__emoji-picker-hover-background-color: var( + --str-chat__background-utility-hover + ); + --str-chat__emoji-picker-selected-background-color: var( + --str-chat__background-utility-selected + ); + --str-chat__emoji-picker-border-color: var(--str-chat__border-core-on-surface); + // Gentle hairline for the search/footer dividers (the default on-surface border is + // intentionally strong and reads as too heavy here). + --str-chat__emoji-picker-separator-color: var(--str-chat__border-core-subtle); + // A soft grey fill for the search field (the app background token is white). + --str-chat__emoji-picker-search-background-color: var( + --str-chat__background-utility-hover + ); + --str-chat__emoji-picker-active-indicator-color: var(--str-chat__accent-primary); + --str-chat__emoji-picker-width: 22.5rem; + --str-chat__emoji-picker-height: 27.5rem; + --str-chat__emoji-picker-emoji-size: 1.5rem; + + display: flex; + flex-direction: column; + inline-size: var(--str-chat__emoji-picker-width); + max-inline-size: 100%; + block-size: var(--str-chat__emoji-picker-height); + background-color: var(--str-chat__emoji-picker-background-color); + color: var(--str-chat__emoji-picker-text-color); + font: var(--str-chat__font-body-default); + + // The whole row is styled as one filled search field, with the icon inside it. + &__search { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + margin: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm) + var(--str-chat__spacing-sm); + // No inline padding: the icon's inset already comes from the flex `gap` after + // the visually-hidden label (the first flex child). Adding padding here would + // stack on top of that gap and push the icon deeper into the field. + min-block-size: 2rem; + border-radius: var(--str-chat__radius-8); + background-color: var(--str-chat__emoji-picker-search-background-color); + + &:focus-within { + @include utils.focusable; + } + + .str-chat__icon { + flex: none; + inline-size: 1rem; + block-size: 1rem; + color: var(--str-chat__emoji-picker-secondary-text-color); + } + } + + &__search-input { + flex: 1 1 auto; + min-inline-size: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + font-size: 0.875rem; + line-height: 1.5; + + // The filled field shows focus via `:focus-within`, so the input itself doesn't. + &:focus, + &:focus-visible { + outline: none; + } + + &::placeholder { + color: var(--str-chat__emoji-picker-secondary-text-color); + } + } + + &__search-clear { + flex: none; + } + + // Search + skin-tone selector share one row (only when skinTonePosition='search'). + &__search-row { + display: flex; + align-items: center; + + .str-chat__emoji-picker__search { + flex: 1 1 auto; + } + + .str-chat__emoji-picker__skin-tone-toggle, + .str-chat__emoji-picker__skin-tones { + margin-inline-end: var(--str-chat__spacing-sm); + } + } + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--str-chat__spacing-xs); + min-block-size: 12rem; + padding: var(--str-chat__spacing-md); + color: var(--str-chat__emoji-picker-secondary-text-color); + text-align: center; + } + + &__empty-emoji { + font-size: 2rem; + line-height: 1; + } + + &__category-nav { + display: flex; + align-items: stretch; + gap: var(--str-chat__spacing-xxs); + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm) 0; + } + + &__category-nav-button { + @include utils.button-reset; + cursor: pointer; + position: relative; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + padding-block: var(--str-chat__spacing-xs); + font-size: 1.125rem; + line-height: 1; + // Muted tabs; the active one is emphasized with an underline bar (like + // emoji-mart) rather than a filled background. + opacity: 0.55; + + &:hover { + opacity: 0.85; + } + + &:focus-visible { + @include utils.focusable; + } + + &--active { + opacity: 1; + + &::after { + content: ''; + position: absolute; + inset-inline: 15%; + inset-block-end: 0; + block-size: 2px; + border-radius: 2px 2px 0 0; + background-color: var(--str-chat__emoji-picker-active-indicator-color); + } + } + } + + &__body { + flex: 1 1 auto; + min-block-size: 0; + border-block-start: 1px solid var(--str-chat__emoji-picker-separator-color); + } + + &__grid-container { + @include utils.scrollable-y; + block-size: 100%; + padding-inline: var(--str-chat__spacing-xs); + } + + &__category { + padding-inline: var(--str-chat__spacing-xs); + + &-label { + position: sticky; + inset-block-start: 0; + z-index: 1; + padding-block: var(--str-chat__spacing-xs) var(--str-chat__spacing-xxs); + background-color: var(--str-chat__emoji-picker-background-color); + color: var(--str-chat__emoji-picker-secondary-text-color); + font-size: 0.8125rem; + font-weight: 500; + } + + &-emojis { + display: grid; + // Fixed column count driven by the `perLine` option (default 9); cells share the + // row evenly with a real gap so the grid breathes rather than packing tight. + grid-template-columns: repeat( + var(--str-chat__emoji-picker-per-line, 9), + minmax(0, 1fr) + ); + gap: var(--str-chat__spacing-xs); + padding-block-end: var(--str-chat__spacing-xs); + } + + // Pin the "frequently used" section to a single row. The panel caps its ids to + // FREQUENTLY_USED_LIMIT (see frequentlyUsed.ts) and these fixed columns match the + // default grid width, so it fills one aligned row and never wraps to a second as + // more emoji are used. (Uses the literal child class, not `&-emojis`, so the + // combinator doesn't re-expand the whole parent chain.) + &[data-category-id='frequent'] > .str-chat__emoji-picker__category-emojis { + grid-template-columns: repeat( + var(--str-chat__emoji-picker-per-line, 9), + minmax(0, 1fr) + ); + } + } + + &__emoji { + @include utils.button-reset; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1 / 1; + inline-size: 100%; + border-radius: var(--str-chat__radius-4); + font-size: var(--str-chat__emoji-picker-emoji-size); + line-height: 1; + + &:hover { + background-color: var(--str-chat__emoji-picker-hover-background-color); + } + + &:focus-visible { + @include utils.focusable; + } + } + + &__footer { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-sm); + min-block-size: 2.75rem; + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm); + border-block-start: 1px solid var(--str-chat__emoji-picker-separator-color); + } + + // Preview positioned at the top: divider below it instead of above. + &__footer--top { + border-block-start: none; + border-block-end: 1px solid var(--str-chat__emoji-picker-separator-color); + } + + &__preview { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: var(--str-chat__spacing-sm); + min-inline-size: 0; + + &-emoji { + flex: none; + font-size: 1.5rem; + line-height: 1; + } + + // Name (prominent) stacked over the `:shortcode:` (muted), both truncated. + &-text { + display: flex; + flex-direction: column; + min-inline-size: 0; + line-height: 1.2; + } + + &-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 0.875rem; + } + + &-shortcode { + overflow: hidden; + color: var(--str-chat__emoji-picker-secondary-text-color); + white-space: nowrap; + text-overflow: ellipsis; + font-size: 0.8125rem; + } + } + + &__skin-tones { + display: flex; + flex: none; + align-items: center; + gap: var(--str-chat__spacing-xxs); + } + + &__skin-tone-toggle, + &__skin-tone { + @include utils.button-reset; + cursor: pointer; + display: flex; + flex: none; + align-items: center; + justify-content: center; + padding: var(--str-chat__spacing-xxs); + border-radius: var(--str-chat__radius-4); + font-size: 1.25rem; + line-height: 1; + + &:hover { + background-color: var(--str-chat__emoji-picker-hover-background-color); + } + + &:focus-visible { + @include utils.focusable; + } + } + + &__skin-tone--active { + background-color: var(--str-chat__emoji-picker-selected-background-color); + } + + &__loading { + flex: 1 1 auto; + min-block-size: 12rem; + } + + &__error { + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--str-chat__spacing-sm); + min-block-size: 12rem; + padding: var(--str-chat__spacing-md); + color: var(--str-chat__emoji-picker-secondary-text-color); + text-align: center; + } + + &__error-retry { + @include utils.button-reset; + cursor: pointer; + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-md); + border: 1px solid var(--str-chat__emoji-picker-border-color); + border-radius: var(--str-chat__radius-8); + color: var(--str-chat__emoji-picker-text-color); + font: inherit; + + &:hover { + background-color: var(--str-chat__emoji-picker-hover-background-color); + } + + &:focus-visible { + @include utils.focusable; + } + } + } +} + +// Forced light theme (`pickerProps.theme='light'`). +// +// The SDK's global theme rules (variable-tokens.scss) treat light as the `.str-chat` +// default and apply dark via `.str-chat__theme-dark`; there is no rule that asserts +// light values for a `.str-chat__theme-light` class. So a picker forced to light while +// nested in a `.str-chat__theme-dark` ancestor would inherit the ancestor's dark tokens. +// Re-assert the full light set on the forced-light panel so it wins over any inherited +// theme — mirroring how variable-tokens.scss emits `light.variables` for the +// `.str-chat__theme-dark .str-chat__theme-inverse` ("light inside dark") case. +// +// `theme='dark'` needs no equivalent block: the panel carries `.str-chat__theme-dark`, +// which the global rule (always loaded via index.css) already matches. `theme='auto'` +// intentionally inherits the ancestor theme. +.str-chat__emoji-picker.str-chat__theme-light { + @include light.variables; } diff --git a/src/styling/_emoji-replacement.scss b/src/styling/_emoji-replacement.scss index 5d6170dfb..1c34a7b56 100644 --- a/src/styling/_emoji-replacement.scss +++ b/src/styling/_emoji-replacement.scss @@ -21,7 +21,8 @@ $emoji-flag-unicode-range: U+1F1E6-1F1FF; .str-chat__message-textarea, .str-chat__message-text-inner *, .str-chat__emoji-item--entity, - .emoji-mart-emoji-native * { + .str-chat__emoji-picker__emoji, + .str-chat__emoji-picker__preview-emoji { font-family: ReplaceFlagEmojiPNG, var(--str-chat__font-family), sans-serif; font-display: swap; } @@ -33,7 +34,8 @@ $emoji-flag-unicode-range: U+1F1E6-1F1FF; .str-chat__message-textarea, .str-chat__message-text-inner *, .str-chat__emoji-item--entity, - .emoji-mart-emoji-native * { + .str-chat__emoji-picker__emoji, + .str-chat__emoji-picker__preview-emoji { font-family: ReplaceFlagEmojiSVG, var(--str-chat__font-family), sans-serif; font-display: swap; } diff --git a/yarn.lock b/yarn.lock index 5197cd485..dbceada6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -614,7 +614,7 @@ __metadata: languageName: node linkType: hard -"@emoji-mart/data@npm:^1.2.1": +"@emoji-mart/data@npm:1.2.1, @emoji-mart/data@npm:^1.2.1": version: 1.2.1 resolution: "@emoji-mart/data@npm:1.2.1" checksum: 10c0/6784b97bf49a0d3ff110d8447bbd3b0449fcbc497294be3d1c3a6cb1609308776895c7520200be604cbecaa5e172c76927e47f34419c72ba8a76fd4e5a53674b @@ -2072,11 +2072,9 @@ __metadata: version: 0.0.0-use.local resolution: "@stream-io/stream-chat-react-tutorial@workspace:examples/tutorial" dependencies: - "@emoji-mart/data": "npm:^1.2.1" "@types/react": "npm:^19.2.15" "@types/react-dom": "npm:^19.2.3" "@vitejs/plugin-react": "npm:^6.0.3" - emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" stream-chat: "npm:^9.49.0" @@ -2092,6 +2090,7 @@ __metadata: dependencies: "@babel/core": "npm:^7.29.0" "@emoji-mart/data": "npm:^1.2.1" + "@emoji-mart/react": "npm:^1.1.1" "@playwright/test": "npm:^1.60.0" "@types/react": "npm:^19.2.15" "@types/react-dom": "npm:^19.2.3" @@ -10041,7 +10040,7 @@ __metadata: "@breezystack/lamejs": "npm:^1.2.7" "@commitlint/cli": "npm:^21.0.1" "@commitlint/config-conventional": "npm:^21.0.1" - "@emoji-mart/data": "npm:^1.2.1" + "@emoji-mart/data": "npm:1.2.1" "@emoji-mart/react": "npm:^1.1.1" "@eslint/js": "npm:^9.39.4" "@floating-ui/react": "npm:^0.27.19"