mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 23:16:50 +02:00
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:
@@ -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"
|
||||
|
||||
152
src/components/editor/EmojiSuggestionList.tsx
Normal file
152
src/components/editor/EmojiSuggestionList.tsx
Normal 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";
|
||||
@@ -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
117
src/hooks/useEmojiSearch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
373
src/lib/unicode-emojis.ts
Normal 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}" },
|
||||
];
|
||||
187
src/services/emoji-search.ts
Normal file
187
src/services/emoji-search.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user