feat(editor): add nostr bech32 paste handler with inline previews

Implements paste handling for nostr: URIs (npub, note, nevent, naddr, nprofile)
that transforms them into rich inline preview chips in the chat composer.

Changes:
- Add NostrEventPreview TipTap node type for displaying bech32 previews
- Add NostrPasteHandler extension to detect and transform pasted bech32 strings
- Update serializeContent to convert previews back to nostr: URIs on submit
- Add CSS styling for preview chips with hover effects
- Support all major bech32 types: npub, note, nevent, naddr, nprofile

Features:
- Automatic detection of nostr: URIs in pasted text (with or without prefix)
- Visual preview chips with type icon, label, and truncated ID
- Maintains nostr: URI format in final message content (NIP-27 compatible)
- Simple implementation without event fetching (fast, no loading states)
- Styled with primary theme colors for consistency

Technical details:
- Uses ProseMirror Plugin API for paste interception
- Inline atomic nodes for previews (similar to mentions and blob attachments)
- Regex pattern matches all valid bech32 formats
- Proper error handling for invalid bech32 strings
- Extensible foundation for future rich metadata fetching
This commit is contained in:
Claude
2026-01-20 19:18:45 +00:00
parent f2cf7a1a0e
commit a41aba0cfe
2 changed files with 273 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ import {
} from "react";
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
import { Extension, Node, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import StarterKit from "@tiptap/starter-kit";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
@@ -276,6 +277,234 @@ function formatBlobSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
// Create nostr event preview node for nevent/naddr/note/npub/nprofile
const NostrEventPreview = Node.create({
name: "nostrEventPreview",
group: "inline",
inline: true,
atom: true,
addAttributes() {
return {
type: { default: null }, // 'npub' | 'note' | 'nevent' | 'naddr' | 'nprofile'
data: { default: null }, // Decoded bech32 data (varies by type)
};
},
parseHTML() {
return [
{
tag: 'span[data-nostr-preview="true"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, { "data-nostr-preview": "true" }),
];
},
renderText({ node }) {
// Serialize back to nostr: URI for plain text export
const { type, data } = node.attrs;
try {
if (type === "npub") {
return `nostr:${nip19.npubEncode(data)}`;
} else if (type === "note") {
return `nostr:${nip19.noteEncode(data)}`;
} else if (type === "nevent") {
return `nostr:${nip19.neventEncode(data)}`;
} else if (type === "naddr") {
return `nostr:${nip19.naddrEncode(data)}`;
} else if (type === "nprofile") {
return `nostr:${nip19.nprofileEncode(data)}`;
}
} catch (err) {
console.error("[NostrEventPreview] Failed to encode:", err);
}
return "";
},
addNodeView() {
return ({ node }) => {
const { type, data } = node.attrs;
// Create wrapper span
const dom = document.createElement("span");
dom.className =
"nostr-event-preview inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/30 text-xs align-middle";
dom.contentEditable = "false";
// Icon based on type
const icon = document.createElement("span");
icon.className = "text-primary flex-shrink-0";
if (type === "npub" || type === "nprofile") {
icon.textContent = "👤";
} else if (type === "note" || type === "nevent") {
icon.textContent = "📝";
} else if (type === "naddr") {
icon.textContent = "📄";
} else {
icon.textContent = "🔗";
}
dom.appendChild(icon);
// Type label
const typeLabel = document.createElement("span");
typeLabel.className = "text-primary font-mono font-medium";
typeLabel.textContent = type.toUpperCase();
dom.appendChild(typeLabel);
// ID preview (truncated)
const idLabel = document.createElement("span");
idLabel.className = "text-muted-foreground truncate max-w-[80px]";
if (type === "npub") {
idLabel.textContent = `${data.slice(0, 8)}...`;
} else if (type === "note") {
idLabel.textContent = `${data.slice(0, 8)}...`;
} else if (type === "nevent") {
idLabel.textContent = `${data.id.slice(0, 8)}...`;
} else if (type === "naddr") {
idLabel.textContent = data.identifier
? `${data.identifier.slice(0, 12)}...`
: `kind:${data.kind}`;
} else if (type === "nprofile") {
idLabel.textContent = `${data.pubkey.slice(0, 8)}...`;
}
dom.appendChild(idLabel);
return { dom };
};
},
});
// Paste handler extension to transform bech32 strings into preview nodes
const NostrPasteHandler = Extension.create({
name: "nostrPasteHandler",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("nostrPasteHandler"),
props: {
handlePaste: (view, event) => {
const text = event.clipboardData?.getData("text/plain");
if (!text) return false;
// Regex to detect nostr bech32 strings (with or without nostr: prefix)
const bech32Regex =
/(?:nostr:)?(npub1[\w]{58,}|note1[\w]{58,}|nevent1[\w]+|naddr1[\w]+|nprofile1[\w]+)/g;
const matches = Array.from(text.matchAll(bech32Regex));
if (matches.length === 0) return false; // No bech32 found, use default paste
// Build content with text and preview nodes
const nodes: any[] = [];
let lastIndex = 0;
for (const match of matches) {
const matchedText = match[0];
const matchIndex = match.index!;
const bech32 = match[1]; // The bech32 without nostr: prefix
// Add text before this match
if (lastIndex < matchIndex) {
const textBefore = text.slice(lastIndex, matchIndex);
if (textBefore) {
nodes.push(view.state.schema.text(textBefore));
}
}
// Try to decode bech32 and create preview node
try {
const decoded = nip19.decode(bech32);
// Create preview node based on type
if (decoded.type === "npub") {
nodes.push(
view.state.schema.nodes.nostrEventPreview.create({
type: "npub",
data: decoded.data,
}),
);
} else if (decoded.type === "note") {
nodes.push(
view.state.schema.nodes.nostrEventPreview.create({
type: "note",
data: decoded.data,
}),
);
} else if (decoded.type === "nevent") {
nodes.push(
view.state.schema.nodes.nostrEventPreview.create({
type: "nevent",
data: decoded.data,
}),
);
} else if (decoded.type === "naddr") {
nodes.push(
view.state.schema.nodes.nostrEventPreview.create({
type: "naddr",
data: decoded.data,
}),
);
} else if (decoded.type === "nprofile") {
nodes.push(
view.state.schema.nodes.nostrEventPreview.create({
type: "nprofile",
data: decoded.data,
}),
);
}
// Add space after preview node
nodes.push(view.state.schema.text(" "));
} catch (err) {
// Invalid bech32, insert as plain text
console.warn(
"[NostrPasteHandler] Failed to decode:",
bech32,
err,
);
nodes.push(view.state.schema.text(matchedText));
}
lastIndex = matchIndex + matchedText.length;
}
// Add remaining text after last match
if (lastIndex < text.length) {
const textAfter = text.slice(lastIndex);
if (textAfter) {
nodes.push(view.state.schema.text(textAfter));
}
}
// Insert all nodes at cursor position
if (nodes.length > 0) {
const { tr } = view.state;
const { from } = view.state.selection;
// Insert content
nodes.forEach((node, index) => {
tr.insert(from + index, node);
});
view.dispatch(tr);
return true; // Prevent default paste
}
return false;
},
},
}),
];
},
});
export const MentionEditor = forwardRef<
MentionEditorHandle,
MentionEditorProps
@@ -632,6 +861,27 @@ export const MentionEditor = forwardRef<
});
}
}
} else if (child.type === "nostrEventPreview") {
// Nostr event preview - serialize back to nostr: URI
const { type, data } = child.attrs;
try {
if (type === "npub") {
text += `nostr:${nip19.npubEncode(data)}`;
} else if (type === "note") {
text += `nostr:${nip19.noteEncode(data)}`;
} else if (type === "nevent") {
text += `nostr:${nip19.neventEncode(data)}`;
} else if (type === "naddr") {
text += `nostr:${nip19.naddrEncode(data)}`;
} else if (type === "nprofile") {
text += `nostr:${nip19.nprofileEncode(data)}`;
}
} catch (err) {
console.error(
"[MentionEditor] Failed to serialize nostr preview:",
err,
);
}
}
});
text += "\n";
@@ -736,6 +986,10 @@ export const MentionEditor = forwardRef<
}),
// Add blob attachment extension for media previews
BlobAttachmentNode,
// Add nostr event preview extension for bech32 links
NostrEventPreview,
// Add paste handler to transform bech32 strings into previews
NostrPasteHandler,
];
// Add emoji extension if search is provided

View File

@@ -414,6 +414,25 @@ body.animating-layout
vertical-align: middle;
}
/* Nostr event preview styles */
.ProseMirror .nostr-event-preview {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background-color: hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.3);
border-radius: 0.25rem;
font-size: 0.75rem;
vertical-align: middle;
cursor: default;
transition: background-color 0.2s;
}
.ProseMirror .nostr-event-preview:hover {
background-color: hsl(var(--primary) / 0.15);
}
/* Hide scrollbar utility */
.hide-scrollbar {
scrollbar-width: none; /* Firefox */