From 51ef08a97ef078e19902c3c2ec141bed44dbd73f Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Mon, 16 Mar 2026 12:18:49 -0700 Subject: [PATCH 01/22] feat: add custom emoji category support Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cf809-1bbe-7158-a32f-d7f240e4c1ce --- CUSTOM-EMOJIS.md | 99 +++++++++++++++++++++++++++++++++ src/components/emoji-picker.tsx | 9 ++- src/data/emoji-picker.ts | 74 +++++++++++++++++++++++- src/index.ts | 2 + src/types.ts | 22 +++++++- 5 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 CUSTOM-EMOJIS.md diff --git a/CUSTOM-EMOJIS.md b/CUSTOM-EMOJIS.md new file mode 100644 index 0000000..0e0f08d --- /dev/null +++ b/CUSTOM-EMOJIS.md @@ -0,0 +1,99 @@ +# Custom Emoji Support β€” @gluegroups/frimousse + +## Overview + +This fork adds a `custom` prop to `` that lets you inject image-based emoji categories into the picker. Custom categories appear after the standard Unicode emoji categories and are searchable by label and tags. + +## Types + +```typescript +import type { CustomEmoji, CustomCategory } from "@gluegroups/frimousse"; + +type CustomEmoji = { + id: string; + label: string; + url: string; + tags?: string[]; +}; + +type CustomCategory = { + id: string; + label: string; + emojis: CustomEmoji[]; +}; +``` + +## Usage + +Pass custom categories via the `custom` prop on `` and provide a custom `Emoji` component via `` to render images: + +```tsx +import { EmojiPicker } from "@gluegroups/frimousse"; + +const customCategories = [ + { + id: "team", + label: "Team", + emojis: [ + { id: "shipit", label: "Ship It", url: "/emojis/shipit.png", tags: ["ship", "deploy"] }, + { id: "lgtm", label: "Looks Good To Me", url: "/emojis/lgtm.png", tags: ["approve"] }, + ], + }, +]; + +function MyEmojiPicker() { + return ( + { + if (emoji.url) { + // Handle custom image emoji + console.log("Custom emoji:", emoji.id, emoji.url); + } else { + // Handle standard Unicode emoji + console.log("Emoji:", emoji.emoji); + } + }} + > + + + ( + + ), + }} + /> + + + ); +} +``` + +## Search + +Custom emojis are searchable using the same scoring as standard emojis: + +- **Label match**: +10 points +- **Tag match**: +1 point per tag + +Results are sorted by score descending and filtered when using ``. + +## `onEmojiSelect` Handling + +The `emoji` object passed to `onEmojiSelect` differs for custom vs standard emojis: + +| Field | Standard Emoji | Custom Emoji | +| ------- | -------------- | ------------ | +| `emoji` | `"πŸ˜€"` | `undefined` | +| `label` | `"Grinning"` | `"Ship It"` | +| `url` | `undefined` | `"/emojis/shipit.png"` | +| `id` | `undefined` | `"shipit"` | + +Check for `emoji.url` to distinguish between the two. diff --git a/src/components/emoji-picker.tsx b/src/components/emoji-picker.tsx index 6ca9eaa..c48bc51 100644 --- a/src/components/emoji-picker.tsx +++ b/src/components/emoji-picker.tsx @@ -66,7 +66,8 @@ import { useStableCallback } from "../utils/use-stable-callback"; function EmojiPickerDataHandler({ emojiVersion, emojibaseUrl, -}: Pick) { + custom, +}: Pick) { const [emojiData, setEmojiData] = useState(undefined); const store = useEmojiPickerStore(); const locale = useSelectorKey(store, "locale"); @@ -103,12 +104,12 @@ function EmojiPickerDataHandler({ store .get() .onDataChange( - getEmojiPickerData(emojiData, columns, skinTone, search), + getEmojiPickerData(emojiData, columns, skinTone, search, custom), ); }, { timeout: 100 }, ); - }, [emojiData, columns, skinTone, search]); + }, [emojiData, columns, skinTone, search, custom]); return null; } @@ -143,6 +144,7 @@ const EmojiPickerRoot = forwardRef( columns = 9, skinTone = "none", onEmojiSelect = noop, + custom, emojiVersion, emojibaseUrl, onFocusCapture, @@ -472,6 +474,7 @@ const EmojiPickerRoot = forwardRef( {children} diff --git a/src/data/emoji-picker.ts b/src/data/emoji-picker.ts index 07635b1..62d0659 100644 --- a/src/data/emoji-picker.ts +++ b/src/data/emoji-picker.ts @@ -1,4 +1,5 @@ import type { + CustomCategory, Emoji, EmojiData, EmojiDataEmoji, @@ -48,6 +49,7 @@ export function getEmojiPickerData( columns: number, skinTone: SkinTone | undefined, search: string, + custom?: CustomCategory[], ): EmojiPickerData { const emojis = searchEmojis(data.emojis, search); const rows: EmojiPickerDataRow[] = []; @@ -98,8 +100,78 @@ export function getEmojiPickerData( startRowIndex += categoryRows.length; } + let customCount = 0; + + if (custom) { + const searchText = search?.toLowerCase().trim(); + + for (const customCategory of custom) { + let filtered = customCategory.emojis; + + if (searchText) { + const scores = new Map(); + + filtered = filtered.filter((ce) => { + let score = 0; + + if (ce.label.toLowerCase().includes(searchText)) { + score += 10; + } + + if (ce.tags) { + for (const tag of ce.tags) { + if (tag.toLowerCase().includes(searchText)) { + score += 1; + } + } + } + + if (score > 0) { + scores.set(ce.id, score); + return true; + } + + return false; + }); + + filtered = filtered.sort( + (a, b) => (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0), + ); + } + + if (filtered.length === 0) { + continue; + } + + const customEmojis: EmojiPickerEmoji[] = filtered.map((ce) => ({ + label: ce.label, + url: ce.url, + id: ce.id, + })); + + customCount += customEmojis.length; + + const categoryRows = chunk(customEmojis, columns).map((emojis) => ({ + categoryIndex, + emojis, + })); + + rows.push(...categoryRows); + categories.push({ + label: customCategory.label, + rowsCount: categoryRows.length, + startRowIndex, + }); + + categoriesStartRowIndices.push(startRowIndex); + + categoryIndex++; + startRowIndex += categoryRows.length; + } + } + return { - count: emojis.length, + count: emojis.length + customCount, categories, categoriesStartRowIndices, rows, diff --git a/src/index.ts b/src/index.ts index 43203f2..173d134 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ export * as EmojiPicker from "./components/emoji-picker"; export { useActiveEmoji, useSkinTone } from "./hooks"; export type { Category, + CustomCategory, + CustomEmoji, Emoji, EmojiPickerActiveEmojiProps, EmojiPickerEmptyProps, diff --git a/src/types.ts b/src/types.ts index c8b9fa5..ee52dc1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,9 +62,24 @@ export type EmojiData = { skinTones: Record, string>; }; +export type CustomEmoji = { + id: string; + label: string; + url: string; + tags?: string[]; +}; + +export type CustomCategory = { + id: string; + label: string; + emojis: CustomEmoji[]; +}; + export type EmojiPickerEmoji = { - emoji: string; + emoji?: string; label: string; + url?: string; + id?: string; }; export type EmojiPickerCategory = { @@ -143,6 +158,11 @@ export interface EmojiPickerListProps extends ComponentProps<"div"> { } export interface EmojiPickerRootProps extends ComponentProps<"div"> { + /** + * Custom emoji categories to append to the picker. + */ + custom?: CustomCategory[]; + /** * A callback invoked when an emoji is selected. */ From ba1c7ad4e904a42d7cd05afa5a275fbce93759be Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Mon, 16 Mar 2026 15:26:29 -0700 Subject: [PATCH 02/22] fix: use id comparison for custom emoji isActive check Custom emojis have emoji=undefined, so comparing by emoji string made all custom emojis highlight simultaneously. Compare by id for custom emojis, fall back to emoji string for native emojis. Amp-Thread-ID: https://ampcode.com/threads/T-019cf8a1-c9b3-73c2-9875-c764ee274352 Co-authored-by: Amp --- package.json | 6 +++--- src/components/emoji-picker.tsx | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 4ba0c0c..1158a59 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "frimousse", + "name": "@gluegroups/frimousse", "description": "A lightweight, unstyled, and composable emoji picker for React.", - "version": "0.3.0", + "version": "0.3.2", "license": "MIT", "packageManager": "npm@11.6.0", "type": "module", @@ -81,7 +81,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/liveblocks/frimousse.git" + "url": "git+https://github.com/gluegroups/frimousse.git" }, "homepage": "https://frimousse.liveblocks.io", "keywords": [ diff --git a/src/components/emoji-picker.tsx b/src/components/emoji-picker.tsx index c48bc51..d8d55a5 100644 --- a/src/components/emoji-picker.tsx +++ b/src/components/emoji-picker.tsx @@ -843,10 +843,12 @@ const EmojiPickerListEmoji = memo( rowIndex: number; } & Pick) => { const store = useEmojiPickerStore(); - const isActive = useSelector( - store, - (state) => $activeEmoji(state)?.emoji === emoji.emoji, - ); + const isActive = useSelector(store, (state) => { + const active = $activeEmoji(state); + if (!active) return false; + if (active.id !== undefined) return active.id === emoji.id; + return active.emoji === emoji.emoji; + }); const handleSelect = useCallback(() => { store.get().onEmojiSelect(emoji); From 704b6bc2861f07e48810af3c46ef00e770e46b56 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Mon, 16 Mar 2026 15:37:31 -0700 Subject: [PATCH 03/22] feat: add frequently used emojis support Adds a 'frequently' prop to EmojiPicker.Root that accepts EmojiPickerEmoji[] and prepends them as a 'Frequently Used' category at the top of the picker. Hidden during search. Amp-Thread-ID: https://ampcode.com/threads/T-019cf8a1-c9b3-73c2-9875-c764ee274352 Co-authored-by: Amp --- package.json | 2 +- src/components/emoji-picker.tsx | 12 +++++++++--- src/data/emoji-picker.ts | 24 +++++++++++++++++++++++- src/types.ts | 6 ++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1158a59..d6777f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@gluegroups/frimousse", "description": "A lightweight, unstyled, and composable emoji picker for React.", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "packageManager": "npm@11.6.0", "type": "module", diff --git a/src/components/emoji-picker.tsx b/src/components/emoji-picker.tsx index d8d55a5..ce8666a 100644 --- a/src/components/emoji-picker.tsx +++ b/src/components/emoji-picker.tsx @@ -67,7 +67,11 @@ function EmojiPickerDataHandler({ emojiVersion, emojibaseUrl, custom, -}: Pick) { + frequently, +}: Pick< + EmojiPickerRootProps, + "emojiVersion" | "emojibaseUrl" | "custom" | "frequently" +>) { const [emojiData, setEmojiData] = useState(undefined); const store = useEmojiPickerStore(); const locale = useSelectorKey(store, "locale"); @@ -104,12 +108,12 @@ function EmojiPickerDataHandler({ store .get() .onDataChange( - getEmojiPickerData(emojiData, columns, skinTone, search, custom), + getEmojiPickerData(emojiData, columns, skinTone, search, custom, frequently), ); }, { timeout: 100 }, ); - }, [emojiData, columns, skinTone, search, custom]); + }, [emojiData, columns, skinTone, search, custom, frequently]); return null; } @@ -145,6 +149,7 @@ const EmojiPickerRoot = forwardRef( skinTone = "none", onEmojiSelect = noop, custom, + frequently, emojiVersion, emojibaseUrl, onFocusCapture, @@ -475,6 +480,7 @@ const EmojiPickerRoot = forwardRef( emojibaseUrl={emojibaseUrl} emojiVersion={emojiVersion} custom={custom} + frequently={frequently} /> {children} diff --git a/src/data/emoji-picker.ts b/src/data/emoji-picker.ts index 62d0659..f94c784 100644 --- a/src/data/emoji-picker.ts +++ b/src/data/emoji-picker.ts @@ -50,6 +50,7 @@ export function getEmojiPickerData( skinTone: SkinTone | undefined, search: string, custom?: CustomCategory[], + frequently?: EmojiPickerEmoji[], ): EmojiPickerData { const emojis = searchEmojis(data.emojis, search); const rows: EmojiPickerDataRow[] = []; @@ -58,6 +59,27 @@ export function getEmojiPickerData( const emojisByCategory: Record = {}; let categoryIndex = 0; let startRowIndex = 0; + let frequentlyCount = 0; + + if (frequently && frequently.length > 0 && !search) { + const frequentlyRows = chunk(frequently, columns).map((emojis) => ({ + categoryIndex, + emojis, + })); + + rows.push(...frequentlyRows); + categories.push({ + label: "Frequently Used", + rowsCount: frequentlyRows.length, + startRowIndex, + }); + + categoriesStartRowIndices.push(startRowIndex); + frequentlyCount = frequently.length; + + categoryIndex++; + startRowIndex += frequentlyRows.length; + } for (const emoji of emojis) { if (!emojisByCategory[emoji.category]) { @@ -171,7 +193,7 @@ export function getEmojiPickerData( } return { - count: emojis.length + customCount, + count: emojis.length + customCount + frequentlyCount, categories, categoriesStartRowIndices, rows, diff --git a/src/types.ts b/src/types.ts index ee52dc1..4e14941 100644 --- a/src/types.ts +++ b/src/types.ts @@ -163,6 +163,12 @@ export interface EmojiPickerRootProps extends ComponentProps<"div"> { */ custom?: CustomCategory[]; + /** + * Frequently used emojis to display at the top of the picker. + * Supports both native emojis (with `emoji` field) and custom emojis (with `url` field). + */ + frequently?: EmojiPickerEmoji[]; + /** * A callback invoked when an emoji is selected. */ From f931012840a1e9874872d4a63615720af8ba45fd Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Mon, 16 Mar 2026 15:48:23 -0700 Subject: [PATCH 04/22] docs: add frequently used emojis and changelog to CUSTOM-EMOJIS.md Amp-Thread-ID: https://ampcode.com/threads/T-019cf8a1-c9b3-73c2-9875-c764ee274352 Co-authored-by: Amp --- CUSTOM-EMOJIS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CUSTOM-EMOJIS.md b/CUSTOM-EMOJIS.md index 0e0f08d..f8e1561 100644 --- a/CUSTOM-EMOJIS.md +++ b/CUSTOM-EMOJIS.md @@ -97,3 +97,30 @@ The `emoji` object passed to `onEmojiSelect` differs for custom vs standard emoj | `id` | `undefined` | `"shipit"` | Check for `emoji.url` to distinguish between the two. + +## Frequently Used Emojis + +Pass an array of `EmojiPickerEmoji` objects via the `frequently` prop to display a "Frequently Used" category at the top of the picker. Supports both native and custom emojis. The category is hidden during search. + +```tsx + + {/* ... */} + +``` + +The consumer is responsible for tracking and persisting frequency data β€” the fork does not manage localStorage or usage counts internally. + +## Changes from Upstream + +| Version | Change | Files | +| ------- | ------ | ----- | +| 0.3.1 | Custom emoji support (`custom` prop) | `types.ts`, `emoji-picker.ts`, `emoji-picker.tsx` | +| 0.3.2 | Fix `isActive` for custom emojis (compare by `id` instead of `emoji` string) | `emoji-picker.tsx` | +| 0.3.3 | Frequently used emojis (`frequently` prop) | `types.ts`, `emoji-picker.ts`, `emoji-picker.tsx` | From ee351126e881afa0ab1f977470f7532c8af32649 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Tue, 17 Mar 2026 20:31:39 -0700 Subject: [PATCH 05/22] feat: add configurable frequentlyLabel prop, bump to 0.3.4 Amp-Thread-ID: https://ampcode.com/threads/T-019cf809-1bbe-7158-a32f-d7f240e4c1ce Co-authored-by: Amp --- CUSTOM-EMOJIS.md | 36 ++++----------------------------- package-lock.json | 4 ++-- package.json | 2 +- src/components/emoji-picker.tsx | 9 ++++++--- src/data/emoji-picker.ts | 3 ++- src/types.ts | 7 +++++++ 6 files changed, 22 insertions(+), 39 deletions(-) diff --git a/CUSTOM-EMOJIS.md b/CUSTOM-EMOJIS.md index f8e1561..50f4f7e 100644 --- a/CUSTOM-EMOJIS.md +++ b/CUSTOM-EMOJIS.md @@ -1,35 +1,14 @@ -# Custom Emoji Support β€” @gluegroups/frimousse +# Custom Emoji Support ## Overview -This fork adds a `custom` prop to `` that lets you inject image-based emoji categories into the picker. Custom categories appear after the standard Unicode emoji categories and are searchable by label and tags. - -## Types - -```typescript -import type { CustomEmoji, CustomCategory } from "@gluegroups/frimousse"; - -type CustomEmoji = { - id: string; - label: string; - url: string; - tags?: string[]; -}; - -type CustomCategory = { - id: string; - label: string; - emojis: CustomEmoji[]; -}; -``` +The `custom` prop on `` lets you inject image-based emoji categories into the picker. Custom categories appear after the standard Unicode emoji categories and are searchable by label and tags. The `frequently` prop adds a "Frequently Used" category at the top of the picker, supporting both native and custom emojis. ## Usage Pass custom categories via the `custom` prop on `` and provide a custom `Emoji` component via `` to render images: ```tsx -import { EmojiPicker } from "@gluegroups/frimousse"; - const customCategories = [ { id: "team", @@ -109,18 +88,11 @@ Pass an array of `EmojiPickerEmoji` objects via the `frequently` prop to display { emoji: "❀️", label: "Red Heart" }, { id: "shipit", label: "Ship It", url: "/emojis/shipit.png" }, ]} + frequentlyLabel="Favorites" onEmojiSelect={handleSelect} > {/* ... */} ``` -The consumer is responsible for tracking and persisting frequency data β€” the fork does not manage localStorage or usage counts internally. - -## Changes from Upstream - -| Version | Change | Files | -| ------- | ------ | ----- | -| 0.3.1 | Custom emoji support (`custom` prop) | `types.ts`, `emoji-picker.ts`, `emoji-picker.tsx` | -| 0.3.2 | Fix `isActive` for custom emojis (compare by `id` instead of `emoji` string) | `emoji-picker.tsx` | -| 0.3.3 | Frequently used emojis (`frequently` prop) | `types.ts`, `emoji-picker.ts`, `emoji-picker.tsx` | +The consumer is responsible for tracking and persisting frequency data β€” frimousse does not manage localStorage or usage counts internally. diff --git a/package-lock.json b/package-lock.json index 5101152..f536100 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "frimousse", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frimousse", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "workspaces": [ ".", diff --git a/package.json b/package.json index d6777f3..87edd6c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@gluegroups/frimousse", "description": "A lightweight, unstyled, and composable emoji picker for React.", - "version": "0.3.3", + "version": "0.3.4", "license": "MIT", "packageManager": "npm@11.6.0", "type": "module", diff --git a/src/components/emoji-picker.tsx b/src/components/emoji-picker.tsx index ce8666a..d303052 100644 --- a/src/components/emoji-picker.tsx +++ b/src/components/emoji-picker.tsx @@ -68,9 +68,10 @@ function EmojiPickerDataHandler({ emojibaseUrl, custom, frequently, + frequentlyLabel, }: Pick< EmojiPickerRootProps, - "emojiVersion" | "emojibaseUrl" | "custom" | "frequently" + "emojiVersion" | "emojibaseUrl" | "custom" | "frequently" | "frequentlyLabel" >) { const [emojiData, setEmojiData] = useState(undefined); const store = useEmojiPickerStore(); @@ -108,12 +109,12 @@ function EmojiPickerDataHandler({ store .get() .onDataChange( - getEmojiPickerData(emojiData, columns, skinTone, search, custom, frequently), + getEmojiPickerData(emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel), ); }, { timeout: 100 }, ); - }, [emojiData, columns, skinTone, search, custom, frequently]); + }, [emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel]); return null; } @@ -150,6 +151,7 @@ const EmojiPickerRoot = forwardRef( onEmojiSelect = noop, custom, frequently, + frequentlyLabel, emojiVersion, emojibaseUrl, onFocusCapture, @@ -481,6 +483,7 @@ const EmojiPickerRoot = forwardRef( emojiVersion={emojiVersion} custom={custom} frequently={frequently} + frequentlyLabel={frequentlyLabel} /> {children} diff --git a/src/data/emoji-picker.ts b/src/data/emoji-picker.ts index f94c784..4a8a404 100644 --- a/src/data/emoji-picker.ts +++ b/src/data/emoji-picker.ts @@ -51,6 +51,7 @@ export function getEmojiPickerData( search: string, custom?: CustomCategory[], frequently?: EmojiPickerEmoji[], + frequentlyLabel?: string, ): EmojiPickerData { const emojis = searchEmojis(data.emojis, search); const rows: EmojiPickerDataRow[] = []; @@ -69,7 +70,7 @@ export function getEmojiPickerData( rows.push(...frequentlyRows); categories.push({ - label: "Frequently Used", + label: frequentlyLabel ?? "Frequently Used", rowsCount: frequentlyRows.length, startRowIndex, }); diff --git a/src/types.ts b/src/types.ts index 4e14941..8647650 100644 --- a/src/types.ts +++ b/src/types.ts @@ -169,6 +169,13 @@ export interface EmojiPickerRootProps extends ComponentProps<"div"> { */ frequently?: EmojiPickerEmoji[]; + /** + * The label for the frequently used category header. + * + * @default "Frequently Used" + */ + frequentlyLabel?: string; + /** * A callback invoked when an emoji is selected. */ From 69e817fb3e4b30e3d08c55c20769dee3e81d9211 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Tue, 17 Mar 2026 20:42:18 -0700 Subject: [PATCH 06/22] fix: revert package.json name, version, and repository to upstream values Amp-Thread-ID: https://ampcode.com/threads/T-019cf809-1bbe-7158-a32f-d7f240e4c1ce Co-authored-by: Amp --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 87edd6c..4ba0c0c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@gluegroups/frimousse", + "name": "frimousse", "description": "A lightweight, unstyled, and composable emoji picker for React.", - "version": "0.3.4", + "version": "0.3.0", "license": "MIT", "packageManager": "npm@11.6.0", "type": "module", @@ -81,7 +81,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/gluegroups/frimousse.git" + "url": "git+https://github.com/liveblocks/frimousse.git" }, "homepage": "https://frimousse.liveblocks.io", "keywords": [ From 410b9bbb466edbec5dc88cef21737b3685e519e1 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Tue, 17 Mar 2026 20:54:47 -0700 Subject: [PATCH 07/22] fix: use CSS style for img sizing in docs example Amp-Thread-ID: https://ampcode.com/threads/T-019cf809-1bbe-7158-a32f-d7f240e4c1ce Co-authored-by: Amp --- CUSTOM-EMOJIS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CUSTOM-EMOJIS.md b/CUSTOM-EMOJIS.md index 50f4f7e..4c91ec8 100644 --- a/CUSTOM-EMOJIS.md +++ b/CUSTOM-EMOJIS.md @@ -41,7 +41,7 @@ function MyEmojiPicker() { Emoji: ({ emoji, ...props }) => ( + ), + }} +/> +``` + +## Publishing β€” Internal Fork + +- Publish as `@gluegroups/frimousse` to GitHub Packages +- Consumed in glue-web via existing `.yarnrc.yml` `@gluegroups` scope config +- `main` branch uses fork-specific `package.json` values: + - `"name": "@gluegroups/frimousse"` + - `"version"`: our own version (e.g. `"0.3.4"`) + - `"repository.url"`: `"git+https://github.com/gluegroups/frimousse.git"` + +## Publishing β€” Upstream PR + +When pushing branches that target `liveblocks/frimousse`, **revert `package.json` to upstream values**: +- `"name": "frimousse"` +- `"version"`: match upstream (e.g. `"0.3.0"`) +- `"repository.url"`: `"git+https://github.com/liveblocks/frimousse.git"` + +Do **not** include `AGENTS.md` or any `@gluegroups`-specific references in upstream PRs. + +## Principles for Making Changes + +This is a fork. Every change we make is a future merge conflict. Follow these principles to keep upstream rebases clean and painless: + +1. **Isolate into new files.** Prefer adding new files (e.g. `src/data/custom-emojis.ts`) over editing existing ones. New files have zero merge conflict surface area. + +2. **Extract before inserting.** When logic must touch an existing file, extract it into a self-contained function first, then call that function from the existing code at a single, minimal call site. The insertion point should be as small as possible β€” ideally one line. + +3. **Make it trivially removable.** Any change should be removable by deleting our new files and reverting a small number of call sites. If removing a feature requires untangling logic scattered across an existing file, the change was not isolated enough. + +4. **No refactoring of upstream code.** Do not rename, reorganize, or restructure upstream code, even if it would be cleaner. Each such change is a rebase hazard. + +5. **Avoid touching upstream types where possible.** Prefer extending types via intersection or wrapping rather than modifying upstream type definitions in place. + +## Upstream Sync + +Keep changes isolated and additive so rebasing on `upstream/main` stays clean: +```sh +git fetch upstream +git rebase upstream/main +# Re-apply @gluegroups name/version/repo in package.json +# Run tests, then publish +``` diff --git a/src/components/emoji-picker.tsx b/src/components/emoji-picker.tsx index d303052..7a61b7c 100644 --- a/src/components/emoji-picker.tsx +++ b/src/components/emoji-picker.tsx @@ -18,6 +18,7 @@ import { useState, } from "react"; import { EMOJI_FONT_FAMILY } from "../constants"; +import type { CustomEmojiRootProps } from "../custom-emoji-types"; import { getEmojiData, validateLocale, validateSkinTone } from "../data/emoji"; import { getEmojiPickerData } from "../data/emoji-picker"; import { useActiveEmoji, useSkinTone } from "../hooks"; @@ -57,6 +58,7 @@ import type { WithAttributes, } from "../types"; import { shallow } from "../utils/compare"; +import { isSameEmoji } from "../utils/emoji-identity"; import { noop } from "../utils/noop"; import { requestIdleCallback } from "../utils/request-idle-callback"; import { useCreateStore, useSelector, useSelectorKey } from "../utils/store"; @@ -70,7 +72,7 @@ function EmojiPickerDataHandler({ frequently, frequentlyLabel, }: Pick< - EmojiPickerRootProps, + EmojiPickerRootProps & CustomEmojiRootProps, "emojiVersion" | "emojibaseUrl" | "custom" | "frequently" | "frequentlyLabel" >) { const [emojiData, setEmojiData] = useState(undefined); @@ -142,7 +144,7 @@ function EmojiPickerDataHandler({ * * ``` */ -const EmojiPickerRoot = forwardRef( +const EmojiPickerRoot = forwardRef( ( { locale = "en", @@ -852,12 +854,9 @@ const EmojiPickerListEmoji = memo( rowIndex: number; } & Pick) => { const store = useEmojiPickerStore(); - const isActive = useSelector(store, (state) => { - const active = $activeEmoji(state); - if (!active) return false; - if (active.id !== undefined) return active.id === emoji.id; - return active.emoji === emoji.emoji; - }); + const isActive = useSelector(store, (state) => + isSameEmoji($activeEmoji(state), emoji), + ); const handleSelect = useCallback(() => { store.get().onEmojiSelect(emoji); diff --git a/src/custom-emoji-types.ts b/src/custom-emoji-types.ts new file mode 100644 index 0000000..53a1e32 --- /dev/null +++ b/src/custom-emoji-types.ts @@ -0,0 +1,44 @@ +import type { EmojiPickerEmoji, EmojiPickerRootProps } from "./types"; + +export type CustomEmoji = { + id: string; + label: string; + url: string; + tags?: string[]; +}; + +export type CustomCategory = { + id: string; + label: string; + emojis: CustomEmoji[]; +}; + +/** + * Additional root props for custom emoji support. + * Intersected with the upstream EmojiPickerRootProps at the component level. + */ +export interface CustomEmojiRootProps { + /** + * Custom emoji categories to append to the picker. + */ + custom?: CustomCategory[]; + + /** + * Frequently used emojis to display at the top of the picker. + * Supports both native emojis (with `emoji` field) and custom emojis (with `url` and `id` fields). + */ + frequently?: EmojiPickerEmoji[]; + + /** + * The label for the frequently used category header. + * + * @default "Frequently Used" + */ + frequentlyLabel?: string; +} + +/** + * Full root props type, including custom emoji extensions. + * Re-exported from index.ts as EmojiPickerRootProps to shadow the upstream type. + */ +export type AugmentedEmojiPickerRootProps = EmojiPickerRootProps & CustomEmojiRootProps; diff --git a/src/data/custom-emoji.ts b/src/data/custom-emoji.ts new file mode 100644 index 0000000..a0c5462 --- /dev/null +++ b/src/data/custom-emoji.ts @@ -0,0 +1,126 @@ +import type { CustomCategory } from "../custom-emoji-types"; +import type { + EmojiPickerDataCategory, + EmojiPickerDataRow, + EmojiPickerEmoji, +} from "../types"; +import { chunk } from "../utils/chunk"; + +type BuiltRows = { + rows: EmojiPickerDataRow[]; + category: EmojiPickerDataCategory; + count: number; +}; + +type BuiltCategoryRows = { + rows: EmojiPickerDataRow[]; + categories: EmojiPickerDataCategory[]; + count: number; +}; + +export function buildFrequentlyUsedRows( + frequently: EmojiPickerEmoji[], + columns: number, + categoryIndex: number, + startRowIndex: number, + frequentlyLabel: string | undefined, +): BuiltRows { + const rows = chunk(frequently, columns).map((emojis) => ({ + categoryIndex, + emojis, + })); + + return { + rows, + category: { + label: frequentlyLabel ?? "Frequently Used", + rowsCount: rows.length, + startRowIndex, + }, + count: frequently.length, + }; +} + +function searchCustomEmojis( + emojis: CustomCategory["emojis"], + searchText: string, +): CustomCategory["emojis"] { + const scores = new Map(); + + const filtered = emojis.filter((ce) => { + let score = 0; + + if (ce.label.toLowerCase().includes(searchText)) { + score += 10; + } + + if (ce.tags) { + for (const tag of ce.tags) { + if (tag.toLowerCase().includes(searchText)) { + score += 1; + } + } + } + + if (score > 0) { + scores.set(ce.id, score); + return true; + } + + return false; + }); + + return filtered.sort( + (a, b) => (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0), + ); +} + +export function buildCustomCategoryRows( + custom: CustomCategory[], + search: string, + columns: number, + startingCategoryIndex: number, + startingRowIndex: number, +): BuiltCategoryRows { + const rows: EmojiPickerDataRow[] = []; + const categories: EmojiPickerDataCategory[] = []; + const searchText = search.toLowerCase().trim(); + let categoryIndex = startingCategoryIndex; + let startRowIndex = startingRowIndex; + let count = 0; + + for (const customCategory of custom) { + const filtered = searchText + ? searchCustomEmojis(customCategory.emojis, searchText) + : customCategory.emojis; + + if (filtered.length === 0) { + continue; + } + + const customEmojis: EmojiPickerEmoji[] = filtered.map((ce) => ({ + label: ce.label, + url: ce.url, + id: ce.id, + })); + + count += customEmojis.length; + + const categoryRows = chunk(customEmojis, columns).map((emojis) => ({ + categoryIndex, + emojis, + })); + + rows.push(...categoryRows); + categories.push({ + label: customCategory.label, + rowsCount: categoryRows.length, + startRowIndex, + }); + + categoryIndex++; + startRowIndex += categoryRows.length; + } + + return { rows, categories, count }; +} diff --git a/src/data/emoji-picker.ts b/src/data/emoji-picker.ts index 4a8a404..272874a 100644 --- a/src/data/emoji-picker.ts +++ b/src/data/emoji-picker.ts @@ -1,5 +1,6 @@ +import type { CustomCategory } from "../custom-emoji-types"; +import { buildCustomCategoryRows, buildFrequentlyUsedRows } from "./custom-emoji"; import type { - CustomCategory, Emoji, EmojiData, EmojiDataEmoji, @@ -63,23 +64,13 @@ export function getEmojiPickerData( let frequentlyCount = 0; if (frequently && frequently.length > 0 && !search) { - const frequentlyRows = chunk(frequently, columns).map((emojis) => ({ - categoryIndex, - emojis, - })); - - rows.push(...frequentlyRows); - categories.push({ - label: frequentlyLabel ?? "Frequently Used", - rowsCount: frequentlyRows.length, - startRowIndex, - }); - + const built = buildFrequentlyUsedRows(frequently, columns, categoryIndex, startRowIndex, frequentlyLabel); + rows.push(...built.rows); + categories.push(built.category); categoriesStartRowIndices.push(startRowIndex); - frequentlyCount = frequently.length; - + frequentlyCount = built.count; categoryIndex++; - startRowIndex += frequentlyRows.length; + startRowIndex += built.rows.length; } for (const emoji of emojis) { @@ -126,71 +117,13 @@ export function getEmojiPickerData( let customCount = 0; if (custom) { - const searchText = search?.toLowerCase().trim(); - - for (const customCategory of custom) { - let filtered = customCategory.emojis; - - if (searchText) { - const scores = new Map(); - - filtered = filtered.filter((ce) => { - let score = 0; - - if (ce.label.toLowerCase().includes(searchText)) { - score += 10; - } - - if (ce.tags) { - for (const tag of ce.tags) { - if (tag.toLowerCase().includes(searchText)) { - score += 1; - } - } - } - - if (score > 0) { - scores.set(ce.id, score); - return true; - } - - return false; - }); - - filtered = filtered.sort( - (a, b) => (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0), - ); - } - - if (filtered.length === 0) { - continue; - } - - const customEmojis: EmojiPickerEmoji[] = filtered.map((ce) => ({ - label: ce.label, - url: ce.url, - id: ce.id, - })); - - customCount += customEmojis.length; - - const categoryRows = chunk(customEmojis, columns).map((emojis) => ({ - categoryIndex, - emojis, - })); - - rows.push(...categoryRows); - categories.push({ - label: customCategory.label, - rowsCount: categoryRows.length, - startRowIndex, - }); - - categoriesStartRowIndices.push(startRowIndex); - - categoryIndex++; - startRowIndex += categoryRows.length; + const built = buildCustomCategoryRows(custom, search, columns, categoryIndex, startRowIndex); + rows.push(...built.rows); + categories.push(...built.categories); + for (const cat of built.categories) { + categoriesStartRowIndices.push(cat.startRowIndex); } + customCount = built.count; } return { diff --git a/src/index.ts b/src/index.ts index 173d134..7773065 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ export * as EmojiPicker from "./components/emoji-picker"; export { useActiveEmoji, useSkinTone } from "./hooks"; +export type { CustomCategory, CustomEmoji } from "./custom-emoji-types"; +export type { AugmentedEmojiPickerRootProps as EmojiPickerRootProps } from "./custom-emoji-types"; export type { Category, - CustomCategory, - CustomEmoji, Emoji, EmojiPickerActiveEmojiProps, EmojiPickerEmptyProps, @@ -13,7 +13,6 @@ export type { EmojiPickerListProps, EmojiPickerListRowProps, EmojiPickerLoadingProps, - EmojiPickerRootProps, EmojiPickerSearchProps, EmojiPickerSkinToneProps, EmojiPickerSkinToneSelectorProps, diff --git a/src/types.ts b/src/types.ts index 8647650..3c1ff67 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,18 +62,7 @@ export type EmojiData = { skinTones: Record, string>; }; -export type CustomEmoji = { - id: string; - label: string; - url: string; - tags?: string[]; -}; - -export type CustomCategory = { - id: string; - label: string; - emojis: CustomEmoji[]; -}; +export type { CustomEmoji, CustomCategory } from "./custom-emoji-types"; export type EmojiPickerEmoji = { emoji?: string; @@ -158,24 +147,6 @@ export interface EmojiPickerListProps extends ComponentProps<"div"> { } export interface EmojiPickerRootProps extends ComponentProps<"div"> { - /** - * Custom emoji categories to append to the picker. - */ - custom?: CustomCategory[]; - - /** - * Frequently used emojis to display at the top of the picker. - * Supports both native emojis (with `emoji` field) and custom emojis (with `url` field). - */ - frequently?: EmojiPickerEmoji[]; - - /** - * The label for the frequently used category header. - * - * @default "Frequently Used" - */ - frequentlyLabel?: string; - /** * A callback invoked when an emoji is selected. */ diff --git a/src/utils/emoji-identity.ts b/src/utils/emoji-identity.ts new file mode 100644 index 0000000..6cc9795 --- /dev/null +++ b/src/utils/emoji-identity.ts @@ -0,0 +1,15 @@ +import type { EmojiPickerEmoji } from "../types"; + +/** + * Compares two emoji picker emojis for identity. + * For custom emojis (with `id`), compares by id. + * For native emojis, compares by emoji string. + */ +export function isSameEmoji( + a: EmojiPickerEmoji | undefined, + b: EmojiPickerEmoji | undefined, +): boolean { + if (!a || !b) return false; + if (a.id !== undefined && b.id !== undefined) return a.id === b.id; + return a.emoji === b.emoji; +} From 074c0604f99576aaefd5c1e7e5959e1e7e52f54f Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 16:24:44 -0700 Subject: [PATCH 09/22] docs: update AGENTS.md with current architecture and reversal guide Replace the stale 3-file modification list with an accurate description of the isolated structure: new files, upstream touch points, and a step-by-step guide for removing the feature entirely. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 83 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 52 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c86c964..b6a802b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,43 +4,64 @@ Add **custom emoji support** to frimousse so glue-web can inject custom emoji categories (image-based) into the picker. Changes should be minimal and additive to keep rebasing on upstream easy. -## Modifications (3 files) - -### 1. `src/types.ts` β€” Add custom emoji types - -```typescript -export type CustomEmoji = { - id: string; - label: string; - url: string; - tags?: string[]; -}; - -export type CustomCategory = { - id: string; - label: string; - emojis: CustomEmoji[]; -}; -``` +## Our Files + +New files added by this fork. Zero merge conflict surface area β€” upstream rebase never touches these. + +| File | Purpose | +|---|---| +| `src/custom-emoji-types.ts` | `CustomEmoji`, `CustomCategory`, `CustomEmojiRootProps`, `AugmentedEmojiPickerRootProps` | +| `src/data/custom-emoji.ts` | `buildFrequentlyUsedRows()`, `buildCustomCategoryRows()`, `searchCustomEmojis()` | +| `src/utils/emoji-identity.ts` | `isSameEmoji()` β€” discriminated identity check for native vs. custom emojis | + +## Upstream Touch Points + +Minimal changes to upstream files. Each is a small, targeted insertion. + +### `src/types.ts` + +- `EmojiPickerEmoji`: widened with `emoji?`, `url?`, `id?` to accommodate custom emojis flowing through the upstream data pipeline (required because `$activeEmoji` in `store.ts` returns this type and cannot be modified) +- Re-exports `CustomEmoji` and `CustomCategory` from `custom-emoji-types.ts` + +### `src/data/emoji-picker.ts` + +- `getEmojiPickerData()`: three new optional params (`custom`, `frequently`, `frequentlyLabel`) +- Two delegation call sites added β€” one for frequently used rows (before the native emoji loop), one for custom category rows (after it). All logic lives in `custom-emoji.ts`. + +### `src/components/emoji-picker.tsx` + +- `EmojiPickerRoot` and `EmojiPickerDataHandler`: prop type changed to `EmojiPickerRootProps & CustomEmojiRootProps`; props forwarded to `getEmojiPickerData()` +- `EmojiPickerListEmoji`: `isActive` selector replaced with `isSameEmoji()` call + +### `src/index.ts` + +- `CustomEmoji`, `CustomCategory` added to exports +- `EmojiPickerRootProps` re-exported as `AugmentedEmojiPickerRootProps` (the merged type), shadowing the upstream export so consumers see the full prop surface + +## Removing This Feature + +To strip the custom emoji feature entirely: -- Add `custom?: CustomCategory[]`, `frequently?: EmojiPickerEmoji[]`, and `frequentlyLabel?: string` to `EmojiPickerRootProps` -- Extend `EmojiPickerEmoji` with optional `url` and `id` fields for custom emojis (`emoji` is optional) +1. **Delete** `src/custom-emoji-types.ts`, `src/data/custom-emoji.ts`, `src/utils/emoji-identity.ts` -### 2. `src/data/emoji-picker.ts` β€” Merge custom categories into data pipeline +2. **Revert `src/types.ts`:** + - Remove the re-exports of `CustomEmoji` and `CustomCategory` + - Restore `EmojiPickerEmoji` to `{ emoji: string; label: string }` -- Add `custom`, `frequently`, and `frequentlyLabel` parameters to `getEmojiPickerData()` -- When `frequently` is provided and search is empty, prepend a "Frequently Used" category (label configurable via `frequentlyLabel`) -- When `search` is non-empty, filter custom emojis by `label` and `tags` using the same scoring approach as `searchEmojis()` -- After building standard category rows, append custom category rows using the same `chunk()` utility -- Ensure custom categories appear in `categories[]` and `rows[]` with correct `startRowIndex` offsets +3. **Revert `src/data/emoji-picker.ts`:** + - Remove the `custom`, `frequently`, `frequentlyLabel` params from `getEmojiPickerData()` + - Remove the two delegation call sites and their imports -### 3. `src/components/emoji-picker.tsx` β€” Wire the props through +4. **Revert `src/components/emoji-picker.tsx`:** + - Remove `CustomEmojiRootProps` import and type intersections; restore `EmojiPickerRootProps` alone + - Remove `isSameEmoji` import; restore `isActive` to `$activeEmoji(state)?.emoji === emoji.emoji` + - Remove destructuring and forwarding of `custom`, `frequently`, `frequentlyLabel` -- `EmojiPickerRoot`: Destructure `custom`, `frequently`, `frequentlyLabel` from props, forward to `EmojiPickerDataHandler` -- `EmojiPickerDataHandler`: Pass all three to `getEmojiPickerData()` -- `EmojiPickerListEmoji`: Compare active emoji by `id` for custom emojis, fall back to `emoji` string for native emojis +5. **Revert `src/index.ts`:** + - Remove `CustomEmoji`, `CustomCategory` exports + - Restore `EmojiPickerRootProps` to export directly from `./types` -### Note: Image rendering is handled by the consumer +## Note: Image Rendering The `DefaultEmojiPickerListEmoji` is **not** modified. Consumers render custom emoji images via the existing `components` prop on ``: From ecb609fa99c64976f08118c5aa12a7a18cd30545 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 16:29:41 -0700 Subject: [PATCH 10/22] docs: note searchCustomEmojis mirrors upstream searchEmojis scoring Co-Authored-By: Claude Sonnet 4.6 --- src/data/custom-emoji.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/data/custom-emoji.ts b/src/data/custom-emoji.ts index a0c5462..9529c4b 100644 --- a/src/data/custom-emoji.ts +++ b/src/data/custom-emoji.ts @@ -41,6 +41,8 @@ export function buildFrequentlyUsedRows( }; } +// Mirrors the scoring algorithm in the upstream searchEmojis() (src/data/emoji-picker.ts). +// If that function's scoring logic changes, update this one to match. function searchCustomEmojis( emojis: CustomCategory["emojis"], searchText: string, From 57d2d4fc5d680d3f15091ba6d0934cb7710a9cca Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 16:40:43 -0700 Subject: [PATCH 11/22] feat: add searchLabel prop for unified cross-category search results When searchLabel is provided, search results from native and custom emojis are merged into a single flat category ranked by relevance (label +10, tag +1), replacing the default per-category display. When omitted, upstream search behaviour is preserved. - Add searchLabel to CustomEmojiRootProps - Add scoreEmoji() shared scoring helper to custom-emoji.ts - Refactor searchCustomEmojis() to use scoreEmoji() - Add buildUnifiedSearchRows() to custom-emoji.ts - Add early-return unified search branch in getEmojiPickerData() - Wire searchLabel through EmojiPickerDataHandler and EmojiPickerRoot - Update AGENTS.md Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 13 +++-- src/components/emoji-picker.tsx | 9 ++- src/custom-emoji-types.ts | 10 ++++ src/data/custom-emoji.ts | 98 ++++++++++++++++++++++++++++----- src/data/emoji-picker.ts | 14 ++++- 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b6a802b..56a489a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ New files added by this fork. Zero merge conflict surface area β€” upstream reba | File | Purpose | |---|---| | `src/custom-emoji-types.ts` | `CustomEmoji`, `CustomCategory`, `CustomEmojiRootProps`, `AugmentedEmojiPickerRootProps` | -| `src/data/custom-emoji.ts` | `buildFrequentlyUsedRows()`, `buildCustomCategoryRows()`, `searchCustomEmojis()` | +| `src/data/custom-emoji.ts` | `buildFrequentlyUsedRows()`, `buildCustomCategoryRows()`, `buildUnifiedSearchRows()`, `scoreEmoji()`, `searchCustomEmojis()` | | `src/utils/emoji-identity.ts` | `isSameEmoji()` β€” discriminated identity check for native vs. custom emojis | ## Upstream Touch Points @@ -25,8 +25,9 @@ Minimal changes to upstream files. Each is a small, targeted insertion. ### `src/data/emoji-picker.ts` -- `getEmojiPickerData()`: three new optional params (`custom`, `frequently`, `frequentlyLabel`) -- Two delegation call sites added β€” one for frequently used rows (before the native emoji loop), one for custom category rows (after it). All logic lives in `custom-emoji.ts`. +- `getEmojiPickerData()`: four new optional params (`custom`, `frequently`, `frequentlyLabel`, `searchLabel`) +- When `search` is non-empty and both `custom` and `searchLabel` are provided, delegates immediately to `buildUnifiedSearchRows()` and early-returns a single flat category (unified ranking across native and custom emojis) +- Otherwise: two delegation call sites β€” one for frequently used rows (before the native emoji loop), one for custom category rows (after it). All logic lives in `custom-emoji.ts`. ### `src/components/emoji-picker.tsx` @@ -49,13 +50,13 @@ To strip the custom emoji feature entirely: - Restore `EmojiPickerEmoji` to `{ emoji: string; label: string }` 3. **Revert `src/data/emoji-picker.ts`:** - - Remove the `custom`, `frequently`, `frequentlyLabel` params from `getEmojiPickerData()` - - Remove the two delegation call sites and their imports + - Remove the `custom`, `frequently`, `frequentlyLabel`, `searchLabel` params from `getEmojiPickerData()` + - Remove the unified search early-return branch and the two delegation call sites, and their imports 4. **Revert `src/components/emoji-picker.tsx`:** - Remove `CustomEmojiRootProps` import and type intersections; restore `EmojiPickerRootProps` alone - Remove `isSameEmoji` import; restore `isActive` to `$activeEmoji(state)?.emoji === emoji.emoji` - - Remove destructuring and forwarding of `custom`, `frequently`, `frequentlyLabel` + - Remove destructuring and forwarding of `custom`, `frequently`, `frequentlyLabel`, `searchLabel` 5. **Revert `src/index.ts`:** - Remove `CustomEmoji`, `CustomCategory` exports diff --git a/src/components/emoji-picker.tsx b/src/components/emoji-picker.tsx index 7a61b7c..0685690 100644 --- a/src/components/emoji-picker.tsx +++ b/src/components/emoji-picker.tsx @@ -71,9 +71,10 @@ function EmojiPickerDataHandler({ custom, frequently, frequentlyLabel, + searchLabel, }: Pick< EmojiPickerRootProps & CustomEmojiRootProps, - "emojiVersion" | "emojibaseUrl" | "custom" | "frequently" | "frequentlyLabel" + "emojiVersion" | "emojibaseUrl" | "custom" | "frequently" | "frequentlyLabel" | "searchLabel" >) { const [emojiData, setEmojiData] = useState(undefined); const store = useEmojiPickerStore(); @@ -111,12 +112,12 @@ function EmojiPickerDataHandler({ store .get() .onDataChange( - getEmojiPickerData(emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel), + getEmojiPickerData(emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, searchLabel), ); }, { timeout: 100 }, ); - }, [emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel]); + }, [emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, searchLabel]); return null; } @@ -154,6 +155,7 @@ const EmojiPickerRoot = forwardRef {children} diff --git a/src/custom-emoji-types.ts b/src/custom-emoji-types.ts index 53a1e32..f8e0c40 100644 --- a/src/custom-emoji-types.ts +++ b/src/custom-emoji-types.ts @@ -35,6 +35,16 @@ export interface CustomEmojiRootProps { * @default "Frequently Used" */ frequentlyLabel?: string; + + /** + * When provided, search results from both native and custom emojis are + * merged into a single flat category sorted by relevance, using this + * string as the category header label. + * + * When omitted, search results are displayed within their original + * categories (default upstream behaviour). + */ + searchLabel?: string; } /** diff --git a/src/data/custom-emoji.ts b/src/data/custom-emoji.ts index 9529c4b..4659593 100644 --- a/src/data/custom-emoji.ts +++ b/src/data/custom-emoji.ts @@ -1,8 +1,10 @@ import type { CustomCategory } from "../custom-emoji-types"; import type { + EmojiDataEmoji, EmojiPickerDataCategory, EmojiPickerDataRow, EmojiPickerEmoji, + SkinTone, } from "../types"; import { chunk } from "../utils/chunk"; @@ -42,7 +44,23 @@ export function buildFrequentlyUsedRows( } // Mirrors the scoring algorithm in the upstream searchEmojis() (src/data/emoji-picker.ts). -// If that function's scoring logic changes, update this one to match. +// If that function's scoring logic changes, update scoreEmoji() to match. +function scoreEmoji(label: string, tags: string[], searchText: string): number { + let score = 0; + + if (label.toLowerCase().includes(searchText)) { + score += 10; + } + + for (const tag of tags) { + if (tag.toLowerCase().includes(searchText)) { + score += 1; + } + } + + return score; +} + function searchCustomEmojis( emojis: CustomCategory["emojis"], searchText: string, @@ -50,19 +68,7 @@ function searchCustomEmojis( const scores = new Map(); const filtered = emojis.filter((ce) => { - let score = 0; - - if (ce.label.toLowerCase().includes(searchText)) { - score += 10; - } - - if (ce.tags) { - for (const tag of ce.tags) { - if (tag.toLowerCase().includes(searchText)) { - score += 1; - } - } - } + const score = scoreEmoji(ce.label, ce.tags ?? [], searchText); if (score > 0) { scores.set(ce.id, score); @@ -126,3 +132,67 @@ export function buildCustomCategoryRows( return { rows, categories, count }; } + +export function buildUnifiedSearchRows( + nativeEmojis: EmojiDataEmoji[], + custom: CustomCategory[], + search: string, + columns: number, + categoryIndex: number, + startRowIndex: number, + skinTone: SkinTone | undefined, + searchLabel: string, +): BuiltRows { + const searchText = search.toLowerCase().trim(); + + type ScoredEmoji = { emoji: EmojiPickerEmoji; score: number }; + const scored: ScoredEmoji[] = []; + + for (const e of nativeEmojis) { + const score = scoreEmoji(e.label, e.tags, searchText); + + if (score > 0) { + scored.push({ + emoji: { + emoji: + skinTone && skinTone !== "none" && e.skins + ? e.skins[skinTone] + : e.emoji, + label: e.label, + }, + score, + }); + } + } + + for (const customCategory of custom) { + for (const ce of customCategory.emojis) { + const score = scoreEmoji(ce.label, ce.tags ?? [], searchText); + + if (score > 0) { + scored.push({ + emoji: { label: ce.label, url: ce.url, id: ce.id }, + score, + }); + } + } + } + + scored.sort((a, b) => b.score - a.score); + + const emojis = scored.map((s) => s.emoji); + const rows = chunk(emojis, columns).map((emojis) => ({ + categoryIndex, + emojis, + })); + + return { + rows, + category: { + label: searchLabel, + rowsCount: rows.length, + startRowIndex, + }, + count: emojis.length, + }; +} diff --git a/src/data/emoji-picker.ts b/src/data/emoji-picker.ts index 272874a..965974c 100644 --- a/src/data/emoji-picker.ts +++ b/src/data/emoji-picker.ts @@ -1,5 +1,5 @@ import type { CustomCategory } from "../custom-emoji-types"; -import { buildCustomCategoryRows, buildFrequentlyUsedRows } from "./custom-emoji"; +import { buildCustomCategoryRows, buildFrequentlyUsedRows, buildUnifiedSearchRows } from "./custom-emoji"; import type { Emoji, EmojiData, @@ -53,7 +53,19 @@ export function getEmojiPickerData( custom?: CustomCategory[], frequently?: EmojiPickerEmoji[], frequentlyLabel?: string, + searchLabel?: string, ): EmojiPickerData { + if (search && searchLabel && custom) { + const built = buildUnifiedSearchRows(data.emojis, custom, search, columns, 0, 0, skinTone, searchLabel); + return { + count: built.count, + categories: [built.category], + categoriesStartRowIndices: [0], + rows: built.rows, + skinTones: data.skinTones, + }; + } + const emojis = searchEmojis(data.emojis, search); const rows: EmojiPickerDataRow[] = []; const categories: EmojiPickerDataCategory[] = []; From 3d52accac94fa594f830e53a1e397745127e6cb6 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 16:50:02 -0700 Subject: [PATCH 12/22] feat: replace searchLabel gate with explicit unifiedSearch prop Previously, unified search was activated by providing searchLabel. Now unifiedSearch boolean controls the feature and searchLabel is an independent optional label (defaults to empty string). Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 8 ++++---- src/components/emoji-picker.tsx | 9 ++++++--- src/custom-emoji-types.ts | 17 ++++++++++++----- src/data/emoji-picker.ts | 5 +++-- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 56a489a..e512391 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,8 @@ Minimal changes to upstream files. Each is a small, targeted insertion. ### `src/data/emoji-picker.ts` -- `getEmojiPickerData()`: four new optional params (`custom`, `frequently`, `frequentlyLabel`, `searchLabel`) -- When `search` is non-empty and both `custom` and `searchLabel` are provided, delegates immediately to `buildUnifiedSearchRows()` and early-returns a single flat category (unified ranking across native and custom emojis) +- `getEmojiPickerData()`: five new optional params (`custom`, `frequently`, `frequentlyLabel`, `unifiedSearch`, `searchLabel`) +- When `search` is non-empty and both `custom` and `unifiedSearch` are truthy, delegates immediately to `buildUnifiedSearchRows()` and early-returns a single flat category (unified ranking across native and custom emojis); `searchLabel` sets the category header (defaults to `""`) - Otherwise: two delegation call sites β€” one for frequently used rows (before the native emoji loop), one for custom category rows (after it). All logic lives in `custom-emoji.ts`. ### `src/components/emoji-picker.tsx` @@ -50,13 +50,13 @@ To strip the custom emoji feature entirely: - Restore `EmojiPickerEmoji` to `{ emoji: string; label: string }` 3. **Revert `src/data/emoji-picker.ts`:** - - Remove the `custom`, `frequently`, `frequentlyLabel`, `searchLabel` params from `getEmojiPickerData()` + - Remove the `custom`, `frequently`, `frequentlyLabel`, `unifiedSearch`, `searchLabel` params from `getEmojiPickerData()` - Remove the unified search early-return branch and the two delegation call sites, and their imports 4. **Revert `src/components/emoji-picker.tsx`:** - Remove `CustomEmojiRootProps` import and type intersections; restore `EmojiPickerRootProps` alone - Remove `isSameEmoji` import; restore `isActive` to `$activeEmoji(state)?.emoji === emoji.emoji` - - Remove destructuring and forwarding of `custom`, `frequently`, `frequentlyLabel`, `searchLabel` + - Remove destructuring and forwarding of `custom`, `frequently`, `frequentlyLabel`, `unifiedSearch`, `searchLabel` 5. **Revert `src/index.ts`:** - Remove `CustomEmoji`, `CustomCategory` exports diff --git a/src/components/emoji-picker.tsx b/src/components/emoji-picker.tsx index 0685690..54d64ad 100644 --- a/src/components/emoji-picker.tsx +++ b/src/components/emoji-picker.tsx @@ -71,10 +71,11 @@ function EmojiPickerDataHandler({ custom, frequently, frequentlyLabel, + unifiedSearch, searchLabel, }: Pick< EmojiPickerRootProps & CustomEmojiRootProps, - "emojiVersion" | "emojibaseUrl" | "custom" | "frequently" | "frequentlyLabel" | "searchLabel" + "emojiVersion" | "emojibaseUrl" | "custom" | "frequently" | "frequentlyLabel" | "unifiedSearch" | "searchLabel" >) { const [emojiData, setEmojiData] = useState(undefined); const store = useEmojiPickerStore(); @@ -112,12 +113,12 @@ function EmojiPickerDataHandler({ store .get() .onDataChange( - getEmojiPickerData(emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, searchLabel), + getEmojiPickerData(emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, unifiedSearch, searchLabel), ); }, { timeout: 100 }, ); - }, [emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, searchLabel]); + }, [emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, unifiedSearch, searchLabel]); return null; } @@ -155,6 +156,7 @@ const EmojiPickerRoot = forwardRef {children} diff --git a/src/custom-emoji-types.ts b/src/custom-emoji-types.ts index f8e0c40..8516135 100644 --- a/src/custom-emoji-types.ts +++ b/src/custom-emoji-types.ts @@ -37,12 +37,19 @@ export interface CustomEmojiRootProps { frequentlyLabel?: string; /** - * When provided, search results from both native and custom emojis are - * merged into a single flat category sorted by relevance, using this - * string as the category header label. + * When true, search results from both native and custom emojis are merged + * into a single flat category sorted by relevance, instead of being + * displayed within their original categories. * - * When omitted, search results are displayed within their original - * categories (default upstream behaviour). + * @default false + */ + unifiedSearch?: boolean; + + /** + * The label for the unified search results category header. + * Only used when `unifiedSearch` is true. + * + * @default "" */ searchLabel?: string; } diff --git a/src/data/emoji-picker.ts b/src/data/emoji-picker.ts index 965974c..54f0640 100644 --- a/src/data/emoji-picker.ts +++ b/src/data/emoji-picker.ts @@ -53,10 +53,11 @@ export function getEmojiPickerData( custom?: CustomCategory[], frequently?: EmojiPickerEmoji[], frequentlyLabel?: string, + unifiedSearch?: boolean, searchLabel?: string, ): EmojiPickerData { - if (search && searchLabel && custom) { - const built = buildUnifiedSearchRows(data.emojis, custom, search, columns, 0, 0, skinTone, searchLabel); + if (search && unifiedSearch && custom) { + const built = buildUnifiedSearchRows(data.emojis, custom, search, columns, 0, 0, skinTone, searchLabel ?? ""); return { count: built.count, categories: [built.category], From c1f64111eaed0687d20fc221437410c332256d1a Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 16:58:16 -0700 Subject: [PATCH 13/22] feat: export scoreEmoji for use by consumers Co-Authored-By: Claude Sonnet 4.6 --- src/data/custom-emoji.ts | 2 +- src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/data/custom-emoji.ts b/src/data/custom-emoji.ts index 4659593..18ee1c4 100644 --- a/src/data/custom-emoji.ts +++ b/src/data/custom-emoji.ts @@ -45,7 +45,7 @@ export function buildFrequentlyUsedRows( // Mirrors the scoring algorithm in the upstream searchEmojis() (src/data/emoji-picker.ts). // If that function's scoring logic changes, update scoreEmoji() to match. -function scoreEmoji(label: string, tags: string[], searchText: string): number { +export function scoreEmoji(label: string, tags: string[], searchText: string): number { let score = 0; if (label.toLowerCase().includes(searchText)) { diff --git a/src/index.ts b/src/index.ts index 7773065..d92412c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * as EmojiPicker from "./components/emoji-picker"; export { useActiveEmoji, useSkinTone } from "./hooks"; export type { CustomCategory, CustomEmoji } from "./custom-emoji-types"; +export { scoreEmoji } from "./data/custom-emoji"; export type { AugmentedEmojiPickerRootProps as EmojiPickerRootProps } from "./custom-emoji-types"; export type { Category, From c88ec0536c54e6aaa300cd0d3dcd4e298d6987f1 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 17:17:05 -0700 Subject: [PATCH 14/22] test: add coverage for custom emoji additions - src/utils/__tests__/emoji-identity.test.ts: isSameEmoji (native, custom, cross-type, undefined inputs) - src/data/__tests__/custom-emoji.test.ts: scoreEmoji, buildFrequentlyUsedRows, buildCustomCategoryRows, buildUnifiedSearchRows - src/data/__tests__/emoji-picker.test.ts: custom categories, frequently used, unified search (enabled/disabled/label) Co-Authored-By: Claude Sonnet 4.6 --- src/data/__tests__/custom-emoji.test.ts | 249 +++++++++++++++++++++ src/data/__tests__/emoji-picker.test.ts | 101 +++++++++ src/utils/__tests__/emoji-identity.test.ts | 36 +++ 3 files changed, 386 insertions(+) create mode 100644 src/data/__tests__/custom-emoji.test.ts create mode 100644 src/utils/__tests__/emoji-identity.test.ts diff --git a/src/data/__tests__/custom-emoji.test.ts b/src/data/__tests__/custom-emoji.test.ts new file mode 100644 index 0000000..1963921 --- /dev/null +++ b/src/data/__tests__/custom-emoji.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; +import type { CustomCategory } from "../../custom-emoji-types"; +import type { EmojiDataEmoji } from "../../types"; +import { + buildCustomCategoryRows, + buildFrequentlyUsedRows, + buildUnifiedSearchRows, + scoreEmoji, +} from "../custom-emoji"; + +// --- scoreEmoji --- + +describe("scoreEmoji", () => { + it("should return 0 when there is no match", () => { + expect(scoreEmoji("grinning face", ["happy", "smile"], "cat")).toBe(0); + }); + + it("should score a label match as 10", () => { + expect(scoreEmoji("grinning face", [], "grinning")).toBe(10); + }); + + it("should score each matching tag as 1", () => { + expect(scoreEmoji("face", ["happy", "smile", "happy-go-lucky"], "happy")).toBe(2); + }); + + it("should combine label and tag scores", () => { + expect(scoreEmoji("happy face", ["happy", "smile"], "happy")).toBe(11); + }); + + it("should be case-insensitive", () => { + expect(scoreEmoji("Grinning Face", ["Happy"], "grinning")).toBe(10); + expect(scoreEmoji("face", ["SMILE"], "smile")).toBe(1); + }); +}); + +// --- shared fixtures --- + +const customCategories: CustomCategory[] = [ + { + id: "brand", + label: "Brand", + emojis: [ + { id: "glue-logo", label: "Glue logo", url: "/glue.png", tags: ["glue", "brand"] }, + { id: "glue-icon", label: "Glue icon", url: "/icon.png", tags: ["glue", "icon"] }, + ], + }, + { + id: "reactions", + label: "Reactions", + emojis: [ + { id: "thumbs-up", label: "Thumbs up", url: "/thumbs-up.png", tags: ["approve", "yes"] }, + { id: "fire", label: "Fire", url: "/fire.png", tags: ["hot", "lit"] }, + ], + }, +]; + +// --- buildFrequentlyUsedRows --- + +describe("buildFrequentlyUsedRows", () => { + const frequently = [ + { emoji: "πŸ˜€", label: "grinning face" }, + { id: "glue-logo", label: "Glue logo", url: "/glue.png" }, + ]; + + it("should build rows chunked by columns", () => { + const result = buildFrequentlyUsedRows(frequently, 1, 0, 0, undefined); + + expect(result.rows).toHaveLength(2); + expect(result.rows[0]?.emojis).toHaveLength(1); + expect(result.count).toBe(2); + }); + + it("should use the provided label", () => { + const result = buildFrequentlyUsedRows(frequently, 10, 0, 0, "Recently Used"); + + expect(result.category.label).toBe("Recently Used"); + }); + + it("should default the label to 'Frequently Used'", () => { + const result = buildFrequentlyUsedRows(frequently, 10, 0, 0, undefined); + + expect(result.category.label).toBe("Frequently Used"); + }); + + it("should set correct startRowIndex and rowsCount on the category", () => { + const result = buildFrequentlyUsedRows(frequently, 1, 2, 5, undefined); + + expect(result.category.startRowIndex).toBe(5); + expect(result.category.rowsCount).toBe(2); + }); +}); + +// --- buildCustomCategoryRows --- + +describe("buildCustomCategoryRows", () => { + it("should include all emojis when search is empty", () => { + const result = buildCustomCategoryRows(customCategories, "", 10, 0, 0); + + expect(result.count).toBe(4); + expect(result.categories).toHaveLength(2); + expect(result.categories[0]?.label).toBe("Brand"); + expect(result.categories[1]?.label).toBe("Reactions"); + }); + + it("should filter emojis by label during search", () => { + const result = buildCustomCategoryRows(customCategories, "fire", 10, 0, 0); + + expect(result.count).toBe(1); + expect(result.categories).toHaveLength(1); + expect(result.categories[0]?.label).toBe("Reactions"); + expect(result.rows[0]?.emojis[0]?.id).toBe("fire"); + }); + + it("should filter emojis by tags during search", () => { + const result = buildCustomCategoryRows(customCategories, "glue", 10, 0, 0); + + expect(result.count).toBe(2); + expect(result.categories).toHaveLength(1); + expect(result.categories[0]?.label).toBe("Brand"); + }); + + it("should skip categories with no matching emojis", () => { + const result = buildCustomCategoryRows(customCategories, "fire", 10, 0, 0); + + expect(result.categories.every((c) => c.label !== "Brand")).toBe(true); + }); + + it("should rank results by score within each category", () => { + const categories: CustomCategory[] = [ + { + id: "test", + label: "Test", + emojis: [ + { id: "a", label: "glue thing", url: "/a.png", tags: [] }, + { id: "b", label: "thing", url: "/b.png", tags: ["glue"] }, + ], + }, + ]; + const result = buildCustomCategoryRows(categories, "glue", 10, 0, 0); + const emojis = result.rows.flatMap((r) => r.emojis); + + expect(emojis[0]?.id).toBe("a"); // label match scores higher + expect(emojis[1]?.id).toBe("b"); + }); + + it("should return empty results when nothing matches", () => { + const result = buildCustomCategoryRows(customCategories, "zzznomatch", 10, 0, 0); + + expect(result.count).toBe(0); + expect(result.categories).toHaveLength(0); + expect(result.rows).toHaveLength(0); + }); + + it("should set correct startRowIndex offsets across categories", () => { + const result = buildCustomCategoryRows(customCategories, "", 10, 0, 0); + + expect(result.categories[0]?.startRowIndex).toBe(0); + expect(result.categories[1]?.startRowIndex).toBe(1); // Brand has 1 row of 10 + }); +}); + +// --- buildUnifiedSearchRows --- + +const nativeEmojis: EmojiDataEmoji[] = [ + { + emoji: "πŸ™‚", + category: 0, + version: 1, + label: "Slightly smiling face", + tags: ["face", "smile"], + countryFlag: undefined, + skins: undefined, + }, + { + emoji: "πŸ‘‹", + category: 1, + version: 0.6, + label: "Waving hand", + tags: ["hello", "hi", "wave"], + countryFlag: undefined, + skins: { + light: "πŸ‘‹πŸ»", + "medium-light": "πŸ‘‹πŸΌ", + medium: "πŸ‘‹πŸ½", + "medium-dark": "πŸ‘‹πŸΎ", + dark: "πŸ‘‹πŸΏ", + }, + }, +]; + +describe("buildUnifiedSearchRows", () => { + it("should merge native and custom emojis into a single category", () => { + const result = buildUnifiedSearchRows( + nativeEmojis, customCategories, "glue", 10, 0, 0, undefined, "Results" + ); + + expect(result.category.label).toBe("Results"); + expect(result.count).toBe(2); // "Glue logo" and "Glue icon" + expect(result.rows[0]?.emojis.every((e) => e.id !== undefined)).toBe(true); + }); + + it("should return native and custom results together when both match", () => { + const result = buildUnifiedSearchRows( + nativeEmojis, customCategories, "face", 10, 0, 0, undefined, "" + ); + const emojis = result.rows.flatMap((r) => r.emojis); + + // "Slightly smiling face" matches natively; no custom match for "face" + expect(result.count).toBe(1); + expect(emojis[0]?.emoji).toBe("πŸ™‚"); + }); + + it("should rank by score across both native and custom emojis", () => { + // "smile" matches native tag (+1) and no custom emoji + // Add a custom emoji with "smile" in label for a higher score + const custom: CustomCategory[] = [ + { + id: "test", + label: "Test", + emojis: [{ id: "smile-custom", label: "smile", url: "/s.png", tags: [] }], + }, + ]; + const result = buildUnifiedSearchRows( + nativeEmojis, custom, "smile", 10, 0, 0, undefined, "" + ); + const emojis = result.rows.flatMap((r) => r.emojis); + + // custom label match = 10, native tag match = 1 β€” custom should be first + expect(emojis[0]?.id).toBe("smile-custom"); + }); + + it("should apply skin tone to native emojis", () => { + const result = buildUnifiedSearchRows( + nativeEmojis, customCategories, "wave", 10, 0, 0, "dark", "" + ); + const emojis = result.rows.flatMap((r) => r.emojis); + + expect(emojis[0]?.emoji).toBe("πŸ‘‹πŸΏ"); + }); + + it("should return empty results when nothing matches", () => { + const result = buildUnifiedSearchRows( + nativeEmojis, customCategories, "zzznomatch", 10, 0, 0, undefined, "" + ); + + expect(result.count).toBe(0); + expect(result.rows).toHaveLength(0); + }); +}); diff --git a/src/data/__tests__/emoji-picker.test.ts b/src/data/__tests__/emoji-picker.test.ts index 90a8822..d558843 100644 --- a/src/data/__tests__/emoji-picker.test.ts +++ b/src/data/__tests__/emoji-picker.test.ts @@ -297,4 +297,105 @@ describe("getEmojiPickerData", () => { expect(result.rows).toEqual([]); expect(result.categoriesStartRowIndices).toEqual([]); }); + + it("should append custom categories after native categories", () => { + const custom = [ + { + id: "brand", + label: "Brand", + emojis: [ + { id: "glue-logo", label: "Glue logo", url: "/glue.png" }, + ], + }, + ]; + const result = getEmojiPickerData(data, 10, undefined, "", custom); + const labels = result.categories.map((c) => c.label); + + expect(result.count).toBe(data.emojis.length + 1); + expect(labels.at(-1)).toBe("Brand"); + }); + + it("should filter custom categories during search", () => { + const custom = [ + { + id: "brand", + label: "Brand", + emojis: [ + { id: "glue-logo", label: "Glue logo", url: "/glue.png", tags: ["glue"] }, + ], + }, + ]; + const result = getEmojiPickerData(data, 10, undefined, "glue", custom); + + expect(result.count).toBe(1); + expect(result.categories[0]?.label).toBe("Brand"); + }); + + it("should prepend frequently used emojis when search is empty", () => { + const frequently = [ + { emoji: "πŸ˜€", label: "grinning face" }, + { id: "glue-logo", label: "Glue logo", url: "/glue.png" }, + ]; + const result = getEmojiPickerData(data, 10, undefined, "", undefined, frequently); + + expect(result.categories[0]?.label).toBe("Frequently Used"); + expect(result.count).toBe(data.emojis.length + 2); + }); + + it("should use a custom frequentlyLabel when provided", () => { + const frequently = [{ emoji: "πŸ˜€", label: "grinning face" }]; + const result = getEmojiPickerData(data, 10, undefined, "", undefined, frequently, "Recent"); + + expect(result.categories[0]?.label).toBe("Recent"); + }); + + it("should hide frequently used emojis during search", () => { + const frequently = [{ emoji: "πŸ˜€", label: "grinning face" }]; + const result = getEmojiPickerData(data, 10, undefined, "broccoli", undefined, frequently); + + expect(result.categories.every((c) => c.label !== "Frequently Used")).toBe(true); + }); + + it("should return a single unified category when unifiedSearch is true", () => { + const custom = [ + { + id: "brand", + label: "Brand", + emojis: [ + { id: "glue-logo", label: "Glue logo", url: "/glue.png", tags: ["glue"] }, + ], + }, + ]; + const result = getEmojiPickerData(data, 10, undefined, "broccoli", custom, undefined, undefined, true, "Results"); + + expect(result.categories).toHaveLength(1); + expect(result.categories[0]?.label).toBe("Results"); + }); + + it("should use an empty string for the unified category label when searchLabel is omitted", () => { + const custom = [ + { + id: "brand", + label: "Brand", + emojis: [{ id: "glue-logo", label: "Glue logo", url: "/glue.png" }], + }, + ]; + const result = getEmojiPickerData(data, 10, undefined, "broccoli", custom, undefined, undefined, true); + + expect(result.categories[0]?.label).toBe(""); + }); + + it("should not activate unified search when unifiedSearch is false", () => { + const custom = [ + { + id: "brand", + label: "Brand", + emojis: [{ id: "glue-logo", label: "Glue logo", url: "/glue.png" }], + }, + ]; + const result = getEmojiPickerData(data, 10, undefined, "broccoli", custom, undefined, undefined, false); + + // Falls through to default path β€” results are in their original category + expect(result.categories[0]?.label).toBe("Food & drink"); + }); }); diff --git a/src/utils/__tests__/emoji-identity.test.ts b/src/utils/__tests__/emoji-identity.test.ts new file mode 100644 index 0000000..a03272a --- /dev/null +++ b/src/utils/__tests__/emoji-identity.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { isSameEmoji } from "../emoji-identity"; + +describe("isSameEmoji", () => { + it("should return false when either argument is undefined", () => { + expect(isSameEmoji(undefined, undefined)).toBe(false); + expect(isSameEmoji({ emoji: "πŸ˜€", label: "grinning face" }, undefined)).toBe(false); + expect(isSameEmoji(undefined, { emoji: "πŸ˜€", label: "grinning face" })).toBe(false); + }); + + it("should compare native emojis by emoji string", () => { + const a = { emoji: "πŸ˜€", label: "grinning face" }; + const b = { emoji: "πŸ˜€", label: "grinning face" }; + const c = { emoji: "πŸ˜‚", label: "face with tears of joy" }; + + expect(isSameEmoji(a, b)).toBe(true); + expect(isSameEmoji(a, c)).toBe(false); + }); + + it("should compare custom emojis by id", () => { + const a = { id: "glue-logo", label: "Glue logo", url: "https://example.com/glue.png" }; + const b = { id: "glue-logo", label: "Glue logo", url: "https://example.com/glue.png" }; + const c = { id: "other-logo", label: "Other logo", url: "https://example.com/other.png" }; + + expect(isSameEmoji(a, b)).toBe(true); + expect(isSameEmoji(a, c)).toBe(false); + }); + + it("should return false when comparing a native emoji to a custom emoji", () => { + const native = { emoji: "πŸ˜€", label: "grinning face" }; + const custom = { id: "glue-logo", label: "Glue logo", url: "https://example.com/glue.png" }; + + expect(isSameEmoji(native, custom)).toBe(false); + expect(isSameEmoji(custom, native)).toBe(false); + }); +}); From 5e9093db118c2941ee647cae10d954c046996f09 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 17:22:21 -0700 Subject: [PATCH 15/22] fix: update sameEmojiPickerEmoji to compare custom emojis by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, useActiveEmoji() always returns undefined when hovering custom emojis β€” sameEmojiPickerEmoji compared by emoji string, and custom emojis have emoji: undefined, so undefined === undefined always returned true, suppressing selector updates. Mirrors the logic in isSameEmoji (src/utils/emoji-identity.ts). Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 11 +++++++++-- src/store.ts | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e512391..b6bf047 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,10 @@ Minimal changes to upstream files. Each is a small, targeted insertion. - When `search` is non-empty and both `custom` and `unifiedSearch` are truthy, delegates immediately to `buildUnifiedSearchRows()` and early-returns a single flat category (unified ranking across native and custom emojis); `searchLabel` sets the category header (defaults to `""`) - Otherwise: two delegation call sites β€” one for frequently used rows (before the native emoji loop), one for custom category rows (after it). All logic lives in `custom-emoji.ts`. +### `src/store.ts` + +- `sameEmojiPickerEmoji`: updated to compare by `id` when both sides have one, falling back to `emoji` string. Without this, `useActiveEmoji()` always returns `undefined` for custom emojis because `undefined === undefined` suppresses selector updates. Mirrors `isSameEmoji` in `emoji-identity.ts`. + ### `src/components/emoji-picker.tsx` - `EmojiPickerRoot` and `EmojiPickerDataHandler`: prop type changed to `EmojiPickerRootProps & CustomEmojiRootProps`; props forwarded to `getEmojiPickerData()` @@ -53,12 +57,15 @@ To strip the custom emoji feature entirely: - Remove the `custom`, `frequently`, `frequentlyLabel`, `unifiedSearch`, `searchLabel` params from `getEmojiPickerData()` - Remove the unified search early-return branch and the two delegation call sites, and their imports -4. **Revert `src/components/emoji-picker.tsx`:** +4. **Revert `src/store.ts`:** + - Restore `sameEmojiPickerEmoji` to `return a?.emoji === b?.emoji` + +5. **Revert `src/components/emoji-picker.tsx`:** - Remove `CustomEmojiRootProps` import and type intersections; restore `EmojiPickerRootProps` alone - Remove `isSameEmoji` import; restore `isActive` to `$activeEmoji(state)?.emoji === emoji.emoji` - Remove destructuring and forwarding of `custom`, `frequently`, `frequentlyLabel`, `unifiedSearch`, `searchLabel` -5. **Revert `src/index.ts`:** +6. **Revert `src/index.ts`:** - Remove `CustomEmoji`, `CustomCategory` exports - Restore `EmojiPickerRootProps` to export directly from `./types` diff --git a/src/store.ts b/src/store.ts index 63c90b4..a3e0219 100644 --- a/src/store.ts +++ b/src/store.ts @@ -354,6 +354,10 @@ export function sameEmojiPickerEmoji( a: EmojiPickerEmoji | undefined, b: EmojiPickerEmoji | undefined, ) { + // Mirrors isSameEmoji (src/utils/emoji-identity.ts) β€” compare by id for + // custom emojis, fall back to emoji string for native emojis. + // If either logic changes, update the other to match. + if (a?.id !== undefined && b?.id !== undefined) return a.id === b.id; return a?.emoji === b?.emoji; } From 61f722113108bfbb7a66afa2e40a1c6e1dc3dcc0 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 17:25:10 -0700 Subject: [PATCH 16/22] fix: guard undefined in sameEmojiPickerEmoji before id/emoji checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without the guard, transitioning from no hover (undefined) to a custom emoji called sameEmojiPickerEmoji(undefined, customEmoji). The id check didn't fire (a?.id is undefined), falling through to undefined === undefined β†’ true, still suppressing the update. Co-Authored-By: Claude Sonnet 4.6 --- src/store.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/store.ts b/src/store.ts index a3e0219..b488cd6 100644 --- a/src/store.ts +++ b/src/store.ts @@ -357,8 +357,9 @@ export function sameEmojiPickerEmoji( // Mirrors isSameEmoji (src/utils/emoji-identity.ts) β€” compare by id for // custom emojis, fall back to emoji string for native emojis. // If either logic changes, update the other to match. - if (a?.id !== undefined && b?.id !== undefined) return a.id === b.id; - return a?.emoji === b?.emoji; + if (!a || !b) return a === b; + if (a.id !== undefined && b.id !== undefined) return a.id === b.id; + return a.emoji === b.emoji; } export function sameEmojiPickerRow( From ab889c1ce15cea9bba97624d4372b3103e922e12 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 17:45:50 -0700 Subject: [PATCH 17/22] docs: update CUSTOM-EMOJIS.md with unifiedSearch, searchLabel, scoreEmoji, and prop reference Co-Authored-By: Claude Sonnet 4.6 --- CUSTOM-EMOJIS.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/CUSTOM-EMOJIS.md b/CUSTOM-EMOJIS.md index 4c91ec8..fe7c43b 100644 --- a/CUSTOM-EMOJIS.md +++ b/CUSTOM-EMOJIS.md @@ -64,6 +64,24 @@ Custom emojis are searchable using the same scoring as standard emojis: Results are sorted by score descending and filtered when using ``. +### Unified Search + +By default, search results are displayed within their original categories (standard Unicode categories and custom categories separately). Enable `unifiedSearch` to merge all results β€” native and custom β€” into a single ranked list sorted by relevance score: + +```tsx + + {/* ... */} + +``` + +- **`unifiedSearch`** (`boolean`, default `false`): When `true`, all search results are combined into one category ranked by score. Has no effect when there is no active search query. +- **`searchLabel`** (`string`, optional): Label for the unified results category header. Defaults to `""` if omitted. + ## `onEmojiSelect` Handling The `emoji` object passed to `onEmojiSelect` differs for custom vs standard emojis: @@ -96,3 +114,51 @@ Pass an array of `EmojiPickerEmoji` objects via the `frequently` prop to display ``` The consumer is responsible for tracking and persisting frequency data β€” frimousse does not manage localStorage or usage counts internally. + +## `scoreEmoji` Utility + +The scoring function used internally to rank search results is exported for consumer use: + +```ts +import { scoreEmoji } from "@gluegroups/frimousse"; + +const score = scoreEmoji("Ship It", ["ship", "deploy"], "ship"); +// Returns 11 (10 for label match + 1 for tag match) +``` + +```ts +function scoreEmoji(label: string, tags: string[], searchText: string): number +``` + +Useful if you want to rank or filter custom emojis outside of the picker (e.g., in a custom search UI or for pre-sorting). + +## Prop Reference + +All custom emoji props are added to ``: + +| Prop | Type | Default | Description | +| ----------------- | --------------------- | ------------------- | ----------- | +| `custom` | `CustomCategory[]` | β€” | Custom image-based emoji categories, appended after standard categories. | +| `frequently` | `EmojiPickerEmoji[]` | β€” | Emojis to show in a "Frequently Used" category at the top. Hidden during search. | +| `frequentlyLabel` | `string` | `"Frequently Used"` | Label for the frequently used category header. | +| `unifiedSearch` | `boolean` | `false` | When `true`, merges all search results into one ranked list. | +| `searchLabel` | `string` | `""` | Category header label when `unifiedSearch` is active. | + +## Types + +```ts +type CustomEmoji = { + id: string; + label: string; + url: string; + tags?: string[]; +}; + +type CustomCategory = { + id: string; + label: string; + emojis: CustomEmoji[]; +}; +``` + +Both are exported from `@gluegroups/frimousse`. From 63df20c696aabe34623717a82ea83ef7e37deb62 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Wed, 25 Mar 2026 17:46:29 -0700 Subject: [PATCH 18/22] chore: remove AGENTS.md (fork-internal documentation) Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 132 ------------------------------------------------------ 1 file changed, 132 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index b6bf047..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,132 +0,0 @@ -# Frimousse Fork β€” @gluegroups/frimousse - -## Goal - -Add **custom emoji support** to frimousse so glue-web can inject custom emoji categories (image-based) into the picker. Changes should be minimal and additive to keep rebasing on upstream easy. - -## Our Files - -New files added by this fork. Zero merge conflict surface area β€” upstream rebase never touches these. - -| File | Purpose | -|---|---| -| `src/custom-emoji-types.ts` | `CustomEmoji`, `CustomCategory`, `CustomEmojiRootProps`, `AugmentedEmojiPickerRootProps` | -| `src/data/custom-emoji.ts` | `buildFrequentlyUsedRows()`, `buildCustomCategoryRows()`, `buildUnifiedSearchRows()`, `scoreEmoji()`, `searchCustomEmojis()` | -| `src/utils/emoji-identity.ts` | `isSameEmoji()` β€” discriminated identity check for native vs. custom emojis | - -## Upstream Touch Points - -Minimal changes to upstream files. Each is a small, targeted insertion. - -### `src/types.ts` - -- `EmojiPickerEmoji`: widened with `emoji?`, `url?`, `id?` to accommodate custom emojis flowing through the upstream data pipeline (required because `$activeEmoji` in `store.ts` returns this type and cannot be modified) -- Re-exports `CustomEmoji` and `CustomCategory` from `custom-emoji-types.ts` - -### `src/data/emoji-picker.ts` - -- `getEmojiPickerData()`: five new optional params (`custom`, `frequently`, `frequentlyLabel`, `unifiedSearch`, `searchLabel`) -- When `search` is non-empty and both `custom` and `unifiedSearch` are truthy, delegates immediately to `buildUnifiedSearchRows()` and early-returns a single flat category (unified ranking across native and custom emojis); `searchLabel` sets the category header (defaults to `""`) -- Otherwise: two delegation call sites β€” one for frequently used rows (before the native emoji loop), one for custom category rows (after it). All logic lives in `custom-emoji.ts`. - -### `src/store.ts` - -- `sameEmojiPickerEmoji`: updated to compare by `id` when both sides have one, falling back to `emoji` string. Without this, `useActiveEmoji()` always returns `undefined` for custom emojis because `undefined === undefined` suppresses selector updates. Mirrors `isSameEmoji` in `emoji-identity.ts`. - -### `src/components/emoji-picker.tsx` - -- `EmojiPickerRoot` and `EmojiPickerDataHandler`: prop type changed to `EmojiPickerRootProps & CustomEmojiRootProps`; props forwarded to `getEmojiPickerData()` -- `EmojiPickerListEmoji`: `isActive` selector replaced with `isSameEmoji()` call - -### `src/index.ts` - -- `CustomEmoji`, `CustomCategory` added to exports -- `EmojiPickerRootProps` re-exported as `AugmentedEmojiPickerRootProps` (the merged type), shadowing the upstream export so consumers see the full prop surface - -## Removing This Feature - -To strip the custom emoji feature entirely: - -1. **Delete** `src/custom-emoji-types.ts`, `src/data/custom-emoji.ts`, `src/utils/emoji-identity.ts` - -2. **Revert `src/types.ts`:** - - Remove the re-exports of `CustomEmoji` and `CustomCategory` - - Restore `EmojiPickerEmoji` to `{ emoji: string; label: string }` - -3. **Revert `src/data/emoji-picker.ts`:** - - Remove the `custom`, `frequently`, `frequentlyLabel`, `unifiedSearch`, `searchLabel` params from `getEmojiPickerData()` - - Remove the unified search early-return branch and the two delegation call sites, and their imports - -4. **Revert `src/store.ts`:** - - Restore `sameEmojiPickerEmoji` to `return a?.emoji === b?.emoji` - -5. **Revert `src/components/emoji-picker.tsx`:** - - Remove `CustomEmojiRootProps` import and type intersections; restore `EmojiPickerRootProps` alone - - Remove `isSameEmoji` import; restore `isActive` to `$activeEmoji(state)?.emoji === emoji.emoji` - - Remove destructuring and forwarding of `custom`, `frequently`, `frequentlyLabel`, `unifiedSearch`, `searchLabel` - -6. **Revert `src/index.ts`:** - - Remove `CustomEmoji`, `CustomCategory` exports - - Restore `EmojiPickerRootProps` to export directly from `./types` - -## Note: Image Rendering - -The `DefaultEmojiPickerListEmoji` is **not** modified. Consumers render custom emoji images via the existing `components` prop on ``: - -```tsx - ( - - ), - }} -/> -``` - -## Publishing β€” Internal Fork - -- Publish as `@gluegroups/frimousse` to GitHub Packages -- Consumed in glue-web via existing `.yarnrc.yml` `@gluegroups` scope config -- `main` branch uses fork-specific `package.json` values: - - `"name": "@gluegroups/frimousse"` - - `"version"`: our own version (e.g. `"0.3.4"`) - - `"repository.url"`: `"git+https://github.com/gluegroups/frimousse.git"` - -## Publishing β€” Upstream PR - -When pushing branches that target `liveblocks/frimousse`, **revert `package.json` to upstream values**: -- `"name": "frimousse"` -- `"version"`: match upstream (e.g. `"0.3.0"`) -- `"repository.url"`: `"git+https://github.com/liveblocks/frimousse.git"` - -Do **not** include `AGENTS.md` or any `@gluegroups`-specific references in upstream PRs. - -## Principles for Making Changes - -This is a fork. Every change we make is a future merge conflict. Follow these principles to keep upstream rebases clean and painless: - -1. **Isolate into new files.** Prefer adding new files (e.g. `src/data/custom-emojis.ts`) over editing existing ones. New files have zero merge conflict surface area. - -2. **Extract before inserting.** When logic must touch an existing file, extract it into a self-contained function first, then call that function from the existing code at a single, minimal call site. The insertion point should be as small as possible β€” ideally one line. - -3. **Make it trivially removable.** Any change should be removable by deleting our new files and reverting a small number of call sites. If removing a feature requires untangling logic scattered across an existing file, the change was not isolated enough. - -4. **No refactoring of upstream code.** Do not rename, reorganize, or restructure upstream code, even if it would be cleaner. Each such change is a rebase hazard. - -5. **Avoid touching upstream types where possible.** Prefer extending types via intersection or wrapping rather than modifying upstream type definitions in place. - -## Upstream Sync - -Keep changes isolated and additive so rebasing on `upstream/main` stays clean: -```sh -git fetch upstream -git rebase upstream/main -# Re-apply @gluegroups name/version/repo in package.json -# Run tests, then publish -``` From e53564824ca3e868fa8d4edf77948ff7967185bd Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Thu, 26 Mar 2026 09:42:34 -0700 Subject: [PATCH 19/22] feat: remove scoreEmoji export Co-Authored-By: Claude Sonnet 4.6 --- CUSTOM-EMOJIS.md | 17 ----------- PR-32-DESCRIPTION.md | 66 ++++++++++++++++++++++++++++++++++++++++ src/data/custom-emoji.ts | 2 +- src/index.ts | 1 - 4 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 PR-32-DESCRIPTION.md diff --git a/CUSTOM-EMOJIS.md b/CUSTOM-EMOJIS.md index fe7c43b..e703605 100644 --- a/CUSTOM-EMOJIS.md +++ b/CUSTOM-EMOJIS.md @@ -115,23 +115,6 @@ Pass an array of `EmojiPickerEmoji` objects via the `frequently` prop to display The consumer is responsible for tracking and persisting frequency data β€” frimousse does not manage localStorage or usage counts internally. -## `scoreEmoji` Utility - -The scoring function used internally to rank search results is exported for consumer use: - -```ts -import { scoreEmoji } from "@gluegroups/frimousse"; - -const score = scoreEmoji("Ship It", ["ship", "deploy"], "ship"); -// Returns 11 (10 for label match + 1 for tag match) -``` - -```ts -function scoreEmoji(label: string, tags: string[], searchText: string): number -``` - -Useful if you want to rank or filter custom emojis outside of the picker (e.g., in a custom search UI or for pre-sorting). - ## Prop Reference All custom emoji props are added to ``: diff --git a/PR-32-DESCRIPTION.md b/PR-32-DESCRIPTION.md new file mode 100644 index 0000000..08db7fe --- /dev/null +++ b/PR-32-DESCRIPTION.md @@ -0,0 +1,66 @@ +## Summary + +Adds support for image-based custom emoji categories, frequently used emojis, and unified cross-type search β€” all as opt-in props on ``. + +## New Props + +| Prop | Type | Default | Description | +| ----------------- | -------------------- | ------------------- | ----------- | +| `custom` | `CustomCategory[]` | β€” | Image-based emoji categories appended after standard categories. | +| `frequently` | `EmojiPickerEmoji[]` | β€” | Emojis shown in a "Frequently Used" category at the top. Hidden during search. | +| `frequentlyLabel` | `string` | `"Frequently Used"` | Label for the frequently used category header. | +| `unifiedSearch` | `boolean` | `false` | When `true`, all search results are merged into one ranked list. | +| `searchLabel` | `string` | `""` | Category header label when `unifiedSearch` is active. | + +## New Types + +```ts +type CustomEmoji = { id: string; label: string; url: string; tags?: string[]; }; +type CustomCategory = { id: string; label: string; emojis: CustomEmoji[]; }; +``` + +Both are exported from the package. `EmojiPickerRootProps` is augmented via intersection (not mutation) so the public type includes all new props. + +## New Exports + +- `CustomEmoji` β€” type for individual custom emoji +- `CustomCategory` β€” type for a custom emoji category + +## Architecture + +All new code lives in dedicated files: + +- **`src/custom-emoji-types.ts`** β€” new types and `CustomEmojiRootProps` interface +- **`src/data/custom-emoji.ts`** β€” `buildFrequentlyUsedRows`, `buildCustomCategoryRows`, `buildUnifiedSearchRows`, `scoreEmoji` (internal) +- **`src/utils/emoji-identity.ts`** β€” `isSameEmoji` for comparing native and custom emojis + +Upstream files (`src/types.ts`, `src/data/emoji-picker.ts`, `src/components/emoji-picker.tsx`, `src/store.ts`) are touched minimally β€” each change is a delegation call or an optional-param addition at a single call site. + +### Search Scoring + +Custom emoji search mirrors the upstream `searchEmojis` scoring (+10 label match, +1 per tag match). `scoreEmoji` is extracted as a shared helper to reduce duplication within our own code. + +### Unified Search + +When `unifiedSearch` is enabled, `buildUnifiedSearchRows` merges native and custom emoji results into a single score-ranked list instead of displaying them in separate categories. + +### Type Widening + +`EmojiPickerEmoji` is widened to `{ emoji?: string; label: string; url?: string; id?: string }` to accommodate both native and custom emojis without casts. The upstream `EmojiPickerRootProps` export is shadowed in `src/index.ts` by an augmented version that includes `CustomEmojiRootProps`. + +## Bug Fixes + +- `sameEmojiPickerEmoji` in `store.ts`: fixed two bugs where custom emojis (with `emoji: undefined`) would always compare as equal, suppressing `useActiveEmoji()` updates. Now guards for `undefined` first and compares custom emojis by `id`. + +## Tests + +New test files: +- `src/data/__tests__/custom-emoji.test.ts` β€” 15 tests for `scoreEmoji`, `buildFrequentlyUsedRows`, `buildCustomCategoryRows`, `buildUnifiedSearchRows` +- `src/utils/__tests__/emoji-identity.test.ts` β€” 4 tests for `isSameEmoji` + +Extended: +- `src/data/__tests__/emoji-picker.test.ts` β€” 9 new tests for custom categories, frequently used, and unified search + +## Docs + +See `CUSTOM-EMOJIS.md` for full usage documentation including examples, prop reference, and type definitions. diff --git a/src/data/custom-emoji.ts b/src/data/custom-emoji.ts index 18ee1c4..4659593 100644 --- a/src/data/custom-emoji.ts +++ b/src/data/custom-emoji.ts @@ -45,7 +45,7 @@ export function buildFrequentlyUsedRows( // Mirrors the scoring algorithm in the upstream searchEmojis() (src/data/emoji-picker.ts). // If that function's scoring logic changes, update scoreEmoji() to match. -export function scoreEmoji(label: string, tags: string[], searchText: string): number { +function scoreEmoji(label: string, tags: string[], searchText: string): number { let score = 0; if (label.toLowerCase().includes(searchText)) { diff --git a/src/index.ts b/src/index.ts index d92412c..7773065 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ export * as EmojiPicker from "./components/emoji-picker"; export { useActiveEmoji, useSkinTone } from "./hooks"; export type { CustomCategory, CustomEmoji } from "./custom-emoji-types"; -export { scoreEmoji } from "./data/custom-emoji"; export type { AugmentedEmojiPickerRootProps as EmojiPickerRootProps } from "./custom-emoji-types"; export type { Category, From 65906632b6cdafe3f50c116cda034e0575d29368 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Thu, 26 Mar 2026 09:43:14 -0700 Subject: [PATCH 20/22] chore: remove PR-32-DESCRIPTION.md (local reference only) Co-Authored-By: Claude Sonnet 4.6 --- PR-32-DESCRIPTION.md | 66 -------------------------------------------- 1 file changed, 66 deletions(-) delete mode 100644 PR-32-DESCRIPTION.md diff --git a/PR-32-DESCRIPTION.md b/PR-32-DESCRIPTION.md deleted file mode 100644 index 08db7fe..0000000 --- a/PR-32-DESCRIPTION.md +++ /dev/null @@ -1,66 +0,0 @@ -## Summary - -Adds support for image-based custom emoji categories, frequently used emojis, and unified cross-type search β€” all as opt-in props on ``. - -## New Props - -| Prop | Type | Default | Description | -| ----------------- | -------------------- | ------------------- | ----------- | -| `custom` | `CustomCategory[]` | β€” | Image-based emoji categories appended after standard categories. | -| `frequently` | `EmojiPickerEmoji[]` | β€” | Emojis shown in a "Frequently Used" category at the top. Hidden during search. | -| `frequentlyLabel` | `string` | `"Frequently Used"` | Label for the frequently used category header. | -| `unifiedSearch` | `boolean` | `false` | When `true`, all search results are merged into one ranked list. | -| `searchLabel` | `string` | `""` | Category header label when `unifiedSearch` is active. | - -## New Types - -```ts -type CustomEmoji = { id: string; label: string; url: string; tags?: string[]; }; -type CustomCategory = { id: string; label: string; emojis: CustomEmoji[]; }; -``` - -Both are exported from the package. `EmojiPickerRootProps` is augmented via intersection (not mutation) so the public type includes all new props. - -## New Exports - -- `CustomEmoji` β€” type for individual custom emoji -- `CustomCategory` β€” type for a custom emoji category - -## Architecture - -All new code lives in dedicated files: - -- **`src/custom-emoji-types.ts`** β€” new types and `CustomEmojiRootProps` interface -- **`src/data/custom-emoji.ts`** β€” `buildFrequentlyUsedRows`, `buildCustomCategoryRows`, `buildUnifiedSearchRows`, `scoreEmoji` (internal) -- **`src/utils/emoji-identity.ts`** β€” `isSameEmoji` for comparing native and custom emojis - -Upstream files (`src/types.ts`, `src/data/emoji-picker.ts`, `src/components/emoji-picker.tsx`, `src/store.ts`) are touched minimally β€” each change is a delegation call or an optional-param addition at a single call site. - -### Search Scoring - -Custom emoji search mirrors the upstream `searchEmojis` scoring (+10 label match, +1 per tag match). `scoreEmoji` is extracted as a shared helper to reduce duplication within our own code. - -### Unified Search - -When `unifiedSearch` is enabled, `buildUnifiedSearchRows` merges native and custom emoji results into a single score-ranked list instead of displaying them in separate categories. - -### Type Widening - -`EmojiPickerEmoji` is widened to `{ emoji?: string; label: string; url?: string; id?: string }` to accommodate both native and custom emojis without casts. The upstream `EmojiPickerRootProps` export is shadowed in `src/index.ts` by an augmented version that includes `CustomEmojiRootProps`. - -## Bug Fixes - -- `sameEmojiPickerEmoji` in `store.ts`: fixed two bugs where custom emojis (with `emoji: undefined`) would always compare as equal, suppressing `useActiveEmoji()` updates. Now guards for `undefined` first and compares custom emojis by `id`. - -## Tests - -New test files: -- `src/data/__tests__/custom-emoji.test.ts` β€” 15 tests for `scoreEmoji`, `buildFrequentlyUsedRows`, `buildCustomCategoryRows`, `buildUnifiedSearchRows` -- `src/utils/__tests__/emoji-identity.test.ts` β€” 4 tests for `isSameEmoji` - -Extended: -- `src/data/__tests__/emoji-picker.test.ts` β€” 9 new tests for custom categories, frequently used, and unified search - -## Docs - -See `CUSTOM-EMOJIS.md` for full usage documentation including examples, prop reference, and type definitions. From 19862a2767abfb1a0fc0f8ab9de0c4245c4410ee Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Mon, 30 Mar 2026 10:03:25 -0700 Subject: [PATCH 21/22] feat: add shortcode search support to unified emoji search Searching by shortcode (e.g. "white_check_mark") now matches native and custom emojis even when the shortcode doesn't appear in the label or tags. - Normalize underscores to spaces in search text so shortcode-style queries ("waving_hand") work as phrase matches against labels - Extend scoreEmoji() with a shortcodes parameter (scored +10); custom emojis use their id as a shortcode, native emojis use emojibase data - Add src/data/shortcodes.ts: fetches en/shortcodes/emojibase.json on mount (sessionStorage-cached), exposes getShortcodesForEmoji() keyed by Unicode hexcode (variation selectors stripped) - Kick off loadShortcodes() in EmojiPickerDataHandler alongside the existing getEmojiData() call (fire-and-forget, degrades gracefully) Co-Authored-By: Claude Sonnet 4.6 --- src/components/emoji-picker.tsx | 3 + src/data/__tests__/custom-emoji.test.ts | 113 ++++++++++++++++----- src/data/__tests__/shortcodes.test.ts | 128 ++++++++++++++++++++++++ src/data/custom-emoji.ts | 23 +++-- src/data/shortcodes.ts | 61 +++++++++++ 5 files changed, 294 insertions(+), 34 deletions(-) create mode 100644 src/data/__tests__/shortcodes.test.ts create mode 100644 src/data/shortcodes.ts diff --git a/src/components/emoji-picker.tsx b/src/components/emoji-picker.tsx index 54d64ad..094f7bd 100644 --- a/src/components/emoji-picker.tsx +++ b/src/components/emoji-picker.tsx @@ -21,6 +21,7 @@ import { EMOJI_FONT_FAMILY } from "../constants"; import type { CustomEmojiRootProps } from "../custom-emoji-types"; import { getEmojiData, validateLocale, validateSkinTone } from "../data/emoji"; import { getEmojiPickerData } from "../data/emoji-picker"; +import { loadShortcodes } from "../data/shortcodes"; import { useActiveEmoji, useSkinTone } from "../hooks"; import { $activeEmoji, @@ -88,6 +89,8 @@ function EmojiPickerDataHandler({ const controller = new AbortController(); const signal = controller.signal; + loadShortcodes(emojibaseUrl, emojiVersion).catch(() => {}); + getEmojiData({ locale, emojiVersion, emojibaseUrl, signal }) .then((data) => { setEmojiData(data); diff --git a/src/data/__tests__/custom-emoji.test.ts b/src/data/__tests__/custom-emoji.test.ts index 1963921..30e7f95 100644 --- a/src/data/__tests__/custom-emoji.test.ts +++ b/src/data/__tests__/custom-emoji.test.ts @@ -1,37 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { CustomCategory } from "../../custom-emoji-types"; import type { EmojiDataEmoji } from "../../types"; import { buildCustomCategoryRows, buildFrequentlyUsedRows, buildUnifiedSearchRows, - scoreEmoji, } from "../custom-emoji"; -// --- scoreEmoji --- - -describe("scoreEmoji", () => { - it("should return 0 when there is no match", () => { - expect(scoreEmoji("grinning face", ["happy", "smile"], "cat")).toBe(0); - }); - - it("should score a label match as 10", () => { - expect(scoreEmoji("grinning face", [], "grinning")).toBe(10); - }); - - it("should score each matching tag as 1", () => { - expect(scoreEmoji("face", ["happy", "smile", "happy-go-lucky"], "happy")).toBe(2); - }); - - it("should combine label and tag scores", () => { - expect(scoreEmoji("happy face", ["happy", "smile"], "happy")).toBe(11); - }); - - it("should be case-insensitive", () => { - expect(scoreEmoji("Grinning Face", ["Happy"], "grinning")).toBe(10); - expect(scoreEmoji("face", ["SMILE"], "smile")).toBe(1); - }); -}); +vi.mock("../shortcodes", () => ({ + getShortcodesForEmoji: (emoji: string) => { + // βœ… (U+2705, with or without variation selector U+FE0F) + if (emoji === "βœ…οΈ" || emoji === "βœ…") { + return ["check_mark_button", "white_check_mark"]; + } + return []; + }, +})); // --- shared fixtures --- @@ -151,6 +135,31 @@ describe("buildCustomCategoryRows", () => { expect(result.rows).toHaveLength(0); }); + it("should normalize underscores to spaces in the search query", () => { + // "thumbs_up" should match "Thumbs up" label + const result = buildCustomCategoryRows(customCategories, "thumbs_up", 10, 0, 0); + + expect(result.count).toBe(1); + expect(result.rows[0]?.emojis[0]?.id).toBe("thumbs-up"); + }); + + it("should match emojis by id (shortcode) when the label does not match", () => { + const categories: CustomCategory[] = [ + { + id: "custom", + label: "Custom", + emojis: [ + { id: "white-check-mark", label: "Done", url: "/done.png", tags: [] }, + ], + }, + ]; + // label "Done" won't match "white check mark", but id "white-check-mark" will + const result = buildCustomCategoryRows(categories, "white_check_mark", 10, 0, 0); + + expect(result.count).toBe(1); + expect(result.rows[0]?.emojis[0]?.id).toBe("white-check-mark"); + }); + it("should set correct startRowIndex offsets across categories", () => { const result = buildCustomCategoryRows(customCategories, "", 10, 0, 0); @@ -186,6 +195,15 @@ const nativeEmojis: EmojiDataEmoji[] = [ dark: "πŸ‘‹πŸΏ", }, }, + { + emoji: "βœ…οΈ", + category: 8, + version: 0.6, + label: "Check mark button", + tags: ["check", "mark"], + countryFlag: undefined, + skins: undefined, + }, ]; describe("buildUnifiedSearchRows", () => { @@ -246,4 +264,47 @@ describe("buildUnifiedSearchRows", () => { expect(result.count).toBe(0); expect(result.rows).toHaveLength(0); }); + + it("should normalize underscores to spaces in the search query", () => { + // "waving_hand" should match native emoji with label "Waving hand" + const result = buildUnifiedSearchRows( + nativeEmojis, customCategories, "waving_hand", 10, 0, 0, undefined, "" + ); + const emojis = result.rows.flatMap((r) => r.emojis); + + expect(result.count).toBe(1); + expect(emojis[0]?.emoji).toBe("πŸ‘‹"); + }); + + it("should match native emojis by shortcode when the label does not match", () => { + // "white_check_mark" β†’ "white check mark": label "Check mark button" doesn't contain it, + // but the mocked shortcodes for βœ…οΈ include "white_check_mark" + const result = buildUnifiedSearchRows( + nativeEmojis, [], "white_check_mark", 10, 0, 0, undefined, "" + ); + const emojis = result.rows.flatMap((r) => r.emojis); + + expect(result.count).toBe(1); + expect(emojis[0]?.emoji).toBe("βœ…οΈ"); + }); + + it("should match custom emojis by id (shortcode) when the label does not match", () => { + const custom: CustomCategory[] = [ + { + id: "custom", + label: "Custom", + emojis: [ + { id: "white-check-mark", label: "Done", url: "/done.png", tags: [] }, + ], + }, + ]; + // label "Done" won't match "white check mark", but id "white-check-mark" will + const result = buildUnifiedSearchRows( + [], custom, "white_check_mark", 10, 0, 0, undefined, "" + ); + const emojis = result.rows.flatMap((r) => r.emojis); + + expect(result.count).toBe(1); + expect(emojis[0]?.id).toBe("white-check-mark"); + }); }); diff --git a/src/data/__tests__/shortcodes.test.ts b/src/data/__tests__/shortcodes.test.ts new file mode 100644 index 0000000..0103683 --- /dev/null +++ b/src/data/__tests__/shortcodes.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const SHORTCODES_DATA = { + "2705": ["check_mark_button", "white_check_mark"], + "1F44B": "waving_hand", + "1F1FA-1F1F8": "us", +}; + +function mockFetch(data: unknown) { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + json: () => Promise.resolve(data), + } as Response); +} + +describe("loadShortcodes / getShortcodesForEmoji", () => { + beforeEach(() => { + vi.resetModules(); + sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + }); + + it("should return [] before shortcodes are loaded", async () => { + const { getShortcodesForEmoji } = await import("../shortcodes"); + + expect(getShortcodesForEmoji("βœ…")).toEqual([]); + }); + + it("should fetch and populate shortcodes", async () => { + mockFetch(SHORTCODES_DATA); + const { loadShortcodes, getShortcodesForEmoji } = await import("../shortcodes"); + + await loadShortcodes(); + + expect(getShortcodesForEmoji("βœ…οΈ")).toEqual(["check_mark_button", "white_check_mark"]); + }); + + it("should normalize a single-string shortcode to an array", async () => { + mockFetch(SHORTCODES_DATA); + const { loadShortcodes, getShortcodesForEmoji } = await import("../shortcodes"); + + await loadShortcodes(); + + expect(getShortcodesForEmoji("πŸ‘‹")).toEqual(["waving_hand"]); + }); + + it("should handle compound emoji hexcodes (e.g. flag)", async () => { + mockFetch(SHORTCODES_DATA); + const { loadShortcodes, getShortcodesForEmoji } = await import("../shortcodes"); + + await loadShortcodes(); + + expect(getShortcodesForEmoji("πŸ‡ΊπŸ‡Έ")).toEqual(["us"]); + }); + + it("should strip variation selectors when deriving the hexcode", async () => { + mockFetch({ "2705": ["white_check_mark"] }); + const { loadShortcodes, getShortcodesForEmoji } = await import("../shortcodes"); + + await loadShortcodes(); + + // Both with and without U+FE0F should resolve + expect(getShortcodesForEmoji("βœ…οΈ")).toEqual(["white_check_mark"]); + expect(getShortcodesForEmoji("βœ…")).toEqual(["white_check_mark"]); + }); + + it("should not fetch again if called a second time", async () => { + mockFetch(SHORTCODES_DATA); + const { loadShortcodes } = await import("../shortcodes"); + + await loadShortcodes(); + await loadShortcodes(); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it("should use the sessionStorage cache on a second load", async () => { + mockFetch(SHORTCODES_DATA); + const { loadShortcodes, getShortcodesForEmoji } = await import("../shortcodes"); + + await loadShortcodes(); + + // Reset in-memory state but keep sessionStorage + vi.resetModules(); + const { loadShortcodes: loadShortcodes2, getShortcodesForEmoji: get2 } = + await import("../shortcodes"); + + vi.spyOn(globalThis, "fetch"); + await loadShortcodes2(); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(get2("βœ…οΈ")).toEqual(["check_mark_button", "white_check_mark"]); + }); + + it("should use the provided emojibaseUrl to build the fetch URL", async () => { + mockFetch(SHORTCODES_DATA); + const { loadShortcodes } = await import("../shortcodes"); + + await loadShortcodes("https://example.com/emojibase"); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://example.com/emojibase/en/shortcodes/emojibase.json", + ); + }); + + it("should use emojiVersion to build the CDN URL", async () => { + mockFetch(SHORTCODES_DATA); + const { loadShortcodes } = await import("../shortcodes"); + + await loadShortcodes(undefined, 6); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://cdn.jsdelivr.net/npm/emojibase-data@6/en/shortcodes/emojibase.json", + ); + }); + + it("should return [] for an emoji with no matching shortcode", async () => { + mockFetch(SHORTCODES_DATA); + const { loadShortcodes, getShortcodesForEmoji } = await import("../shortcodes"); + + await loadShortcodes(); + + expect(getShortcodesForEmoji("πŸ˜€")).toEqual([]); + }); +}); diff --git a/src/data/custom-emoji.ts b/src/data/custom-emoji.ts index 4659593..c138afa 100644 --- a/src/data/custom-emoji.ts +++ b/src/data/custom-emoji.ts @@ -1,4 +1,5 @@ import type { CustomCategory } from "../custom-emoji-types"; +import { getShortcodesForEmoji } from "./shortcodes"; import type { EmojiDataEmoji, EmojiPickerDataCategory, @@ -43,9 +44,9 @@ export function buildFrequentlyUsedRows( }; } -// Mirrors the scoring algorithm in the upstream searchEmojis() (src/data/emoji-picker.ts). -// If that function's scoring logic changes, update scoreEmoji() to match. -function scoreEmoji(label: string, tags: string[], searchText: string): number { +// Mirrors the scoring algorithm in the upstream searchEmojis() (src/data/emoji-picker.ts), +// extended with shortcode scoring. If that function's scoring logic changes, update this to match. +function scoreEmoji(label: string, tags: string[], shortcodes: string[], searchText: string): number { let score = 0; if (label.toLowerCase().includes(searchText)) { @@ -58,6 +59,12 @@ function scoreEmoji(label: string, tags: string[], searchText: string): number { } } + for (const shortcode of shortcodes) { + if (shortcode.toLowerCase().replace(/[-_]/g, " ").includes(searchText)) { + score += 10; + } + } + return score; } @@ -68,7 +75,7 @@ function searchCustomEmojis( const scores = new Map(); const filtered = emojis.filter((ce) => { - const score = scoreEmoji(ce.label, ce.tags ?? [], searchText); + const score = scoreEmoji(ce.label, ce.tags ?? [], [ce.id], searchText); if (score > 0) { scores.set(ce.id, score); @@ -92,7 +99,7 @@ export function buildCustomCategoryRows( ): BuiltCategoryRows { const rows: EmojiPickerDataRow[] = []; const categories: EmojiPickerDataCategory[] = []; - const searchText = search.toLowerCase().trim(); + const searchText = search.toLowerCase().trim().replace(/_/g, " "); let categoryIndex = startingCategoryIndex; let startRowIndex = startingRowIndex; let count = 0; @@ -143,13 +150,13 @@ export function buildUnifiedSearchRows( skinTone: SkinTone | undefined, searchLabel: string, ): BuiltRows { - const searchText = search.toLowerCase().trim(); + const searchText = search.toLowerCase().trim().replace(/_/g, " "); type ScoredEmoji = { emoji: EmojiPickerEmoji; score: number }; const scored: ScoredEmoji[] = []; for (const e of nativeEmojis) { - const score = scoreEmoji(e.label, e.tags, searchText); + const score = scoreEmoji(e.label, e.tags, getShortcodesForEmoji(e.emoji), searchText); if (score > 0) { scored.push({ @@ -167,7 +174,7 @@ export function buildUnifiedSearchRows( for (const customCategory of custom) { for (const ce of customCategory.emojis) { - const score = scoreEmoji(ce.label, ce.tags ?? [], searchText); + const score = scoreEmoji(ce.label, ce.tags ?? [], [ce.id], searchText); if (score > 0) { scored.push({ diff --git a/src/data/shortcodes.ts b/src/data/shortcodes.ts new file mode 100644 index 0000000..968f4ec --- /dev/null +++ b/src/data/shortcodes.ts @@ -0,0 +1,61 @@ +type ShortcodesRecord = Record; + +const SESSION_KEY = "frimousse/shortcodes"; + +let shortcodesMap: Map | undefined; + +function hexcodeFromEmoji(emoji: string): string { + return [...emoji] + .map((c) => c.codePointAt(0)!) + .filter((cp) => cp !== 0xfe0e && cp !== 0xfe0f) + .map((cp) => cp.toString(16).toUpperCase()) + .join("-"); +} + +function buildMap(data: ShortcodesRecord): Map { + return new Map( + Object.entries(data).map(([hexcode, codes]) => [ + hexcode, + Array.isArray(codes) ? codes : [codes], + ]), + ); +} + +export function getShortcodesForEmoji(emoji: string): string[] { + return shortcodesMap?.get(hexcodeFromEmoji(emoji)) ?? []; +} + +export async function loadShortcodes( + emojibaseUrl?: string, + emojiVersion?: number, +): Promise { + if (shortcodesMap) { + return; + } + + try { + const cached = sessionStorage.getItem(SESSION_KEY); + if (cached) { + shortcodesMap = buildMap(JSON.parse(cached) as ShortcodesRecord); + return; + } + } catch { + // ignore + } + + const baseUrl = + typeof emojibaseUrl === "string" + ? emojibaseUrl + : `https://cdn.jsdelivr.net/npm/emojibase-data@${typeof emojiVersion === "number" ? Math.floor(emojiVersion) : "latest"}`; + + const response = await fetch(`${baseUrl}/en/shortcodes/emojibase.json`); + const data = (await response.json()) as ShortcodesRecord; + + try { + sessionStorage.setItem(SESSION_KEY, JSON.stringify(data)); + } catch { + // ignore storage errors (e.g. quota exceeded) + } + + shortcodesMap = buildMap(data); +} From 9e5ea9e7789b381d79f9aa98335292a4ad0180e8 Mon Sep 17 00:00:00 2001 From: Matthew Campagna Date: Tue, 31 Mar 2026 11:01:11 -0700 Subject: [PATCH 22/22] docs: fix package name reference in CUSTOM-EMOJIS.md Co-Authored-By: Claude Sonnet 4.6 --- CUSTOM-EMOJIS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CUSTOM-EMOJIS.md b/CUSTOM-EMOJIS.md index e703605..b3ff9cb 100644 --- a/CUSTOM-EMOJIS.md +++ b/CUSTOM-EMOJIS.md @@ -144,4 +144,4 @@ type CustomCategory = { }; ``` -Both are exported from `@gluegroups/frimousse`. +Both are exported from `frimousse`.