mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
feat: add inline nostr: URI preview in chat composer
Add EventMentionNode TipTap extension that shows compact inline previews
when users paste nostr: URIs (note/nevent/naddr) in the chat composer.
Features:
- Detects pasted nostr: URIs and converts them to rich preview chips
- Shows event kind name and URI type (note/nevent/naddr)
- Serializes back to original nostr: URI when message is sent
- Styled consistently with existing blob attachment previews
- Non-editable inline atoms prevent accidental modification
The preview displays as a compact chip with:
- Icon (📝)
- Event kind name (from getKindName)
- URI type indicator
- Hover state for better UX
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";
|
||||
@@ -31,6 +32,7 @@ import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import type { ChatAction } from "@/types/chat-actions";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { getKindName } from "@/constants/kinds";
|
||||
|
||||
/**
|
||||
* Represents an emoji tag for NIP-30
|
||||
@@ -276,6 +278,148 @@ function formatBlobSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
// Create event mention node for nostr: URI previews
|
||||
const EventMentionNode = Node.create({
|
||||
name: "eventMention",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
nostrUri: { default: null },
|
||||
decodedType: { default: null }, // 'note', 'nevent', or 'naddr'
|
||||
eventId: { default: null },
|
||||
kind: { default: null },
|
||||
pubkey: { default: null },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-event-mention="true"]',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, { "data-event-mention": "true" }),
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const { decodedType, kind, nostrUri } = node.attrs;
|
||||
|
||||
// Create wrapper span
|
||||
const dom = document.createElement("span");
|
||||
dom.className =
|
||||
"event-mention inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-xs align-middle cursor-pointer hover:bg-primary/20 transition-colors";
|
||||
dom.contentEditable = "false";
|
||||
dom.title = nostrUri || "Event mention";
|
||||
|
||||
// Icon based on type
|
||||
const icon = document.createElement("span");
|
||||
icon.textContent = "📝";
|
||||
dom.appendChild(icon);
|
||||
|
||||
// Label showing event type
|
||||
const label = document.createElement("span");
|
||||
label.className = "text-foreground font-medium";
|
||||
const kindName = kind !== null ? getKindName(kind) : "event";
|
||||
label.textContent = kindName;
|
||||
dom.appendChild(label);
|
||||
|
||||
// Type indicator
|
||||
const typeLabel = document.createElement("span");
|
||||
typeLabel.className = "text-muted-foreground text-[10px]";
|
||||
typeLabel.textContent = decodedType || "note";
|
||||
dom.appendChild(typeLabel);
|
||||
|
||||
return { dom };
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("eventMentionPaste"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (!text) return false;
|
||||
|
||||
// Match nostr: URIs (note, nevent, naddr)
|
||||
const nostrUriRegex = /nostr:(note|nevent|naddr)1[a-z0-9]+/gi;
|
||||
const matches = text.match(nostrUriRegex);
|
||||
|
||||
if (!matches) return false;
|
||||
|
||||
// If the entire paste is just a nostr URI, convert it to an event mention
|
||||
if (matches.length === 1 && text.trim() === matches[0]) {
|
||||
event.preventDefault();
|
||||
|
||||
const nostrUri = matches[0];
|
||||
try {
|
||||
const decoded = nip19.decode(nostrUri.replace("nostr:", ""));
|
||||
let decodedType: string;
|
||||
let eventId: string | null = null;
|
||||
let kind: number | null = null;
|
||||
let pubkey: string | null = null;
|
||||
|
||||
if (decoded.type === "note") {
|
||||
decodedType = "note";
|
||||
eventId = decoded.data as string;
|
||||
kind = 1; // Assume kind 1 for note
|
||||
} else if (decoded.type === "nevent") {
|
||||
decodedType = "nevent";
|
||||
const data = decoded.data as nip19.EventPointer;
|
||||
eventId = data.id;
|
||||
kind = data.kind ?? null;
|
||||
pubkey = data.author ?? null;
|
||||
} else if (decoded.type === "naddr") {
|
||||
decodedType = "naddr";
|
||||
const data = decoded.data as nip19.AddressPointer;
|
||||
kind = data.kind;
|
||||
pubkey = data.pubkey;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { tr } = view.state;
|
||||
const { from } = view.state.selection;
|
||||
|
||||
tr.replaceWith(
|
||||
from,
|
||||
from,
|
||||
view.state.schema.nodes.eventMention.create({
|
||||
nostrUri,
|
||||
decodedType,
|
||||
eventId,
|
||||
kind,
|
||||
pubkey,
|
||||
}),
|
||||
);
|
||||
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Invalid nostr URI, let default paste behavior handle it
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const MentionEditor = forwardRef<
|
||||
MentionEditorHandle,
|
||||
MentionEditorProps
|
||||
@@ -632,6 +776,12 @@ export const MentionEditor = forwardRef<
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (child.type === "eventMention") {
|
||||
// Event mention - output the original nostr: URI
|
||||
const { nostrUri } = child.attrs;
|
||||
if (nostrUri) {
|
||||
text += nostrUri;
|
||||
}
|
||||
}
|
||||
});
|
||||
text += "\n";
|
||||
@@ -736,6 +886,8 @@ export const MentionEditor = forwardRef<
|
||||
}),
|
||||
// Add blob attachment extension for media previews
|
||||
BlobAttachmentNode,
|
||||
// Add event mention extension for nostr: URI previews
|
||||
EventMentionNode,
|
||||
];
|
||||
|
||||
// Add emoji extension if search is provided
|
||||
|
||||
Reference in New Issue
Block a user