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,