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)
Content
{!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.
+
+
+
setThreadPosts(splitThreadPosts(content).length > 0 ? splitThreadPosts(content) : splitThreadPosts(threadContent()))}
+ >
+ Split on ---
+
+
+ Add post
+
+
+ {threadPosts.map((post, index) => (
+
+
+ Post {index + 1}
+ {threadPosts.length > 1 && (
+ removeThreadPost(index)}>
+
+
+ )}
+
+
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
setIsLongForm(false)}
+ onClick={() => {
+ setIsLongForm(false)
+ setIsThread(false)
+ }}
className={cn(
'flex-1 px-3 py-2 rounded-full text-sm font-medium transition-colors',
!isLongForm
@@ -595,7 +776,21 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
Short Note
setIsLongForm(true)}
+ onClick={enableThreadMode}
+ className={cn(
+ 'flex-1 px-3 py-2 rounded-full text-sm font-medium transition-colors',
+ !isLongForm && isThread
+ ? 'bg-background text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground'
+ )}
+ >
+ Thread
+
+ {
+ if (isThread) disableThreadMode()
+ setIsLongForm(true)
+ }}
className={cn(
'flex-1 px-3 py-2 rounded-full text-sm font-medium transition-colors',
isLongForm
@@ -607,7 +802,9 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
- {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) => (
+
+
+ Post {index + 1} {!isProcessed && "(Editable)"}
+ {!isProcessed && editedThreadPosts.length > 1 && (
+ removeThreadPost(index)}
+ >
+
+
+ )}
+
+
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) {
Content
- {!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.
+
+
+
+ Split on ---
+
+
+ {threadPosts.map((post, index) => (
+
+
+
Post {index + 1}
+
+ 280 ? "text-destructive" : "text-muted-foreground"
+ )}>
+ {post.length}/280
+
+ {threadPosts.length > 1 && (
+ handleRemoveThreadPost(index)}
+ disabled={isSubmittedOrPublished}
+ title="Remove post"
+ >
+
+
+ )}
+
+
+
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
+
+ Thread
+
- {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) {
Post {index + 1}
-
280 ? "text-destructive" : "text-muted-foreground"
- )}>
- {post.length}/280
-
{threadPosts.length > 1 && (
>
)}
-
- {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" : ""}`}
-
+ {hasImages && !isLongForm && !isThread && (
+
+ {attachedImages.length} image{attachedImages.length !== 1 ? "s" : ""}
+
+ )}
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 && (