mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-07 22:16:50 +02:00
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:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
34
src/hooks/useEmojiSearchSync.ts
Normal file
34
src/hooks/useEmojiSearchSync.ts
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
137
src/services/emoji-set-cache.ts
Normal file
137
src/services/emoji-set-cache.ts
Normal 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
139
src/services/emoji-usage.ts
Normal 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();
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user