diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 4c71940..a7741cf 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -25,8 +25,10 @@ import { Button } from "./ui/button"; import { MentionEditor, type MentionEditorHandle, + type EmojiTag, } from "./editor/MentionEditor"; import { useProfileSearch } from "@/hooks/useProfileSearch"; +import { useEmojiSearch } from "@/hooks/useEmojiSearch"; import { Label } from "./ui/label"; interface ChatViewerProps { @@ -228,6 +230,9 @@ export function ChatViewer({ // Profile search for mentions const { searchProfiles } = useProfileSearch(); + // Emoji search for custom emoji autocomplete + const { searchEmojis } = useEmojiSearch(); + // Get the appropriate adapter for this protocol const adapter = useMemo(() => getAdapter(protocol), [protocol]); @@ -288,9 +293,16 @@ export function ChatViewer({ const editorRef = useRef(null); // Handle sending messages - const handleSend = async (content: string, replyToId?: string) => { + const handleSend = async ( + content: string, + replyToId?: string, + emojiTags?: EmojiTag[], + ) => { if (!conversation || !hasActiveAccount) return; - await adapter.sendMessage(conversation, content, replyToId); + await adapter.sendMessage(conversation, content, { + replyTo: replyToId, + emojiTags, + }); setReplyTo(undefined); // Clear reply context after sending }; @@ -418,9 +430,10 @@ export function ChatViewer({ ref={editorRef} placeholder="Type a message..." searchProfiles={searchProfiles} - onSubmit={(content) => { + searchEmojis={searchEmojis} + onSubmit={(content, emojiTags) => { if (content.trim()) { - handleSend(content, replyTo); + handleSend(content, replyTo, emojiTags); } }} className="flex-1 min-w-0" diff --git a/src/components/editor/EmojiSuggestionList.tsx b/src/components/editor/EmojiSuggestionList.tsx new file mode 100644 index 0000000..6105470 --- /dev/null +++ b/src/components/editor/EmojiSuggestionList.tsx @@ -0,0 +1,152 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import type { EmojiSearchResult } from "@/services/emoji-search"; +import { cn } from "@/lib/utils"; + +export interface EmojiSuggestionListProps { + items: EmojiSearchResult[]; + command: (item: EmojiSearchResult) => void; + onClose?: () => void; +} + +export interface EmojiSuggestionListHandle { + onKeyDown: (event: KeyboardEvent) => boolean; +} + +const GRID_COLS = 8; + +export const EmojiSuggestionList = forwardRef< + EmojiSuggestionListHandle, + EmojiSuggestionListProps +>(({ items, command, onClose }, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const listRef = useRef(null); + + // Keyboard navigation with grid support + useImperativeHandle(ref, () => ({ + onKeyDown: (event: KeyboardEvent) => { + if (event.key === "ArrowUp") { + setSelectedIndex((prev) => { + const newIndex = prev - GRID_COLS; + return newIndex < 0 ? Math.max(0, items.length + newIndex) : newIndex; + }); + return true; + } + + if (event.key === "ArrowDown") { + setSelectedIndex((prev) => { + const newIndex = prev + GRID_COLS; + return newIndex >= items.length + ? Math.min(items.length - 1, newIndex % GRID_COLS) + : newIndex; + }); + return true; + } + + if (event.key === "ArrowLeft") { + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1)); + return true; + } + + if (event.key === "ArrowRight") { + setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0)); + return true; + } + + if (event.key === "Enter") { + if (items[selectedIndex]) { + command(items[selectedIndex]); + } + return true; + } + + if (event.key === "Escape") { + onClose?.(); + return true; + } + + return false; + }, + })); + + // Scroll selected item into view + useEffect(() => { + const selectedElement = listRef.current?.querySelector( + `[data-index="${selectedIndex}"]`, + ); + if (selectedElement) { + selectedElement.scrollIntoView({ + block: "nearest", + }); + } + }, [selectedIndex]); + + // Reset selected index when items change + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + if (items.length === 0) { + return ( +
+ No emoji found +
+ ); + } + + return ( +
+
+ {items.map((item, index) => ( + + ))} +
+ {/* Show selected emoji shortcode */} + {items[selectedIndex] && ( +
+ :{items[selectedIndex].shortcode}: +
+ )} +
+ ); +}); + +EmojiSuggestionList.displayName = "EmojiSuggestionList"; diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index 04dde66..56d9995 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -18,13 +18,37 @@ import { ProfileSuggestionList, type ProfileSuggestionListHandle, } from "./ProfileSuggestionList"; +import { + EmojiSuggestionList, + type EmojiSuggestionListHandle, +} from "./EmojiSuggestionList"; import type { ProfileSearchResult } from "@/services/profile-search"; +import type { EmojiSearchResult } from "@/services/emoji-search"; import { nip19 } from "nostr-tools"; +/** + * Represents an emoji tag for NIP-30 + */ +export interface EmojiTag { + shortcode: string; + url: string; +} + +/** + * Result of serializing editor content + */ +export interface SerializedContent { + /** The text content with mentions as nostr: URIs and emoji as :shortcode: */ + text: string; + /** Emoji tags to include in the event (NIP-30) */ + emojiTags: EmojiTag[]; +} + export interface MentionEditorProps { placeholder?: string; - onSubmit?: (content: string) => void; + onSubmit?: (content: string, emojiTags: EmojiTag[]) => void; searchProfiles: (query: string) => Promise; + searchEmojis?: (query: string) => Promise; autoFocus?: boolean; className?: string; } @@ -33,11 +57,16 @@ export interface MentionEditorHandle { focus: () => void; clear: () => void; getContent: () => string; - getContentWithMentions: () => string; + getSerializedContent: () => SerializedContent; isEmpty: () => boolean; submit: () => void; } +// Create emoji extension by extending Mention with a different name +const EmojiMention = Mention.extend({ + name: "emoji", +}); + export const MentionEditor = forwardRef< MentionEditorHandle, MentionEditorProps @@ -47,13 +76,14 @@ export const MentionEditor = forwardRef< placeholder = "Type a message...", onSubmit, searchProfiles, + searchEmojis, autoFocus = false, className = "", }, ref, ) => { - // Create mention suggestion configuration - const suggestion: Omit = useMemo( + // Create mention suggestion configuration for @ mentions + const mentionSuggestion: Omit = useMemo( () => ({ char: "@", allowSpaces: false, @@ -126,52 +156,156 @@ export const MentionEditor = forwardRef< [searchProfiles], ); - // Helper function to serialize editor content with mentions - const serializeContent = useCallback((editorInstance: any) => { - let text = ""; - const json = editorInstance.getJSON(); + // Create emoji suggestion configuration for : emoji + const emojiSuggestion: Omit | null = useMemo( + () => + searchEmojis + ? { + char: ":", + allowSpaces: false, + items: async ({ query }) => { + return await searchEmojis(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; - json.content?.forEach((node: any) => { - if (node.type === "paragraph") { - node.content?.forEach((child: any) => { - if (child.type === "text") { - text += child.text; - } else if (child.type === "mention") { - const pubkey = child.attrs?.id; - if (pubkey) { - try { - const npub = nip19.npubEncode(pubkey); - text += `nostr:${npub}`; - } catch { - // Fallback to display name if encoding fails - text += `@${child.attrs?.label || "unknown"}`; + return { + onStart: (props) => { + component = new ReactRenderer(EmojiSuggestionList, { + props: { + items: props.items, + command: props.command, + onClose: () => { + popup[0]?.hide(); + }, + }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + + onUpdate(props) { + component.updateProps({ + items: props.items, + command: props.command, + }); + + if (!props.clientRect) { + return; + } + + popup[0]?.setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0]?.hide(); + return true; + } + + return component.ref?.onKeyDown(props.event) ?? false; + }, + + onExit() { + popup[0]?.destroy(); + component.destroy(); + }, + }; + }, + } + : null, + [searchEmojis], + ); + + // Helper function to serialize editor content with mentions and emojis + const serializeContent = useCallback( + (editorInstance: any): SerializedContent => { + let text = ""; + const emojiTags: EmojiTag[] = []; + const seenEmojis = new Set(); + const json = editorInstance.getJSON(); + + json.content?.forEach((node: any) => { + if (node.type === "paragraph") { + node.content?.forEach((child: any) => { + if (child.type === "text") { + text += child.text; + } else if (child.type === "mention") { + const pubkey = child.attrs?.id; + if (pubkey) { + try { + const npub = nip19.npubEncode(pubkey); + text += `nostr:${npub}`; + } catch { + // Fallback to display name if encoding fails + text += `@${child.attrs?.label || "unknown"}`; + } + } + } else if (child.type === "emoji") { + const shortcode = child.attrs?.id; + const url = child.attrs?.url; + const source = child.attrs?.source; + + if (shortcode) { + text += `:${shortcode}:`; + + // Only add emoji tag for custom emojis (not unicode) + if ( + url && + source !== "unicode" && + !seenEmojis.has(shortcode) + ) { + seenEmojis.add(shortcode); + emojiTags.push({ shortcode, url }); + } } } - } - }); - text += "\n"; - } - }); + }); + text += "\n"; + } + }); - return text.trim(); - }, []); + return { + text: text.trim(), + emojiTags, + }; + }, + [], + ); // Helper function to handle submission const handleSubmit = useCallback( (editorInstance: any) => { if (!editorInstance || !onSubmit) return; - const content = serializeContent(editorInstance); - if (content) { - onSubmit(content); + const { text, emojiTags } = serializeContent(editorInstance); + if (text) { + onSubmit(text, emojiTags); editorInstance.commands.clearContent(); } }, [onSubmit, serializeContent], ); - const editor = useEditor({ - extensions: [ + // Build extensions array + const extensions = useMemo(() => { + const exts = [ StarterKit.configure({ // Disable Enter to submit via Mod-Enter instead hardBreak: { @@ -183,7 +317,7 @@ export const MentionEditor = forwardRef< class: "mention", }, suggestion: { - ...suggestion, + ...mentionSuggestion, command: ({ editor, range, props }: any) => { // props is the ProfileSearchResult editor @@ -209,7 +343,49 @@ export const MentionEditor = forwardRef< Placeholder.configure({ placeholder, }), - ], + ]; + + // Add emoji extension if search is provided + if (emojiSuggestion) { + exts.push( + EmojiMention.configure({ + HTMLAttributes: { + class: "emoji", + }, + suggestion: { + ...emojiSuggestion, + command: ({ editor, range, props }: any) => { + // props is the EmojiSearchResult + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: "emoji", + attrs: { + id: props.shortcode, + label: props.shortcode, + url: props.url, + source: props.source, + }, + }, + { type: "text", text: " " }, + ]) + .run(); + }, + }, + renderLabel({ node }) { + return `:${node.attrs.label}:`; + }, + }), + ); + } + + return exts; + }, [mentionSuggestion, emojiSuggestion, placeholder]); + + const editor = useEditor({ + extensions, editorProps: { attributes: { class: @@ -240,8 +416,8 @@ export const MentionEditor = forwardRef< focus: () => editor?.commands.focus(), clear: () => editor?.commands.clearContent(), getContent: () => editor?.getText() || "", - getContentWithMentions: () => { - if (!editor) return ""; + getSerializedContent: () => { + if (!editor) return { text: "", emojiTags: [] }; return serializeContent(editor); }, isEmpty: () => editor?.isEmpty ?? true, diff --git a/src/hooks/useEmojiSearch.ts b/src/hooks/useEmojiSearch.ts new file mode 100644 index 0000000..86ad12a --- /dev/null +++ b/src/hooks/useEmojiSearch.ts @@ -0,0 +1,117 @@ +import { useEffect, useMemo, useRef } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { + EmojiSearchService, + type EmojiSearchResult, +} from "@/services/emoji-search"; +import { UNICODE_EMOJIS } from "@/lib/unicode-emojis"; +import eventStore from "@/services/event-store"; +import accounts from "@/services/accounts"; +import type { NostrEvent } from "@/types/nostr"; + +/** + * Hook to provide emoji search functionality with automatic indexing + * of Unicode emojis and user's custom emojis from the event store + */ +export function useEmojiSearch(contextEvent?: NostrEvent) { + const serviceRef = useRef(null); + const activeAccount = use$(accounts.active$); + + // 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 + useEffect(() => { + if (contextEvent) { + service.addContextEmojis(contextEvent); + } + }, [contextEvent, service]); + + // Subscribe to user's emoji list (kind 10030) and emoji sets (kind 30030) + useEffect(() => { + if (!activeAccount?.pubkey) { + return; + } + + const pubkey = activeAccount.pubkey; + + // 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(); + }; + }, [activeAccount?.pubkey, service]); + + // Memoize search function + const searchEmojis = useMemo( + () => + async (query: string): Promise => { + return await service.search(query, { limit: 24 }); + }, + [service], + ); + + return { + searchEmojis, + service, + }; +} diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index dd4ddf0..74b6be6 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -11,6 +11,16 @@ import type { } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; +/** + * Options for sending a message + */ +export interface SendMessageOptions { + /** Event ID being replied to */ + replyTo?: string; + /** NIP-30 custom emoji tags */ + emojiTags?: Array<{ shortcode: string; url: string }>; +} + /** * Abstract base class for all chat protocol adapters * @@ -62,7 +72,7 @@ export abstract class ChatProtocolAdapter { abstract sendMessage( conversation: Conversation, content: string, - replyTo?: string, + options?: SendMessageOptions, ): Promise; /** diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index f43bafb..94f61dd 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -2,7 +2,7 @@ import { Observable } from "rxjs"; import { map, first } from "rxjs/operators"; import type { Filter } from "nostr-tools"; import { nip19 } from "nostr-tools"; -import { ChatProtocolAdapter } from "./base-adapter"; +import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; import type { Conversation, Message, @@ -377,7 +377,7 @@ export class Nip29Adapter extends ChatProtocolAdapter { async sendMessage( conversation: Conversation, content: string, - replyTo?: string, + options?: SendMessageOptions, ): Promise { const activePubkey = accountManager.active$.value?.pubkey; const activeSigner = accountManager.active$.value?.signer; @@ -399,9 +399,16 @@ export class Nip29Adapter extends ChatProtocolAdapter { const tags: string[][] = [["h", groupId]]; - if (replyTo) { + if (options?.replyTo) { // NIP-29 uses q-tag for replies (same as NIP-C7) - tags.push(["q", replyTo]); + tags.push(["q", options.replyTo]); + } + + // Add NIP-30 emoji tags + if (options?.emojiTags) { + for (const emoji of options.emojiTags) { + tags.push(["emoji", emoji.shortcode, emoji.url]); + } } // Use kind 9 for group chat messages diff --git a/src/lib/chat/adapters/nip-c7-adapter.ts b/src/lib/chat/adapters/nip-c7-adapter.ts index c82d7d5..28aa3cc 100644 --- a/src/lib/chat/adapters/nip-c7-adapter.ts +++ b/src/lib/chat/adapters/nip-c7-adapter.ts @@ -2,7 +2,7 @@ import { Observable, firstValueFrom } from "rxjs"; import { map, first } from "rxjs/operators"; import { nip19 } from "nostr-tools"; import type { Filter } from "nostr-tools"; -import { ChatProtocolAdapter } from "./base-adapter"; +import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; import type { Conversation, Message, @@ -203,7 +203,7 @@ export class NipC7Adapter extends ChatProtocolAdapter { async sendMessage( conversation: Conversation, content: string, - replyTo?: string, + options?: SendMessageOptions, ): Promise { const activePubkey = accountManager.active$.value?.pubkey; const activeSigner = accountManager.active$.value?.signer; @@ -224,8 +224,15 @@ export class NipC7Adapter extends ChatProtocolAdapter { factory.setSigner(activeSigner); const tags: string[][] = [["p", partner.pubkey]]; - if (replyTo) { - tags.push(["q", replyTo]); // NIP-C7 quote tag for threading + if (options?.replyTo) { + tags.push(["q", options.replyTo]); // NIP-C7 quote tag for threading + } + + // Add NIP-30 emoji tags + if (options?.emojiTags) { + for (const emoji of options.emojiTags) { + tags.push(["emoji", emoji.shortcode, emoji.url]); + } } const draft = await factory.build({ kind: 9, content, tags }); diff --git a/src/lib/unicode-emojis.ts b/src/lib/unicode-emojis.ts new file mode 100644 index 0000000..551765a --- /dev/null +++ b/src/lib/unicode-emojis.ts @@ -0,0 +1,373 @@ +/** + * Common Unicode emojis with shortcode mappings + * Based on common shortcodes used across platforms (Slack, Discord, GitHub) + */ +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}" }, + + // 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}" }, +]; diff --git a/src/services/emoji-search.ts b/src/services/emoji-search.ts new file mode 100644 index 0000000..4b7dcd8 --- /dev/null +++ b/src/services/emoji-search.ts @@ -0,0 +1,187 @@ +import { Index } from "flexsearch"; +import type { NostrEvent } from "nostr-tools"; +import { getEmojiTags } from "@/lib/emoji-helpers"; + +export interface EmojiSearchResult { + shortcode: string; + url: string; + /** Source of the emoji: "unicode", "user", "set:", or "context" */ + source: string; +} + +export class EmojiSearchService { + private index: Index; + private emojis: Map; + + constructor() { + this.emojis = new Map(); + this.index = new Index({ + tokenize: "forward", + cache: true, + resolution: 9, + }); + } + + /** + * Add a single emoji to the search index + */ + async addEmoji( + shortcode: string, + url: string, + source: string = "custom", + ): Promise { + // Normalize shortcode (lowercase, no colons) + const normalized = shortcode.toLowerCase().replace(/^:|:$/g, ""); + + // Don't overwrite user emoji with other sources + const existing = this.emojis.get(normalized); + if (existing && existing.source === "user" && source !== "user") { + return; + } + + const emoji: EmojiSearchResult = { + shortcode: normalized, + url, + source, + }; + + this.emojis.set(normalized, emoji); + await this.index.addAsync(normalized, normalized); + } + + /** + * Add emojis from an emoji set event (kind 30030) + */ + async addEmojiSet(event: NostrEvent): Promise { + if (event.kind !== 30030) return; + + const identifier = + event.tags.find((t) => t[0] === "d")?.[1] || "unnamed-set"; + const emojis = getEmojiTags(event); + + for (const emoji of emojis) { + await this.addEmoji(emoji.shortcode, emoji.url, `set:${identifier}`); + } + } + + /** + * Add emojis from user's emoji list (kind 10030) + */ + async addUserEmojiList(event: NostrEvent): Promise { + if (event.kind !== 10030) return; + + const emojis = getEmojiTags(event); + + for (const emoji of emojis) { + await this.addEmoji(emoji.shortcode, emoji.url, "user"); + } + } + + /** + * Add context emojis from an event being replied to + */ + async addContextEmojis(event: NostrEvent): Promise { + const emojis = getEmojiTags(event); + + for (const emoji of emojis) { + await this.addEmoji(emoji.shortcode, emoji.url, "context"); + } + } + + /** + * Add multiple Unicode emojis + */ + async addUnicodeEmojis( + emojis: Array<{ shortcode: string; emoji: string }>, + ): Promise { + 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"); + } + } + + /** + * Search emojis by shortcode + */ + async search( + query: string, + options: { limit?: number } = {}, + ): Promise { + const { limit = 24 } = options; + + // Normalize query + 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; + } + + // Search index + const ids = (await this.index.searchAsync(normalizedQuery, { + limit, + })) as string[]; + + // Map IDs to emojis + const items = ids + .map((id) => this.emojis.get(id)) + .filter(Boolean) as EmojiSearchResult[]; + + return items; + } + + /** + * Get emoji by shortcode + */ + getByShortcode(shortcode: string): EmojiSearchResult | undefined { + const normalized = shortcode.toLowerCase().replace(/^:|:$/g, ""); + return this.emojis.get(normalized); + } + + /** + * Clear all emojis + */ + clear(): void { + this.emojis.clear(); + this.index = new Index({ + tokenize: "forward", + cache: true, + resolution: 9, + }); + } + + /** + * Clear only custom emojis (keep unicode) + */ + 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"); + } + } + + /** + * Get total number of indexed emojis + */ + get size(): number { + return this.emojis.size; + } +}