From 31221515a343a9242166fe887629d61251a7f45a Mon Sep 17 00:00:00 2001 From: Harrison-F Date: Wed, 13 May 2026 23:58:40 -0400 Subject: [PATCH 1/3] Add thread draft submission workflow --- src/components/admin/DirectPostEditor.tsx | 217 +++++++++++++++++++++- src/components/admin/PublishDialog.tsx | 128 ++++++++++++- src/components/admin/ReviewPane.tsx | 111 ++++++++++- src/components/delegate/DraftEditor.tsx | 198 ++++++++++++++++++-- src/components/delegate/SubmitDialog.tsx | 12 ++ src/lib/nostr/nip59.ts | 1 + src/types/submission.ts | 2 + 7 files changed, 635 insertions(+), 34 deletions(-) diff --git a/src/components/admin/DirectPostEditor.tsx b/src/components/admin/DirectPostEditor.tsx index 46145ac..533cf01 100644 --- a/src/components/admin/DirectPostEditor.tsx +++ b/src/components/admin/DirectPostEditor.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react' -import { ArrowLeft, Send, Loader2, Save, Trash2, RefreshCw } from 'lucide-react' +import { ArrowLeft, Send, Loader2, Save, Trash2, RefreshCw, Plus, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -49,6 +49,8 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) const [summary, setSummary] = useState('') const [content, setContent] = useState('') const [isLongForm, setIsLongForm] = useState(false) + const [isThread, setIsThread] = useState(false) + const [threadPosts, setThreadPosts] = useState(['']) const [coverImage, setCoverImage] = useState() const [isPublishing, setIsPublishing] = useState(false) const [isSaving, setIsSaving] = useState(false) @@ -92,6 +94,10 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) setTitle(currentDraft.title) setContent(currentDraft.content) setIsLongForm(currentDraft.targetKind === 30023) + const savedAsThread = currentDraft.targetKind === 1 && currentDraft.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') + const savedThreadPosts = currentDraft.content.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) + setIsThread(savedAsThread || savedThreadPosts.length > 1) + setThreadPosts(savedThreadPosts.length > 0 ? savedThreadPosts : ['']) setCoverImage(currentDraft.coverImage) setAttachedImages(currentDraft.uploadedImages || []) setHasChanges(false) @@ -134,11 +140,14 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) attachedLinks.forEach((link) => { tags.push(['r', link.url]) }) + if (isThread) { + tags.push(['ghostr-thread', 'true']) + } } updateDraft(currentDraftId, { title, - content, + content: !isLongForm && isThread ? threadContent() : content, targetKind: isLongForm ? 30023 : 1, tags, coverImage, @@ -147,7 +156,35 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) }, 1000) return () => clearTimeout(autoSaveTimeoutRef.current) - }, [title, content, isLongForm, coverImage, attachedImages, attachedLinks, currentDraftId, currentDraft, updateDraft]) + }, [title, content, isLongForm, isThread, threadPosts, coverImage, attachedImages, attachedLinks, currentDraftId, currentDraft, updateDraft]) + + const splitThreadPosts = (value: string) => + value.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) + + const threadContent = (posts: string[] = threadPosts) => + posts.map((post) => post.trim()).filter(Boolean).join('\n---\n') + + const updateThreadPost = (index: number, value: string) => { + setThreadPosts((prev) => prev.map((post, i) => (i === index ? value : post))) + } + + const addThreadPost = () => setThreadPosts((prev) => [...prev, '']) + + const removeThreadPost = (index: number) => { + setThreadPosts((prev) => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev) + } + + const enableThreadMode = () => { + const posts = splitThreadPosts(content) + setThreadPosts(posts.length > 0 ? posts : [content]) + setIsThread(true) + setIsLongForm(false) + } + + const disableThreadMode = () => { + setContent(threadContent()) + setIsThread(false) + } const handleImageUpload = (url: string) => { if (isLongForm) { @@ -212,12 +249,15 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) attachedLinks.forEach((link) => { tags.push(['r', link.url]) }) + if (isThread) { + tags.push(['ghostr-thread', 'true']) + } } // Update local state updateDraft(currentDraft.id, { title, - content, + content: !isLongForm && isThread ? threadContent() : content, targetKind: isLongForm ? 30023 : 1, tags, coverImage, @@ -264,11 +304,14 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) attachedLinks.forEach((link) => { tags.push(['r', link.url]) }) + if (isThread) { + tags.push(['ghostr-thread', 'true']) + } } updateDraft(currentDraft.id, { title, - content, + content: !isLongForm && isThread ? threadContent() : content, targetKind: isLongForm ? 30023 : 1, tags, coverImage, @@ -307,6 +350,91 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) setIsPublishing(true) try { + const cleanThreadPosts = isThread && !isLongForm + ? threadPosts.map((post) => post.trim()).filter(Boolean) + : [] + + if (!isLongForm && isThread) { + if (cleanThreadPosts.length < 2) { + toast({ + title: 'Cannot publish thread', + description: 'Add at least two non-empty posts before publishing a thread.', + variant: 'destructive', + }) + return + } + + const publishedIds: string[] = [] + let publisherPubkey: string | undefined + for (const [index, postContent] of cleanThreadPosts.entries()) { + let postFinalContent = postContent + if (index === cleanThreadPosts.length - 1 && attachedImages.length > 0) { + const newImages = attachedImages.filter(url => !postFinalContent.includes(url)) + if (newImages.length > 0) { + postFinalContent += '\n\n' + newImages.join('\n') + } + } + + const threadEvent = new NDKEvent(ndk) + threadEvent.kind = 1 + threadEvent.content = postFinalContent + const tags: string[][] = [] + if (includeCredit) { + tags.push(['client', 'Ghostr']) + } + if (index > 0) { + const rootId = publishedIds[0] + const replyId = publishedIds[publishedIds.length - 1] + if (!rootId || !replyId) throw new Error('Missing thread root or reply event id') + tags.push(['e', rootId, '', 'root']) + tags.push(['e', replyId, '', 'reply']) + if (publisherPubkey) { + tags.push(['p', publisherPubkey]) + } + } + threadEvent.tags = tags + await threadEvent.sign(signer) + publisherPubkey = publisherPubkey || threadEvent.pubkey + await threadEvent.publish() + publishedIds.push(threadEvent.id) + + addItem({ + id: threadEvent.id, + content: postFinalContent, + kind: 1, + publishedAt: Date.now(), + source: 'direct', + }) + } + + toast({ + title: 'Thread published!', + description: `${publishedIds.length} posts have been published as a thread.`, + }) + + if (currentDraftId) { + updateDraft(currentDraftId, { + status: 'published', + publishedEventId: publishedIds[0], + }) + setCurrentDraft(null) + } + + setTitle('') + setSummary('') + setContent('') + setThreadPosts(['']) + setIsThread(false) + setCoverImage(undefined) + setAttachedImages([]) + setAttachedLinks([]) + initializedDraftId.current = null + + onPublished?.() + onBack() + return + } + const event = new NDKEvent(ndk) event.kind = isLongForm ? 30023 : 1 @@ -479,6 +607,16 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) onClick={() => { const draft = createDraft(isLongForm ? 30023 : 1) initializedDraftId.current = draft.id + const tags: string[][] = [] + if (!isLongForm && isThread) tags.push(['ghostr-thread', 'true']) + updateDraft(draft.id, { + title, + content: !isLongForm && isThread ? threadContent() : content, + targetKind: isLongForm ? 30023 : 1, + tags, + coverImage, + uploadedImages: attachedImages, + }) setHasChanges(false) toast({ title: 'Draft saved', @@ -544,7 +682,45 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) {!isLongForm && } - {isLongForm ? ( + {!isLongForm && isThread ? ( +
+
+ Publisher thread mode publishes each box as a sequential kind 1 reply. Paste text separated by lines containing only ---, then split if needed. +
+
+ + +
+ {threadPosts.map((post, index) => ( +
+
+ + {threadPosts.length > 1 && ( + + )} +
+ updateThreadPost(index, value)} + placeholder={`Thread post ${index + 1}`} + minHeight="120px" + /> +

{post.length} characters

+
+ ))} +
+ ) : isLongForm ? ( )}

- {content.length} characters + {!isLongForm && isThread + ? `${threadPosts.length} post${threadPosts.length !== 1 ? 's' : ''} · ${threadPosts.reduce((sum, post) => sum + post.length, 0)} total characters` + : `${content.length} characters`} {hasImages && !isLongForm && ` | ${attachedImages.length} image${attachedImages.length !== 1 ? 's' : ''}`}

@@ -584,7 +762,10 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)

Post Type

+

- {isLongForm + {!isLongForm && isThread + ? 'Threads publish multiple kind 1 notes with root/reply tags.' + : isLongForm ? 'Long-form articles (NIP-23, kind 30023) support markdown and are best for blog posts and articles.' : 'Short notes (kind 1) are like tweets - brief updates and thoughts.'}

diff --git a/src/components/admin/PublishDialog.tsx b/src/components/admin/PublishDialog.tsx index 8b0894a..95100c5 100644 --- a/src/components/admin/PublishDialog.tsx +++ b/src/components/admin/PublishDialog.tsx @@ -54,6 +54,17 @@ export function PublishDialog({ const [error, setError] = useState(null) const [includeCredit, setIncludeCredit] = useState(creditGhostr) + const splitThreadPosts = (value: string) => + value.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) + const hasThreadTag = submission.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') + const payloadThreadPosts = submission.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? [] + const contentThreadPosts = splitThreadPosts(editedContent) + const isThreadSubmission = submission.kind === 1 && (hasThreadTag || payloadThreadPosts.length > 1 || contentThreadPosts.length > 1) + const dialogThreadPosts = isThreadSubmission + ? (contentThreadPosts.length > 1 ? contentThreadPosts : payloadThreadPosts) + : [] + const overLimitCount = dialogThreadPosts.filter((post) => post.length > 280).length + const handlePublish = async () => { if (!ndk || !signer) { setError('Not connected or authenticated') @@ -70,6 +81,85 @@ export function PublishDialog({ setError(null) try { + const isThread = isThreadSubmission + const editedThreadPosts = isThread + ? (editedContent.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean).length > 1 + ? editedContent.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) + : (submission.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? [])) + : [] + + if (isThread && submission.kind === 1) { + if (editedThreadPosts.length < 2) { + setError('Threads need at least two non-empty posts before publishing') + setIsPublishing(false) + return + } + const publishedIds: string[] = [] + let publisherPubkey: string | undefined + for (const [index, postContent] of editedThreadPosts.entries()) { + const threadEvent = new NDKEvent(ndk) + threadEvent.kind = 1 + threadEvent.content = postContent + const tags: string[][] = submission.tags.filter((t) => t[0] !== 'ghostr-thread') + if (includeCredit) { + tags.push(['client', 'Ghostr']) + } + if (index > 0) { + const rootId = publishedIds[0] + const replyId = publishedIds[publishedIds.length - 1] + if (!rootId || !replyId) throw new Error('Missing thread root or reply event id') + tags.push(['e', rootId, '', 'root']) + tags.push(['e', replyId, '', 'reply']) + if (publisherPubkey) { + tags.push(['p', publisherPubkey]) + } + } + threadEvent.tags = tags + await threadEvent.sign(signer) + publisherPubkey = publisherPubkey || threadEvent.pubkey + await threadEvent.publish() + publishedIds.push(threadEvent.id) + } + + const rootEventId = publishedIds[0] + if (!rootEventId) throw new Error('No thread posts were published') + try { + const receipt: ReceiptPayload = { + protocol: PROTOCOL_VERSION, + type: 'receipt', + submissionId: submission.id, + action: 'approved', + eventId: rootEventId, + timestamp: Date.now(), + } + await sendGiftWrappedReceipt(submission.delegatePubkey, receipt) + } catch (receiptError) { + console.error('Failed to send receipt:', receiptError) + } + + markAsApproved(submission.id, rootEventId) + publishedIds.forEach((eventId, index) => { + addItem({ + id: eventId, + content: editedThreadPosts[index] || '', + kind: 1, + publishedAt: Date.now(), + source: 'delegate', + delegatePubkey: submission.delegatePubkey, + delegateNpub: submission.delegateNpub, + }) + }) + + toast({ + title: 'Thread published successfully', + description: `${publishedIds.length} posts have been published as a thread.`, + }) + + onOpenChange(false) + onSuccess() + return + } + // Normalize line breaks for markdown: convert single \n to \n\n for proper paragraph breaks const normalizedContent = editedContent.replace(/([^\n])\n([^\n])/g, '$1\n\n$2') @@ -199,19 +289,21 @@ export function PublishDialog({ - Publish Content + {isThreadSubmission ? 'Publish Thread' : 'Publish Content'} - This will create a new event signed with your key and publish it to your relays. + {isThreadSubmission + ? 'This will publish each thread post sequentially with root/reply tags, signed with your key.' + : 'This will create a new event signed with your key and publish it to your relays.'}
-
+

Event Preview

Kind:{' '} - {submission.kind} + {isThreadSubmission ? `${dialogThreadPosts.length} thread posts` : submission.kind}
Content length:{' '} @@ -219,9 +311,24 @@ export function PublishDialog({
Tags:{' '} - {submission.tags.length} tag(s) + {submission.tags.filter((tag) => tag[0] !== 'ghostr-thread').length} public tag(s)
+ {isThreadSubmission && ( +
+ {dialogThreadPosts.map((post, index) => ( +
+
+ Post {index + 1} + 280 ? 'text-destructive' : 'text-muted-foreground'}> + {post.length}/280 + +
+

{post}

+
+ ))} +
+ )}
@@ -231,6 +338,15 @@ export function PublishDialog({

+ {isThreadSubmission && overLimitCount > 0 && ( +
+ + + {overLimitCount} post{overLimitCount === 1 ? '' : 's'} exceed 280 characters. They can still publish to Nostr, but may not fit X-style limits. + +
+ )} + {error && (
@@ -259,7 +375,7 @@ export function PublishDialog({ ) : ( )} - {isPublishing ? 'Publishing...' : 'Publish to Nostr'} + {isPublishing ? 'Publishing...' : isThreadSubmission ? `Publish ${dialogThreadPosts.length} posts` : 'Publish to Nostr'}
diff --git a/src/components/admin/ReviewPane.tsx b/src/components/admin/ReviewPane.tsx index 4e9eefa..bb54c20 100644 --- a/src/components/admin/ReviewPane.tsx +++ b/src/components/admin/ReviewPane.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect, useCallback } from "react"; -import { ArrowLeft, Check, X, User, AlertTriangle, RefreshCw, Save, Loader2 } from "lucide-react"; +import { ArrowLeft, Check, X, User, AlertTriangle, RefreshCw, Save, Loader2, Plus, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -54,6 +54,20 @@ export function ReviewPane({ onBack }: ReviewPaneProps) { const [editedSummary, setEditedSummary] = useState(savedEdits?.summary || summaryTag?.[1] || ""); const [editedContent, setEditedContent] = useState(savedEdits?.content || submission?.content || ""); const [editedCoverImage, setEditedCoverImage] = useState(savedEdits?.coverImage || coverImageTag?.[1] || ""); + const isThreadSubmission = submission?.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') ?? false; + const splitThreadContent = (value: string) => + value.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean); + const joinThreadPosts = (posts: string[]) => + posts.map((post) => post.trim()).filter(Boolean).join("\n---\n"); + const getInitialThreadPosts = () => { + if (!isThreadSubmission) return []; + const savedPosts = splitThreadContent(savedEdits?.content || ""); + if (savedPosts.length > 0) return savedPosts; + const payloadPosts = submission?.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? []; + if (payloadPosts.length > 0) return payloadPosts; + return splitThreadContent(submission?.content || ""); + }; + const [editedThreadPosts, setEditedThreadPosts] = useState(getInitialThreadPosts); const [isFeedbackDialogOpen, setFeedbackDialogOpen] = useState(false); const [attachedLinks, setAttachedLinks] = useState([]); const [lastRelaySaveTime, setLastRelaySaveTime] = useState(0); @@ -81,6 +95,15 @@ export function ReviewPane({ onBack }: ReviewPaneProps) { return contentImages.length > 0 || hasCoverImage; }, [originalContentRef.current, submission]); + // Reset local edits when switching between submissions + useEffect(() => { + setEditedTitle(savedEdits?.title || titleTag?.[1] || ""); + setEditedSummary(savedEdits?.summary || summaryTag?.[1] || ""); + setEditedContent(savedEdits?.content || submission?.content || ""); + setEditedCoverImage(savedEdits?.coverImage || coverImageTag?.[1] || ""); + setEditedThreadPosts(getInitialThreadPosts()); + }, [submission?.id]); + // Auto-save edits (debounced, similar to DraftEditor) useEffect(() => { if (!submission || submission.status !== 'pending') return; @@ -152,6 +175,34 @@ export function ReviewPane({ onBack }: ReviewPaneProps) { } }, [submission, editedContent, editedTitle, editedSummary, editedCoverImage, updateSubmissionEdits]); + const updateThreadPost = (index: number, value: string) => { + setEditedThreadPosts((posts) => { + const nextPosts = posts.map((post, i) => (i === index ? value : post)); + const nextContent = joinThreadPosts(nextPosts); + setEditedContent(nextContent); + if (submission) { + updateSubmissionContent(submission.id, nextContent); + } + return nextPosts; + }); + }; + + const addThreadPost = () => { + setEditedThreadPosts((posts) => [...posts, ""]); + }; + + const removeThreadPost = (index: number) => { + setEditedThreadPosts((posts) => { + const nextPosts = posts.filter((_, i) => i !== index); + const nextContent = joinThreadPosts(nextPosts); + setEditedContent(nextContent); + if (submission) { + updateSubmissionContent(submission.id, nextContent); + } + return nextPosts; + }); + }; + // Update content and persist to store const handleContentChange = (newContent: string) => { setEditedContent(newContent); @@ -219,7 +270,9 @@ export function ReviewPane({ onBack }: ReviewPaneProps) {
- Kind {submission.kind === 1 ? "1 (Note)" : "30023 (Article)"} + {submission.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') + ? 'Thread' + : submission.kind === 1 ? "1 (Note)" : "30023 (Article)"}
@@ -316,7 +369,53 @@ export function ReviewPane({ onBack }: ReviewPaneProps) { )}
- {submission.kind === 30023 ? ( + {isThreadSubmission ? ( +
+ {editedThreadPosts.map((post, index) => ( +
+
+ + {!isProcessed && editedThreadPosts.length > 1 && ( + + )} +
+ updateThreadPost(index, value)} + placeholder="Thread post text..." + disabled={isProcessed} + minHeight="120px" + /> +
+ {post.length}/280 characters + {post.length > 280 && Over 280 characters} +
+
+ ))} + {!isProcessed && ( +
+ +
+ )} +
+ ) : submission.kind === 30023 ? ( // Long-form article - use MarkdownEditor - {editedContent.length} characters + {isThreadSubmission + ? `${editedThreadPosts.length} posts · ${editedContent.length} total characters` + : `${editedContent.length} characters`} {editedContent !== submission.content && " (modified)"} - {submission.kind === 1 && attachedImages.length > 0 && + {submission.kind === 1 && !isThreadSubmission && attachedImages.length > 0 && ` | ${attachedImages.length} image${attachedImages.length !== 1 ? "s" : ""}`}

diff --git a/src/components/delegate/DraftEditor.tsx b/src/components/delegate/DraftEditor.tsx index f7c8b33..a1e4351 100644 --- a/src/components/delegate/DraftEditor.tsx +++ b/src/components/delegate/DraftEditor.tsx @@ -11,6 +11,7 @@ import { AlertCircle, Trash2, RefreshCw, + Plus, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -76,6 +77,16 @@ export function DraftEditor({ onBack }: DraftEditorProps) { const [summary, setSummary] = useState(draft?.summary ?? ""); const [content, setContent] = useState(draft?.content ?? ""); const [isLongForm, setIsLongForm] = useState(draft?.targetKind === 30023); + const [threadPosts, setThreadPosts] = useState(() => { + const existingPosts = draft?.content.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) ?? []; + return existingPosts.length > 0 ? existingPosts : ["", ""]; + }); + const [isThread, setIsThread] = useState(() => { + const existingPosts = draft?.content.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) ?? []; + return draft?.targetKind === 1 && ( + (draft?.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') ?? false) || existingPosts.length > 1 + ); + }); const [coverImage, setCoverImage] = useState( draft?.coverImage, ); @@ -91,6 +102,14 @@ export function DraftEditor({ onBack }: DraftEditorProps) { // Track if initial mount to avoid auto-save on first render const isInitialMount = useRef(true); + const threadContent = (posts: string[] = threadPosts) => + posts.map((post) => post.trim()).filter(Boolean).join("\n---\n"); + + const tagsForCurrentMode = () => { + const baseTags = draft?.tags.filter((tag) => tag[0] !== 'ghostr-thread') ?? []; + return isThread ? [...baseTags, ['ghostr-thread', 'true']] : baseTags; + }; + // Stable callback for MarkdownEditor onChange const handleMarkdownChange = useCallback((val: string) => { setContent(val); @@ -237,9 +256,9 @@ export function DraftEditor({ onBack }: DraftEditorProps) { if (currentDraftId && hasChanges) { // For kind 1 notes, append images and links at the end - let finalContent = debouncedContentLocal.trim(); + let finalContent = (isThread ? threadContent() : debouncedContentLocal).trim(); - if (!isLongForm) { + if (!isLongForm && !isThread) { if (debouncedImagesLocal.length > 0) { finalContent += '\n' + debouncedImagesLocal.join('\n'); } @@ -253,6 +272,7 @@ export function DraftEditor({ onBack }: DraftEditorProps) { summary: isLongForm && debouncedSummaryLocal.trim() ? debouncedSummaryLocal : undefined, content: finalContent, targetKind: isLongForm ? 30023 : 1, + tags: tagsForCurrentMode(), targetPublisher: selectedPublisher ?? undefined, coverImage: isLongForm ? coverImage : undefined, uploadedImages: !isLongForm && debouncedImagesLocal.length > 0 ? debouncedImagesLocal : undefined, @@ -346,6 +366,55 @@ export function DraftEditor({ onBack }: DraftEditorProps) { const handleKindChange = (checked: boolean) => { setIsLongForm(checked); + setIsThread(false); + setHasChanges(true); + }; + + const handleThreadModeChange = () => { + if (!isThread) { + const splitPosts = content + .split(/\n\s*---\s*\n/g) + .map((post) => post.trim()) + .filter(Boolean); + setThreadPosts(splitPosts.length > 0 ? splitPosts : ["", ""]); + } + setIsLongForm(false); + setIsThread(true); + setHasChanges(true); + }; + + const handleThreadPostChange = (index: number, value: string) => { + setThreadPosts((posts) => { + const nextPosts = posts.map((post, i) => (i === index ? value : post)); + setContent(threadContent(nextPosts)); + return nextPosts; + }); + setHasChanges(true); + }; + + const handleAddThreadPost = () => { + setThreadPosts((posts) => [...posts, ""]); + setHasChanges(true); + }; + + const handleRemoveThreadPost = (index: number) => { + setThreadPosts((posts) => { + const nextPosts = posts.filter((_, i) => i !== index); + setContent(threadContent(nextPosts)); + return nextPosts; + }); + setHasChanges(true); + }; + + const handleSplitThreadPaste = () => { + const splitPosts = threadPosts + .join("\n---\n") + .split(/\n\s*---\s*\n/g) + .map((post) => post.trim()) + .filter(Boolean); + const nextPosts = splitPosts.length > 0 ? splitPosts : [""]; + setThreadPosts(nextPosts); + setContent(threadContent(nextPosts)); setHasChanges(true); }; @@ -398,9 +467,9 @@ export function DraftEditor({ onBack }: DraftEditorProps) { if (!draft) return; // For kind 1 notes, append images and links at the end - let finalContent = content.trim(); + let finalContent = (isThread ? threadContent() : content).trim(); - if (!isLongForm) { + if (!isLongForm && !isThread) { if (attachedImages.length > 0) { finalContent += '\n' + attachedImages.join('\n'); } @@ -414,6 +483,7 @@ export function DraftEditor({ onBack }: DraftEditorProps) { summary: isLongForm && summary.trim() ? summary : undefined, content: finalContent, targetKind: isLongForm ? 30023 : 1, + tags: tagsForCurrentMode(), targetPublisher: selectedPublisher ?? undefined, coverImage: isLongForm ? coverImage : undefined, uploadedImages: !isLongForm && attachedImages.length > 0 ? attachedImages : undefined, @@ -428,7 +498,7 @@ export function DraftEditor({ onBack }: DraftEditorProps) { }; const handleSubmitForReview = () => { - if (!content.trim()) { + if (!(isThread ? threadContent() : content).trim()) { toast({ title: "Cannot submit", description: "Please add some content before submitting.", @@ -436,6 +506,22 @@ export function DraftEditor({ onBack }: DraftEditorProps) { }); return; } + if (isThread && threadPosts.map((post) => post.trim()).filter(Boolean).length < 2) { + toast({ + title: "Cannot submit thread", + description: "Add at least two non-empty posts before submitting a thread.", + variant: "destructive", + }); + return; + } + if (draft) { + updateDraft(draft.id, { + content: (isThread ? threadContent() : content).trim(), + targetKind: isLongForm ? 30023 : 1, + tags: tagsForCurrentMode(), + targetPublisher: selectedPublisher ?? undefined, + }); + } setSubmitDialogOpen(true); }; @@ -460,9 +546,9 @@ export function DraftEditor({ onBack }: DraftEditorProps) { // Save before going back if there are changes if (draft && hasChanges) { // For kind 1 notes, append images and links at the end - let finalContent = content.trim(); + let finalContent = (isThread ? threadContent() : content).trim(); - if (!isLongForm) { + if (!isLongForm && !isThread) { if (attachedImages.length > 0) { finalContent += '\n' + attachedImages.join('\n'); } @@ -476,6 +562,7 @@ export function DraftEditor({ onBack }: DraftEditorProps) { summary: isLongForm && summary.trim() ? summary : undefined, content: finalContent, targetKind: isLongForm ? 30023 : 1, + tags: tagsForCurrentMode(), targetPublisher: selectedPublisher ?? undefined, coverImage: isLongForm ? coverImage : undefined, uploadedImages: !isLongForm && attachedImages.length > 0 ? attachedImages : undefined, @@ -653,11 +740,78 @@ export function DraftEditor({ onBack }: DraftEditorProps) {
- {!isLongForm && !isSubmittedOrPublished && ( + {!isLongForm && !isThread && !isSubmittedOrPublished && ( )}
- {isLongForm ? ( + {isThread ? ( +
+
+ Thread submissions are sent for publisher review as ordered kind 1 posts. + Paste a draft separated by lines containing only ---, then split into posts. +
+
+ +
+ {threadPosts.map((post, index) => ( +
+
+ +
+ 280 ? "text-destructive" : "text-muted-foreground" + )}> + {post.length}/280 + + {threadPosts.length > 1 && ( + + )} +
+
+ handleThreadPostChange(index, value)} + placeholder={index === 0 ? "Start your thread..." : "Continue the thread..."} + disabled={isSubmittedOrPublished} + minHeight="140px" + /> +
+ ))} +
+
+ +
+
+ ) : isLongForm ? ( )}

- {content.length} characters + {isThread + ? `${threadPosts.length} post${threadPosts.length !== 1 ? "s" : ""} · ${threadPosts.reduce((sum, post) => sum + post.length, 0)} total characters` + : `${content.length} characters`} {hasImages && !isLongForm && + !isThread && ` | ${attachedImages.length} image${attachedImages.length !== 1 ? "s" : ""}`}

@@ -822,7 +979,7 @@ export function DraftEditor({ onBack }: DraftEditorProps) { disabled={isSubmittedOrPublished} className={cn( 'flex-1 px-3 py-2 rounded-full text-sm font-medium transition-colors', - !isLongForm + !isLongForm && !isThread ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground', isSubmittedOrPublished && 'opacity-50 cursor-not-allowed' @@ -835,7 +992,7 @@ export function DraftEditor({ onBack }: DraftEditorProps) { disabled={isSubmittedOrPublished} className={cn( 'flex-1 px-3 py-2 rounded-full text-sm font-medium transition-colors', - isLongForm + isLongForm && !isThread ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground', isSubmittedOrPublished && 'opacity-50 cursor-not-allowed' @@ -843,9 +1000,24 @@ export function DraftEditor({ onBack }: DraftEditorProps) { > Long-form +

- {isLongForm + {isThread + ? "Threads submit as multiple kind 1 posts for publisher review and sequential publishing." + : isLongForm ? "Long-form articles (NIP-23, kind 30023) support markdown and are best for blog posts and articles." : "Short notes (kind 1) are like tweets - brief updates and thoughts."}

diff --git a/src/components/delegate/SubmitDialog.tsx b/src/components/delegate/SubmitDialog.tsx index f18408b..f55e465 100644 --- a/src/components/delegate/SubmitDialog.tsx +++ b/src/components/delegate/SubmitDialog.tsx @@ -126,6 +126,13 @@ export function SubmitDialog({ open, onOpenChange, draft }: SubmitDialogProps) { try { // Build tags array, including title, summary, and cover image if present const tags = [...draft.tags] + const contentPosts = draft.content.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) + const isThreadSubmission = draft.targetKind === 1 && ( + tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') || contentPosts.length > 1 + ) + if (isThreadSubmission && !tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true')) { + tags.push(['ghostr-thread', 'true']) + } if (draft.targetKind === 30023) { // Add title tag (NIP-23 requires it) if (draft.title?.trim()) { @@ -150,6 +157,10 @@ export function SubmitDialog({ open, onOpenChange, draft }: SubmitDialogProps) { ? draft.content.replace(/([^\n])\n([^\n])/g, '$1\n\n$2') : draft.content + const threadPosts = isThreadSubmission + ? contentPosts + : undefined + const payload: SubmissionPayload = { protocol: PROTOCOL_VERSION, type: 'submission', @@ -157,6 +168,7 @@ export function SubmitDialog({ open, onOpenChange, draft }: SubmitDialogProps) { content: normalizedContent, kind: draft.targetKind, tags, + threadPosts, note: note.trim(), submittedAt: Math.floor(Date.now() / 1000), // Actual submission time } diff --git a/src/lib/nostr/nip59.ts b/src/lib/nostr/nip59.ts index a027ef2..25e8c90 100644 --- a/src/lib/nostr/nip59.ts +++ b/src/lib/nostr/nip59.ts @@ -298,6 +298,7 @@ export function submissionFromPayload( content: payload.content, kind: payload.kind, tags: payload.tags, + threadPosts: payload.threadPosts, note: payload.note, receivedAt: createdAt * 1000, // Convert to milliseconds for JS Date status: "pending", diff --git a/src/types/submission.ts b/src/types/submission.ts index 0833fcb..9f98628 100644 --- a/src/types/submission.ts +++ b/src/types/submission.ts @@ -5,6 +5,7 @@ export interface SubmissionPayload { content: string kind: 1 | 30023 tags: string[][] + threadPosts?: string[] note: string submittedAt?: number // Unix timestamp in seconds when submitted (added for accurate timestamps) } @@ -23,6 +24,7 @@ export interface Submission { content: string kind: 1 | 30023 tags: string[][] + threadPosts?: string[] note: string receivedAt: number status: 'pending' | 'approved' | 'rejected' | 'archived' From 397ae69f21144b8deb7267993c82840bb9ed9453 Mon Sep 17 00:00:00 2001 From: Harrison-F Date: Wed, 13 May 2026 23:58:40 -0400 Subject: [PATCH 2/3] Improve thread publishing resilience --- src/components/admin/DirectPostEditor.tsx | 28 +++++------ src/components/admin/PublishDialog.tsx | 60 ++++++++++------------- src/components/admin/ReviewPane.tsx | 38 +++----------- src/components/delegate/DraftEditor.tsx | 51 ++++++------------- src/components/delegate/SubmitDialog.tsx | 7 +-- src/lib/threadUtils.ts | 18 +++++++ 6 files changed, 84 insertions(+), 118 deletions(-) create mode 100644 src/lib/threadUtils.ts diff --git a/src/components/admin/DirectPostEditor.tsx b/src/components/admin/DirectPostEditor.tsx index 533cf01..5d05c03 100644 --- a/src/components/admin/DirectPostEditor.tsx +++ b/src/components/admin/DirectPostEditor.tsx @@ -20,6 +20,7 @@ import { NDKEvent } from '@nostr-dev-kit/ndk' import { extractImageUrls } from '@/lib/blossom' import { extractLinkUrls, fetchLinkMetadata, type LinkMetadata } from '@/lib/urlUtils' import { cn } from '@/lib/utils/cn' +import { hasThreadMarker, joinThreadPosts, splitThreadPosts } from '@/lib/threadUtils' import { AlertDialog, AlertDialogAction, @@ -94,9 +95,9 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) setTitle(currentDraft.title) setContent(currentDraft.content) setIsLongForm(currentDraft.targetKind === 30023) - const savedAsThread = currentDraft.targetKind === 1 && currentDraft.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') - const savedThreadPosts = currentDraft.content.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) - setIsThread(savedAsThread || savedThreadPosts.length > 1) + const savedAsThread = currentDraft.targetKind === 1 && hasThreadMarker(currentDraft.tags) + const savedThreadPosts = savedAsThread ? splitThreadPosts(currentDraft.content) : [] + setIsThread(savedAsThread) setThreadPosts(savedThreadPosts.length > 0 ? savedThreadPosts : ['']) setCoverImage(currentDraft.coverImage) setAttachedImages(currentDraft.uploadedImages || []) @@ -158,11 +159,7 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) return () => clearTimeout(autoSaveTimeoutRef.current) }, [title, content, isLongForm, isThread, threadPosts, coverImage, attachedImages, attachedLinks, currentDraftId, currentDraft, updateDraft]) - const splitThreadPosts = (value: string) => - value.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) - - const threadContent = (posts: string[] = threadPosts) => - posts.map((post) => post.trim()).filter(Boolean).join('\n---\n') + const threadContent = (posts: string[] = threadPosts) => joinThreadPosts(posts) const updateThreadPost = (index: number, value: string) => { setThreadPosts((prev) => prev.map((post, i) => (i === index ? value : post))) @@ -175,8 +172,7 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) } const enableThreadMode = () => { - const posts = splitThreadPosts(content) - setThreadPosts(posts.length > 0 ? posts : [content]) + setThreadPosts(content.trim() ? [content] : ['']) setIsThread(true) setIsLongForm(false) } @@ -716,7 +712,6 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) placeholder={`Thread post ${index + 1}`} minHeight="120px" /> -

{post.length} characters

))} @@ -748,12 +743,11 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) /> )} -

- {!isLongForm && isThread - ? `${threadPosts.length} post${threadPosts.length !== 1 ? 's' : ''} · ${threadPosts.reduce((sum, post) => sum + post.length, 0)} total characters` - : `${content.length} characters`} - {hasImages && !isLongForm && ` | ${attachedImages.length} image${attachedImages.length !== 1 ? 's' : ''}`} -

+ {hasImages && !isLongForm && ( +

+ {attachedImages.length} image{attachedImages.length !== 1 ? 's' : ''} +

+ )} diff --git a/src/components/admin/PublishDialog.tsx b/src/components/admin/PublishDialog.tsx index 95100c5..a394ff1 100644 --- a/src/components/admin/PublishDialog.tsx +++ b/src/components/admin/PublishDialog.tsx @@ -22,6 +22,7 @@ import { toast } from '@/hooks/useToast' import { PROTOCOL_VERSION } from '@/lib/constants' import { sendBotNotification } from '@/lib/nostr/nip04' import { createApprovalNotification } from '@/lib/notifications/messageTemplates' +import { hasThreadMarker, splitThreadPosts, stripThreadMarker } from '@/lib/threadUtils' interface PublishDialogProps { open: boolean @@ -53,18 +54,15 @@ export function PublishDialog({ const [isPublishing, setIsPublishing] = useState(false) const [error, setError] = useState(null) const [includeCredit, setIncludeCredit] = useState(creditGhostr) + const [publishingPostIndex, setPublishingPostIndex] = useState(null) - const splitThreadPosts = (value: string) => - value.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) - const hasThreadTag = submission.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') + const hasThreadTag = hasThreadMarker(submission.tags) const payloadThreadPosts = submission.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? [] const contentThreadPosts = splitThreadPosts(editedContent) const isThreadSubmission = submission.kind === 1 && (hasThreadTag || payloadThreadPosts.length > 1 || contentThreadPosts.length > 1) const dialogThreadPosts = isThreadSubmission ? (contentThreadPosts.length > 1 ? contentThreadPosts : payloadThreadPosts) : [] - const overLimitCount = dialogThreadPosts.filter((post) => post.length > 280).length - const handlePublish = async () => { if (!ndk || !signer) { setError('Not connected or authenticated') @@ -79,14 +77,14 @@ export function PublishDialog({ setIsPublishing(true) setError(null) + setPublishingPostIndex(null) + let threadPublishedIds: string[] = [] + let threadPostCount = 0 try { const isThread = isThreadSubmission - const editedThreadPosts = isThread - ? (editedContent.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean).length > 1 - ? editedContent.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) - : (submission.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? [])) - : [] + const editedThreadPosts = isThread ? dialogThreadPosts : [] + threadPostCount = editedThreadPosts.length if (isThread && submission.kind === 1) { if (editedThreadPosts.length < 2) { @@ -95,12 +93,14 @@ export function PublishDialog({ return } const publishedIds: string[] = [] + threadPublishedIds = publishedIds let publisherPubkey: string | undefined for (const [index, postContent] of editedThreadPosts.entries()) { + setPublishingPostIndex(index) const threadEvent = new NDKEvent(ndk) threadEvent.kind = 1 threadEvent.content = postContent - const tags: string[][] = submission.tags.filter((t) => t[0] !== 'ghostr-thread') + const tags: string[][] = stripThreadMarker(submission.tags) if (includeCredit) { tags.push(['client', 'Ghostr']) } @@ -279,9 +279,15 @@ export function PublishDialog({ onSuccess() } catch (err) { console.error('Failed to publish:', err) - setError(err instanceof Error ? err.message : 'Failed to publish') + const baseMessage = err instanceof Error ? err.message : 'Failed to publish' + if (threadPostCount > 0 && threadPublishedIds.length > 0 && threadPublishedIds.length < threadPostCount) { + setError(`${threadPublishedIds.length} of ${threadPostCount} posts went live before the error — posts 1 through ${threadPublishedIds.length} are already published. ${baseMessage}`) + } else { + setError(baseMessage) + } } finally { setIsPublishing(false) + setPublishingPostIndex(null) } } @@ -305,25 +311,16 @@ export function PublishDialog({ Kind:{' '} {isThreadSubmission ? `${dialogThreadPosts.length} thread posts` : submission.kind} -
- Content length:{' '} - {editedContent.length} characters -
Tags:{' '} - {submission.tags.filter((tag) => tag[0] !== 'ghostr-thread').length} public tag(s) + {stripThreadMarker(submission.tags).length} public tag(s)
{isThreadSubmission && (
{dialogThreadPosts.map((post, index) => (
-
- Post {index + 1} - 280 ? 'text-destructive' : 'text-muted-foreground'}> - {post.length}/280 - -
+
Post {index + 1}

{post}

))} @@ -338,15 +335,6 @@ export function PublishDialog({

- {isThreadSubmission && overLimitCount > 0 && ( -
- - - {overLimitCount} post{overLimitCount === 1 ? '' : 's'} exceed 280 characters. They can still publish to Nostr, but may not fit X-style limits. - -
- )} - {error && (
@@ -375,7 +363,13 @@ export function PublishDialog({ ) : ( )} - {isPublishing ? 'Publishing...' : isThreadSubmission ? `Publish ${dialogThreadPosts.length} posts` : 'Publish to Nostr'} + {isPublishing + ? (isThreadSubmission && publishingPostIndex !== null + ? `Publishing post ${publishingPostIndex + 1} of ${dialogThreadPosts.length}…` + : 'Publishing...') + : isThreadSubmission + ? `Publish ${dialogThreadPosts.length} posts` + : 'Publish to Nostr'}
diff --git a/src/components/admin/ReviewPane.tsx b/src/components/admin/ReviewPane.tsx index bb54c20..9a193cc 100644 --- a/src/components/admin/ReviewPane.tsx +++ b/src/components/admin/ReviewPane.tsx @@ -22,6 +22,7 @@ import { extractImageUrls } from "@/lib/blossom"; import { extractLinkUrls, type LinkMetadata } from "@/lib/urlUtils"; import { useProfileQuery } from "@/hooks/queries/useProfileQuery"; import { toast } from "@/hooks/useToast"; +import { hasThreadMarker, joinThreadPosts, splitThreadPosts } from "@/lib/threadUtils"; interface ReviewPaneProps { onBack: () => void; @@ -54,18 +55,14 @@ export function ReviewPane({ onBack }: ReviewPaneProps) { const [editedSummary, setEditedSummary] = useState(savedEdits?.summary || summaryTag?.[1] || ""); const [editedContent, setEditedContent] = useState(savedEdits?.content || submission?.content || ""); const [editedCoverImage, setEditedCoverImage] = useState(savedEdits?.coverImage || coverImageTag?.[1] || ""); - const isThreadSubmission = submission?.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') ?? false; - const splitThreadContent = (value: string) => - value.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean); - const joinThreadPosts = (posts: string[]) => - posts.map((post) => post.trim()).filter(Boolean).join("\n---\n"); + const isThreadSubmission = hasThreadMarker(submission?.tags ?? []); const getInitialThreadPosts = () => { if (!isThreadSubmission) return []; - const savedPosts = splitThreadContent(savedEdits?.content || ""); + const savedPosts = splitThreadPosts(savedEdits?.content || ""); if (savedPosts.length > 0) return savedPosts; const payloadPosts = submission?.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? []; if (payloadPosts.length > 0) return payloadPosts; - return splitThreadContent(submission?.content || ""); + return splitThreadPosts(submission?.content || ""); }; const [editedThreadPosts, setEditedThreadPosts] = useState(getInitialThreadPosts); const [isFeedbackDialogOpen, setFeedbackDialogOpen] = useState(false); @@ -270,7 +267,7 @@ export function ReviewPane({ onBack }: ReviewPaneProps) {
- {submission.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') + {hasThreadMarker(submission.tags) ? 'Thread' : submission.kind === 1 ? "1 (Note)" : "30023 (Article)"} @@ -394,10 +391,6 @@ export function ReviewPane({ onBack }: ReviewPaneProps) { disabled={isProcessed} minHeight="120px" /> -
- {post.length}/280 characters - {post.length > 280 && Over 280 characters} -
))} {!isProcessed && ( @@ -448,14 +441,9 @@ export function ReviewPane({ onBack }: ReviewPaneProps) { )} -

- {isThreadSubmission - ? `${editedThreadPosts.length} posts · ${editedContent.length} total characters` - : `${editedContent.length} characters`} - {editedContent !== submission.content && " (modified)"} - {submission.kind === 1 && !isThreadSubmission && attachedImages.length > 0 && - ` | ${attachedImages.length} image${attachedImages.length !== 1 ? "s" : ""}`} -

+ {editedContent !== submission.content && ( +

Modified

+ )} {/* Image Re-hosting Options for submissions with images */} @@ -536,16 +524,6 @@ export function ReviewPane({ onBack }: ReviewPaneProps) {

- {submission.note && ( -
- - Note from delegate: - -

- {submission.note} -

-
- )} {submission.tags.length > 0 && ( diff --git a/src/components/delegate/DraftEditor.tsx b/src/components/delegate/DraftEditor.tsx index a1e4351..a2419b9 100644 --- a/src/components/delegate/DraftEditor.tsx +++ b/src/components/delegate/DraftEditor.tsx @@ -49,6 +49,7 @@ import type { DraftPublisher } from "@/types/draft"; import { extractImageUrls } from "@/lib/blossom"; import { extractAllUrls, fetchLinkMetadata, type LinkMetadata, isImageUrl } from "@/lib/urlUtils"; import { cn } from "@/lib/utils/cn"; +import { hasThreadMarker, joinThreadPosts, splitThreadPosts, stripThreadMarker } from "@/lib/threadUtils"; interface DraftEditorProps { onBack: () => void; @@ -78,15 +79,14 @@ export function DraftEditor({ onBack }: DraftEditorProps) { const [content, setContent] = useState(draft?.content ?? ""); const [isLongForm, setIsLongForm] = useState(draft?.targetKind === 30023); const [threadPosts, setThreadPosts] = useState(() => { - const existingPosts = draft?.content.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) ?? []; + const savedAsThread = draft?.targetKind === 1 && hasThreadMarker(draft?.tags ?? []); + if (!savedAsThread) return [draft?.content ?? "", ""]; + const existingPosts = splitThreadPosts(draft?.content ?? ""); return existingPosts.length > 0 ? existingPosts : ["", ""]; }); - const [isThread, setIsThread] = useState(() => { - const existingPosts = draft?.content.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) ?? []; - return draft?.targetKind === 1 && ( - (draft?.tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') ?? false) || existingPosts.length > 1 - ); - }); + const [isThread, setIsThread] = useState(() => + draft?.targetKind === 1 && hasThreadMarker(draft?.tags ?? []), + ); const [coverImage, setCoverImage] = useState( draft?.coverImage, ); @@ -102,11 +102,10 @@ export function DraftEditor({ onBack }: DraftEditorProps) { // Track if initial mount to avoid auto-save on first render const isInitialMount = useRef(true); - const threadContent = (posts: string[] = threadPosts) => - posts.map((post) => post.trim()).filter(Boolean).join("\n---\n"); + const threadContent = (posts: string[] = threadPosts) => joinThreadPosts(posts); const tagsForCurrentMode = () => { - const baseTags = draft?.tags.filter((tag) => tag[0] !== 'ghostr-thread') ?? []; + const baseTags = stripThreadMarker(draft?.tags ?? []); return isThread ? [...baseTags, ['ghostr-thread', 'true']] : baseTags; }; @@ -372,11 +371,7 @@ export function DraftEditor({ onBack }: DraftEditorProps) { const handleThreadModeChange = () => { if (!isThread) { - const splitPosts = content - .split(/\n\s*---\s*\n/g) - .map((post) => post.trim()) - .filter(Boolean); - setThreadPosts(splitPosts.length > 0 ? splitPosts : ["", ""]); + setThreadPosts(content.trim() ? [content] : ["", ""]); } setIsLongForm(false); setIsThread(true); @@ -407,11 +402,7 @@ export function DraftEditor({ onBack }: DraftEditorProps) { }; const handleSplitThreadPaste = () => { - const splitPosts = threadPosts - .join("\n---\n") - .split(/\n\s*---\s*\n/g) - .map((post) => post.trim()) - .filter(Boolean); + const splitPosts = splitThreadPosts(joinThreadPosts(threadPosts)); const nextPosts = splitPosts.length > 0 ? splitPosts : [""]; setThreadPosts(nextPosts); setContent(threadContent(nextPosts)); @@ -766,12 +757,6 @@ export function DraftEditor({ onBack }: DraftEditorProps) {
- 280 ? "text-destructive" : "text-muted-foreground" - )}> - {post.length}/280 - {threadPosts.length > 1 && (
diff --git a/src/components/delegate/SubmitDialog.tsx b/src/components/delegate/SubmitDialog.tsx index f55e465..83a304f 100644 --- a/src/components/delegate/SubmitDialog.tsx +++ b/src/components/delegate/SubmitDialog.tsx @@ -27,6 +27,7 @@ import { createNewSubmissionNotification, createSubmissionReceivedNotification, } from '@/lib/notifications/messageTemplates' +import { hasThreadMarker, splitThreadPosts } from '@/lib/threadUtils' interface SubmitDialogProps { open: boolean @@ -126,11 +127,11 @@ export function SubmitDialog({ open, onOpenChange, draft }: SubmitDialogProps) { try { // Build tags array, including title, summary, and cover image if present const tags = [...draft.tags] - const contentPosts = draft.content.split(/\n\s*---\s*\n/g).map((post) => post.trim()).filter(Boolean) + const contentPosts = splitThreadPosts(draft.content) const isThreadSubmission = draft.targetKind === 1 && ( - tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') || contentPosts.length > 1 + hasThreadMarker(tags) || contentPosts.length > 1 ) - if (isThreadSubmission && !tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true')) { + if (isThreadSubmission && !hasThreadMarker(tags)) { tags.push(['ghostr-thread', 'true']) } if (draft.targetKind === 30023) { diff --git a/src/lib/threadUtils.ts b/src/lib/threadUtils.ts new file mode 100644 index 0000000..15b476b --- /dev/null +++ b/src/lib/threadUtils.ts @@ -0,0 +1,18 @@ +export const THREAD_DELIMITER = '\n---\n' +export const THREAD_DELIMITER_PATTERN = /\n\s*---\s*\n/g + +export function splitThreadPosts(value: string): string[] { + return value.split(THREAD_DELIMITER_PATTERN).map((post) => post.trim()).filter(Boolean) +} + +export function joinThreadPosts(posts: string[]): string { + return posts.map((post) => post.trim()).filter(Boolean).join(THREAD_DELIMITER) +} + +export function hasThreadMarker(tags: string[][]): boolean { + return tags.some((tag) => tag[0] === 'ghostr-thread' && tag[1] === 'true') +} + +export function stripThreadMarker(tags: string[][]): string[][] { + return tags.filter((tag) => tag[0] !== 'ghostr-thread') +} From 1549878e66349b8e7d45967b991d8b1905fa0c43 Mon Sep 17 00:00:00 2001 From: Harrison-F Date: Wed, 13 May 2026 23:58:40 -0400 Subject: [PATCH 3/3] Address remaining thread publish feedback --- src/components/admin/DirectPostEditor.tsx | 20 ++++++++++++++++++-- src/components/admin/ReviewPane.tsx | 9 +++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/components/admin/DirectPostEditor.tsx b/src/components/admin/DirectPostEditor.tsx index 5d05c03..9117857 100644 --- a/src/components/admin/DirectPostEditor.tsx +++ b/src/components/admin/DirectPostEditor.tsx @@ -54,6 +54,7 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) const [threadPosts, setThreadPosts] = useState(['']) const [coverImage, setCoverImage] = useState() const [isPublishing, setIsPublishing] = useState(false) + const [publishingPostIndex, setPublishingPostIndex] = useState(null) const [isSaving, setIsSaving] = useState(false) const [includeCredit, setIncludeCredit] = useState(creditGhostr) const [attachedImages, setAttachedImages] = useState([]) @@ -344,11 +345,15 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) } setIsPublishing(true) + setPublishingPostIndex(null) + let threadPublishedIds: string[] = [] + let threadPostCount = 0 try { const cleanThreadPosts = isThread && !isLongForm ? threadPosts.map((post) => post.trim()).filter(Boolean) : [] + threadPostCount = cleanThreadPosts.length if (!isLongForm && isThread) { if (cleanThreadPosts.length < 2) { @@ -361,8 +366,10 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) } const publishedIds: string[] = [] + threadPublishedIds = publishedIds let publisherPubkey: string | undefined for (const [index, postContent] of cleanThreadPosts.entries()) { + setPublishingPostIndex(index) let postFinalContent = postContent if (index === cleanThreadPosts.length - 1 && attachedImages.length > 0) { const newImages = attachedImages.filter(url => !postFinalContent.includes(url)) @@ -531,13 +538,18 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) onBack() } catch (err) { console.error('Failed to publish:', err) + const baseMessage = err instanceof Error ? err.message : 'An error occurred' + const description = threadPostCount > 0 && threadPublishedIds.length > 0 && threadPublishedIds.length < threadPostCount + ? `${threadPublishedIds.length} of ${threadPostCount} posts went live before the error — posts 1 through ${threadPublishedIds.length} are already published. ${baseMessage}` + : baseMessage toast({ title: 'Failed to publish', - description: err instanceof Error ? err.message : 'An error occurred', + description, variant: 'destructive', }) } finally { setIsPublishing(false) + setPublishingPostIndex(null) } } @@ -631,7 +643,11 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) ) : ( )} - {isPublishing ? 'Publishing...' : isDirectPostMode.current ? 'Publish Now' : 'Publish'} + {isPublishing + ? (!isLongForm && isThread && publishingPostIndex !== null + ? `Publishing post ${publishingPostIndex + 1} of ${threadPosts.map((post) => post.trim()).filter(Boolean).length}…` + : 'Publishing...') + : isDirectPostMode.current ? 'Publish Now' : 'Publish'} diff --git a/src/components/admin/ReviewPane.tsx b/src/components/admin/ReviewPane.tsx index 9a193cc..15ff06c 100644 --- a/src/components/admin/ReviewPane.tsx +++ b/src/components/admin/ReviewPane.tsx @@ -524,6 +524,15 @@ export function ReviewPane({ onBack }: ReviewPaneProps) {

+ {submission.note && ( +
+ Delegate note: +

+ {submission.note} +

+
+ )} + {submission.tags.length > 0 && (