diff --git a/package-lock.json b/package-lock.json index 24aa938..d622bf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "date-fns": "^4.1.0", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", + "emojilib": "^4.0.2", "flexsearch": "^0.8.212", "framer-motion": "^12.23.26", "hash-sum": "^2.0.0", @@ -79,7 +80,8 @@ "shell-quote": "^1.8.3", "shiki": "^3.20.0", "sonner": "^2.0.7", - "tailwind-merge": "^2.5.5" + "tailwind-merge": "^2.5.5", + "unicode-emoji-json": "^0.8.0" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -7596,6 +7598,12 @@ "dev": true, "license": "MIT" }, + "node_modules/emojilib": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-4.0.2.tgz", + "integrity": "sha512-8sP3sfAi861cNaFFp7RmyKi4S26K34ZKy4si75ojUW627AIVBHhMSu2SFJo3YRoK66YWnbysIbOk/d48uEqtKQ==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -12406,6 +12414,12 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unicode-emoji-json": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-json/-/unicode-emoji-json-0.8.0.tgz", + "integrity": "sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/package.json b/package.json index e518951..c7e97bb 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "date-fns": "^4.1.0", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", + "emojilib": "^4.0.2", "flexsearch": "^0.8.212", "framer-motion": "^12.23.26", "hash-sum": "^2.0.0", @@ -88,7 +89,8 @@ "shell-quote": "^1.8.3", "shiki": "^3.20.0", "sonner": "^2.0.7", - "tailwind-merge": "^2.5.5" + "tailwind-merge": "^2.5.5", + "unicode-emoji-json": "^0.8.0" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/src/components/chat/EmojiPickerDialog.tsx b/src/components/chat/EmojiPickerDialog.tsx index 4784b99..a5f26c3 100644 --- a/src/components/chat/EmojiPickerDialog.tsx +++ b/src/components/chat/EmojiPickerDialog.tsx @@ -4,6 +4,7 @@ import { Input } from "@/components/ui/input"; import { Search } from "lucide-react"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import type { EmojiSearchResult } from "@/services/emoji-search"; +import { recordEmojiUsage, getRecentEmojiKeys } from "@/services/emoji-usage"; import type { EmojiTag } from "@/lib/emoji-helpers"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; @@ -15,34 +16,9 @@ interface EmojiPickerDialogProps { contextEmojis?: EmojiTag[]; } -// Frequently used emojis stored in localStorage -const STORAGE_KEY = "grimoire:reaction-history"; - const ITEM_HEIGHT = 40; const MAX_VISIBLE = 8; -function getReactionHistory(): Record { - try { - const stored = localStorage.getItem(STORAGE_KEY); - return stored ? JSON.parse(stored) : {}; - } catch { - return {}; - } -} - -function updateReactionHistory(emoji: string): void { - try { - const history = getReactionHistory(); - history[emoji] = (history[emoji] || 0) + 1; - localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); - } catch (err) { - console.error( - "[EmojiPickerDialog] Failed to update reaction history:", - err, - ); - } -} - /** * EmojiPickerDialog - Searchable emoji picker for reactions * @@ -98,12 +74,9 @@ export function EmojiPickerDialog({ } }, [open]); - // Get frequently used emojis from history (sorted by use count) + // Get frequently used emojis ranked by recency+frequency const frequentlyUsed = useMemo(() => { - const history = getReactionHistory(); - return Object.entries(history) - .sort((a, b) => b[1] - a[1]) - .map(([emoji]) => emoji); + return getRecentEmojiKeys(50); }, []); // Resolve top 8 recently used emojis to EmojiSearchResult for rendering @@ -157,16 +130,15 @@ export function EmojiPickerDialog({ const handleEmojiClick = useCallback( (result: EmojiSearchResult) => { + recordEmojiUsage(result); if (result.source === "unicode") { onEmojiSelect(result.url); - updateReactionHistory(result.url); } else { onEmojiSelect(`:${result.shortcode}:`, { shortcode: result.shortcode, url: result.url, address: result.address, }); - updateReactionHistory(`:${result.shortcode}:`); } onOpenChange(false); }, diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index 43d0a19..8a43e61 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -17,6 +17,7 @@ import { EmojiSuggestionList } from "./EmojiSuggestionList"; import { SlashCommandSuggestionList } from "./SlashCommandSuggestionList"; import type { ProfileSearchResult } from "@/services/profile-search"; import type { EmojiSearchResult } from "@/services/emoji-search"; +import { recordEmojiUsage } from "@/services/emoji-usage"; import type { ChatAction } from "@/types/chat-actions"; import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; import { FilePasteHandler } from "./extensions/file-paste-handler"; @@ -219,6 +220,7 @@ export const MentionEditor = forwardRef< suggestion: { ...emojiSuggestion, command: ({ editor, range, props }: any) => { + recordEmojiUsage(props); editor .chain() .focus() diff --git a/src/components/editor/RichEditor.tsx b/src/components/editor/RichEditor.tsx index ec06243..99b4b25 100644 --- a/src/components/editor/RichEditor.tsx +++ b/src/components/editor/RichEditor.tsx @@ -14,6 +14,7 @@ import { ProfileSuggestionList } from "./ProfileSuggestionList"; import { EmojiSuggestionList } from "./EmojiSuggestionList"; import type { ProfileSearchResult } from "@/services/profile-search"; import type { EmojiSearchResult } from "@/services/emoji-search"; +import { recordEmojiUsage } from "@/services/emoji-usage"; import { nip19 } from "nostr-tools"; import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; import { FilePasteHandler } from "./extensions/file-paste-handler"; @@ -196,6 +197,7 @@ export const RichEditor = forwardRef( suggestion: { ...emojiSuggestion, command: ({ editor, range, props }: any) => { + recordEmojiUsage(props); editor .chain() .focus() diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx index ecb4c87..d0df932 100644 --- a/src/components/layouts/AppShell.tsx +++ b/src/components/layouts/AppShell.tsx @@ -3,6 +3,7 @@ import { Terminal } from "lucide-react"; import { useAccountSync } from "@/hooks/useAccountSync"; import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync"; import { useBlossomServerCacheSync } from "@/hooks/useBlossomServerCacheSync"; +import { useEmojiSearchSync } from "@/hooks/useEmojiSearchSync"; import { useRelayState } from "@/hooks/useRelayState"; import relayStateManager from "@/services/relay-state-manager"; import { TabBar } from "../TabBar"; @@ -30,6 +31,9 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) { // Auto-cache kind:10063 blossom server lists from EventStore to Dexie useBlossomServerCacheSync(); + // Cache emoji lists (kind:10030) and emoji sets (kind:30030) for instant availability + useEmojiSearchSync(); + // Initialize global relay state manager useEffect(() => { relayStateManager.initialize(); diff --git a/src/hooks/useEmojiSearch.ts b/src/hooks/useEmojiSearch.ts index e5edfee..e78addb 100644 --- a/src/hooks/useEmojiSearch.ts +++ b/src/hooks/useEmojiSearch.ts @@ -1,114 +1,33 @@ -import { useEffect, useMemo, useRef } from "react"; -import { - EmojiSearchService, +import { useEffect, useCallback } from "react"; +import emojiSearchService, { type EmojiSearchResult, } from "@/services/emoji-search"; -import { UNICODE_EMOJIS } from "@/lib/unicode-emojis"; -import eventStore from "@/services/event-store"; import type { NostrEvent } from "@/types/nostr"; -import { useAccount } from "./useAccount"; /** - * Hook to provide emoji search functionality with automatic indexing - * of Unicode emojis and user's custom emojis from the event store + * Hook to provide emoji search functionality. + * + * The singleton EmojiSearchService is initialized and kept in sync by + * useEmojiSearchSync (called once in AppShell). This hook is a thin + * wrapper that exposes search and handles per-component context emojis. */ export function useEmojiSearch(contextEvent?: NostrEvent) { - const serviceRef = useRef(null); - const { pubkey } = useAccount(); - - // Create service instance (singleton per component mount) - if (!serviceRef.current) { - serviceRef.current = new EmojiSearchService(); - // Load Unicode emojis immediately - serviceRef.current.addUnicodeEmojis(UNICODE_EMOJIS); - } - - const service = serviceRef.current; - - // Add context emojis when context event changes + // Add context emojis when context event changes (per-component, transient) useEffect(() => { if (contextEvent) { - service.addContextEmojis(contextEvent); + emojiSearchService.addContextEmojis(contextEvent); } - }, [contextEvent, service]); + }, [contextEvent]); - // Subscribe to user's emoji list (kind 10030) and emoji sets (kind 30030) - useEffect(() => { - if (!pubkey) { - return; - } - - // Subscribe to user's emoji list (kind 10030 - replaceable) - const userEmojiList$ = eventStore.replaceable(10030, pubkey); - const userEmojiSub = userEmojiList$.subscribe({ - next: (event) => { - if (event) { - service.addUserEmojiList(event); - - // Also load referenced emoji sets from "a" tags - const aTags = event.tags.filter( - (t) => t[0] === "a" && t[1]?.startsWith("30030:"), - ); - for (const aTag of aTags) { - const [, coordinate] = aTag; - const [kind, setPubkey, identifier] = coordinate.split(":"); - if (kind && setPubkey && identifier !== undefined) { - // Subscribe to each referenced emoji set - const emojiSet$ = eventStore.replaceable( - parseInt(kind, 10), - setPubkey, - identifier, - ); - emojiSet$.subscribe({ - next: (setEvent) => { - if (setEvent) { - service.addEmojiSet(setEvent); - } - }, - }); - } - } - } - }, - error: (error) => { - console.error("Failed to load user emoji list:", error); - }, - }); - - // Also subscribe to any emoji sets authored by the user - const userEmojiSets$ = eventStore.timeline([ - { kinds: [30030], authors: [pubkey], limit: 50 }, - ]); - const userEmojiSetsSub = userEmojiSets$.subscribe({ - next: (events) => { - for (const event of events) { - service.addEmojiSet(event); - } - }, - error: (error) => { - console.error("Failed to load user emoji sets:", error); - }, - }); - - return () => { - userEmojiSub.unsubscribe(); - userEmojiSetsSub.unsubscribe(); - // Clear custom emojis but keep unicode - service.clearCustom(); - }; - }, [pubkey, service]); - - // Memoize search function - const searchEmojis = useMemo( - () => - async (query: string): Promise => { - return await service.search(query, { limit: 200 }); - }, - [service], + const searchEmojis = useCallback( + async (query: string): Promise => { + return emojiSearchService.search(query, { limit: 200 }); + }, + [], ); return { searchEmojis, - service, + service: emojiSearchService, }; } diff --git a/src/hooks/useEmojiSearchSync.ts b/src/hooks/useEmojiSearchSync.ts new file mode 100644 index 0000000..2ae6af9 --- /dev/null +++ b/src/hooks/useEmojiSearchSync.ts @@ -0,0 +1,34 @@ +/** + * Hook to keep emoji search service in sync with EventStore + * + * Loads cached emojis from Dexie on startup (instant availability), + * then subscribes to kind:10030 and kind:30030 events for live updates. + * Should be used once at app root level (AppShell). + */ + +import { useEffect } from "react"; +import { useEventStore } from "applesauce-react/hooks"; +import { useAccount } from "./useAccount"; +import emojiSearchService from "@/services/emoji-search"; + +export function useEmojiSearchSync() { + const eventStore = useEventStore(); + const { pubkey } = useAccount(); + + useEffect(() => { + if (!pubkey) return; + let cancelled = false; + + // Load from Dexie first (instant), then subscribe for fresh data + emojiSearchService.loadCachedForUser(pubkey).then(() => { + if (!cancelled) { + emojiSearchService.subscribeForUser(pubkey, eventStore); + } + }); + + return () => { + cancelled = true; + emojiSearchService.unsubscribeUser(); + }; + }, [pubkey, eventStore]); +} diff --git a/src/lib/unicode-emojis.ts b/src/lib/unicode-emojis.ts index 551765a..8f35150 100644 --- a/src/lib/unicode-emojis.ts +++ b/src/lib/unicode-emojis.ts @@ -1,373 +1,19 @@ +import dataByEmoji from "unicode-emoji-json/data-by-emoji.json"; +import emojilib from "emojilib"; + /** - * Common Unicode emojis with shortcode mappings - * Based on common shortcodes used across platforms (Slack, Discord, GitHub) + * Comprehensive Unicode emoji list derived from unicode-emoji-json. + * ~1,900 RGI emojis with slugs as shortcodes. */ -export const UNICODE_EMOJIS: Array<{ shortcode: string; emoji: string }> = [ - // Smileys & Emotion - { shortcode: "smile", emoji: "\u{1F604}" }, - { shortcode: "grinning", emoji: "\u{1F600}" }, - { shortcode: "joy", emoji: "\u{1F602}" }, - { shortcode: "rofl", emoji: "\u{1F923}" }, - { shortcode: "smiley", emoji: "\u{1F603}" }, - { shortcode: "sweat_smile", emoji: "\u{1F605}" }, - { shortcode: "laughing", emoji: "\u{1F606}" }, - { shortcode: "wink", emoji: "\u{1F609}" }, - { shortcode: "blush", emoji: "\u{1F60A}" }, - { shortcode: "yum", emoji: "\u{1F60B}" }, - { shortcode: "sunglasses", emoji: "\u{1F60E}" }, - { shortcode: "heart_eyes", emoji: "\u{1F60D}" }, - { shortcode: "kissing_heart", emoji: "\u{1F618}" }, - { shortcode: "kissing", emoji: "\u{1F617}" }, - { shortcode: "relaxed", emoji: "\u{263A}\u{FE0F}" }, - { shortcode: "stuck_out_tongue", emoji: "\u{1F61B}" }, - { shortcode: "stuck_out_tongue_winking_eye", emoji: "\u{1F61C}" }, - { shortcode: "stuck_out_tongue_closed_eyes", emoji: "\u{1F61D}" }, - { shortcode: "money_mouth_face", emoji: "\u{1F911}" }, - { shortcode: "hugs", emoji: "\u{1F917}" }, - { shortcode: "nerd_face", emoji: "\u{1F913}" }, - { shortcode: "smirk", emoji: "\u{1F60F}" }, - { shortcode: "unamused", emoji: "\u{1F612}" }, - { shortcode: "disappointed", emoji: "\u{1F61E}" }, - { shortcode: "pensive", emoji: "\u{1F614}" }, - { shortcode: "worried", emoji: "\u{1F61F}" }, - { shortcode: "confused", emoji: "\u{1F615}" }, - { shortcode: "slightly_frowning_face", emoji: "\u{1F641}" }, - { shortcode: "frowning_face", emoji: "\u{2639}\u{FE0F}" }, - { shortcode: "persevere", emoji: "\u{1F623}" }, - { shortcode: "confounded", emoji: "\u{1F616}" }, - { shortcode: "tired_face", emoji: "\u{1F62B}" }, - { shortcode: "weary", emoji: "\u{1F629}" }, - { shortcode: "cry", emoji: "\u{1F622}" }, - { shortcode: "sob", emoji: "\u{1F62D}" }, - { shortcode: "triumph", emoji: "\u{1F624}" }, - { shortcode: "angry", emoji: "\u{1F620}" }, - { shortcode: "rage", emoji: "\u{1F621}" }, - { shortcode: "no_mouth", emoji: "\u{1F636}" }, - { shortcode: "neutral_face", emoji: "\u{1F610}" }, - { shortcode: "expressionless", emoji: "\u{1F611}" }, - { shortcode: "hushed", emoji: "\u{1F62F}" }, - { shortcode: "flushed", emoji: "\u{1F633}" }, - { shortcode: "astonished", emoji: "\u{1F632}" }, - { shortcode: "open_mouth", emoji: "\u{1F62E}" }, - { shortcode: "scream", emoji: "\u{1F631}" }, - { shortcode: "fearful", emoji: "\u{1F628}" }, - { shortcode: "cold_sweat", emoji: "\u{1F630}" }, - { shortcode: "disappointed_relieved", emoji: "\u{1F625}" }, - { shortcode: "sweat", emoji: "\u{1F613}" }, - { shortcode: "sleeping", emoji: "\u{1F634}" }, - { shortcode: "sleepy", emoji: "\u{1F62A}" }, - { shortcode: "dizzy_face", emoji: "\u{1F635}" }, - { shortcode: "zipper_mouth_face", emoji: "\u{1F910}" }, - { shortcode: "mask", emoji: "\u{1F637}" }, - { shortcode: "thermometer_face", emoji: "\u{1F912}" }, - { shortcode: "head_bandage", emoji: "\u{1F915}" }, - { shortcode: "thinking", emoji: "\u{1F914}" }, - { shortcode: "rolling_eyes", emoji: "\u{1F644}" }, - { shortcode: "upside_down_face", emoji: "\u{1F643}" }, - { shortcode: "face_with_hand_over_mouth", emoji: "\u{1F92D}" }, - { shortcode: "shushing_face", emoji: "\u{1F92B}" }, - { shortcode: "exploding_head", emoji: "\u{1F92F}" }, - { shortcode: "cowboy_hat_face", emoji: "\u{1F920}" }, - { shortcode: "partying_face", emoji: "\u{1F973}" }, - { shortcode: "woozy_face", emoji: "\u{1F974}" }, - { shortcode: "pleading_face", emoji: "\u{1F97A}" }, - { shortcode: "skull", emoji: "\u{1F480}" }, +export const UNICODE_EMOJIS: Array<{ shortcode: string; emoji: string }> = + Object.entries(dataByEmoji).map(([emoji, data]) => ({ + shortcode: data.slug, + emoji, + })); - // Gestures & Body - { shortcode: "thumbsup", emoji: "\u{1F44D}" }, - { shortcode: "+1", emoji: "\u{1F44D}" }, - { shortcode: "thumbsdown", emoji: "\u{1F44E}" }, - { shortcode: "-1", emoji: "\u{1F44E}" }, - { shortcode: "ok_hand", emoji: "\u{1F44C}" }, - { shortcode: "punch", emoji: "\u{1F44A}" }, - { shortcode: "fist", emoji: "\u{270A}" }, - { shortcode: "wave", emoji: "\u{1F44B}" }, - { shortcode: "hand", emoji: "\u{270B}" }, - { shortcode: "open_hands", emoji: "\u{1F450}" }, - { shortcode: "point_up", emoji: "\u{261D}\u{FE0F}" }, - { shortcode: "point_down", emoji: "\u{1F447}" }, - { shortcode: "point_left", emoji: "\u{1F448}" }, - { shortcode: "point_right", emoji: "\u{1F449}" }, - { shortcode: "clap", emoji: "\u{1F44F}" }, - { shortcode: "pray", emoji: "\u{1F64F}" }, - { shortcode: "muscle", emoji: "\u{1F4AA}" }, - { shortcode: "metal", emoji: "\u{1F918}" }, - { shortcode: "crossed_fingers", emoji: "\u{1F91E}" }, - { shortcode: "v", emoji: "\u{270C}\u{FE0F}" }, - { shortcode: "love_you_gesture", emoji: "\u{1F91F}" }, - { shortcode: "call_me_hand", emoji: "\u{1F919}" }, - { shortcode: "raised_back_of_hand", emoji: "\u{1F91A}" }, - { shortcode: "handshake", emoji: "\u{1F91D}" }, - { shortcode: "writing_hand", emoji: "\u{270D}\u{FE0F}" }, - { shortcode: "eyes", emoji: "\u{1F440}" }, - { shortcode: "eye", emoji: "\u{1F441}\u{FE0F}" }, - { shortcode: "brain", emoji: "\u{1F9E0}" }, - - // Hearts & Symbols - { shortcode: "heart", emoji: "\u{2764}\u{FE0F}" }, - { shortcode: "red_heart", emoji: "\u{2764}\u{FE0F}" }, - { shortcode: "orange_heart", emoji: "\u{1F9E1}" }, - { shortcode: "yellow_heart", emoji: "\u{1F49B}" }, - { shortcode: "green_heart", emoji: "\u{1F49A}" }, - { shortcode: "blue_heart", emoji: "\u{1F499}" }, - { shortcode: "purple_heart", emoji: "\u{1F49C}" }, - { shortcode: "black_heart", emoji: "\u{1F5A4}" }, - { shortcode: "broken_heart", emoji: "\u{1F494}" }, - { shortcode: "two_hearts", emoji: "\u{1F495}" }, - { shortcode: "sparkling_heart", emoji: "\u{1F496}" }, - { shortcode: "heartpulse", emoji: "\u{1F497}" }, - { shortcode: "heartbeat", emoji: "\u{1F493}" }, - { shortcode: "fire", emoji: "\u{1F525}" }, - { shortcode: "star", emoji: "\u{2B50}" }, - { shortcode: "star2", emoji: "\u{1F31F}" }, - { shortcode: "sparkles", emoji: "\u{2728}" }, - { shortcode: "zap", emoji: "\u{26A1}" }, - { shortcode: "boom", emoji: "\u{1F4A5}" }, - { shortcode: "100", emoji: "\u{1F4AF}" }, - { shortcode: "checkmark", emoji: "\u{2714}\u{FE0F}" }, - { shortcode: "white_check_mark", emoji: "\u{2705}" }, - { shortcode: "x", emoji: "\u{274C}" }, - { shortcode: "question", emoji: "\u{2753}" }, - { shortcode: "exclamation", emoji: "\u{2757}" }, - { shortcode: "warning", emoji: "\u{26A0}\u{FE0F}" }, - - // Animals - { shortcode: "dog", emoji: "\u{1F436}" }, - { shortcode: "cat", emoji: "\u{1F431}" }, - { shortcode: "mouse", emoji: "\u{1F42D}" }, - { shortcode: "rabbit", emoji: "\u{1F430}" }, - { shortcode: "bear", emoji: "\u{1F43B}" }, - { shortcode: "panda_face", emoji: "\u{1F43C}" }, - { shortcode: "lion", emoji: "\u{1F981}" }, - { shortcode: "pig", emoji: "\u{1F437}" }, - { shortcode: "frog", emoji: "\u{1F438}" }, - { shortcode: "monkey_face", emoji: "\u{1F435}" }, - { shortcode: "see_no_evil", emoji: "\u{1F648}" }, - { shortcode: "hear_no_evil", emoji: "\u{1F649}" }, - { shortcode: "speak_no_evil", emoji: "\u{1F64A}" }, - { shortcode: "chicken", emoji: "\u{1F414}" }, - { shortcode: "penguin", emoji: "\u{1F427}" }, - { shortcode: "bird", emoji: "\u{1F426}" }, - { shortcode: "eagle", emoji: "\u{1F985}" }, - { shortcode: "duck", emoji: "\u{1F986}" }, - { shortcode: "owl", emoji: "\u{1F989}" }, - { shortcode: "bat", emoji: "\u{1F987}" }, - { shortcode: "wolf", emoji: "\u{1F43A}" }, - { shortcode: "fox_face", emoji: "\u{1F98A}" }, - { shortcode: "unicorn", emoji: "\u{1F984}" }, - { shortcode: "bee", emoji: "\u{1F41D}" }, - { shortcode: "bug", emoji: "\u{1F41B}" }, - { shortcode: "butterfly", emoji: "\u{1F98B}" }, - { shortcode: "snail", emoji: "\u{1F40C}" }, - { shortcode: "turtle", emoji: "\u{1F422}" }, - { shortcode: "snake", emoji: "\u{1F40D}" }, - { shortcode: "dragon", emoji: "\u{1F409}" }, - { shortcode: "octopus", emoji: "\u{1F419}" }, - { shortcode: "whale", emoji: "\u{1F433}" }, - { shortcode: "dolphin", emoji: "\u{1F42C}" }, - { shortcode: "shark", emoji: "\u{1F988}" }, - { shortcode: "crab", emoji: "\u{1F980}" }, - { shortcode: "shrimp", emoji: "\u{1F990}" }, - - // Food & Drink - { shortcode: "apple", emoji: "\u{1F34E}" }, - { shortcode: "green_apple", emoji: "\u{1F34F}" }, - { shortcode: "banana", emoji: "\u{1F34C}" }, - { shortcode: "orange", emoji: "\u{1F34A}" }, - { shortcode: "lemon", emoji: "\u{1F34B}" }, - { shortcode: "watermelon", emoji: "\u{1F349}" }, - { shortcode: "grapes", emoji: "\u{1F347}" }, - { shortcode: "strawberry", emoji: "\u{1F353}" }, - { shortcode: "peach", emoji: "\u{1F351}" }, - { shortcode: "cherries", emoji: "\u{1F352}" }, - { shortcode: "pineapple", emoji: "\u{1F34D}" }, - { shortcode: "avocado", emoji: "\u{1F951}" }, - { shortcode: "tomato", emoji: "\u{1F345}" }, - { shortcode: "eggplant", emoji: "\u{1F346}" }, - { shortcode: "carrot", emoji: "\u{1F955}" }, - { shortcode: "corn", emoji: "\u{1F33D}" }, - { shortcode: "pizza", emoji: "\u{1F355}" }, - { shortcode: "hamburger", emoji: "\u{1F354}" }, - { shortcode: "fries", emoji: "\u{1F35F}" }, - { shortcode: "hotdog", emoji: "\u{1F32D}" }, - { shortcode: "taco", emoji: "\u{1F32E}" }, - { shortcode: "burrito", emoji: "\u{1F32F}" }, - { shortcode: "popcorn", emoji: "\u{1F37F}" }, - { shortcode: "sushi", emoji: "\u{1F363}" }, - { shortcode: "ramen", emoji: "\u{1F35C}" }, - { shortcode: "cookie", emoji: "\u{1F36A}" }, - { shortcode: "cake", emoji: "\u{1F370}" }, - { shortcode: "birthday", emoji: "\u{1F382}" }, - { shortcode: "ice_cream", emoji: "\u{1F368}" }, - { shortcode: "doughnut", emoji: "\u{1F369}" }, - { shortcode: "chocolate_bar", emoji: "\u{1F36B}" }, - { shortcode: "candy", emoji: "\u{1F36C}" }, - { shortcode: "coffee", emoji: "\u{2615}" }, - { shortcode: "tea", emoji: "\u{1F375}" }, - { shortcode: "beer", emoji: "\u{1F37A}" }, - { shortcode: "beers", emoji: "\u{1F37B}" }, - { shortcode: "wine_glass", emoji: "\u{1F377}" }, - { shortcode: "cocktail", emoji: "\u{1F378}" }, - { shortcode: "champagne", emoji: "\u{1F37E}" }, - - // Activities & Objects - { shortcode: "soccer", emoji: "\u{26BD}" }, - { shortcode: "basketball", emoji: "\u{1F3C0}" }, - { shortcode: "football", emoji: "\u{1F3C8}" }, - { shortcode: "baseball", emoji: "\u{26BE}" }, - { shortcode: "tennis", emoji: "\u{1F3BE}" }, - { shortcode: "golf", emoji: "\u{26F3}" }, - { shortcode: "trophy", emoji: "\u{1F3C6}" }, - { shortcode: "medal_sports", emoji: "\u{1F3C5}" }, - { shortcode: "guitar", emoji: "\u{1F3B8}" }, - { shortcode: "microphone", emoji: "\u{1F3A4}" }, - { shortcode: "headphones", emoji: "\u{1F3A7}" }, - { shortcode: "video_game", emoji: "\u{1F3AE}" }, - { shortcode: "dart", emoji: "\u{1F3AF}" }, - { shortcode: "game_die", emoji: "\u{1F3B2}" }, - { shortcode: "art", emoji: "\u{1F3A8}" }, - { shortcode: "movie_camera", emoji: "\u{1F3A5}" }, - { shortcode: "camera", emoji: "\u{1F4F7}" }, - { shortcode: "tv", emoji: "\u{1F4FA}" }, - { shortcode: "computer", emoji: "\u{1F4BB}" }, - { shortcode: "keyboard", emoji: "\u{2328}\u{FE0F}" }, - { shortcode: "iphone", emoji: "\u{1F4F1}" }, - { shortcode: "telephone", emoji: "\u{260E}\u{FE0F}" }, - { shortcode: "bulb", emoji: "\u{1F4A1}" }, - { shortcode: "flashlight", emoji: "\u{1F526}" }, - { shortcode: "wrench", emoji: "\u{1F527}" }, - { shortcode: "hammer", emoji: "\u{1F528}" }, - { shortcode: "gear", emoji: "\u{2699}\u{FE0F}" }, - { shortcode: "link", emoji: "\u{1F517}" }, - { shortcode: "lock", emoji: "\u{1F512}" }, - { shortcode: "unlock", emoji: "\u{1F513}" }, - { shortcode: "key", emoji: "\u{1F511}" }, - { shortcode: "mag", emoji: "\u{1F50D}" }, - { shortcode: "hourglass", emoji: "\u{231B}" }, - { shortcode: "alarm_clock", emoji: "\u{23F0}" }, - { shortcode: "stopwatch", emoji: "\u{23F1}\u{FE0F}" }, - { shortcode: "calendar", emoji: "\u{1F4C5}" }, - { shortcode: "memo", emoji: "\u{1F4DD}" }, - { shortcode: "pencil2", emoji: "\u{270F}\u{FE0F}" }, - { shortcode: "scissors", emoji: "\u{2702}\u{FE0F}" }, - { shortcode: "paperclip", emoji: "\u{1F4CE}" }, - { shortcode: "bookmark", emoji: "\u{1F516}" }, - { shortcode: "books", emoji: "\u{1F4DA}" }, - { shortcode: "book", emoji: "\u{1F4D6}" }, - { shortcode: "notebook", emoji: "\u{1F4D3}" }, - { shortcode: "newspaper", emoji: "\u{1F4F0}" }, - { shortcode: "envelope", emoji: "\u{2709}\u{FE0F}" }, - { shortcode: "email", emoji: "\u{1F4E7}" }, - { shortcode: "mailbox", emoji: "\u{1F4EB}" }, - { shortcode: "package", emoji: "\u{1F4E6}" }, - { shortcode: "gift", emoji: "\u{1F381}" }, - { shortcode: "balloon", emoji: "\u{1F388}" }, - { shortcode: "tada", emoji: "\u{1F389}" }, - { shortcode: "confetti_ball", emoji: "\u{1F38A}" }, - { shortcode: "ribbon", emoji: "\u{1F380}" }, - { shortcode: "medal_military", emoji: "\u{1F396}\u{FE0F}" }, - - // Nature & Weather - { shortcode: "sunny", emoji: "\u{2600}\u{FE0F}" }, - { shortcode: "cloud", emoji: "\u{2601}\u{FE0F}" }, - { shortcode: "rain_cloud", emoji: "\u{1F327}\u{FE0F}" }, - { shortcode: "thunder_cloud_and_rain", emoji: "\u{26C8}\u{FE0F}" }, - { shortcode: "rainbow", emoji: "\u{1F308}" }, - { shortcode: "snowflake", emoji: "\u{2744}\u{FE0F}" }, - { shortcode: "snowman", emoji: "\u{26C4}" }, - { shortcode: "wind_face", emoji: "\u{1F32C}\u{FE0F}" }, - { shortcode: "tornado", emoji: "\u{1F32A}\u{FE0F}" }, - { shortcode: "ocean", emoji: "\u{1F30A}" }, - { shortcode: "droplet", emoji: "\u{1F4A7}" }, - { shortcode: "sun_with_face", emoji: "\u{1F31E}" }, - { shortcode: "full_moon", emoji: "\u{1F315}" }, - { shortcode: "new_moon", emoji: "\u{1F311}" }, - { shortcode: "crescent_moon", emoji: "\u{1F319}" }, - { shortcode: "earth_americas", emoji: "\u{1F30E}" }, - { shortcode: "earth_africa", emoji: "\u{1F30D}" }, - { shortcode: "earth_asia", emoji: "\u{1F30F}" }, - { shortcode: "globe_with_meridians", emoji: "\u{1F310}" }, - { shortcode: "sun_behind_cloud", emoji: "\u{26C5}" }, - { shortcode: "rose", emoji: "\u{1F339}" }, - { shortcode: "sunflower", emoji: "\u{1F33B}" }, - { shortcode: "tulip", emoji: "\u{1F337}" }, - { shortcode: "cherry_blossom", emoji: "\u{1F338}" }, - { shortcode: "hibiscus", emoji: "\u{1F33A}" }, - { shortcode: "bouquet", emoji: "\u{1F490}" }, - { shortcode: "seedling", emoji: "\u{1F331}" }, - { shortcode: "evergreen_tree", emoji: "\u{1F332}" }, - { shortcode: "deciduous_tree", emoji: "\u{1F333}" }, - { shortcode: "palm_tree", emoji: "\u{1F334}" }, - { shortcode: "cactus", emoji: "\u{1F335}" }, - { shortcode: "herb", emoji: "\u{1F33F}" }, - { shortcode: "four_leaf_clover", emoji: "\u{1F340}" }, - { shortcode: "maple_leaf", emoji: "\u{1F341}" }, - { shortcode: "fallen_leaf", emoji: "\u{1F342}" }, - { shortcode: "mushroom", emoji: "\u{1F344}" }, - - // Travel & Places - { shortcode: "car", emoji: "\u{1F697}" }, - { shortcode: "taxi", emoji: "\u{1F695}" }, - { shortcode: "bus", emoji: "\u{1F68C}" }, - { shortcode: "truck", emoji: "\u{1F69A}" }, - { shortcode: "bike", emoji: "\u{1F6B2}" }, - { shortcode: "motorcycle", emoji: "\u{1F3CD}\u{FE0F}" }, - { shortcode: "airplane", emoji: "\u{2708}\u{FE0F}" }, - { shortcode: "rocket", emoji: "\u{1F680}" }, - { shortcode: "helicopter", emoji: "\u{1F681}" }, - { shortcode: "boat", emoji: "\u{26F5}" }, - { shortcode: "ship", emoji: "\u{1F6A2}" }, - { shortcode: "anchor", emoji: "\u{2693}" }, - { shortcode: "train", emoji: "\u{1F686}" }, - { shortcode: "metro", emoji: "\u{1F687}" }, - { shortcode: "house", emoji: "\u{1F3E0}" }, - { shortcode: "office", emoji: "\u{1F3E2}" }, - { shortcode: "hospital", emoji: "\u{1F3E5}" }, - { shortcode: "school", emoji: "\u{1F3EB}" }, - { shortcode: "church", emoji: "\u{26EA}" }, - { shortcode: "tent", emoji: "\u{26FA}" }, - { shortcode: "mountain", emoji: "\u{26F0}\u{FE0F}" }, - { shortcode: "camping", emoji: "\u{1F3D5}\u{FE0F}" }, - { shortcode: "beach_umbrella", emoji: "\u{1F3D6}\u{FE0F}" }, - { shortcode: "desert", emoji: "\u{1F3DC}\u{FE0F}" }, - { shortcode: "desert_island", emoji: "\u{1F3DD}\u{FE0F}" }, - { shortcode: "national_park", emoji: "\u{1F3DE}\u{FE0F}" }, - { shortcode: "stadium", emoji: "\u{1F3DF}\u{FE0F}" }, - { shortcode: "statue_of_liberty", emoji: "\u{1F5FD}" }, - { shortcode: "japan", emoji: "\u{1F5FE}" }, - { shortcode: "moyai", emoji: "\u{1F5FF}" }, - - // Bitcoin/Crypto related - { shortcode: "bitcoin", emoji: "\u{20BF}" }, - { shortcode: "moneybag", emoji: "\u{1F4B0}" }, - { shortcode: "money_with_wings", emoji: "\u{1F4B8}" }, - { shortcode: "dollar", emoji: "\u{1F4B5}" }, - { shortcode: "euro", emoji: "\u{1F4B6}" }, - { shortcode: "yen", emoji: "\u{1F4B4}" }, - { shortcode: "pound", emoji: "\u{1F4B7}" }, - { shortcode: "gem", emoji: "\u{1F48E}" }, - { shortcode: "chart", emoji: "\u{1F4C8}" }, - { shortcode: "chart_with_upwards_trend", emoji: "\u{1F4C8}" }, - { shortcode: "chart_with_downwards_trend", emoji: "\u{1F4C9}" }, - - // Misc popular - { shortcode: "zzz", emoji: "\u{1F4A4}" }, - { shortcode: "poop", emoji: "\u{1F4A9}" }, - { shortcode: "hankey", emoji: "\u{1F4A9}" }, - { shortcode: "ghost", emoji: "\u{1F47B}" }, - { shortcode: "alien", emoji: "\u{1F47D}" }, - { shortcode: "robot", emoji: "\u{1F916}" }, - { shortcode: "jack_o_lantern", emoji: "\u{1F383}" }, - { shortcode: "santa", emoji: "\u{1F385}" }, - { shortcode: "christmas_tree", emoji: "\u{1F384}" }, - { shortcode: "egg", emoji: "\u{1F95A}" }, - { shortcode: "crown", emoji: "\u{1F451}" }, - { shortcode: "ring", emoji: "\u{1F48D}" }, - { shortcode: "lipstick", emoji: "\u{1F484}" }, - { shortcode: "pill", emoji: "\u{1F48A}" }, - { shortcode: "syringe", emoji: "\u{1F489}" }, - { shortcode: "herb", emoji: "\u{1F33F}" }, - { shortcode: "cigarette", emoji: "\u{1F6AC}" }, - { shortcode: "coffin", emoji: "\u{26B0}\u{FE0F}" }, - { shortcode: "moyai", emoji: "\u{1F5FF}" }, -]; +/** + * Keyword map for enriched search indexing (from emojilib). + * Maps emoji character → array of search keywords. + * e.g. "😀" → ["grinning_face", "face", "smile", "happy", "joy", ":D", "grin"] + */ +export const EMOJI_KEYWORDS: Record = emojilib; diff --git a/src/services/db.ts b/src/services/db.ts index ab9d330..e2097af 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -115,6 +115,21 @@ export interface GrimoireZap { comment?: string; // Optional zap comment/message } +export interface CachedUserEmojiList { + pubkey: string; // Primary key + event: NostrEvent; // Raw kind 10030 event + emojis: Array<{ shortcode: string; url: string }>; // Derived inline emoji tags + setAddresses: string[]; // Derived "a" tag coordinates for referenced 30030 sets + updatedAt: number; +} + +export interface CachedEmojiSet { + address: string; // Primary key: "30030:pubkey:identifier" + event: NostrEvent; // Raw kind 30030 event + emojis: Array<{ shortcode: string; url: string }>; // Derived emoji tags + updatedAt: number; +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -129,6 +144,8 @@ class GrimoireDb extends Dexie { lnurlCache!: Table; nsiteMetadata!: Table; grimoireZaps!: Table; + userEmojiLists!: Table; + emojiSets!: Table; constructor(name: string) { super(name); @@ -392,6 +409,26 @@ class GrimoireDb extends Dexie { "&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]", nsiteMetadata: "&hash", }); + + // Version 19: Add emoji caching (kind 10030 user emoji lists, kind 30030 emoji sets) + this.version(19).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + lnurlCache: "&address, fetchedAt", + grimoireZaps: + "&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]", + nsiteMetadata: "&hash", + userEmojiLists: "&pubkey", + emojiSets: "&address", + }); } } diff --git a/src/services/emoji-search.ts b/src/services/emoji-search.ts index 5c78a90..bc21edf 100644 --- a/src/services/emoji-search.ts +++ b/src/services/emoji-search.ts @@ -1,6 +1,13 @@ import { Index } from "flexsearch"; import type { NostrEvent } from "nostr-tools"; +import type { IEventStore } from "applesauce-core/event-store"; +import type { Subscription } from "rxjs"; +import { firstValueFrom } from "rxjs"; +import { filter, timeout } from "rxjs/operators"; import { getEmojiTags } from "@/lib/emoji-helpers"; +import { UNICODE_EMOJIS, EMOJI_KEYWORDS } from "@/lib/unicode-emojis"; +import { emojiSetCache } from "./emoji-set-cache"; +import { getRecentEmojiKeys } from "./emoji-usage"; export interface EmojiSearchResult { shortcode: string; @@ -15,6 +22,12 @@ export class EmojiSearchService { private index: Index; private emojis: Map; + // Subscription management — only one open sub (kind 10030) + private userListSub: Subscription | null = null; + private trackedSetAddresses = new Set(); + private currentPubkey: string | null = null; + private eventStore: IEventStore | null = null; + constructor() { this.emojis = new Map(); this.index = new Index({ @@ -99,18 +112,164 @@ export class EmojiSearchService { } /** - * Add multiple Unicode emojis + * Add multiple Unicode emojis with keyword-enriched search indexing. + * Keywords from emojilib are joined into the indexed string so that + * searching "happy" finds 😀, "animal" finds 🐶, etc. */ - async addUnicodeEmojis( + addUnicodeEmojis( emojis: Array<{ shortcode: string; emoji: string }>, - ): Promise { + keywords?: Record, + ): void { for (const { shortcode, emoji } of emojis) { - // For Unicode emoji, the "url" is actually the emoji character - // We'll handle this specially in the UI - await this.addEmoji(shortcode, emoji, "unicode"); + const normalized = shortcode.toLowerCase().replace(/^:|:$/g, ""); + + const emojiResult: EmojiSearchResult = { + shortcode: normalized, + url: emoji, + source: "unicode", + }; + + this.emojis.set(normalized, emojiResult); + + // Build search string: shortcode + emojilib keywords for richer matching + const emojiKeywords = keywords?.[emoji]; + const searchText = emojiKeywords + ? `${normalized} ${emojiKeywords.join(" ")}` + : normalized; + + this.index.add(normalized, searchText); } } + /** + * Load cached emojis from Dexie for immediate availability. + * Called before relay subscriptions so emojis are usable instantly. + */ + async loadCachedForUser(pubkey: string): Promise { + // Load cached user emoji list (kind 10030) + const cachedList = await emojiSetCache.getUserEmojiList(pubkey); + if (cachedList) { + // Add inline emojis from the user's list + for (const emoji of cachedList.emojis) { + await this.addEmoji(emoji.shortcode, emoji.url, "user"); + } + + // Load all referenced emoji sets in bulk + if (cachedList.setAddresses.length > 0) { + const cachedSets = await emojiSetCache.getEmojiSetsForAddresses( + cachedList.setAddresses, + ); + for (const cachedSet of cachedSets) { + const identifier = cachedSet.address.split(":")[2] || "unnamed-set"; + for (const emoji of cachedSet.emojis) { + await this.addEmoji( + emoji.shortcode, + emoji.url, + `set:${identifier}`, + cachedSet.address, + ); + } + } + } + } + + console.debug( + `[EmojiSearch] Loaded ${this.emojis.size} emojis from cache for ${pubkey.slice(0, 8)}`, + ); + } + + /** + * Subscribe to EventStore for live emoji updates. + * Only keeps one open subscription (kind 10030 user emoji list). + * Referenced emoji sets are fetched once when the list changes. + */ + subscribeForUser(pubkey: string, eventStore: IEventStore): void { + if (this.currentPubkey === pubkey) return; + + // Clean up any existing subscriptions + this.unsubscribeUser(); + this.currentPubkey = pubkey; + this.eventStore = eventStore; + + // Subscribe to user's emoji list (kind 10030) — the only open subscription + const userEmojiList$ = eventStore.replaceable(10030, pubkey); + this.userListSub = userEmojiList$.subscribe({ + next: (event) => { + if (!event) return; + + this.addUserEmojiList(event); + emojiSetCache.setUserEmojiList(event); + + // Diff "a" tags to incrementally fetch new emoji sets + const newAddresses = new Set( + event.tags + .filter((t) => t[0] === "a" && t[1]?.startsWith("30030:")) + .map((t) => t[1]), + ); + + // Fetch only newly-referenced sets (one-shot, no persistent sub) + for (const address of newAddresses) { + if (!this.trackedSetAddresses.has(address)) { + this.fetchEmojiSet(address); + } + } + + this.trackedSetAddresses = newAddresses; + }, + error: (error) => { + console.error("[EmojiSearch] Failed to load user emoji list:", error); + }, + }); + } + + /** + * One-shot fetch of an emoji set by address coordinate. + * Loads from EventStore (which triggers the address loader if missing), + * indexes the emojis, and caches to Dexie. No persistent subscription. + */ + private async fetchEmojiSet(address: string): Promise { + if (!this.eventStore) return; + + const parts = address.split(":"); + if (parts.length < 3) return; + + const [kind, setPubkey, identifier] = parts; + if (!kind || !setPubkey || identifier === undefined) return; + + try { + const setEvent = await firstValueFrom( + this.eventStore + .replaceable(parseInt(kind, 10), setPubkey, identifier) + .pipe( + filter((e): e is NostrEvent => e !== undefined), + timeout(15_000), + ), + ); + + this.addEmojiSet(setEvent); + emojiSetCache.setEmojiSet(setEvent); + } catch { + // Observable completed without emitting — set not found on relays + console.debug(`[EmojiSearch] Emoji set not found: ${address}`); + } + } + + /** + * Tear down relay subscription and clear custom emojis + */ + unsubscribeUser(): void { + if (this.userListSub) { + this.userListSub.unsubscribe(); + this.userListSub = null; + } + + this.trackedSetAddresses.clear(); + this.currentPubkey = null; + this.eventStore = null; + + this.clearCustom(); + } + /** * Search emojis by shortcode */ @@ -124,21 +283,54 @@ export class EmojiSearchService { const normalizedQuery = query.toLowerCase().replace(/^:|:$/g, ""); if (!normalizedQuery.trim()) { - // Return recent/popular emojis when no query - // Prioritize user emojis, then sets, then unicode - const items = Array.from(this.emojis.values()) - .sort((a, b) => { - const priority = { user: 0, context: 1, unicode: 3 }; - const aPriority = a.source.startsWith("set:") - ? 2 - : (priority[a.source as keyof typeof priority] ?? 2); - const bPriority = b.source.startsWith("set:") - ? 2 - : (priority[b.source as keyof typeof priority] ?? 2); - return aPriority - bPriority; - }) - .slice(0, limit); - return items; + // Show recently-used emojis first, then fill with source-priority order + const recentKeys = getRecentEmojiKeys(limit); + const results: EmojiSearchResult[] = []; + const included = new Set(); + + // Resolve recent keys to indexed emojis + for (const key of recentKeys) { + let result: EmojiSearchResult | undefined; + if (key.startsWith(":") && key.endsWith(":")) { + result = this.emojis.get(key.slice(1, -1)); + } else { + // Unicode: key is the emoji character, stored as `url` + for (const emoji of this.emojis.values()) { + if (emoji.source === "unicode" && emoji.url === key) { + result = emoji; + break; + } + } + } + if (result) { + results.push(result); + included.add(result.shortcode); + } + } + + // Fill remaining slots with source-priority sorted emojis + if (results.length < limit) { + const sourcePriority: Record = { + user: 0, + context: 1, + unicode: 3, + }; + const remaining = Array.from(this.emojis.values()) + .filter((e) => !included.has(e.shortcode)) + .sort((a, b) => { + const aPriority = a.source.startsWith("set:") + ? 2 + : (sourcePriority[a.source] ?? 2); + const bPriority = b.source.startsWith("set:") + ? 2 + : (sourcePriority[b.source] ?? 2); + return aPriority - bPriority; + }) + .slice(0, limit - results.length); + results.push(...remaining); + } + + return results; } // Search index @@ -175,17 +367,12 @@ export class EmojiSearchService { } /** - * Clear only custom emojis (keep unicode) + * Clear only custom emojis (keep unicode). + * Re-indexes unicode emojis synchronously with keyword-enriched search. */ clearCustom(): void { - const unicodeEmojis = Array.from(this.emojis.values()).filter( - (e) => e.source === "unicode", - ); this.clear(); - // Re-add unicode emojis - for (const emoji of unicodeEmojis) { - this.addEmoji(emoji.shortcode, emoji.url, "unicode"); - } + this.addUnicodeEmojis(UNICODE_EMOJIS, EMOJI_KEYWORDS); } /** @@ -195,3 +382,9 @@ export class EmojiSearchService { return this.emojis.size; } } + +// Singleton instance with Unicode emojis pre-loaded (with keyword-enriched search) +const emojiSearchService = new EmojiSearchService(); +emojiSearchService.addUnicodeEmojis(UNICODE_EMOJIS, EMOJI_KEYWORDS); + +export default emojiSearchService; diff --git a/src/services/emoji-set-cache.ts b/src/services/emoji-set-cache.ts new file mode 100644 index 0000000..9394ce7 --- /dev/null +++ b/src/services/emoji-set-cache.ts @@ -0,0 +1,137 @@ +/** + * Emoji Set Cache Service + * + * Caches user emoji lists (kind:10030) and emoji sets (kind:30030) in Dexie + * for instant availability on startup. Relay subscriptions update the cache + * as fresh data arrives. + */ + +import type { NostrEvent } from "@/types/nostr"; +import { getEmojiTags } from "@/lib/emoji-helpers"; +import db, { type CachedUserEmojiList, type CachedEmojiSet } from "./db"; + +class EmojiSetCache { + /** + * Get cached user emoji list (kind 10030) + */ + async getUserEmojiList( + pubkey: string, + ): Promise { + try { + return await db.userEmojiLists.get(pubkey); + } catch (error) { + console.error( + `[EmojiSetCache] Error reading user emoji list for ${pubkey.slice(0, 8)}:`, + error, + ); + return undefined; + } + } + + /** + * Cache a user emoji list event (kind 10030) + */ + async setUserEmojiList(event: NostrEvent): Promise { + try { + if (event.kind !== 10030) return; + + const emojis = getEmojiTags(event).map((e) => ({ + shortcode: e.shortcode, + url: e.url, + })); + + const setAddresses = event.tags + .filter((t) => t[0] === "a" && t[1]?.startsWith("30030:")) + .map((t) => t[1]); + + const entry: CachedUserEmojiList = { + pubkey: event.pubkey, + event, + emojis, + setAddresses, + updatedAt: Date.now(), + }; + + await db.userEmojiLists.put(entry); + } catch (error) { + console.error( + `[EmojiSetCache] Error caching user emoji list for ${event.pubkey.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Get a cached emoji set (kind 30030) + */ + async getEmojiSet(address: string): Promise { + try { + return await db.emojiSets.get(address); + } catch (error) { + console.error( + `[EmojiSetCache] Error reading emoji set ${address}:`, + error, + ); + return undefined; + } + } + + /** + * Cache an emoji set event (kind 30030) + */ + async setEmojiSet(event: NostrEvent): Promise { + try { + if (event.kind !== 30030) return; + + const identifier = + event.tags.find((t) => t[0] === "d")?.[1] || "unnamed-set"; + const address = `30030:${event.pubkey}:${identifier}`; + + const emojis = getEmojiTags(event).map((e) => ({ + shortcode: e.shortcode, + url: e.url, + })); + + const entry: CachedEmojiSet = { + address, + event, + emojis, + updatedAt: Date.now(), + }; + + await db.emojiSets.put(entry); + } catch (error) { + console.error(`[EmojiSetCache] Error caching emoji set:`, error); + } + } + + /** + * Bulk-read emoji sets by their addresses + */ + async getEmojiSetsForAddresses( + addresses: string[], + ): Promise { + try { + const results = await db.emojiSets.bulkGet(addresses); + return results.filter((r): r is CachedEmojiSet => r !== undefined); + } catch (error) { + console.error(`[EmojiSetCache] Error bulk-reading emoji sets:`, error); + return []; + } + } + + /** + * Clear all cached emoji data + */ + async clear(): Promise { + try { + await db.userEmojiLists.clear(); + await db.emojiSets.clear(); + } catch (error) { + console.error("[EmojiSetCache] Error clearing cache:", error); + } + } +} + +export const emojiSetCache = new EmojiSetCache(); +export default emojiSetCache; diff --git a/src/services/emoji-usage.ts b/src/services/emoji-usage.ts new file mode 100644 index 0000000..0cf9e4c --- /dev/null +++ b/src/services/emoji-usage.ts @@ -0,0 +1,139 @@ +/** + * Emoji Usage Tracking Service + * + * Tracks which emojis the user picks (both unicode and custom) in localStorage. + * Provides a recency+frequency ranked list for empty-query suggestions. + * Shared by both the `:` autocomplete and the EmojiPickerDialog. + */ + +import type { EmojiSearchResult } from "./emoji-search"; + +const STORAGE_KEY = "grimoire:emoji-usage"; +const OLD_STORAGE_KEY = "grimoire:reaction-history"; +const MAX_ENTRIES = 100; + +interface EmojiUsageEntry { + count: number; + lastUsed: number; +} + +type EmojiUsageData = Record; + +/** Convert an EmojiSearchResult to a storage key */ +function toKey(result: EmojiSearchResult): string { + return result.source === "unicode" ? result.url : `:${result.shortcode}:`; +} + +/** One-time migration from old reaction-history format */ +function migrateOldHistory(): EmojiUsageData | null { + try { + const old = localStorage.getItem(OLD_STORAGE_KEY); + if (!old) return null; + + const parsed: Record = JSON.parse(old); + const now = Date.now(); + const migrated: EmojiUsageData = {}; + + for (const [key, count] of Object.entries(parsed)) { + if (typeof count === "number" && count > 0) { + migrated[key] = { count, lastUsed: now }; + } + } + + localStorage.removeItem(OLD_STORAGE_KEY); + return migrated; + } catch { + return null; + } +} + +/** Read usage data from localStorage, migrating if needed */ +function readData(): EmojiUsageData { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) return JSON.parse(stored); + + // Try migrating old format + const migrated = migrateOldHistory(); + if (migrated) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)); + return migrated; + } + + return {}; + } catch { + return {}; + } +} + +/** Write usage data, evicting lowest-scored entries if over cap */ +function writeData(data: EmojiUsageData): void { + try { + const keys = Object.keys(data); + if (keys.length > MAX_ENTRIES) { + const scored = keys.map((key) => ({ + key, + score: computeScore(data[key]), + })); + scored.sort((a, b) => b.score - a.score); + + const trimmed: EmojiUsageData = {}; + for (const { key } of scored.slice(0, MAX_ENTRIES)) { + trimmed[key] = data[key]; + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)); + } else { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } + } catch (err) { + console.error("[emoji-usage] Failed to write usage data:", err); + } +} + +/** Score: frequency weighted by recency (decays over days) */ +function computeScore(entry: EmojiUsageEntry): number { + const daysSinceLastUse = + (Date.now() - entry.lastUsed) / (1000 * 60 * 60 * 24); + return entry.count / (1 + daysSinceLastUse); +} + +/** + * Record that the user picked an emoji. + * Call this from autocomplete command callbacks and the emoji picker. + */ +export function recordEmojiUsage(result: EmojiSearchResult): void { + const key = toKey(result); + const data = readData(); + const existing = data[key]; + + data[key] = { + count: (existing?.count ?? 0) + 1, + lastUsed: Date.now(), + }; + + writeData(data); +} + +/** + * Get recently-used emoji keys ranked by recency+frequency score. + * Keys are emoji chars for unicode (`"😀"`) or `":shortcode:"` for custom. + */ +export function getRecentEmojiKeys(limit = 24): string[] { + const data = readData(); + const entries = Object.entries(data); + if (entries.length === 0) return []; + + return entries + .map(([key, entry]) => ({ key, score: computeScore(entry) })) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ key }) => key); +} + +/** + * Get raw usage data for components that need the full map + * (e.g., EmojiPickerDialog's frequently-used top bar). + */ +export function getEmojiUsageMap(): EmojiUsageData { + return readData(); +} diff --git a/tsconfig.app.json b/tsconfig.app.json index a573713..04fbfd9 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -10,6 +10,7 @@ "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "isolatedModules": true, + "resolveJsonModule": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx",