diff --git a/CUSTOM-EMOJIS.md b/CUSTOM-EMOJIS.md new file mode 100644 index 0000000..b3ff9cb --- /dev/null +++ b/CUSTOM-EMOJIS.md @@ -0,0 +1,147 @@ +# Custom Emoji Support + +## Overview + +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 +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 ``. + +### 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: + +| 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. + +## 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 β€” frimousse does not manage localStorage or usage counts internally. + +## 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 `frimousse`. 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/src/components/emoji-picker.tsx b/src/components/emoji-picker.tsx index 6ca9eaa..094f7bd 100644 --- a/src/components/emoji-picker.tsx +++ b/src/components/emoji-picker.tsx @@ -18,8 +18,10 @@ 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 { loadShortcodes } from "../data/shortcodes"; import { useActiveEmoji, useSkinTone } from "../hooks"; import { $activeEmoji, @@ -57,6 +59,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"; @@ -66,7 +69,15 @@ import { useStableCallback } from "../utils/use-stable-callback"; function EmojiPickerDataHandler({ emojiVersion, emojibaseUrl, -}: Pick) { + custom, + frequently, + frequentlyLabel, + unifiedSearch, + searchLabel, +}: Pick< + EmojiPickerRootProps & CustomEmojiRootProps, + "emojiVersion" | "emojibaseUrl" | "custom" | "frequently" | "frequentlyLabel" | "unifiedSearch" | "searchLabel" +>) { const [emojiData, setEmojiData] = useState(undefined); const store = useEmojiPickerStore(); const locale = useSelectorKey(store, "locale"); @@ -78,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); @@ -103,12 +116,12 @@ function EmojiPickerDataHandler({ store .get() .onDataChange( - getEmojiPickerData(emojiData, columns, skinTone, search), + getEmojiPickerData(emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, unifiedSearch, searchLabel), ); }, { timeout: 100 }, ); - }, [emojiData, columns, skinTone, search]); + }, [emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, unifiedSearch, searchLabel]); return null; } @@ -136,13 +149,18 @@ function EmojiPickerDataHandler({ * * ``` */ -const EmojiPickerRoot = forwardRef( +const EmojiPickerRoot = forwardRef( ( { locale = "en", columns = 9, skinTone = "none", onEmojiSelect = noop, + custom, + frequently, + frequentlyLabel, + unifiedSearch, + searchLabel, emojiVersion, emojibaseUrl, onFocusCapture, @@ -472,6 +490,11 @@ const EmojiPickerRoot = forwardRef( {children} @@ -840,9 +863,8 @@ const EmojiPickerListEmoji = memo( rowIndex: number; } & Pick) => { const store = useEmojiPickerStore(); - const isActive = useSelector( - store, - (state) => $activeEmoji(state)?.emoji === emoji.emoji, + const isActive = useSelector(store, (state) => + isSameEmoji($activeEmoji(state), emoji), ); const handleSelect = useCallback(() => { diff --git a/src/custom-emoji-types.ts b/src/custom-emoji-types.ts new file mode 100644 index 0000000..8516135 --- /dev/null +++ b/src/custom-emoji-types.ts @@ -0,0 +1,61 @@ +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; + + /** + * 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. + * + * @default false + */ + unifiedSearch?: boolean; + + /** + * The label for the unified search results category header. + * Only used when `unifiedSearch` is true. + * + * @default "" + */ + searchLabel?: 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/__tests__/custom-emoji.test.ts b/src/data/__tests__/custom-emoji.test.ts new file mode 100644 index 0000000..30e7f95 --- /dev/null +++ b/src/data/__tests__/custom-emoji.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CustomCategory } from "../../custom-emoji-types"; +import type { EmojiDataEmoji } from "../../types"; +import { + buildCustomCategoryRows, + buildFrequentlyUsedRows, + buildUnifiedSearchRows, +} from "../custom-emoji"; + +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 --- + +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 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); + + 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: "πŸ‘‹πŸΏ", + }, + }, + { + emoji: "βœ…οΈ", + category: 8, + version: 0.6, + label: "Check mark button", + tags: ["check", "mark"], + countryFlag: undefined, + skins: undefined, + }, +]; + +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); + }); + + 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__/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/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 new file mode 100644 index 0000000..c138afa --- /dev/null +++ b/src/data/custom-emoji.ts @@ -0,0 +1,205 @@ +import type { CustomCategory } from "../custom-emoji-types"; +import { getShortcodesForEmoji } from "./shortcodes"; +import type { + EmojiDataEmoji, + EmojiPickerDataCategory, + EmojiPickerDataRow, + EmojiPickerEmoji, + SkinTone, +} 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, + }; +} + +// 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)) { + score += 10; + } + + for (const tag of tags) { + if (tag.toLowerCase().includes(searchText)) { + score += 1; + } + } + + for (const shortcode of shortcodes) { + if (shortcode.toLowerCase().replace(/[-_]/g, " ").includes(searchText)) { + score += 10; + } + } + + return score; +} + +function searchCustomEmojis( + emojis: CustomCategory["emojis"], + searchText: string, +): CustomCategory["emojis"] { + const scores = new Map(); + + const filtered = emojis.filter((ce) => { + const score = scoreEmoji(ce.label, ce.tags ?? [], [ce.id], searchText); + + 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().replace(/_/g, " "); + 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 }; +} + +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().replace(/_/g, " "); + + type ScoredEmoji = { emoji: EmojiPickerEmoji; score: number }; + const scored: ScoredEmoji[] = []; + + for (const e of nativeEmojis) { + const score = scoreEmoji(e.label, e.tags, getShortcodesForEmoji(e.emoji), 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 ?? [], [ce.id], 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 07635b1..54f0640 100644 --- a/src/data/emoji-picker.ts +++ b/src/data/emoji-picker.ts @@ -1,3 +1,5 @@ +import type { CustomCategory } from "../custom-emoji-types"; +import { buildCustomCategoryRows, buildFrequentlyUsedRows, buildUnifiedSearchRows } from "./custom-emoji"; import type { Emoji, EmojiData, @@ -48,7 +50,23 @@ export function getEmojiPickerData( columns: number, skinTone: SkinTone | undefined, search: string, + custom?: CustomCategory[], + frequently?: EmojiPickerEmoji[], + frequentlyLabel?: string, + unifiedSearch?: boolean, + searchLabel?: string, ): EmojiPickerData { + if (search && unifiedSearch && 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[] = []; @@ -56,6 +74,17 @@ export function getEmojiPickerData( const emojisByCategory: Record = {}; let categoryIndex = 0; let startRowIndex = 0; + let frequentlyCount = 0; + + if (frequently && frequently.length > 0 && !search) { + const built = buildFrequentlyUsedRows(frequently, columns, categoryIndex, startRowIndex, frequentlyLabel); + rows.push(...built.rows); + categories.push(built.category); + categoriesStartRowIndices.push(startRowIndex); + frequentlyCount = built.count; + categoryIndex++; + startRowIndex += built.rows.length; + } for (const emoji of emojis) { if (!emojisByCategory[emoji.category]) { @@ -98,8 +127,20 @@ export function getEmojiPickerData( startRowIndex += categoryRows.length; } + let customCount = 0; + + if (custom) { + 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 { - count: emojis.length, + count: emojis.length + customCount + frequentlyCount, categories, categoriesStartRowIndices, rows, 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); +} diff --git a/src/index.ts b/src/index.ts index 43203f2..7773065 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ 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, Emoji, @@ -11,7 +13,6 @@ export type { EmojiPickerListProps, EmojiPickerListRowProps, EmojiPickerLoadingProps, - EmojiPickerRootProps, EmojiPickerSearchProps, EmojiPickerSkinToneProps, EmojiPickerSkinToneSelectorProps, diff --git a/src/store.ts b/src/store.ts index 63c90b4..b488cd6 100644 --- a/src/store.ts +++ b/src/store.ts @@ -354,7 +354,12 @@ export function sameEmojiPickerEmoji( a: EmojiPickerEmoji | undefined, b: EmojiPickerEmoji | undefined, ) { - return a?.emoji === b?.emoji; + // 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 || !b) return a === b; + if (a.id !== undefined && b.id !== undefined) return a.id === b.id; + return a.emoji === b.emoji; } export function sameEmojiPickerRow( diff --git a/src/types.ts b/src/types.ts index c8b9fa5..3c1ff67 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,9 +62,13 @@ export type EmojiData = { skinTones: Record, string>; }; +export type { CustomEmoji, CustomCategory } from "./custom-emoji-types"; + export type EmojiPickerEmoji = { - emoji: string; + emoji?: string; label: string; + url?: string; + id?: string; }; export type EmojiPickerCategory = { 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); + }); +}); 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; +}