diff --git a/src/components/admin/DirectPostEditor.tsx b/src/components/admin/DirectPostEditor.tsx index 46145ac..9117857 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' @@ -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, @@ -49,8 +50,11 @@ 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 [publishingPostIndex, setPublishingPostIndex] = useState(null) const [isSaving, setIsSaving] = useState(false) const [includeCredit, setIncludeCredit] = useState(creditGhostr) const [attachedImages, setAttachedImages] = useState([]) @@ -92,6 +96,10 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps) setTitle(currentDraft.title) setContent(currentDraft.content) setIsLongForm(currentDraft.targetKind === 30023) + 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 || []) setHasChanges(false) @@ -134,11 +142,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 +158,30 @@ 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 threadContent = (posts: string[] = threadPosts) => joinThreadPosts(posts) + + 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 = () => { + setThreadPosts(content.trim() ? [content] : ['']) + setIsThread(true) + setIsLongForm(false) + } + + const disableThreadMode = () => { + setContent(threadContent()) + setIsThread(false) + } const handleImageUpload = (url: string) => { if (isLongForm) { @@ -212,12 +246,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 +301,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, @@ -305,8 +345,99 @@ 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) { + toast({ + title: 'Cannot publish thread', + description: 'Add at least two non-empty posts before publishing a thread.', + variant: 'destructive', + }) + return + } + + 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)) + 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 @@ -407,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) } } @@ -479,6 +615,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', @@ -497,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'} @@ -544,7 +694,44 @@ 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" + /> +
+ ))} +
+ ) : isLongForm ? ( )} -

- {content.length} characters - {hasImages && !isLongForm && ` | ${attachedImages.length} image${attachedImages.length !== 1 ? 's' : ''}`} -

+ {hasImages && !isLongForm && ( +

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

+ )} @@ -584,7 +772,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..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,7 +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 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 handlePublish = async () => { if (!ndk || !signer) { setError('Not connected or authenticated') @@ -68,8 +77,89 @@ export function PublishDialog({ setIsPublishing(true) setError(null) + setPublishingPostIndex(null) + let threadPublishedIds: string[] = [] + let threadPostCount = 0 try { + const isThread = isThreadSubmission + const editedThreadPosts = isThread ? dialogThreadPosts : [] + threadPostCount = editedThreadPosts.length + + 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[] = [] + 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[][] = stripThreadMarker(submission.tags) + 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') @@ -189,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) } } @@ -199,29 +295,37 @@ 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} -
-
- Content length:{' '} - {editedContent.length} characters + {isThreadSubmission ? `${dialogThreadPosts.length} thread posts` : submission.kind}
Tags:{' '} - {submission.tags.length} tag(s) + {stripThreadMarker(submission.tags).length} public tag(s)
+ {isThreadSubmission && ( +
+ {dialogThreadPosts.map((post, index) => ( +
+
Post {index + 1}
+

{post}

+
+ ))} +
+ )}
@@ -259,7 +363,13 @@ export function PublishDialog({ ) : ( )} - {isPublishing ? 'Publishing...' : '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 4e9eefa..15ff06c 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"; @@ -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,6 +55,16 @@ 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 = hasThreadMarker(submission?.tags ?? []); + const getInitialThreadPosts = () => { + if (!isThreadSubmission) return []; + 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 splitThreadPosts(submission?.content || ""); + }; + const [editedThreadPosts, setEditedThreadPosts] = useState(getInitialThreadPosts); const [isFeedbackDialogOpen, setFeedbackDialogOpen] = useState(false); const [attachedLinks, setAttachedLinks] = useState([]); const [lastRelaySaveTime, setLastRelaySaveTime] = useState(0); @@ -81,6 +92,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 +172,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 +267,9 @@ export function ReviewPane({ onBack }: ReviewPaneProps) {
- Kind {submission.kind === 1 ? "1 (Note)" : "30023 (Article)"} + {hasThreadMarker(submission.tags) + ? 'Thread' + : submission.kind === 1 ? "1 (Note)" : "30023 (Article)"}
@@ -316,7 +366,49 @@ 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" + /> +
+ ))} + {!isProcessed && ( +
+ +
+ )} +
+ ) : submission.kind === 30023 ? ( // Long-form article - use MarkdownEditor )} -

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

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

Modified

+ )} {/* Image Re-hosting Options for submissions with images */} @@ -437,14 +526,13 @@ export function ReviewPane({ onBack }: ReviewPaneProps) { {submission.note && (
- - Note from delegate: - -

+ Delegate note: +

{submission.note}

)} + {submission.tags.length > 0 && ( diff --git a/src/components/delegate/DraftEditor.tsx b/src/components/delegate/DraftEditor.tsx index f7c8b33..a2419b9 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"; @@ -48,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; @@ -76,6 +78,15 @@ 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 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(() => + draft?.targetKind === 1 && hasThreadMarker(draft?.tags ?? []), + ); const [coverImage, setCoverImage] = useState( draft?.coverImage, ); @@ -91,6 +102,13 @@ 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) => joinThreadPosts(posts); + + const tagsForCurrentMode = () => { + const baseTags = stripThreadMarker(draft?.tags ?? []); + return isThread ? [...baseTags, ['ghostr-thread', 'true']] : baseTags; + }; + // Stable callback for MarkdownEditor onChange const handleMarkdownChange = useCallback((val: string) => { setContent(val); @@ -237,9 +255,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 +271,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 +365,47 @@ export function DraftEditor({ onBack }: DraftEditorProps) { const handleKindChange = (checked: boolean) => { setIsLongForm(checked); + setIsThread(false); + setHasChanges(true); + }; + + const handleThreadModeChange = () => { + if (!isThread) { + setThreadPosts(content.trim() ? [content] : ["", ""]); + } + 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 = splitThreadPosts(joinThreadPosts(threadPosts)); + const nextPosts = splitPosts.length > 0 ? splitPosts : [""]; + setThreadPosts(nextPosts); + setContent(threadContent(nextPosts)); setHasChanges(true); }; @@ -398,9 +458,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 +474,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 +489,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 +497,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 +537,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 +553,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 +731,72 @@ 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) => ( +
+
+ +
+ {threadPosts.length > 1 && ( + + )} +
+
+ handleThreadPostChange(index, value)} + placeholder={index === 0 ? "Start your thread..." : "Continue the thread..."} + disabled={isSubmittedOrPublished} + minHeight="140px" + /> +
+ ))} +
+
+ +
+
+ ) : isLongForm ? ( )} -

- {content.length} characters - {hasImages && - !isLongForm && - ` | ${attachedImages.length} image${attachedImages.length !== 1 ? "s" : ""}`} -

+ {hasImages && !isLongForm && !isThread && ( +

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

+ )}
@@ -822,7 +960,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 +973,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 +981,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..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,6 +127,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 = splitThreadPosts(draft.content) + const isThreadSubmission = draft.targetKind === 1 && ( + hasThreadMarker(tags) || contentPosts.length > 1 + ) + if (isThreadSubmission && !hasThreadMarker(tags)) { + tags.push(['ghostr-thread', 'true']) + } if (draft.targetKind === 30023) { // Add title tag (NIP-23 requires it) if (draft.title?.trim()) { @@ -150,6 +158,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 +169,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/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') +} 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'