From 1d2fd9263f52d5f29332741a1af561a75ab6ff6a Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Sat, 26 Sep 2026 18:08:04 -0300 Subject: [PATCH 1/6] add a Jev fallback for emoji search --- apps/web/app/api/ai/emoji-search/route.ts | 26 +++ apps/web/lib/ai/search-emojis.ts | 207 ++++++++++++++++++ apps/web/lib/upstash/ratelimit-policies.ts | 6 + apps/web/tests/misc/search-emojis.test.ts | 63 ++++++ apps/web/ui/shared/emoji-picker.tsx | 139 +++++++++++- apps/web/ui/shared/inline-emoji-menu.tsx | 136 ++++++++---- .../ui/shared/use-semantic-emoji-search.ts | 126 +++++++++++ 7 files changed, 648 insertions(+), 55 deletions(-) create mode 100644 apps/web/app/api/ai/emoji-search/route.ts create mode 100644 apps/web/lib/ai/search-emojis.ts create mode 100644 apps/web/tests/misc/search-emojis.test.ts create mode 100644 apps/web/ui/shared/use-semantic-emoji-search.ts diff --git a/apps/web/app/api/ai/emoji-search/route.ts b/apps/web/app/api/ai/emoji-search/route.ts new file mode 100644 index 00000000000..70e66fd0d62 --- /dev/null +++ b/apps/web/app/api/ai/emoji-search/route.ts @@ -0,0 +1,26 @@ +import { searchEmojis, warmEmojiCatalog } from "@/lib/ai/search-emojis"; +import { withSession } from "@/lib/auth"; +import { assertRateLimit } from "@/lib/upstash/assert-rate-limit"; +import { RATELIMIT_POLICIES } from "@/lib/upstash/ratelimit-policies"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +const emojiSearchSchema = z.object({ + query: z.string().trim().min(2).max(64), +}); + +warmEmojiCatalog(); + +// POST /api/ai/emoji-search – semantic emoji matches when name search misses +export const POST = withSession(async ({ req, session }) => { + const { query } = emojiSearchSchema.parse(await req.json()); + + await assertRateLimit({ + policy: RATELIMIT_POLICIES.emojiSearch, + identifier: session.user.id, + }); + + const emojis = await searchEmojis(query); + + return NextResponse.json({ emojis }); +}); diff --git a/apps/web/lib/ai/search-emojis.ts b/apps/web/lib/ai/search-emojis.ts new file mode 100644 index 00000000000..f046efdc826 --- /dev/null +++ b/apps/web/lib/ai/search-emojis.ts @@ -0,0 +1,207 @@ +import { experimental_evaluate as evaluate } from "ai"; + +const EMOJIBASE_DATA_URL = + "https://cdn.jsdelivr.net/npm/emojibase-data@16.0.3/en/data.json"; + +const MATCH_LIMIT = 18; +const MATCH_THRESHOLD = 0.25; +const MAX_QUESTIONS_JSON_CHARS = 100_000; +const MAX_TAGS = 12; +const SEARCH_CACHE_LIMIT = 100; + +const MATCH_INSTRUCTIONS = + "Is this a good emoji for the search, including the same subject, synonyms, and close themes such as movies, characters, places, and objects?"; + +export type EmojiMatch = { + emoji: string; + label: string; +}; + +export type EmojiCatalogEntry = EmojiMatch & { + id: string; + tags?: string[]; +}; + +let catalogPromise: Promise | null = null; +const searchCache = new Map(); +const searchRequests = new Map>(); + +export function rankEmojiMatches( + catalog: EmojiCatalogEntry[], + probabilities: Record, +): EmojiMatch[] { + return catalog + .flatMap((entry) => { + const probability = probabilities[entry.id]; + + if (probability === undefined || probability <= MATCH_THRESHOLD) { + return []; + } + + return [{ ...entry, probability }]; + }) + .sort((a, b) => b.probability - a.probability) + .slice(0, MATCH_LIMIT) + .map(({ emoji, label }) => ({ emoji, label })); +} + +function emojiQuestion(entry: EmojiCatalogEntry) { + const tags = (entry.tags ?? []) + .filter( + (tag) => + tag.length > 1 && tag.toLowerCase() !== entry.label.toLowerCase(), + ) + .slice(0, MAX_TAGS); + + return { + type: "boolean" as const, + instructions: MATCH_INSTRUCTIONS, + criteria: { + true: + tags.length === 0 ? entry.label : `${entry.label} (${tags.join(", ")})`, + false: "unrelated subject", + }, + }; +} + +export function chunkEmojiCatalog( + catalog: EmojiCatalogEntry[], +): EmojiCatalogEntry[][] { + const chunks: EmojiCatalogEntry[][] = []; + let current: EmojiCatalogEntry[] = []; + let size = 2; + + for (const entry of catalog) { + const question = emojiQuestion(entry); + const entrySize = + JSON.stringify(entry.id).length + JSON.stringify(question).length + 1; + + if (current.length > 0 && size + entrySize > MAX_QUESTIONS_JSON_CHARS) { + chunks.push(current); + current = []; + size = 2; + } + + current.push(entry); + size += entrySize; + } + + if (current.length > 0) { + chunks.push(current); + } + + return chunks; +} + +function getEmojiCatalog() { + catalogPromise ??= fetch(EMOJIBASE_DATA_URL) + .then(async (response) => { + if (!response.ok) { + throw new Error("Failed to load emoji data"); + } + + const data: unknown = await response.json(); + + if (!Array.isArray(data)) { + throw new Error("Failed to load emoji data"); + } + + const catalog: EmojiCatalogEntry[] = []; + const seen = new Set(); + + for (const item of data) { + if (!item || typeof item !== "object") continue; + + const record = item as Record; + const emoji = typeof record.emoji === "string" ? record.emoji : null; + const label = + typeof record.label === "string" ? record.label.trim() : null; + const hexcode = + typeof record.hexcode === "string" ? record.hexcode : null; + const tags = Array.isArray(record.tags) + ? record.tags.filter((tag): tag is string => typeof tag === "string") + : []; + + if (!emoji || !label || !hexcode || seen.has(hexcode)) continue; + + seen.add(hexcode); + catalog.push({ id: hexcode, emoji, label, tags }); + } + + return catalog; + }) + .catch((error) => { + catalogPromise = null; + throw error; + }); + + return catalogPromise; +} + +export function warmEmojiCatalog() { + void getEmojiCatalog().catch(() => undefined); +} + +export async function searchEmojis(query: string): Promise { + const normalized = query.trim().toLowerCase(); + + if (normalized.length < 2) { + return []; + } + + const cached = searchCache.get(normalized); + if (cached) return cached; + + const pending = searchRequests.get(normalized); + if (pending) return pending; + + const request = getEmojiCatalog() + .then(async (catalog) => { + const probabilitySets = await Promise.all( + chunkEmojiCatalog(catalog).map(async (entries) => { + const result = await evaluate({ + model: "typesafe-ai/jev", + state: normalized, + questions: Object.fromEntries( + entries.map((entry) => [entry.id, emojiQuestion(entry)]), + ), + providerOptions: { + gateway: { + zeroDataRetention: true, + }, + }, + }); + + const probabilities: Record = {}; + + for (const entry of entries) { + const answer = result.answers[entry.id]; + + if (answer?.type === "boolean") { + probabilities[entry.id] = answer.probability; + } + } + + return probabilities; + }), + ); + const matches = rankEmojiMatches( + catalog, + Object.assign({}, ...probabilitySets), + ); + + if (searchCache.size >= SEARCH_CACHE_LIMIT) { + const oldest = searchCache.keys().next().value; + if (oldest) searchCache.delete(oldest); + } + + searchCache.set(normalized, matches); + return matches; + }) + .finally(() => { + searchRequests.delete(normalized); + }); + + searchRequests.set(normalized, request); + return request; +} diff --git a/apps/web/lib/upstash/ratelimit-policies.ts b/apps/web/lib/upstash/ratelimit-policies.ts index 8990f57eb31..b1c7a00ab5f 100644 --- a/apps/web/lib/upstash/ratelimit-policies.ts +++ b/apps/web/lib/upstash/ratelimit-policies.ts @@ -282,4 +282,10 @@ export const RATELIMIT_POLICIES = { window: "24 h", keyPrefix: "rl:tremendous:verify-otp", }, + + emojiSearch: { + attempts: 30, + window: "10 s", + keyPrefix: "rl:ai:emoji-search", + }, } as const satisfies Record; diff --git a/apps/web/tests/misc/search-emojis.test.ts b/apps/web/tests/misc/search-emojis.test.ts new file mode 100644 index 00000000000..4312097a753 --- /dev/null +++ b/apps/web/tests/misc/search-emojis.test.ts @@ -0,0 +1,63 @@ +import { + chunkEmojiCatalog, + rankEmojiMatches, + type EmojiCatalogEntry, +} from "@/lib/ai/search-emojis"; +import { describe, expect, it } from "vitest"; + +function entry( + id: string, + emoji: string, + label = "grinning face", +): EmojiCatalogEntry { + return { id, emoji, label }; +} + +describe("rankEmojiMatches", () => { + const catalog = [ + entry("1", "πŸ˜€", "grinning face"), + entry("2", "πŸ•", "pizza"), + entry("3", "πŸ€’", "face with thermometer"), + ]; + + it("keeps thematic matches above 0.25, highest probability first", () => { + expect( + rankEmojiMatches(catalog, { + "1": 0.3, + "2": 0.25, + "3": 0.91, + }), + ).toEqual([ + { emoji: "πŸ€’", label: "face with thermometer" }, + { emoji: "πŸ˜€", label: "grinning face" }, + ]); + }); + + it("returns at most 18 matches", () => { + const many = Array.from({ length: 50 }, (_, index) => + entry(String(index), String.fromCodePoint(0x1f600 + (index % 20))), + ); + + const probabilities = Object.fromEntries( + many.map((item, index) => [item.id, 0.7 + index / 1000]), + ); + + expect(rankEmojiMatches(many, probabilities)).toHaveLength(18); + }); +}); + +describe("chunkEmojiCatalog", () => { + it("keeps a small catalog in one request", () => { + expect( + chunkEmojiCatalog([entry("1F600", "πŸ˜€"), entry("1F355", "πŸ•", "pizza")]), + ).toHaveLength(1); + }); + + it("splits a catalog that would exceed Jev's context window", () => { + const label = "a".repeat(60_000); + + expect( + chunkEmojiCatalog([entry("1", "πŸ˜€", label), entry("2", "πŸ•", label)]), + ).toHaveLength(2); + }); +}); diff --git a/apps/web/ui/shared/emoji-picker.tsx b/apps/web/ui/shared/emoji-picker.tsx index 05d3bdeefa3..01af81864c0 100644 --- a/apps/web/ui/shared/emoji-picker.tsx +++ b/apps/web/ui/shared/emoji-picker.tsx @@ -1,12 +1,100 @@ +import type { EmojiMatch } from "@/lib/ai/search-emojis"; import { Button, Popover } from "@dub/ui"; import { FaceSmile } from "@dub/ui/icons"; import { EmojiPicker as EmojiPickerBase } from "frimousse"; import { PropsWithChildren, + useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; +import { + DUB_EMOJI_MATCHES, + normalizeEmojiQuery, + useSemanticEmojiSearch, +} from "./use-semantic-emoji-search"; + +const EMOJI_COLUMNS = 9; + +function EmojiMatchGrid({ + matches, + onSelect, +}: { + matches: EmojiMatch[]; + onSelect: (emoji: string) => void; +}) { + const rows = Array.from( + { length: Math.ceil(matches.length / EMOJI_COLUMNS) }, + (_, index) => + matches.slice(index * EMOJI_COLUMNS, (index + 1) * EMOJI_COLUMNS), + ); + + return ( +
+ {rows.map((row, rowIndex) => ( +
+ {row.map((match) => ( + + ))} +
+ ))} +
+ ); +} + +function EmojiSkeleton() { + return ( +
+ {Array.from({ length: 8 }, (_, row) => ( +
+ {Array.from({ length: EMOJI_COLUMNS }, (_, column) => ( +
+ +
+ ))} +
+ ))} +
+ ); +} + +function EmojiSearchFallback({ + search, + onSelect, +}: { + search: string; + onSelect: (emoji: string) => void; +}) { + const query = normalizeEmojiQuery(search); + const semantic = useSemanticEmojiSearch(query, query.length >= 2); + + if (semantic.status === "loading") { + return ; + } + + if (semantic.status !== "ready") { + return ( +
+ No emoji found. +
+ ); + } + + return ; +} type EmojiPickerProps = PropsWithChildren<{ onSelect: (emoji: string) => void; @@ -35,6 +123,16 @@ export function EmojiPicker({ const openPopover = isControlled ? controlledOpen : internalOpen; const setOpenPopover = isControlled ? controlledSetOpen : setInternalOpen; const keyboardDismissRef = useRef(false); + const [search, setSearch] = useState(""); + const showDubEasterEgg = normalizeEmojiQuery(search) === "dub"; + const selectEmoji = (emoji: string) => { + onSelect(emoji); + setOpenPopover(false); + }; + + useEffect(() => { + if (!openPopover) setSearch(""); + }, [openPopover]); const anchorEl = anchorRect ? (
{ + handleBackspaceClose(event); + if (!showDubEasterEgg) return; + if (event.key === "Enter" || event.key.startsWith("Arrow")) { + event.preventDefault(); + event.stopPropagation(); + } + }} > { - onSelect(emoji); - setOpenPopover(false); - }} + onEmojiSelect={({ emoji }) => selectEmoji(emoji)} > - + setSearch(event.target.value)} + className="border-border-default focus:border-border-default z-10 w-full border-0 border-b bg-white px-3 py-2.5 text-base outline-none placeholder:text-neutral-400 focus:ring-0 sm:rounded-t-lg sm:text-sm" + /> - - Loading… + + - - No emoji found. + + {({ search }) => ( + + )} (
diff --git a/apps/web/ui/shared/inline-emoji-menu.tsx b/apps/web/ui/shared/inline-emoji-menu.tsx index 00edc11e50d..ff505d6f381 100644 --- a/apps/web/ui/shared/inline-emoji-menu.tsx +++ b/apps/web/ui/shared/inline-emoji-menu.tsx @@ -21,6 +21,11 @@ import { useRef, useState, } from "react"; +import { + DUB_EMOJI_MATCHES, + normalizeEmojiQuery, + useSemanticEmojiSearch, +} from "./use-semantic-emoji-search"; type EmojiShortcode = { emoji: string; @@ -121,14 +126,34 @@ export const InlineEmojiAutocomplete = forwardRef< const menuRef = useRef(null); const query = token?.query ?? ""; - const results = useMemo( - () => - token && !dismissed && !suspended ? searchEmojiShortcodes(query) : [], - [token, dismissed, suspended, query], + const normalizedQuery = normalizeEmojiQuery(query); + const menuActive = Boolean(token && !dismissed && !suspended); + const showDub = menuActive && normalizedQuery === "dub"; + const localResults = useMemo( + () => (menuActive && !showDub ? searchEmojiShortcodes(query) : []), + [menuActive, showDub, query], + ); + const semantic = useSemanticEmojiSearch( + query, + menuActive && !showDub && localResults.length === 0, ); - const open = results.length > 0; - const activeIndexClamped = results.length - ? Math.min(activeIndex, results.length - 1) + const suggestions = showDub + ? DUB_EMOJI_MATCHES + : localResults.length > 0 + ? localResults.map((item) => ({ + emoji: item.emoji, + label: item.shortcode, + })) + : semantic.matches; + const loading = + menuActive && + !showDub && + localResults.length === 0 && + semantic.status === "loading"; + const open = + showDub || localResults.length > 0 || loading || suggestions.length > 0; + const activeIndexClamped = suggestions.length + ? Math.min(activeIndex, suggestions.length - 1) : 0; const activeOptionId = `${listboxId}-option-${activeIndexClamped}`; @@ -247,7 +272,7 @@ export const InlineEmojiAutocomplete = forwardRef< }, [open, editor, listboxId, activeOptionId]); const selectIndex = (index: number) => { - const next = results[index]; + const next = suggestions[index]; if (!next || !editor || !token) return; editor @@ -264,9 +289,19 @@ export const InlineEmojiAutocomplete = forwardRef< onKeyDown: (event) => { if (!open || event.isComposing) return false; + if (suggestions.length === 0) { + if (event.key === "Escape") { + event.preventDefault(); + setDismissed(true); + return true; + } + + return false; + } + if (event.key === "ArrowDown") { event.preventDefault(); - const next = (activeIndexRef.current + 1) % results.length; + const next = (activeIndexRef.current + 1) % suggestions.length; activeIndexRef.current = next; setActiveIndex(next); return true; @@ -275,7 +310,8 @@ export const InlineEmojiAutocomplete = forwardRef< if (event.key === "ArrowUp") { event.preventDefault(); const next = - (activeIndexRef.current - 1 + results.length) % results.length; + (activeIndexRef.current - 1 + suggestions.length) % + suggestions.length; activeIndexRef.current = next; setActiveIndex(next); return true; @@ -288,7 +324,7 @@ export const InlineEmojiAutocomplete = forwardRef< event.preventDefault(); const index = Math.min( activeIndexRef.current, - Math.max(results.length - 1, 0), + Math.max(suggestions.length - 1, 0), ); selectIndex(index); return true; @@ -303,7 +339,7 @@ export const InlineEmojiAutocomplete = forwardRef< return false; }, }), - [open, results, editor, token], + [open, suggestions, editor, token], ); if (!open) return null; @@ -322,37 +358,51 @@ export const InlineEmojiAutocomplete = forwardRef< onMouseDown={(event) => event.preventDefault()} className="border-border-subtle bg-bg-default z-[60] flex max-h-52 w-max min-w-40 max-w-[min(18rem,calc(100vw-16px))] flex-col overflow-y-auto rounded-lg border p-1 shadow-sm" > - {results.map((item, index) => { - const selected = index === activeIndexClamped; - - return ( - - ); - })} + {loading ? ( +
+ {Array.from({ length: 5 }, (_, index) => ( +
+ + +
+ ))} +
+ ) : ( + suggestions.map((item, index) => { + const selected = index === activeIndexClamped; + + return ( + + ); + }) + )} ); diff --git a/apps/web/ui/shared/use-semantic-emoji-search.ts b/apps/web/ui/shared/use-semantic-emoji-search.ts new file mode 100644 index 00000000000..54d6f0b2cc0 --- /dev/null +++ b/apps/web/ui/shared/use-semantic-emoji-search.ts @@ -0,0 +1,126 @@ +"use client"; + +import type { EmojiMatch } from "@/lib/ai/search-emojis"; +import { useEffect, useState } from "react"; + +export const DUB_EMOJI_MATCHES: EmojiMatch[] = [ + { emoji: "🀝", label: "Partners" }, + { emoji: "πŸ’°", label: "Money" }, + { emoji: "πŸ’Έ", label: "Payout" }, + { emoji: "πŸ”—", label: "Link" }, + { emoji: "πŸ“±", label: "Mobile" }, + { emoji: "πŸš€", label: "Growth" }, + { emoji: "πŸ“Š", label: "Analytics" }, + { emoji: "🎯", label: "Conversion" }, + { emoji: "🌐", label: "Domain" }, + { emoji: "πŸ“ˆ", label: "Clicks" }, + { emoji: "🏷️", label: "Tag" }, + { emoji: "πŸ‘₯", label: "Customers" }, + { emoji: "πŸ’¬", label: "Messages" }, + { emoji: "🎁", label: "Reward" }, + { emoji: "πŸ†", label: "Bounty" }, + { emoji: "πŸ””", label: "Webhook" }, +]; + +const emojiSearchCache = new Map(); +const emojiSearchRequests = new Map>(); + +export function normalizeEmojiQuery(query: string) { + return query.trim().toLowerCase(); +} + +function fetchEmojiMatches(query: string) { + const cached = emojiSearchCache.get(query); + if (cached) return Promise.resolve(cached); + + const pending = emojiSearchRequests.get(query); + if (pending) return pending; + + const request = fetch("/api/ai/emoji-search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }) + .then(async (response) => { + if (!response.ok) { + throw new Error("Emoji search failed"); + } + + const data = (await response.json()) as { emojis?: EmojiMatch[] }; + const matches = Array.isArray(data.emojis) + ? data.emojis.filter( + (match) => + !!match && + typeof match.emoji === "string" && + typeof match.label === "string", + ) + : []; + + emojiSearchCache.set(query, matches); + return matches; + }) + .finally(() => { + emojiSearchRequests.delete(query); + }); + + emojiSearchRequests.set(query, request); + return request; +} + +export function useSemanticEmojiSearch(query: string, enabled: boolean) { + const normalized = normalizeEmojiQuery(query); + const [result, setResult] = useState<{ + query: string; + matches: EmojiMatch[]; + } | null>(null); + const [failedQuery, setFailedQuery] = useState(null); + + useEffect(() => { + if (!enabled || normalized.length < 2 || emojiSearchCache.has(normalized)) { + return; + } + + let active = true; + const timeout = window.setTimeout(() => { + fetchEmojiMatches(normalized) + .then((matches) => { + if (!active) return; + setFailedQuery((current) => + current === normalized ? null : current, + ); + setResult({ query: normalized, matches }); + }) + .catch(() => { + if (!active) return; + setFailedQuery(normalized); + }); + }, 120); + + return () => { + active = false; + window.clearTimeout(timeout); + }; + }, [enabled, normalized]); + + if (!enabled || normalized.length < 2) { + return { status: "idle" as const, matches: [] as EmojiMatch[] }; + } + + const matches = + emojiSearchCache.get(normalized) ?? + (result?.query === normalized ? result.matches : undefined); + + if (!matches) { + if (failedQuery === normalized) { + return { status: "empty" as const, matches: [] as EmojiMatch[] }; + } + + return { status: "loading" as const, matches: [] as EmojiMatch[] }; + } + + if (matches.length === 0) { + return { status: "empty" as const, matches }; + } + + return { status: "ready" as const, matches }; +} From b68f61b9b2ba8f3024bf092fd09e465ec4277a43 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Mon, 28 Sep 2026 12:20:42 -0300 Subject: [PATCH 2/6] code improvements --- apps/web/ui/shared/emoji-picker.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/ui/shared/emoji-picker.tsx b/apps/web/ui/shared/emoji-picker.tsx index 01af81864c0..ea14e6a5859 100644 --- a/apps/web/ui/shared/emoji-picker.tsx +++ b/apps/web/ui/shared/emoji-picker.tsx @@ -184,7 +184,13 @@ export function EmojiPicker({ onKeyDownCapture={(event) => { handleBackspaceClose(event); if (!showDubEasterEgg) return; - if (event.key === "Enter" || event.key.startsWith("Arrow")) { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + selectEmoji(DUB_EMOJI_MATCHES[0].emoji); + return; + } + if (event.key.startsWith("Arrow")) { event.preventDefault(); event.stopPropagation(); } From 952ffed85f2756008e3b47a5efbee966b81b9d78 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Mon, 28 Sep 2026 14:00:08 -0300 Subject: [PATCH 3/6] code improvements --- apps/web/ui/shared/emoji-picker.tsx | 5 +++- apps/web/ui/shared/inline-emoji-menu.tsx | 36 +++++++++++++++--------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/apps/web/ui/shared/emoji-picker.tsx b/apps/web/ui/shared/emoji-picker.tsx index ea14e6a5859..70e4090b543 100644 --- a/apps/web/ui/shared/emoji-picker.tsx +++ b/apps/web/ui/shared/emoji-picker.tsx @@ -184,7 +184,10 @@ export function EmojiPicker({ onKeyDownCapture={(event) => { handleBackspaceClose(event); if (!showDubEasterEgg) return; - if (event.key === "Enter") { + if ( + event.key === "Enter" && + event.target instanceof HTMLInputElement + ) { event.preventDefault(); event.stopPropagation(); selectEmoji(DUB_EMOJI_MATCHES[0].emoji); diff --git a/apps/web/ui/shared/inline-emoji-menu.tsx b/apps/web/ui/shared/inline-emoji-menu.tsx index ff505d6f381..c60911fa30f 100644 --- a/apps/web/ui/shared/inline-emoji-menu.tsx +++ b/apps/web/ui/shared/inline-emoji-menu.tsx @@ -261,7 +261,9 @@ export const InlineEmojiAutocomplete = forwardRef< dom.setAttribute("aria-controls", listboxId); dom.setAttribute("aria-expanded", "true"); dom.setAttribute("aria-autocomplete", "list"); - dom.setAttribute("aria-activedescendant", activeOptionId); + if (suggestions.length > 0) { + dom.setAttribute("aria-activedescendant", activeOptionId); + } return () => { dom.removeAttribute("aria-controls"); @@ -269,7 +271,7 @@ export const InlineEmojiAutocomplete = forwardRef< dom.removeAttribute("aria-autocomplete"); dom.removeAttribute("aria-activedescendant"); }; - }, [open, editor, listboxId, activeOptionId]); + }, [open, editor, listboxId, activeOptionId, suggestions.length]); const selectIndex = (index: number) => { const next = suggestions[index]; @@ -359,17 +361,25 @@ export const InlineEmojiAutocomplete = forwardRef< className="border-border-subtle bg-bg-default z-[60] flex max-h-52 w-max min-w-40 max-w-[min(18rem,calc(100vw-16px))] flex-col overflow-y-auto rounded-lg border p-1 shadow-sm" > {loading ? ( -
- {Array.from({ length: 5 }, (_, index) => ( -
- - -
- ))} -
+ <> +
+ Loading emojis +
+
+ {Array.from({ length: 5 }, (_, index) => ( +
+ + +
+ ))} +
+ ) : ( suggestions.map((item, index) => { const selected = index === activeIndexClamped; From 88d74627cbf976bcf1a9cbf6bef0bef8ee352419 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 30 Sep 2026 16:17:59 -0300 Subject: [PATCH 4/6] replace jev for embeddings --- apps/web/.env.example | 4 + apps/web/app/api/ai/emoji-search/route.ts | 4 +- apps/web/lib/ai/search-emojis.ts | 203 +++--------------- apps/web/lib/upstash/vector.ts | 23 ++ .../scripts/dev/upsert-emoji-embeddings.ts | 102 +++++++++ apps/web/tests/misc/search-emojis.test.ts | 63 ------ 6 files changed, 154 insertions(+), 245 deletions(-) create mode 100644 apps/web/scripts/dev/upsert-emoji-embeddings.ts delete mode 100644 apps/web/tests/misc/search-emojis.test.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index 5c26914520c..ed3641a18a9 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -153,6 +153,10 @@ PLAIN_WEBHOOK_SECRET= UPSTASH_VECTOR_REST_URL= UPSTASH_VECTOR_REST_TOKEN= +# Emoji search index +UPSTASH_VECTOR_EMOJI_REST_URL= +UPSTASH_VECTOR_EMOJI_REST_TOKEN= + # Slack integration SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= diff --git a/apps/web/app/api/ai/emoji-search/route.ts b/apps/web/app/api/ai/emoji-search/route.ts index 70e66fd0d62..c87d55a3399 100644 --- a/apps/web/app/api/ai/emoji-search/route.ts +++ b/apps/web/app/api/ai/emoji-search/route.ts @@ -1,4 +1,4 @@ -import { searchEmojis, warmEmojiCatalog } from "@/lib/ai/search-emojis"; +import { searchEmojis } from "@/lib/ai/search-emojis"; import { withSession } from "@/lib/auth"; import { assertRateLimit } from "@/lib/upstash/assert-rate-limit"; import { RATELIMIT_POLICIES } from "@/lib/upstash/ratelimit-policies"; @@ -9,8 +9,6 @@ const emojiSearchSchema = z.object({ query: z.string().trim().min(2).max(64), }); -warmEmojiCatalog(); - // POST /api/ai/emoji-search – semantic emoji matches when name search misses export const POST = withSession(async ({ req, session }) => { const { query } = emojiSearchSchema.parse(await req.json()); diff --git a/apps/web/lib/ai/search-emojis.ts b/apps/web/lib/ai/search-emojis.ts index f046efdc826..61c4629d663 100644 --- a/apps/web/lib/ai/search-emojis.ts +++ b/apps/web/lib/ai/search-emojis.ts @@ -1,145 +1,38 @@ -import { experimental_evaluate as evaluate } from "ai"; - -const EMOJIBASE_DATA_URL = - "https://cdn.jsdelivr.net/npm/emojibase-data@16.0.3/en/data.json"; +import { getEmojiVectorIndex } from "@/lib/upstash/vector"; const MATCH_LIMIT = 18; -const MATCH_THRESHOLD = 0.25; -const MAX_QUESTIONS_JSON_CHARS = 100_000; -const MAX_TAGS = 12; -const SEARCH_CACHE_LIMIT = 100; - -const MATCH_INSTRUCTIONS = - "Is this a good emoji for the search, including the same subject, synonyms, and close themes such as movies, characters, places, and objects?"; +const MATCH_SCORE_FLOOR = 0.45; export type EmojiMatch = { emoji: string; label: string; }; -export type EmojiCatalogEntry = EmojiMatch & { - id: string; - tags?: string[]; +type EmojiVectorHit = { + score: number; + metadata?: { + emoji?: string; + label?: string; + } | null; }; -let catalogPromise: Promise | null = null; -const searchCache = new Map(); -const searchRequests = new Map>(); - -export function rankEmojiMatches( - catalog: EmojiCatalogEntry[], - probabilities: Record, -): EmojiMatch[] { - return catalog - .flatMap((entry) => { - const probability = probabilities[entry.id]; - - if (probability === undefined || probability <= MATCH_THRESHOLD) { - return []; - } - - return [{ ...entry, probability }]; - }) - .sort((a, b) => b.probability - a.probability) - .slice(0, MATCH_LIMIT) - .map(({ emoji, label }) => ({ emoji, label })); -} - -function emojiQuestion(entry: EmojiCatalogEntry) { - const tags = (entry.tags ?? []) - .filter( - (tag) => - tag.length > 1 && tag.toLowerCase() !== entry.label.toLowerCase(), - ) - .slice(0, MAX_TAGS); - - return { - type: "boolean" as const, - instructions: MATCH_INSTRUCTIONS, - criteria: { - true: - tags.length === 0 ? entry.label : `${entry.label} (${tags.join(", ")})`, - false: "unrelated subject", - }, - }; -} +export function toEmojiMatches(hits: EmojiVectorHit[]): EmojiMatch[] { + const matches: EmojiMatch[] = []; -export function chunkEmojiCatalog( - catalog: EmojiCatalogEntry[], -): EmojiCatalogEntry[][] { - const chunks: EmojiCatalogEntry[][] = []; - let current: EmojiCatalogEntry[] = []; - let size = 2; + for (const hit of hits) { + if (hit.score < MATCH_SCORE_FLOOR) continue; - for (const entry of catalog) { - const question = emojiQuestion(entry); - const entrySize = - JSON.stringify(entry.id).length + JSON.stringify(question).length + 1; + const emoji = hit.metadata?.emoji; + const label = hit.metadata?.label?.trim(); - if (current.length > 0 && size + entrySize > MAX_QUESTIONS_JSON_CHARS) { - chunks.push(current); - current = []; - size = 2; - } + if (!emoji || !label) continue; - current.push(entry); - size += entrySize; - } + matches.push({ emoji, label }); - if (current.length > 0) { - chunks.push(current); + if (matches.length >= MATCH_LIMIT) break; } - return chunks; -} - -function getEmojiCatalog() { - catalogPromise ??= fetch(EMOJIBASE_DATA_URL) - .then(async (response) => { - if (!response.ok) { - throw new Error("Failed to load emoji data"); - } - - const data: unknown = await response.json(); - - if (!Array.isArray(data)) { - throw new Error("Failed to load emoji data"); - } - - const catalog: EmojiCatalogEntry[] = []; - const seen = new Set(); - - for (const item of data) { - if (!item || typeof item !== "object") continue; - - const record = item as Record; - const emoji = typeof record.emoji === "string" ? record.emoji : null; - const label = - typeof record.label === "string" ? record.label.trim() : null; - const hexcode = - typeof record.hexcode === "string" ? record.hexcode : null; - const tags = Array.isArray(record.tags) - ? record.tags.filter((tag): tag is string => typeof tag === "string") - : []; - - if (!emoji || !label || !hexcode || seen.has(hexcode)) continue; - - seen.add(hexcode); - catalog.push({ id: hexcode, emoji, label, tags }); - } - - return catalog; - }) - .catch((error) => { - catalogPromise = null; - throw error; - }); - - return catalogPromise; -} - -export function warmEmojiCatalog() { - void getEmojiCatalog().catch(() => undefined); + return matches; } export async function searchEmojis(query: string): Promise { @@ -149,59 +42,11 @@ export async function searchEmojis(query: string): Promise { return []; } - const cached = searchCache.get(normalized); - if (cached) return cached; - - const pending = searchRequests.get(normalized); - if (pending) return pending; - - const request = getEmojiCatalog() - .then(async (catalog) => { - const probabilitySets = await Promise.all( - chunkEmojiCatalog(catalog).map(async (entries) => { - const result = await evaluate({ - model: "typesafe-ai/jev", - state: normalized, - questions: Object.fromEntries( - entries.map((entry) => [entry.id, emojiQuestion(entry)]), - ), - providerOptions: { - gateway: { - zeroDataRetention: true, - }, - }, - }); - - const probabilities: Record = {}; - - for (const entry of entries) { - const answer = result.answers[entry.id]; - - if (answer?.type === "boolean") { - probabilities[entry.id] = answer.probability; - } - } - - return probabilities; - }), - ); - const matches = rankEmojiMatches( - catalog, - Object.assign({}, ...probabilitySets), - ); - - if (searchCache.size >= SEARCH_CACHE_LIMIT) { - const oldest = searchCache.keys().next().value; - if (oldest) searchCache.delete(oldest); - } - - searchCache.set(normalized, matches); - return matches; - }) - .finally(() => { - searchRequests.delete(normalized); - }); + const hits = await getEmojiVectorIndex().query({ + data: normalized, + topK: MATCH_LIMIT, + includeMetadata: true, + }); - searchRequests.set(normalized, request); - return request; + return toEmojiMatches(hits); } diff --git a/apps/web/lib/upstash/vector.ts b/apps/web/lib/upstash/vector.ts index 8a3ebec27c3..1f54cff39b9 100644 --- a/apps/web/lib/upstash/vector.ts +++ b/apps/web/lib/upstash/vector.ts @@ -4,3 +4,26 @@ export const vectorIndex = new Index({ url: process.env.UPSTASH_VECTOR_REST_URL!, token: process.env.UPSTASH_VECTOR_REST_TOKEN!, }); + +type EmojiVectorMetadata = { + emoji: string; + label: string; +}; + +let emojiVectorIndex: Index | null = null; + +export function getEmojiVectorIndex() { + if (emojiVectorIndex) return emojiVectorIndex; + + const url = process.env.UPSTASH_VECTOR_EMOJI_REST_URL; + const token = process.env.UPSTASH_VECTOR_EMOJI_REST_TOKEN; + + if (!url || !token) { + throw new Error( + "Set UPSTASH_VECTOR_EMOJI_REST_URL and UPSTASH_VECTOR_EMOJI_REST_TOKEN", + ); + } + + emojiVectorIndex = new Index({ url, token }); + return emojiVectorIndex; +} diff --git a/apps/web/scripts/dev/upsert-emoji-embeddings.ts b/apps/web/scripts/dev/upsert-emoji-embeddings.ts new file mode 100644 index 00000000000..21151b0bf17 --- /dev/null +++ b/apps/web/scripts/dev/upsert-emoji-embeddings.ts @@ -0,0 +1,102 @@ +import { toEmojiMatches } from "@/lib/ai/search-emojis"; +import { getEmojiVectorIndex } from "@/lib/upstash/vector"; +import "dotenv-flow/config"; + +const EMOJIBASE_DATA_URL = + "https://cdn.jsdelivr.net/npm/emojibase-data@16.0.3/en/data.json"; +const UPSERT_BATCH_SIZE = 500; + +type EmojiVectorRecord = { + id: string; + data: string; + metadata: { + emoji: string; + label: string; + }; +}; + +async function loadEmojiRecords(): Promise { + const response = await fetch(EMOJIBASE_DATA_URL, { + headers: { "User-Agent": "Mozilla/5.0" }, + }); + + if (!response.ok) { + throw new Error("Failed to load emoji data"); + } + + const data: unknown = await response.json(); + + if (!Array.isArray(data)) { + throw new Error("Failed to load emoji data"); + } + + const records: EmojiVectorRecord[] = []; + const seen = new Set(); + + for (const item of data) { + if (!item || typeof item !== "object") continue; + + const record = item as Record; + const emoji = typeof record.emoji === "string" ? record.emoji : null; + const label = typeof record.label === "string" ? record.label.trim() : null; + const hexcode = typeof record.hexcode === "string" ? record.hexcode : null; + + if (!emoji || !label || !hexcode || seen.has(hexcode)) continue; + + const tags = Array.isArray(record.tags) + ? record.tags.filter( + (tag): tag is string => + typeof tag === "string" && + tag.length > 1 && + tag.toLowerCase() !== label.toLowerCase(), + ) + : []; + + seen.add(hexcode); + records.push({ + id: hexcode, + data: tags.length === 0 ? label : `${label} (${tags.join(", ")})`, + metadata: { emoji, label }, + }); + } + + return records; +} + +async function main() { + if ( + !process.env.UPSTASH_VECTOR_EMOJI_REST_URL || + !process.env.UPSTASH_VECTOR_EMOJI_REST_TOKEN + ) { + throw new Error( + "Set UPSTASH_VECTOR_EMOJI_REST_URL and UPSTASH_VECTOR_EMOJI_REST_TOKEN", + ); + } + + const records = await loadEmojiRecords(); + + for (let index = 0; index < records.length; index += UPSERT_BATCH_SIZE) { + const batch = records.slice(index, index + UPSERT_BATCH_SIZE); + await getEmojiVectorIndex().upsert(batch); + console.log( + `Upserted ${Math.min(index + batch.length, records.length)}/${records.length}`, + ); + } + + const query = "jurassic park"; + const hits = await getEmojiVectorIndex().query({ + data: query, + topK: 18, + includeMetadata: true, + }); + + console.log(`\n${query}`); + for (const match of toEmojiMatches(hits)) { + console.log(`${match.emoji} ${match.label}`); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/web/tests/misc/search-emojis.test.ts b/apps/web/tests/misc/search-emojis.test.ts deleted file mode 100644 index 4312097a753..00000000000 --- a/apps/web/tests/misc/search-emojis.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - chunkEmojiCatalog, - rankEmojiMatches, - type EmojiCatalogEntry, -} from "@/lib/ai/search-emojis"; -import { describe, expect, it } from "vitest"; - -function entry( - id: string, - emoji: string, - label = "grinning face", -): EmojiCatalogEntry { - return { id, emoji, label }; -} - -describe("rankEmojiMatches", () => { - const catalog = [ - entry("1", "πŸ˜€", "grinning face"), - entry("2", "πŸ•", "pizza"), - entry("3", "πŸ€’", "face with thermometer"), - ]; - - it("keeps thematic matches above 0.25, highest probability first", () => { - expect( - rankEmojiMatches(catalog, { - "1": 0.3, - "2": 0.25, - "3": 0.91, - }), - ).toEqual([ - { emoji: "πŸ€’", label: "face with thermometer" }, - { emoji: "πŸ˜€", label: "grinning face" }, - ]); - }); - - it("returns at most 18 matches", () => { - const many = Array.from({ length: 50 }, (_, index) => - entry(String(index), String.fromCodePoint(0x1f600 + (index % 20))), - ); - - const probabilities = Object.fromEntries( - many.map((item, index) => [item.id, 0.7 + index / 1000]), - ); - - expect(rankEmojiMatches(many, probabilities)).toHaveLength(18); - }); -}); - -describe("chunkEmojiCatalog", () => { - it("keeps a small catalog in one request", () => { - expect( - chunkEmojiCatalog([entry("1F600", "πŸ˜€"), entry("1F355", "πŸ•", "pizza")]), - ).toHaveLength(1); - }); - - it("splits a catalog that would exceed Jev's context window", () => { - const label = "a".repeat(60_000); - - expect( - chunkEmojiCatalog([entry("1", "πŸ˜€", label), entry("2", "πŸ•", label)]), - ).toHaveLength(2); - }); -}); From 869389924a0aa30867f3edaa2b7dc09fd67f48c2 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 30 Sep 2026 12:54:51 -0700 Subject: [PATCH 5/6] simplify to emojiVectorIndex --- apps/web/.env.example | 2 +- apps/web/lib/ai/search-emojis.ts | 4 +-- apps/web/lib/upstash/vector.ts | 25 ++++--------------- .../scripts/dev/upsert-emoji-embeddings.ts | 6 ++--- 4 files changed, 11 insertions(+), 26 deletions(-) diff --git a/apps/web/.env.example b/apps/web/.env.example index ed3641a18a9..ce85936d01f 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -149,7 +149,7 @@ PLAIN_WEBHOOK_SECRET= ###### DUB.CO INTERNAL USE ONLY ###### ###################################### -# For storing vector embeddings (/api/support/chat) +# Docs / help center search index UPSTASH_VECTOR_REST_URL= UPSTASH_VECTOR_REST_TOKEN= diff --git a/apps/web/lib/ai/search-emojis.ts b/apps/web/lib/ai/search-emojis.ts index 61c4629d663..93648f1ef29 100644 --- a/apps/web/lib/ai/search-emojis.ts +++ b/apps/web/lib/ai/search-emojis.ts @@ -1,4 +1,4 @@ -import { getEmojiVectorIndex } from "@/lib/upstash/vector"; +import { emojiVectorIndex } from "@/lib/upstash/vector"; const MATCH_LIMIT = 18; const MATCH_SCORE_FLOOR = 0.45; @@ -42,7 +42,7 @@ export async function searchEmojis(query: string): Promise { return []; } - const hits = await getEmojiVectorIndex().query({ + const hits = await emojiVectorIndex.query({ data: normalized, topK: MATCH_LIMIT, includeMetadata: true, diff --git a/apps/web/lib/upstash/vector.ts b/apps/web/lib/upstash/vector.ts index 1f54cff39b9..8b90a36fc0c 100644 --- a/apps/web/lib/upstash/vector.ts +++ b/apps/web/lib/upstash/vector.ts @@ -5,25 +5,10 @@ export const vectorIndex = new Index({ token: process.env.UPSTASH_VECTOR_REST_TOKEN!, }); -type EmojiVectorMetadata = { +export const emojiVectorIndex = new Index<{ emoji: string; label: string; -}; - -let emojiVectorIndex: Index | null = null; - -export function getEmojiVectorIndex() { - if (emojiVectorIndex) return emojiVectorIndex; - - const url = process.env.UPSTASH_VECTOR_EMOJI_REST_URL; - const token = process.env.UPSTASH_VECTOR_EMOJI_REST_TOKEN; - - if (!url || !token) { - throw new Error( - "Set UPSTASH_VECTOR_EMOJI_REST_URL and UPSTASH_VECTOR_EMOJI_REST_TOKEN", - ); - } - - emojiVectorIndex = new Index({ url, token }); - return emojiVectorIndex; -} +}>({ + url: process.env.UPSTASH_VECTOR_EMOJI_REST_URL!, + token: process.env.UPSTASH_VECTOR_EMOJI_REST_TOKEN!, +}); diff --git a/apps/web/scripts/dev/upsert-emoji-embeddings.ts b/apps/web/scripts/dev/upsert-emoji-embeddings.ts index 21151b0bf17..552b33f2a73 100644 --- a/apps/web/scripts/dev/upsert-emoji-embeddings.ts +++ b/apps/web/scripts/dev/upsert-emoji-embeddings.ts @@ -1,6 +1,6 @@ import { toEmojiMatches } from "@/lib/ai/search-emojis"; -import { getEmojiVectorIndex } from "@/lib/upstash/vector"; import "dotenv-flow/config"; +import { emojiVectorIndex } from "../../lib/upstash/vector"; const EMOJIBASE_DATA_URL = "https://cdn.jsdelivr.net/npm/emojibase-data@16.0.3/en/data.json"; @@ -77,14 +77,14 @@ async function main() { for (let index = 0; index < records.length; index += UPSERT_BATCH_SIZE) { const batch = records.slice(index, index + UPSERT_BATCH_SIZE); - await getEmojiVectorIndex().upsert(batch); + await emojiVectorIndex.upsert(batch); console.log( `Upserted ${Math.min(index + batch.length, records.length)}/${records.length}`, ); } const query = "jurassic park"; - const hits = await getEmojiVectorIndex().query({ + const hits = await emojiVectorIndex.query({ data: query, topK: 18, includeMetadata: true, From d6bc1500de59f928411c5aa41e5573bd255912ab Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 30 Sep 2026 13:43:43 -0700 Subject: [PATCH 6/6] small improvements --- apps/web/lib/upstash/vector.ts | 8 ++-- .../scripts/dev/upsert-emoji-embeddings.ts | 2 +- apps/web/ui/shared/emoji-picker.tsx | 44 ++++++++++++++++--- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/web/lib/upstash/vector.ts b/apps/web/lib/upstash/vector.ts index 8b90a36fc0c..66e386a7262 100644 --- a/apps/web/lib/upstash/vector.ts +++ b/apps/web/lib/upstash/vector.ts @@ -1,14 +1,14 @@ import { Index } from "@upstash/vector"; export const vectorIndex = new Index({ - url: process.env.UPSTASH_VECTOR_REST_URL!, - token: process.env.UPSTASH_VECTOR_REST_TOKEN!, + url: process.env.UPSTASH_VECTOR_REST_URL ?? "", + token: process.env.UPSTASH_VECTOR_REST_TOKEN ?? "", }); export const emojiVectorIndex = new Index<{ emoji: string; label: string; }>({ - url: process.env.UPSTASH_VECTOR_EMOJI_REST_URL!, - token: process.env.UPSTASH_VECTOR_EMOJI_REST_TOKEN!, + url: process.env.UPSTASH_VECTOR_EMOJI_REST_URL ?? "", + token: process.env.UPSTASH_VECTOR_EMOJI_REST_TOKEN ?? "", }); diff --git a/apps/web/scripts/dev/upsert-emoji-embeddings.ts b/apps/web/scripts/dev/upsert-emoji-embeddings.ts index 552b33f2a73..e81a21129ac 100644 --- a/apps/web/scripts/dev/upsert-emoji-embeddings.ts +++ b/apps/web/scripts/dev/upsert-emoji-embeddings.ts @@ -1,5 +1,5 @@ -import { toEmojiMatches } from "@/lib/ai/search-emojis"; import "dotenv-flow/config"; +import { toEmojiMatches } from "../../lib/ai/search-emojis"; import { emojiVectorIndex } from "../../lib/upstash/vector"; const EMOJIBASE_DATA_URL = diff --git a/apps/web/ui/shared/emoji-picker.tsx b/apps/web/ui/shared/emoji-picker.tsx index 70e4090b543..d0411c312d8 100644 --- a/apps/web/ui/shared/emoji-picker.tsx +++ b/apps/web/ui/shared/emoji-picker.tsx @@ -1,6 +1,7 @@ import type { EmojiMatch } from "@/lib/ai/search-emojis"; import { Button, Popover } from "@dub/ui"; import { FaceSmile } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; import { EmojiPicker as EmojiPickerBase } from "frimousse"; import { PropsWithChildren, @@ -17,6 +18,18 @@ import { const EMOJI_COLUMNS = 9; +const LOADING_MESSAGES = [ + "Discombobulating...", + "Percolating...", + "Conjuring...", + "Reticulating...", + "Vibing...", + "Finagling...", + "Noodling...", + "Simmering...", + "Baking...", +]; + function EmojiMatchGrid({ matches, onSelect, @@ -52,9 +65,9 @@ function EmojiMatchGrid({ ); } -function EmojiSkeleton() { +function EmojiSkeleton({ className }: { className?: string }) { return ( -
+
{Array.from({ length: 8 }, (_, row) => (
{Array.from({ length: EMOJI_COLUMNS }, (_, column) => ( @@ -80,9 +93,17 @@ function EmojiSearchFallback({ }) { const query = normalizeEmojiQuery(search); const semantic = useSemanticEmojiSearch(query, query.length >= 2); + const [loadingMessage] = useState( + () => LOADING_MESSAGES[Math.floor(Math.random() * LOADING_MESSAGES.length)], + ); if (semantic.status === "loading") { - return ; + return ( + <> + {loadingMessage} + + + ); } if (semantic.status !== "ready") { @@ -93,7 +114,20 @@ function EmojiSearchFallback({ ); } - return ; + return ( + <> + Did you mean... + + + ); +} + +function EmojiSearchHeader({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); } type EmojiPickerProps = PropsWithChildren<{ @@ -165,7 +199,7 @@ export function EmojiPicker({ setOpenPopover={setOpenPopover} side="top" align="start" - sideOffset={anchorRect ? 6 : 38} + sideOffset={anchorRect ? 6 : 8} anchor={anchorEl} onEscapeKeyDown={() => { keyboardDismissRef.current = true;