feat: emoji autocompletion (#54)

* feat: add NIP-30 emoji autocompletion to editor

Implement emoji autocomplete triggered by `:` in the MentionEditor:

- EmojiSearchService: flexsearch-based indexing for emoji shortcodes
- useEmojiSearch hook: loads Unicode emojis + user's custom emoji (kind 10030/30030)
- EmojiSuggestionList: grid-based suggestion UI with keyboard nav
- Update MentionEditor with second Mention extension for emoji
- Serialize emoji as `:shortcode:` format with NIP-30 emoji tags
- Update chat adapters to include emoji tags in messages

Sources:
- Unicode: ~300 common emojis with shortcodes
- Custom: user's emoji list (kind 10030) and referenced sets (kind 30030)
- Context: emoji tags from events being replied to

* feat: add rich emoji preview in editor

Emoji inserted via the autocomplete now display as actual images/characters
instead of :shortcode: text:

- Custom emoji: renders as inline <img> with proper sizing
- Unicode emoji: renders as text with emoji font sizing
- Both show :shortcode: on hover via title attribute

CSS styles ensure proper vertical alignment with surrounding text.

* fix: store emoji url and source attributes in node schema

The TipTap Mention extension only defines `id` and `label` by default.
Added `addAttributes()` to EmojiMention extension to also store `url`
and `source` attributes, fixing emoji tags not being included in sent
messages.

* fix: improve emoji node rendering in editor

- Remove redundant renderLabel (nodeView handles display)
- Add renderText for proper clipboard behavior
- Make nodeView more robust with null checks
- Add fallback to shortcode if image fails to load
- Unicode emoji shows character, custom shows image

* fix: serialize unicode emoji as actual characters, not shortcodes

When sending messages:
- Unicode emoji (😄, 🔥) → outputs 😄, 🔥 (the actual character)
- Custom emoji (:pepe:) → outputs :pepe: with emoji tag for rendering

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-12 11:30:52 +01:00
committed by GitHub
parent ae3af2d63c
commit 2bad592a3a
10 changed files with 1185 additions and 53 deletions

View File

@@ -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<MentionEditorHandle>(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"

View File

@@ -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<HTMLDivElement>(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 (
<div className="border border-border/50 bg-popover p-4 text-sm text-muted-foreground shadow-md">
No emoji found
</div>
);
}
return (
<div
ref={listRef}
role="listbox"
className="max-h-[240px] w-[296px] overflow-y-auto border border-border/50 bg-popover p-2 shadow-md"
>
<div className="grid grid-cols-8 gap-0.5">
{items.map((item, index) => (
<button
key={`${item.shortcode}-${item.source}`}
data-index={index}
role="option"
aria-selected={index === selectedIndex}
onClick={() => command(item)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
"flex size-8 items-center justify-center rounded transition-colors",
index === selectedIndex ? "bg-muted" : "hover:bg-muted/60",
)}
title={`:${item.shortcode}:`}
>
{item.source === "unicode" ? (
// Unicode emoji - render as text
<span className="text-lg leading-none">{item.url}</span>
) : (
// Custom emoji - render as image
<img
src={item.url}
alt={`:${item.shortcode}:`}
className="size-6 object-contain"
loading="lazy"
onError={(e) => {
// Replace with fallback on error
e.currentTarget.style.display = "none";
}}
/>
)}
</button>
))}
</div>
{/* Show selected emoji shortcode */}
{items[selectedIndex] && (
<div className="mt-2 border-t border-border/50 pt-2 text-center text-xs text-muted-foreground">
:{items[selectedIndex].shortcode}:
</div>
)}
</div>
);
});
EmojiSuggestionList.displayName = "EmojiSuggestionList";

View File

@@ -5,8 +5,7 @@ import {
useMemo,
useCallback,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { ReactRenderer } from "@tiptap/react";
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
@@ -18,13 +17,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<ProfileSearchResult[]>;
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
autoFocus?: boolean;
className?: string;
}
@@ -33,11 +56,90 @@ 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 and custom node view
const EmojiMention = Mention.extend({
name: "emoji",
// Add custom attributes for emoji (url and source)
addAttributes() {
return {
...this.parent?.(),
url: {
default: null,
parseHTML: (element) => element.getAttribute("data-url"),
renderHTML: (attributes) => {
if (!attributes.url) return {};
return { "data-url": attributes.url };
},
},
source: {
default: null,
parseHTML: (element) => element.getAttribute("data-source"),
renderHTML: (attributes) => {
if (!attributes.source) return {};
return { "data-source": attributes.source };
},
},
};
},
// Override renderText to return empty string (nodeView handles display)
renderText({ node }) {
// Return the emoji character for unicode, or empty for custom
// This is what gets copied to clipboard
if (node.attrs.source === "unicode") {
return node.attrs.url || "";
}
return `:${node.attrs.id}:`;
},
addNodeView() {
return ({ node }) => {
const { url, source, id } = node.attrs;
const isUnicode = source === "unicode";
// Create wrapper span
const dom = document.createElement("span");
dom.className = "emoji-node";
dom.setAttribute("data-emoji", id || "");
if (isUnicode && url) {
// Unicode emoji - render as text span
const span = document.createElement("span");
span.className = "emoji-unicode";
span.textContent = url;
span.title = `:${id}:`;
dom.appendChild(span);
} else if (url) {
// Custom emoji - render as image
const img = document.createElement("img");
img.src = url;
img.alt = `:${id}:`;
img.title = `:${id}:`;
img.className = "emoji-image";
img.draggable = false;
img.onerror = () => {
// Fallback to shortcode if image fails to load
dom.textContent = `:${id}:`;
};
dom.appendChild(img);
} else {
// Fallback if no url - show shortcode
dom.textContent = `:${id}:`;
}
return {
dom,
};
};
},
});
export const MentionEditor = forwardRef<
MentionEditorHandle,
MentionEditorProps
@@ -47,13 +149,14 @@ export const MentionEditor = forwardRef<
placeholder = "Type a message...",
onSubmit,
searchProfiles,
searchEmojis,
autoFocus = false,
className = "",
},
ref,
) => {
// Create mention suggestion configuration
const suggestion: Omit<SuggestionOptions, "editor"> = useMemo(
// Create mention suggestion configuration for @ mentions
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
() => ({
char: "@",
allowSpaces: false,
@@ -126,52 +229,155 @@ 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<SuggestionOptions, "editor"> | null = useMemo(
() =>
searchEmojis
? {
char: ":",
allowSpaces: false,
items: async ({ query }) => {
return await searchEmojis(query);
},
render: () => {
let component: ReactRenderer<EmojiSuggestionListHandle>;
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<string>();
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 (source === "unicode" && url) {
// Unicode emoji - output the actual character
text += url;
} else if (shortcode) {
// Custom emoji - output :shortcode: and add tag
text += `:${shortcode}:`;
if (url && !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 +389,7 @@ export const MentionEditor = forwardRef<
class: "mention",
},
suggestion: {
...suggestion,
...mentionSuggestion,
command: ({ editor, range, props }: any) => {
// props is the ProfileSearchResult
editor
@@ -209,7 +415,47 @@ 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();
},
},
// Note: renderLabel is not used when nodeView is defined
}),
);
}
return exts;
}, [mentionSuggestion, emojiSuggestion, placeholder]);
const editor = useEditor({
extensions,
editorProps: {
attributes: {
class:
@@ -240,8 +486,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,

117
src/hooks/useEmojiSearch.ts Normal file
View File

@@ -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<EmojiSearchService | null>(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<EmojiSearchResult[]> => {
return await service.search(query, { limit: 24 });
},
[service],
);
return {
searchEmojis,
service,
};
}

View File

@@ -314,3 +314,23 @@ body.animating-layout
.ProseMirror .mention:hover {
background-color: hsl(var(--primary) / 0.2);
}
/* Emoji styles */
.ProseMirror .emoji-node {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.ProseMirror .emoji-image {
height: 1.2em;
width: auto;
vertical-align: middle;
object-fit: contain;
}
.ProseMirror .emoji-unicode {
font-size: 1.1em;
line-height: 1;
vertical-align: middle;
}

View File

@@ -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<void>;
/**

View File

@@ -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<void> {
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

View File

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

373
src/lib/unicode-emojis.ts Normal file
View File

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

View File

@@ -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:<identifier>", or "context" */
source: string;
}
export class EmojiSearchService {
private index: Index;
private emojis: Map<string, EmojiSearchResult>;
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<void> {
// 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<void> {
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<void> {
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<void> {
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<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");
}
}
/**
* Search emojis by shortcode
*/
async search(
query: string,
options: { limit?: number } = {},
): Promise<EmojiSearchResult[]> {
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;
}
}