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