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.
This commit is contained in:
Claude
2026-01-20 21:58:09 +00:00
parent bb6764036c
commit 6e29758648
3 changed files with 212 additions and 8 deletions

View File

@@ -40,6 +40,7 @@ export function PostViewer() {
const [isPublishing, setIsPublishing] = useState(false);
const [relayStates, setRelayStates] = useState<RelayPublishState[]>([]);
const [selectedRelays, setSelectedRelays] = useState<Set<string>>(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() {
<Button
onClick={() => editorRef.current?.submit()}
disabled={isPublishing || selectedRelays.size === 0}
disabled={isPublishing || selectedRelays.size === 0 || isEditorEmpty}
className="gap-2 flex-1"
>
{isPublishing ? (
@@ -433,9 +562,14 @@ export function PostViewer() {
<Check className="h-4 w-4 text-green-500" />
)}
{relay.status === "error" && (
<div title={relay.error || "Failed to publish"}>
<button
onClick={() => retryRelay(relay.url)}
disabled={isPublishing}
className="p-0.5 rounded hover:bg-red-500/10 transition-colors"
title={`${relay.error || "Failed to publish"}. Click to retry.`}
>
<X className="h-4 w-4 text-red-500" />
</div>
</button>
)}
</div>
</div>

View File

@@ -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,

View File

@@ -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<ProfileSearchResult[]>;
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
@@ -155,13 +158,21 @@ const EmojiMention = Mention.extend({
function serializeContent(editor: any): SerializedContent {
const emojiTags: EmojiTag[] = [];
const blobAttachments: BlobAttachment[] = [];
const mentions = new Set<string>();
const eventRefs = new Set<string>();
const addressRefs: Array<{
kind: number;
pubkey: string;
identifier: string;
}> = [];
const seenEmojis = new Set<string>();
const seenBlobs = new Set<string>();
const seenAddrs = new Set<string>();
// 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<RichEditorHandle, RichEditorProps>(
@@ -353,6 +395,9 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
serialized.text,
serialized.emojiTags,
serialized.blobAttachments,
serialized.mentions,
serialized.eventRefs,
serialized.addressRefs,
);
editorInstance.commands.clearContent();
}
@@ -486,7 +531,15 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
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,