diff --git a/web/package.json b/web/package.json index b44226ef..5daeb961 100644 --- a/web/package.json +++ b/web/package.json @@ -86,6 +86,7 @@ "@pierre/diffs": "^1.2.12", "@tanstack/svelte-virtual": "^3.13.35", "ansi_up": "^6.0.6", + "emojibase-data": "^17.0.0", "markdown-it": "^14.3.0", "markdown-it-anchor": "^9.2.1", "markdown-it-emoji": "^3.1.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 209bf142..bc7ee423 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: ansi_up: specifier: ^6.0.6 version: 6.0.6 + emojibase-data: + specifier: ^17.0.0 + version: 17.0.0(emojibase@17.0.0) markdown-it: specifier: ^14.3.0 version: 14.3.0 @@ -2270,6 +2273,15 @@ packages: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} + emojibase-data@17.0.0: + resolution: {integrity: sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==} + peerDependencies: + emojibase: '*' + + emojibase@17.0.0: + resolution: {integrity: sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==} + engines: {node: '>=18.12.0'} + enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -3653,8 +3665,8 @@ snapshots: '@atcute/cid@2.4.2': dependencies: - '@atcute/multibase': 1.2.4 - '@atcute/uint8array': 1.1.4 + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.1.5 '@atcute/client@5.1.1(@atcute/lexicons@2.0.2)(typescript@6.0.3)': dependencies: @@ -5425,6 +5437,12 @@ snapshots: domelementtype: 3.0.0 domhandler: 6.0.1 + emojibase-data@17.0.0(emojibase@17.0.0): + dependencies: + emojibase: 17.0.0 + + emojibase@17.0.0: {} + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 diff --git a/web/src/lib/api/reaction.ts b/web/src/lib/api/reaction.ts index e224c76f..7344bf83 100644 --- a/web/src/lib/api/reaction.ts +++ b/web/src/lib/api/reaction.ts @@ -13,6 +13,20 @@ export type ReactionRecord = ShTangledFeedReaction.Main; const REACTION_COLLECTION = "sh.tangled.feed.reaction" as Nsid; +/** The record we put for one reaction. + * + * Both casts live here rather than at the call sites because they say the same thing in one + * place: `sh.tangled.feed.reaction` still declares `reaction` as an enum of the original eight + * emoji, and the codegen turns that into a union, so any other emoji has to be smuggled past + * the generated type. The appview's ingester checks the same list, so widening the picker for + * real means widening the lexicon and `models.ParseReactionKind` with it. */ +export const reactionRecord = (subjectUri: string, kind: string): ReactionRecord => ({ + $type: "sh.tangled.feed.reaction", + subject: subjectUri as ReactionRecord["subject"], + reaction: kind as ReactionRecord["reaction"], + createdAt: new Date().toISOString() +}); + export const putReaction = async ( agent: OAuthUserAgent, rkey: string, diff --git a/web/src/lib/components/reaction/ReactionPicker.svelte b/web/src/lib/components/reaction/ReactionPicker.svelte index 87f3ceea..bb6047ea 100644 --- a/web/src/lib/components/reaction/ReactionPicker.svelte +++ b/web/src/lib/components/reaction/ReactionPicker.svelte @@ -2,11 +2,15 @@ import { now as tidNow } from "@atcute/tid"; import SmilePlus from "$icon/smile-plus"; import { createAction } from "$lib/action.svelte"; - import { putReaction, deleteReaction } from "$lib/api/reaction"; + import { putReaction, deleteReaction, reactionRecord } from "$lib/api/reaction"; import { getAuth } from "$lib/auth.svelte"; import type { ReactionRecord } from "$lib/api/reaction"; import Button from "$lib/components/ui/Button.svelte"; import { ORDERED_REACTION_KINDS, type ReactionKind } from "./reactions"; + import { normalizeEmoji } from "$lib/emoji/emoji"; + import { frequentEmoji, recordEmojiUse } from "$lib/emoji/recents.svelte"; + import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte"; + import { DEFAULT_REACTION_KINDS, type ReactionKind } from "./reactions"; interface Props { subjectUri: string; @@ -25,31 +29,31 @@ const anchorName = `--${popoverId}`; let panel = $state(); + let open = $state(false); - const pick = createAction(async (kind: ReactionKind) => { + const selected = $derived([...(reacted?.keys() ?? [])]); + const frequent = $derived(frequentEmoji([...DEFAULT_REACTION_KINDS])); + + const pick = createAction(async (raw: string) => { const agent = auth?.agent; if (!agent) return; + const kind = normalizeEmoji(raw); const existingRkey = reacted?.get(kind); if (existingRkey) { await deleteReaction(agent, existingRkey); onunreacted?.(kind); } else { const rkey = tidNow(); - const record: ReactionRecord = { - $type: "sh.tangled.feed.reaction", - subject: subjectUri as ReactionRecord["subject"], - reaction: kind, - createdAt: new Date().toISOString() - }; - await putReaction(agent, rkey, record); + await putReaction(agent, rkey, reactionRecord(subjectUri, kind)); + recordEmojiUse(kind); onreacted?.(kind, rkey); } panel?.hidePopover(); }); - const handlePick = (kind: ReactionKind) => { - if (!pick.loading) void pick.run(kind); + const handlePick = (char: string) => { + if (!pick.loading) void pick.run(char); }; @@ -67,20 +71,32 @@ bind:this={panel} id={popoverId} popover="auto" + ontoggle={(event: ToggleEvent) => (open = event.newState === "open")} style={`position-anchor: ${anchorName}`} - class="inset-auto mt-1 h-fit w-max popover-transition rounded-sm border border-border-default bg-background-default text-foreground-default shadow-regular [position-area:bottom_center]" + class="reaction-picker-panel fixed inset-auto m-0 hidden h-fit max-h-[min(22rem,calc(100dvh-1rem))] popover-transition rounded-sm border border-border-default bg-background-default p-0 text-foreground-default shadow-regular open:flex dark:shadow-none" > -
- {#each ORDERED_REACTION_KINDS as kind (kind)} - - {/each} -
+ + {#if open} + panel?.hidePopover()} + /> + {/if} + + diff --git a/web/src/lib/components/reaction/Reactions.svelte b/web/src/lib/components/reaction/Reactions.svelte index c62ffcf8..074a4d10 100644 --- a/web/src/lib/components/reaction/Reactions.svelte +++ b/web/src/lib/components/reaction/Reactions.svelte @@ -1,7 +1,7 @@ + + +
+ console.log("picked", char)} /> +
+
+ + +
+ console.log("picked", char)} + /> +
+
+ + +
+ {}} /> +
+
diff --git a/web/src/lib/components/ui/EmojiPicker.svelte b/web/src/lib/components/ui/EmojiPicker.svelte new file mode 100644 index 00000000..5be4e2e1 --- /dev/null +++ b/web/src/lib/components/ui/EmojiPicker.svelte @@ -0,0 +1,279 @@ + + + + +
+
+ 0 ? cellId(activeIndex) : undefined} + aria-label="Search emoji" + /> +
+ +
+ {#each blocks as block (block.key)} +
+ {#if block.label} +

{block.label}

+ {/if} + +
+ {/each} + + {#if loading} +

Loading emojiโ€ฆ

+ {:else if flat.length === 0} +

No emoji found

+ {/if} +
+
+ + diff --git a/web/src/lib/emoji/emoji.test.ts b/web/src/lib/emoji/emoji.test.ts new file mode 100644 index 00000000..6a61862c --- /dev/null +++ b/web/src/lib/emoji/emoji.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { isEmoji, normalizeEmoji, loadEmoji, searchEmoji, type EmojiSection } from "./emoji"; +import { DEFAULT_REACTION_KINDS } from "$lib/components/reaction/reactions"; + +let cached: EmojiSection[] | undefined; +const sections = async () => (cached ??= await loadEmoji()); + +describe("isEmoji", () => { + it("accepts the shapes an emoji comes in", () => { + expect(isEmoji("๐Ÿ‘")).toBe(true); + expect(isEmoji("โค๏ธ")).toBe(true); + expect(isEmoji("๐Ÿ‘‹๐Ÿฝ")).toBe(true); + expect(isEmoji("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ")).toBe(true); + expect(isEmoji("๐Ÿ‡ซ๐Ÿ‡ท")).toBe(true); + expect(isEmoji("๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ")).toBe(true); + expect(isEmoji("1๏ธโƒฃ")).toBe(true); + }); + + it("rejects text that merely contains one", () => { + expect(isEmoji("nice ๐Ÿ‘")).toBe(false); + expect(isEmoji("๐Ÿ‘๐Ÿ‘")).toBe(false); + expect(isEmoji("๐Ÿ‘ ")).toBe(false); + }); + + it("rejects the characters \\p{Emoji} would wrongly accept", () => { + expect(isEmoji("1")).toBe(false); + expect(isEmoji("#")).toBe(false); + expect(isEmoji("*")).toBe(false); + }); + + it("rejects plain text and oversized input", () => { + expect(isEmoji("")).toBe(false); + expect(isEmoji("lgtm")).toBe(false); + expect(isEmoji("๐Ÿ‘".repeat(20))).toBe(false); + expect(isEmoji("โ€".repeat(30))).toBe(false); + }); + + it("rejects anything that is not a string", () => { + expect(isEmoji(undefined)).toBe(false); + expect(isEmoji(null)).toBe(false); + expect(isEmoji(42)).toBe(false); + expect(isEmoji({ toString: () => "๐Ÿ‘" })).toBe(false); + }); +}); + +describe("normalizeEmoji", () => { + it("drops the variation selector an emoji does not need", () => { + expect(normalizeEmoji("๐Ÿ‘๏ธ")).toBe("๐Ÿ‘"); + }); + + it("keeps it where it carries meaning", () => { + // โค renders as a black text glyph without the selector, so it is a different character + expect(normalizeEmoji("โค๏ธ")).toBe("โค๏ธ"); + }); + + it("is idempotent", () => { + const once = normalizeEmoji("๐Ÿ‘๏ธ"); + expect(normalizeEmoji(once)).toBe(once); + }); +}); + +describe("loadEmoji", () => { + it("orders the sections for a picker, people first", async () => { + const keys = (await sections()).map((section) => section.key); + expect(keys).toEqual([ + "people", + "nature", + "food", + "activity", + "places", + "objects", + "symbols", + "flags" + ]); + }); + + it("hands back only normalized, valid emoji", async () => { + const chars = (await sections()).flatMap((section) => section.emojis.map((e) => e.char)); + expect(chars.length).toBeGreaterThan(1500); + expect(chars.filter((char) => !isEmoji(char))).toEqual([]); + expect(chars.filter((char) => normalizeEmoji(char) !== char)).toEqual([]); + }); + + it("contains every default reaction, spelled the way we store it", async () => { + const chars = new Set((await sections()).flatMap((s) => s.emojis.map((e) => e.char))); + for (const kind of DEFAULT_REACTION_KINDS) expect(chars.has(kind)).toBe(true); + }); + + it("leaves out the components, which are modifiers rather than emoji", async () => { + const chars = new Set((await sections()).flatMap((s) => s.emojis.map((e) => e.char))); + expect(chars.has("๐Ÿป")).toBe(false); + expect(chars.has("๐Ÿฆฐ")).toBe(false); + }); +}); + +describe("searchEmoji", () => { + it("finds an emoji by a keyword that is not in its name", async () => { + expect(searchEmoji(await sections(), "tada")[0].char).toBe("๐ŸŽ‰"); + }); + + it("puts an exact name first", async () => { + expect(searchEmoji(await sections(), "rocket")[0].char).toBe("๐Ÿš€"); + }); + + it("ranks an exact keyword above a name that only starts the same way", async () => { + // ๐Ÿญ is "lollipop"; several faces carry "lol" as a keyword, and one of those should win + expect(searchEmoji(await sections(), "lol").slice(0, 5)).toContainEqual( + expect.objectContaining({ char: "๐Ÿ˜†" }) + ); + }); + + it("matches a word inside a name", async () => { + const chars = searchEmoji(await sections(), "cat").map((e) => e.char); + expect(chars).toContain("๐Ÿˆ"); + }); + + it("resolves a pasted emoji to itself", async () => { + expect(searchEmoji(await sections(), "๐Ÿš€")).toEqual([ + expect.objectContaining({ char: "๐Ÿš€" }) + ]); + }); + + it("returns nothing for an empty query", async () => { + expect(searchEmoji(await sections(), " ")).toEqual([]); + }); +}); diff --git a/web/src/lib/emoji/emoji.ts b/web/src/lib/emoji/emoji.ts new file mode 100644 index 00000000..1ccc74b0 --- /dev/null +++ b/web/src/lib/emoji/emoji.ts @@ -0,0 +1,124 @@ +export interface Emoji { + char: string; + label: string; + tags: string[]; +} + +export interface EmojiSection { + key: string; + label: string; + emojis: Emoji[]; +} + +interface CompactEmoji { + unicode: string; + label: string; + tags?: string[]; + group?: number; + order?: number; +} + +// emojibase's groups, folded into the sections a picker actually wants. group 2 is the +// components โ€” skin tones, hair styles โ€” which are modifiers, not emoji anyone reacts with. +const SECTIONS: { key: string; label: string; groups: number[] }[] = [ + { key: "people", label: "Smileys & People", groups: [0, 1] }, + { key: "nature", label: "Animals & Nature", groups: [3] }, + { key: "food", label: "Food & Drink", groups: [4] }, + { key: "activity", label: "Activity", groups: [6] }, + { key: "places", label: "Travel & Places", groups: [5] }, + { key: "objects", label: "Objects", groups: [7] }, + { key: "symbols", label: "Symbols", groups: [8] }, + { key: "flags", label: "Flags", groups: [9] } +]; + +/** One emoji, one spelling. U+FE0F only asks for the colourful rendering, and something that + * already defaults to it does not need the ask โ€” but the two strings are not equal, so ๐Ÿ‘ from + * the dataset and ๐Ÿ‘ from an older record would otherwise count as two different reactions. */ +export function normalizeEmoji(raw: string): string { + return raw.normalize("NFC").replace(/(\p{Emoji_Presentation})๏ธ/gu, "$1"); +} + +// \p{Emoji} is useless as a test: it matches the digits, `#` and `*`, because those carry a +// keycap form. Extended_Pictographic is the property that means "a picture", and the rest of +// this covers the sequences built on top of it. +const REGIONAL_PAIR = "\\p{RI}\\p{RI}"; +const KEYCAP = "[0-9#*]\\uFE0F?\\u20E3"; +const TAG_SEQUENCE = "(?:[\\u{E0020}-\\u{E007E}]+\\u{E007F})"; +const PICTOGRAPH = `\\p{Extended_Pictographic}\\uFE0F?(?:\\p{Emoji_Modifier})?${TAG_SEQUENCE}?`; +const ONE_EMOJI = new RegExp( + `^(?:${REGIONAL_PAIR}|${KEYCAP}|(?:${PICTOGRAPH})(?:\\u200D(?:${PICTOGRAPH}|${REGIONAL_PAIR}))*)$`, + "u" +); +// the longest sequences in the dataset are the tag flags, at 14 code units +const MAX_LENGTH = 32; + +/** Whether a string is exactly one emoji. Anything a PDS hands us is user input: a record can + * carry a sentence, a zero-width joiner bomb, or a number, and none of those may reach the bar. */ +export function isEmoji(raw: unknown): boolean { + if (typeof raw !== "string" || raw.length === 0 || raw.length > MAX_LENGTH) return false; + return ONE_EMOJI.test(raw.normalize("NFC")); +} + +let pending: Promise | undefined; + +/** The dataset is 85 kB gzipped, so it loads on demand and only once. */ +export function loadEmoji(): Promise { + pending ??= import("emojibase-data/en/compact.json").then((mod) => { + const raw = mod.default as unknown as CompactEmoji[]; + const byGroup = new Map(); + for (const entry of raw) { + if (entry.group === undefined) continue; + const bucket = byGroup.get(entry.group); + if (bucket) bucket.push(entry); + else byGroup.set(entry.group, [entry]); + } + return SECTIONS.map(({ key, label, groups }) => ({ + key, + label, + emojis: groups + .flatMap((group) => byGroup.get(group) ?? []) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((entry) => ({ + char: normalizeEmoji(entry.unicode), + label: entry.label, + tags: entry.tags ?? [] + })) + })); + }); + return pending; +} + +function score(emoji: Emoji, query: string): number { + const label = emoji.label.toLowerCase(); + if (label === query) return 0; + // an exact keyword beats a name that merely starts the same way: "lol" means ๐Ÿ˜†, not ๐Ÿญ + if (emoji.tags.some((tag) => tag === query)) return 1; + if (label.startsWith(query)) return 2; + if (label.split(/[\s-]+/).some((word) => word.startsWith(query))) return 3; + if (emoji.tags.some((tag) => tag.startsWith(query))) return 4; + if (label.includes(query)) return 5; + return -1; +} + +export function searchEmoji(sections: EmojiSection[], query: string): Emoji[] { + const needle = query.trim().toLowerCase(); + if (needle === "") return []; + + // pasting an emoji into the field should find that emoji, not every name containing it + if (isEmoji(needle)) { + const wanted = normalizeEmoji(needle); + const hit = sections.flatMap((section) => section.emojis).find((e) => e.char === wanted); + if (hit) return [hit]; + } + + const hits: { emoji: Emoji; rank: number; order: number }[] = []; + let order = 0; + for (const section of sections) { + for (const emoji of section.emojis) { + const rank = score(emoji, needle); + if (rank !== -1) hits.push({ emoji, rank, order }); + order++; + } + } + return hits.sort((a, b) => a.rank - b.rank || a.order - b.order).map((hit) => hit.emoji); +} diff --git a/web/src/lib/emoji/frequency.test.ts b/web/src/lib/emoji/frequency.test.ts new file mode 100644 index 00000000..f2be0fa1 --- /dev/null +++ b/web/src/lib/emoji/frequency.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { rankFrequent, type Use } from "./frequency"; + +const NOW = Date.UTC(2026, 0, 30); +const DAY = 24 * 60 * 60 * 1000; +const daysAgo = (days: number) => NOW - days * DAY; + +const defaults = ["๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ˜†", "๐ŸŽ‰", "๐Ÿซค", "โค๏ธ", "๐Ÿš€", "๐Ÿ‘€"]; +const rank = (uses: Use[], limit = 9) => rankFrequent(uses, defaults, limit, NOW); + +describe("rankFrequent", () => { + it("shows the defaults to someone with no history", () => { + expect(rank([])).toEqual(defaults); + }); + + it("puts a used emoji ahead of the defaults", () => { + expect(rank([{ char: "๐Ÿฆ€", count: 1, at: daysAgo(0) }])[0]).toBe("๐Ÿฆ€"); + }); + + it("never repeats an emoji that is also a default", () => { + const row = rank([{ char: "๐ŸŽ‰", count: 5, at: daysAgo(1) }]); + expect(row.filter((char) => char === "๐ŸŽ‰")).toHaveLength(1); + expect(row[0]).toBe("๐ŸŽ‰"); + }); + + it("ranks by count when two emoji are equally recent", () => { + const row = rank([ + { char: "๐Ÿฆ€", count: 2, at: daysAgo(1) }, + { char: "๐Ÿฑ", count: 9, at: daysAgo(1) } + ]); + expect(row.slice(0, 2)).toEqual(["๐Ÿฑ", "๐Ÿฆ€"]); + }); + + it("decays an old burst behind a smaller recent habit", () => { + // 12 picks two months ago lose to 4 picks today: roughly four half-lives of decay + const row = rank([ + { char: "๐Ÿฆ€", count: 12, at: daysAgo(60) }, + { char: "๐Ÿฑ", count: 4, at: daysAgo(0) } + ]); + expect(row.slice(0, 2)).toEqual(["๐Ÿฑ", "๐Ÿฆ€"]); + }); + + it("keeps a long-standing habit ahead of a single recent pick", () => { + const row = rank([ + { char: "๐Ÿฆ€", count: 20, at: daysAgo(3) }, + { char: "๐Ÿฑ", count: 1, at: daysAgo(0) } + ]); + expect(row.slice(0, 2)).toEqual(["๐Ÿฆ€", "๐Ÿฑ"]); + }); + + it("fills the row up to the limit and no further", () => { + const uses = ["๐Ÿฆ€", "๐Ÿฑ", "๐ŸŒฑ"].map((char) => ({ char, count: 3, at: daysAgo(1) })); + expect(rank(uses, 9)).toHaveLength(9); + expect(rank(uses, 4)).toEqual(["๐Ÿฆ€", "๐Ÿฑ", "๐ŸŒฑ", "๐Ÿ‘"]); + }); + + it("drops the defaults entirely once history fills the row", () => { + const uses = ["๐Ÿฆ€", "๐Ÿฑ", "๐ŸŒฑ", "๐Ÿ”ฅ"].map((char) => ({ char, count: 3, at: daysAgo(1) })); + expect(rank(uses, 4)).toEqual(["๐Ÿฆ€", "๐Ÿฑ", "๐ŸŒฑ", "๐Ÿ”ฅ"]); + }); +}); diff --git a/web/src/lib/emoji/frequency.ts b/web/src/lib/emoji/frequency.ts new file mode 100644 index 00000000..3f07592a --- /dev/null +++ b/web/src/lib/emoji/frequency.ts @@ -0,0 +1,30 @@ +export interface Use { + char: string; + count: number; + at: number; +} + +// two weeks: long enough that the emoji you reach for every sprint stay put, short enough that +// the burst you sent during one release week does not own the row a month later +const HALF_LIFE_MS = 14 * 24 * 60 * 60 * 1000; + +const score = (use: Use, now: number) => use.count * Math.pow(2, -(now - use.at) / HALF_LIFE_MS); + +/** The row above the sections: your own emoji first, ranked by frecency, then as many of the + * defaults as there is room for. A viewer with no history sees exactly the defaults, and every + * pick after that pushes one of them down rather than replacing the row outright. */ +export function rankFrequent( + uses: Use[], + fallback: string[], + limit: number, + now = Date.now() +): string[] { + const ranked = [...uses].sort((a, b) => score(b, now) - score(a, now)).map((use) => use.char); + + const row: string[] = []; + for (const char of [...ranked, ...fallback]) { + if (row.length === limit) break; + if (!row.includes(char)) row.push(char); + } + return row; +} diff --git a/web/src/lib/emoji/recents.svelte.ts b/web/src/lib/emoji/recents.svelte.ts new file mode 100644 index 00000000..ff390262 --- /dev/null +++ b/web/src/lib/emoji/recents.svelte.ts @@ -0,0 +1,61 @@ +import { browser } from "$app/environment"; +import { isEmoji, normalizeEmoji } from "./emoji"; +import { rankFrequent, type Use } from "./frequency"; + +const KEY = "tangled:emoji-frequent"; +// enough history to rank from without letting the entry grow forever +const KEEP = 36; + +function read(): Use[] { + if (!browser) return []; + try { + const raw = localStorage.getItem(KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (entry): entry is Use => + typeof entry === "object" && + entry !== null && + isEmoji((entry as Use).char) && + typeof (entry as Use).count === "number" && + typeof (entry as Use).at === "number" + ); + } catch { + return []; + } +} + +let uses = $state(read()); + +function persist() { + if (!browser) return; + try { + localStorage.setItem(KEY, JSON.stringify(uses)); + } catch { + // a full quota is not worth failing a reaction over + } +} + +export function recordEmojiUse(raw: string): void { + if (!isEmoji(raw)) return; + const char = normalizeEmoji(raw); + const now = Date.now(); + const existing = uses.find((use) => use.char === char); + uses = existing + ? uses.map((use) => (use.char === char ? { ...use, count: use.count + 1, at: now } : use)) + : [...uses, { char, count: 1, at: now }]; + if (uses.length > KEEP) { + uses = [...uses].sort((a, b) => b.count - a.count || b.at - a.at).slice(0, KEEP); + } + persist(); +} + +export function clearEmojiUses(): void { + uses = []; + if (browser) localStorage.removeItem(KEY); +} + +export function frequentEmoji(fallback: string[], limit = 9): string[] { + return rankFrequent(uses, fallback, limit); +}