mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user