feat: refactor emoji search with singleton service, Dexie caching, and keyword-enriched search

Replace hand-rolled ~350-entry emoji list with unicode-emoji-json (~1,900 emojis) and emojilib
keywords for richer search matching. Move EmojiSearchService to a singleton with Dexie-backed
caching for instant availability on startup. Add recency+frequency emoji usage tracking shared
across the emoji picker and editor autocomplete.
This commit is contained in:
Alejandro Gómez
2026-03-31 10:18:38 +02:00
parent 0af86b667e
commit 143fa7b4b6
14 changed files with 632 additions and 530 deletions

16
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, number> {
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);
},

View File

@@ -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()

View File

@@ -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<RichEditorHandle, RichEditorProps>(
suggestion: {
...emojiSuggestion,
command: ({ editor, range, props }: any) => {
recordEmojiUsage(props);
editor
.chain()
.focus()

View File

@@ -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();

View File

@@ -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<EmojiSearchService | null>(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<EmojiSearchResult[]> => {
return await service.search(query, { limit: 200 });
},
[service],
const searchEmojis = useCallback(
async (query: string): Promise<EmojiSearchResult[]> => {
return emojiSearchService.search(query, { limit: 200 });
},
[],
);
return {
searchEmojis,
service,
service: emojiSearchService,
};
}

View File

@@ -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]);
}

View File

@@ -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<string, string[]> = emojilib;

View File

@@ -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<Profile>;
nip05!: Table<Nip05>;
@@ -129,6 +144,8 @@ class GrimoireDb extends Dexie {
lnurlCache!: Table<LnurlCache>;
nsiteMetadata!: Table<CachedNsiteMetadata>;
grimoireZaps!: Table<GrimoireZap>;
userEmojiLists!: Table<CachedUserEmojiList>;
emojiSets!: Table<CachedEmojiSet>;
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",
});
}
}

View File

@@ -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<string, EmojiSearchResult>;
// Subscription management — only one open sub (kind 10030)
private userListSub: Subscription | null = null;
private trackedSetAddresses = new Set<string>();
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<void> {
keywords?: Record<string, string[]>,
): 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<void> {
// 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<void> {
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<string>();
// 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<string, number> = {
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;

View File

@@ -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<CachedUserEmojiList | undefined> {
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<void> {
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<CachedEmojiSet | undefined> {
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<void> {
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<CachedEmojiSet[]> {
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<void> {
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;

139
src/services/emoji-usage.ts Normal file
View File

@@ -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<string, EmojiUsageEntry>;
/** 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<string, number> = 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();
}

View File

@@ -10,6 +10,7 @@
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",