From 6e29758648f2842a450a1289ebc16eb8b1738717 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 21:58:09 +0000 Subject: [PATCH] feat(post): add nostr tag extraction, retry failed relays, and disable empty publish Nostr tag extraction: - Extract p tags from @mentions (pubkey references) - Extract e tags from note/nevent references (event ID references) - Extract a tags from naddr references (address/parameterized replaceable) - Update SerializedContent interface to include mentions, eventRefs, addressRefs - Serialize editor content walks all node types to extract references - Build complete tag array for kind 1 events with proper NIP compliance Retry failed relays: - Add retryRelay() function to republish to specific failed relay - Make error icon clickable with hover state - Show "Click to retry" in tooltip - Rebuild event and attempt publish again - Update status indicators in real-time Disable publish when empty: - Track isEditorEmpty state - Update every 2 seconds along with draft save - Disable publish button when editor isEmpty() - Prevents publishing empty notes Tag generation order: 1. p tags (mentions) 2. e tags (event references) 3. a tags (address references) 4. emoji tags (NIP-30) 5. imeta tags (NIP-92 blob attachments) This ensures proper Nostr event structure with all referenced pubkeys, events, and addresses tagged. --- src/components/PostViewer.tsx | 142 +++++++++++++++++++++++- src/components/editor/MentionEditor.tsx | 19 +++- src/components/editor/RichEditor.tsx | 59 +++++++++- 3 files changed, 212 insertions(+), 8 deletions(-) diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index b7958b6..6eec1ac 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -40,6 +40,7 @@ export function PostViewer() { const [isPublishing, setIsPublishing] = useState(false); const [relayStates, setRelayStates] = useState([]); const [selectedRelays, setSelectedRelays] = useState>(new Set()); + const [isEditorEmpty, setIsEditorEmpty] = useState(true); // Get active account's write relays from Grimoire state const writeRelays = useMemo(() => { @@ -126,7 +127,13 @@ export function PostViewer() { // Debounced draft save (save every 2 seconds of inactivity) useEffect(() => { - const timer = setInterval(saveDraft, 2000); + const timer = setInterval(() => { + saveDraft(); + // Update empty state + if (editorRef.current) { + setIsEditorEmpty(editorRef.current.isEmpty()); + } + }, 2000); return () => clearInterval(timer); }, [saveDraft]); @@ -161,12 +168,119 @@ export function PostViewer() { }); }, []); + // Retry publishing to a specific relay + const retryRelay = useCallback( + async (relayUrl: string) => { + if (!editorRef.current) return; + + const serialized = editorRef.current.getSerializedContent(); + if (!serialized.text.trim()) return; + + // Create the event again + if (!canSign || !signer) return; + + try { + const factory = new EventFactory(); + factory.setSigner(signer); + + // Build tags array + const tags: string[][] = []; + + // Add p tags for mentions + for (const pubkey of serialized.mentions) { + tags.push(["p", pubkey]); + } + + // Add e tags for event references + for (const eventId of serialized.eventRefs) { + tags.push(["e", eventId]); + } + + // Add a tags for address references + for (const addr of serialized.addressRefs) { + tags.push(["a", `${addr.kind}:${addr.pubkey}:${addr.identifier}`]); + } + + // Add emoji tags + for (const emoji of serialized.emojiTags) { + tags.push(["emoji", emoji.shortcode, emoji.url]); + } + + // Add blob attachment tags (imeta) + for (const blob of serialized.blobAttachments) { + const imetaTag = [ + "imeta", + `url ${blob.url}`, + `m ${blob.mimeType}`, + `x ${blob.sha256}`, + `size ${blob.size}`, + ]; + if (blob.server) { + imetaTag.push(`server ${blob.server}`); + } + tags.push(imetaTag); + } + + const draft = await factory.build({ + kind: 1, + content: serialized.text, + tags, + }); + const event = await factory.sign(draft); + + // Update status to publishing + setRelayStates((prev) => + prev.map((r) => + r.url === relayUrl + ? { ...r, status: "publishing" as RelayStatus } + : r, + ), + ); + + // Try to publish + await pool.publish([relayUrl], event); + + // Update status to success + setRelayStates((prev) => + prev.map((r) => + r.url === relayUrl + ? { ...r, status: "success" as RelayStatus, error: undefined } + : r, + ), + ); + + toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`); + } catch (error) { + console.error(`Failed to retry publish to ${relayUrl}:`, error); + setRelayStates((prev) => + prev.map((r) => + r.url === relayUrl + ? { + ...r, + status: "error" as RelayStatus, + error: + error instanceof Error ? error.message : "Unknown error", + } + : r, + ), + ); + toast.error( + `Failed to publish to ${relayUrl.replace(/^wss?:\/\//, "")}`, + ); + } + }, + [canSign, signer], + ); + // Publish to selected relays with per-relay status tracking const handlePublish = useCallback( async ( content: string, emojiTags: EmojiTag[], blobAttachments: BlobAttachment[], + mentions: string[], + eventRefs: string[], + addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>, ) => { if (!canSign || !signer || !pubkey) { toast.error("Please log in to publish"); @@ -194,6 +308,21 @@ export function PostViewer() { // Build tags array const tags: string[][] = []; + // Add p tags for mentions + for (const pubkey of mentions) { + tags.push(["p", pubkey]); + } + + // Add e tags for event references + for (const eventId of eventRefs) { + tags.push(["e", eventId]); + } + + // Add a tags for address references + for (const addr of addressRefs) { + tags.push(["a", `${addr.kind}:${addr.pubkey}:${addr.identifier}`]); + } + // Add emoji tags for (const emoji of emojiTags) { tags.push(["emoji", emoji.shortcode, emoji.url]); @@ -354,7 +483,7 @@ export function PostViewer() { )} diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index b271d12..013ed50 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -68,6 +68,12 @@ export interface SerializedContent { emojiTags: EmojiTag[]; /** Blob attachments for imeta tags (NIP-92) */ blobAttachments: BlobAttachment[]; + /** Mentioned pubkeys for p tags */ + mentions: string[]; + /** Referenced event IDs for e tags (from note/nevent) */ + eventRefs: string[]; + /** Referenced addresses for a tags (from naddr) */ + addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>; } export interface MentionEditorProps { @@ -746,6 +752,9 @@ export const MentionEditor = forwardRef< text: text.trim(), emojiTags, blobAttachments, + mentions: [], + eventRefs: [], + addressRefs: [], }; }, [], @@ -947,7 +956,15 @@ export const MentionEditor = forwardRef< clear: () => editor?.commands.clearContent(), getContent: () => editor?.getText() || "", getSerializedContent: () => { - if (!editor) return { text: "", emojiTags: [], blobAttachments: [] }; + if (!editor) + return { + text: "", + emojiTags: [], + blobAttachments: [], + mentions: [], + eventRefs: [], + addressRefs: [], + }; return serializeContent(editor); }, isEmpty: () => editor?.isEmpty ?? true, diff --git a/src/components/editor/RichEditor.tsx b/src/components/editor/RichEditor.tsx index d2a3fc4..359f415 100644 --- a/src/components/editor/RichEditor.tsx +++ b/src/components/editor/RichEditor.tsx @@ -41,6 +41,9 @@ export interface RichEditorProps { content: string, emojiTags: EmojiTag[], blobAttachments: BlobAttachment[], + mentions: string[], + eventRefs: string[], + addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>, ) => void; searchProfiles: (query: string) => Promise; searchEmojis?: (query: string) => Promise; @@ -155,13 +158,21 @@ const EmojiMention = Mention.extend({ function serializeContent(editor: any): SerializedContent { const emojiTags: EmojiTag[] = []; const blobAttachments: BlobAttachment[] = []; + const mentions = new Set(); + const eventRefs = new Set(); + const addressRefs: Array<{ + kind: number; + pubkey: string; + identifier: string; + }> = []; const seenEmojis = new Set(); const seenBlobs = new Set(); + const seenAddrs = new Set(); // Get plain text representation const text = editor.getText(); - // Walk the document to collect emoji and blob data + // Walk the document to collect emoji, blob, mention, and event data editor.state.doc.descendants((node: any) => { if (node.type.name === "emoji") { const { id, url, source } = node.attrs; @@ -177,10 +188,41 @@ function serializeContent(editor: any): SerializedContent { seenBlobs.add(sha256); blobAttachments.push({ url, sha256, mimeType, size, server }); } + } else if (node.type.name === "mention") { + // Extract pubkey from @mentions for p tags + const { id } = node.attrs; + if (id) { + mentions.add(id); + } + } else if (node.type.name === "nostrEventPreview") { + // Extract event/address references for e/a tags + const { type, data } = node.attrs; + if (type === "note" && data) { + eventRefs.add(data); + } else if (type === "nevent" && data?.id) { + eventRefs.add(data.id); + } else if (type === "naddr" && data) { + const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; + if (!seenAddrs.has(addrKey)) { + seenAddrs.add(addrKey); + addressRefs.push({ + kind: data.kind, + pubkey: data.pubkey, + identifier: data.identifier || "", + }); + } + } } }); - return { text, emojiTags, blobAttachments }; + return { + text, + emojiTags, + blobAttachments, + mentions: Array.from(mentions), + eventRefs: Array.from(eventRefs), + addressRefs, + }; } export const RichEditor = forwardRef( @@ -353,6 +395,9 @@ export const RichEditor = forwardRef( serialized.text, serialized.emojiTags, serialized.blobAttachments, + serialized.mentions, + serialized.eventRefs, + serialized.addressRefs, ); editorInstance.commands.clearContent(); } @@ -486,7 +531,15 @@ export const RichEditor = forwardRef( clear: () => editor?.commands.clearContent(), getContent: () => editor?.getText() || "", getSerializedContent: () => { - if (!editor) return { text: "", emojiTags: [], blobAttachments: [] }; + if (!editor) + return { + text: "", + emojiTags: [], + blobAttachments: [], + mentions: [], + eventRefs: [], + addressRefs: [], + }; return serializeContent(editor); }, isEmpty: () => editor?.isEmpty ?? true,