diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index d2e73ab5..781c801b 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -33,7 +33,7 @@ import { createCitationPromptDraftEvent } from '@/lib/draft-event' import { ExtendedKind } from '@/constants' -import { isTouchDevice } from '@/lib/utils' +import { cn, isTouchDevice } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useFeed } from '@/providers/FeedProvider' import { useReply } from '@/providers/ReplyProvider' @@ -44,16 +44,21 @@ import postEditorCache from '@/services/post-editor-cache.service' import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' import { + Book, + Check, + ChevronDown, ImageUp, ListTodo, MessageCircle, MessagesSquare, Settings, Smile, + Users, X, Highlighter, FileText, Quote, + StickyNote, Upload, Mic, Music, @@ -68,7 +73,22 @@ import { successfulPublishRelayUrls, type TRelayPublishStatus } from '@/lib/publ import client, { eventService } from '@/services/client.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import noteStatsService from '@/services/note-stats.service' -import CreateThreadDialog from '@/pages/primary/DiscussionsPage/CreateThreadDialog' +import { + buildAllAvailableTopics, + collectDiscussionThreadTags, + discussionThreadDraftKindParams, + displayTopicLabel, + resolveTopicFromInput, + THREAD_POST_EDITOR_PARENT, + type TDiscussionDynamicTopics +} from '@/lib/discussion-thread-composer' +import { prefixNostrAddresses } from '@/lib/nostr-address' +import dayjs from 'dayjs' +import { TDraftEvent } from '@/types' +import { useGroupList } from '@/providers/GroupListProvider' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Switch } from '@/components/ui/switch' +import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics' import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -95,7 +115,8 @@ export default function PostContent({ openFrom, initialHighlightData, initialPublicMessageTo, - onPublishSuccess + onPublishSuccess, + discussionDynamicTopics }: { defaultContent?: string parentEvent?: Event @@ -106,9 +127,12 @@ export default function PostContent({ initialPublicMessageTo?: string /** Called after a reply/post is successfully published, before closing. */ onPublishSuccess?: () => void + /** Optional hot/discussion topics (e.g. from Discussions spell) for the thread composer. */ + discussionDynamicTopics?: TDiscussionDynamicTopics | null }) { const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() + const { userGroups } = useGroupList() const { feedInfo } = useFeed() const { addReplies } = useReply() @@ -196,7 +220,29 @@ export default function PostContent({ relays: [] }) const [minPow, setMinPow] = useState(0) - const [createThreadOpen, setCreateThreadOpen] = useState(false) + const [isDiscussionThread, setIsDiscussionThread] = useState(false) + const [threadTitle, setThreadTitle] = useState('') + const [threadTopicInput, setThreadTopicInput] = useState(() => { + const row = DISCUSSION_TOPICS.find((x) => x.id === 'general') + return row?.label ?? 'general' + }) + const [threadSelectedTopic, setThreadSelectedTopic] = useState('general') + const [threadSelectedGroup, setThreadSelectedGroup] = useState('') + const [threadIsReadingGroup, setThreadIsReadingGroup] = useState(false) + const [threadReadingAuthor, setThreadReadingAuthor] = useState('') + const [threadReadingSubject, setThreadReadingSubject] = useState('') + const [threadShowReadingsPanel, setThreadShowReadingsPanel] = useState(false) + const [threadTopicPopoverOpen, setThreadTopicPopoverOpen] = useState(false) + const [threadGroupPopoverOpen, setThreadGroupPopoverOpen] = useState(false) + const [threadErrors, setThreadErrors] = useState<{ + title?: string + content?: string + topic?: string + relay?: string + author?: string + subject?: string + group?: string + }>({}) const [mediaNoteKind, setMediaNoteKind] = useState(null) const [mediaImetaTags, setMediaImetaTags] = useState([]) const [mediaUrl, setMediaUrl] = useState('') @@ -247,6 +293,23 @@ export default function PostContent({ const uploadedMediaFileMap = useRef>(new Map()) /** Accumulates imeta tags for kind 20 (picture) so multiple rapid uploads don’t overwrite each other. */ const pictureImetaTagsRef = useRef([]) + /** Stable auto d-tag when the field is left empty; `{ slug, value }` resets when article subtype changes. */ + const articleDTagFallbackRef = useRef<{ slug: string; value: string } | null>(null) + + useEffect(() => { + if (articleDTag.trim()) { + articleDTagFallbackRef.current = null + } + }, [articleDTag]) + + useEffect(() => { + const isArticle = + isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent + if (!isArticle) { + articleDTagFallbackRef.current = null + } + }, [isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent]) + useEffect(() => { if (mediaNoteKind === ExtendedKind.PICTURE && mediaImetaTags.length > 0) { pictureImetaTagsRef.current = mediaImetaTags @@ -254,20 +317,68 @@ export default function PostContent({ }, [mediaNoteKind, mediaImetaTags]) const isFirstRender = useRef(true) + const allAvailableTopics = useMemo( + () => buildAllAvailableTopics(discussionDynamicTopics), + [discussionDynamicTopics] + ) + + const threadTopicResolved = useMemo( + () => (isDiscussionThread ? resolveTopicFromInput(threadTopicInput, allAvailableTopics) : ''), + [isDiscussionThread, threadTopicInput, allAvailableTopics] + ) + + const discussionPreviewExtraTags = useMemo((): string[][] | undefined => { + if (!isDiscussionThread) return undefined + const resolved = resolveTopicFromInput(threadTopicInput, allAvailableTopics) + if (!resolved) return [] + return collectDiscussionThreadTags({ + processedContent: prefixNostrAddresses(text.trim()), + topicForTags: resolved, + title: threadTitle, + selectedGroup: threadSelectedGroup, + dynamicTopics: discussionDynamicTopics, + isReadingGroup: threadIsReadingGroup, + author: threadReadingAuthor, + subject: threadReadingSubject, + isNsfw + }) + }, [ + isDiscussionThread, + threadTopicInput, + allAvailableTopics, + text, + threadTitle, + threadSelectedGroup, + discussionDynamicTopics, + threadIsReadingGroup, + threadReadingAuthor, + threadReadingSubject, + isNsfw + ]) + const canPost = useMemo(() => { - const isArticle = isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent + const discussionOk = + !isDiscussionThread || + !!parentEvent || + (!!threadTitle.trim() && + threadTitle.length <= 100 && + !!threadTopicResolved && + !!text.trim() && + text.length <= 5000 && + additionalRelayUrls.length > 0 && + (!threadIsReadingGroup || (!!threadReadingAuthor.trim() && !!threadReadingSubject.trim())) && + (threadTopicResolved !== 'groups' || !!threadSelectedGroup.trim())) const result = ( !!pubkey && !posting && !uploadProgresses.length && + discussionOk && // For media notes, text is optional - just need media ((mediaNoteKind !== null && mediaUrl) || !!text) && (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && (!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && (!isProtectedEvent || additionalRelayUrls.length > 0) && (!isHighlight || highlightData.sourceValue.trim() !== '') && - // For articles, dTag is mandatory - (!isArticle || !!articleDTag.trim()) && // For citations, required fields must be filled (!isCitationInternal || !!citationInternalCTag.trim()) && (!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) && @@ -287,16 +398,11 @@ export default function PostContent({ pollCreateData, isPublicMessage, extractedMentions, - parentEvent?.kind, + parentEvent, isProtectedEvent, additionalRelayUrls, isHighlight, highlightData, - isLongFormArticle, - isWikiArticle, - isWikiArticleMarkdown, - isPublicationContent, - articleDTag, isCitationInternal, citationInternalCTag, isCitationExternal, @@ -304,7 +410,14 @@ export default function PostContent({ citationAccessedOn, isCitationHardcopy, isCitationPrompt, - citationPromptLlm + citationPromptLlm, + isDiscussionThread, + threadTitle, + threadTopicResolved, + threadIsReadingGroup, + threadReadingAuthor, + threadReadingSubject, + threadSelectedGroup ]) // Clear highlight data when initialHighlightData changes or is removed @@ -366,6 +479,28 @@ export default function PostContent({ }) }, [pubkey]) + useEffect(() => { + if (!isDiscussionThread || parentEvent) return + if (!threadTitle && !text.trim()) return + const h = setTimeout(() => { + const tr = resolveTopicFromInput(threadTopicInput, allAvailableTopics) + postEditorCache.setThreadDraft({ + title: threadTitle, + content: text, + topic: tr || threadSelectedTopic + }) + }, 500) + return () => clearTimeout(h) + }, [ + isDiscussionThread, + parentEvent, + threadTitle, + text, + threadTopicInput, + threadSelectedTopic, + allAvailableTopics + ]) + // Helper function to determine the kind that will be created const getDeterminedKind = useMemo((): number => { // Public messages always take priority - even with media, they stay as PMs @@ -378,6 +513,8 @@ export default function PostContent({ // For voice comments in replies, check mediaNoteKind even if mediaUrl is not set yet (for preview) if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) { return ExtendedKind.VOICE_COMMENT + } else if (isDiscussionThread && !parentEvent) { + return ExtendedKind.DISCUSSION } else if (mediaNoteKind !== null && mediaUrl) { return mediaNoteKind } else if (isLongFormArticle) { @@ -408,6 +545,7 @@ export default function PostContent({ }, [ mediaNoteKind, mediaUrl, + isDiscussionThread, isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, @@ -512,6 +650,29 @@ export default function PostContent({ }) } + if (isDiscussionThread && !parentEvent) { + const processed = prefixNostrAddresses(cleanedText.trim()) + const topicResolved = resolveTopicFromInput(threadTopicInput, allAvailableTopics) || threadSelectedTopic + const tags = collectDiscussionThreadTags({ + processedContent: processed, + topicForTags: topicResolved, + title: threadTitle, + selectedGroup: threadSelectedGroup, + dynamicTopics: discussionDynamicTopics, + isReadingGroup: threadIsReadingGroup, + author: threadReadingAuthor, + subject: threadReadingSubject, + isNsfw + }) + const draft: TDraftEvent = { + kind: ExtendedKind.DISCUSSION, + content: processed, + tags, + created_at: dayjs().unix() + } + return draft + } + // Check for voice comments (only for non-PM replies) if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) { const url = mediaUrl || 'placeholder://audio' @@ -589,9 +750,35 @@ export default function PostContent({ : [] // Articles + const isArticleDraft = + isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent + let effectiveArticleDTag = '' + if (isArticleDraft) { + const trimmedDTag = articleDTag.trim() + if (trimmedDTag) { + effectiveArticleDTag = trimmedDTag + } else { + const slug = isLongFormArticle + ? 'longform-article' + : isWikiArticle + ? 'wiki-article' + : isWikiArticleMarkdown + ? 'wiki-markdown' + : 'publication-content' + const prev = articleDTagFallbackRef.current + if (!prev || prev.slug !== slug) { + articleDTagFallbackRef.current = { + slug, + value: `${slug}-${Math.floor(Date.now() / 1000)}` + } + } + effectiveArticleDTag = articleDTagFallbackRef.current!.value + } + } + if (isLongFormArticle) { return await createLongFormArticleDraftEvent(cleanedText, mentions, { - dTag: articleDTag.trim(), + dTag: effectiveArticleDTag, title: articleTitle.trim() || undefined, summary: articleSummary.trim() || undefined, image: articleImage.trim() || undefined, @@ -605,7 +792,7 @@ export default function PostContent({ }) } else if (isWikiArticle) { return await createWikiArticleDraftEvent(cleanedText, mentions, { - dTag: articleDTag.trim(), + dTag: effectiveArticleDTag, title: articleTitle.trim() || undefined, summary: articleSummary.trim() || undefined, image: articleImage.trim() || undefined, @@ -619,7 +806,7 @@ export default function PostContent({ }) } else if (isWikiArticleMarkdown) { return await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, { - dTag: articleDTag.trim(), + dTag: effectiveArticleDTag, title: articleTitle.trim() || undefined, summary: articleSummary.trim() || undefined, image: articleImage.trim() || undefined, @@ -633,7 +820,7 @@ export default function PostContent({ }) } else if (isPublicationContent) { return await createPublicationContentDraftEvent(cleanedText, mentions, { - dTag: articleDTag.trim(), + dTag: effectiveArticleDTag, title: articleTitle.trim() || undefined, summary: articleSummary.trim() || undefined, image: articleImage.trim() || undefined, @@ -778,6 +965,16 @@ export default function PostContent({ mediaUrl, mediaImetaTags, mentions, + isDiscussionThread, + threadTopicInput, + allAvailableTopics, + threadSelectedTopic, + threadTitle, + threadSelectedGroup, + discussionDynamicTopics, + threadIsReadingGroup, + threadReadingAuthor, + threadReadingSubject, isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, @@ -805,12 +1002,6 @@ export default function PostContent({ // Function to generate draft event JSON for preview const getDraftEventJson = useCallback(async (): Promise => { - // For articles, validate dTag is provided - const isArticle = isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent - if (isArticle && !articleDTag.trim()) { - throw new Error(t('D-Tag is required for articles')) - } - if (!pubkey) { return JSON.stringify({ error: 'Not logged in' }, null, 2) } @@ -824,17 +1015,7 @@ export default function PostContent({ } catch (error) { return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) } - }, [ - text, - pubkey, - isLongFormArticle, - isWikiArticle, - isWikiArticleMarkdown, - isPublicationContent, - articleDTag, - createDraftEvent, - t - ]) + }, [text, pubkey, isDiscussionThread, createDraftEvent]) const post = async (e?: React.MouseEvent) => { e?.stopPropagation() @@ -844,6 +1025,42 @@ export default function PostContent({ return } + if (isDiscussionThread && !parentEvent) { + const newErrors: typeof threadErrors = {} + const topicResolved = resolveTopicFromInput(threadTopicInput, allAvailableTopics) + if (!threadTitle.trim()) { + newErrors.title = t('Title is required') + } else if (threadTitle.length > 100) { + newErrors.title = t('Title must be 100 characters or less') + } + if (!topicResolved) { + newErrors.topic = t('Topic is required') + } + if (!text.trim()) { + newErrors.content = t('Content is required') + } else if (text.length > 5000) { + newErrors.content = t('Content must be 5000 characters or less') + } + if (additionalRelayUrls.length === 0) { + newErrors.relay = t('Please select at least one relay') + } + if (threadIsReadingGroup) { + if (!threadReadingAuthor.trim()) { + newErrors.author = t('Author is required for reading groups') + } + if (!threadReadingSubject.trim()) { + newErrors.subject = t('Subject (book title) is required for reading groups') + } + } + if (topicResolved === 'groups' && !threadSelectedGroup.trim()) { + newErrors.group = t('Please select a group') + } + setThreadErrors(newErrors) + if (Object.keys(newErrors).length > 0) { + return + } + } + // console.log('πŸš€ Starting post process:', { // isPublicMessage, // parentEventKind: parentEvent?.kind, @@ -872,6 +1089,12 @@ export default function PostContent({ // Create draft event using shared function draftEvent = await createDraftEvent(cleanedText) + const publishSuccessMessage = parentEvent + ? t('Reply published') + : isDiscussionThread && !parentEvent + ? t('Thread published') + : t('Post published') + // console.log('Publishing draft event:', draftEvent) // For private events, only publish to private relays const relayUrls = isPrivateEvent && privateRelayUrls.length > 0 @@ -911,15 +1134,20 @@ export default function PostContent({ successCount: (newEvent as any).relayStatuses.filter((s: any) => s.success).length, totalCount: (newEvent as any).relayStatuses.length }, { - message: parentEvent ? t('Reply published') : t('Post published'), + message: publishSuccessMessage, duration: 6000 }) } else { - showSimplePublishSuccess(parentEvent ? t('Reply published') : t('Post published')) + showSimplePublishSuccess(publishSuccessMessage) } // Full success - clean up and close postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) + if (isDiscussionThread && !parentEvent) { + postEditorCache.clearPostCache(discussionThreadDraftKindParams()) + postEditorCache.clearThreadDraft() + discussionFeedCache.clearDiscussionsListCache() + } deleteDraftEventCache(draftEvent) const relayStatuses = (newEvent as any).relayStatuses as TRelayPublishStatus[] | undefined const cleanEvent = { ...newEvent } @@ -970,6 +1198,11 @@ export default function PostContent({ mergePublishedReplyIntoThread(clean, (error as any).relayStatuses) } postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) + if (isDiscussionThread && !parentEvent) { + postEditorCache.clearPostCache(discussionThreadDraftKindParams()) + postEditorCache.clearThreadDraft() + discussionFeedCache.clearDiscussionsListCache() + } if (draftEvent) deleteDraftEventCache(draftEvent) onPublishSuccess?.() close() @@ -1000,10 +1233,18 @@ export default function PostContent({ // When enabling poll mode, clear other modes setIsPublicMessage(false) setIsHighlight(false) + setIsLongFormArticle(false) + setIsWikiArticle(false) + setIsWikiArticleMarkdown(false) + setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) + setIsDiscussionThread(false) + setMediaNoteKind(null) + setMediaUrl('') + setMediaImetaTags([]) } } @@ -1015,13 +1256,78 @@ export default function PostContent({ // When enabling public message mode, clear other modes setIsPoll(false) setIsHighlight(false) + setIsLongFormArticle(false) + setIsWikiArticle(false) + setIsWikiArticleMarkdown(false) + setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) + setIsDiscussionThread(false) + setMediaNoteKind(null) + setMediaUrl('') + setMediaImetaTags([]) } } + const handlePlainNoteMode = () => { + if (parentEvent) return + setIsPoll(false) + setIsPublicMessage(false) + setIsHighlight(false) + setIsLongFormArticle(false) + setIsWikiArticle(false) + setIsWikiArticleMarkdown(false) + setIsPublicationContent(false) + setIsCitationInternal(false) + setIsCitationExternal(false) + setIsCitationHardcopy(false) + setIsCitationPrompt(false) + setIsDiscussionThread(false) + setMediaNoteKind(null) + setMediaUrl('') + setMediaImetaTags([]) + pictureImetaTagsRef.current = [] + uploadedMediaFileMap.current.clear() + } + + const isPlainShortNoteToolbar = useMemo( + () => + !parentEvent && + !isPoll && + !isPublicMessage && + !isHighlight && + !isLongFormArticle && + !isWikiArticle && + !isWikiArticleMarkdown && + !isPublicationContent && + !isCitationInternal && + !isCitationExternal && + !isCitationHardcopy && + !isCitationPrompt && + !isDiscussionThread && + mediaNoteKind === null && + !mediaUrl, + [ + parentEvent, + isPoll, + isPublicMessage, + isHighlight, + isLongFormArticle, + isWikiArticle, + isWikiArticleMarkdown, + isPublicationContent, + isCitationInternal, + isCitationExternal, + isCitationHardcopy, + isCitationPrompt, + isDiscussionThread, + mediaNoteKind, + mediaUrl + ] + ) + const handleHighlightToggle = () => { if (parentEvent) return @@ -1030,14 +1336,57 @@ export default function PostContent({ // When enabling highlight mode, clear other modes and set client tag to true setIsPoll(false) setIsPublicMessage(false) + setIsLongFormArticle(false) + setIsWikiArticle(false) + setIsWikiArticleMarkdown(false) + setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) + setIsDiscussionThread(false) + setMediaNoteKind(null) + setMediaUrl('') + setMediaImetaTags([]) setAddClientTag(true) } } + const handleDiscussionThreadToggle = () => { + if (parentEvent) return + if (!isDiscussionThread) { + setIsPoll(false) + setIsPublicMessage(false) + setIsHighlight(false) + setIsCitationInternal(false) + setIsCitationExternal(false) + setIsCitationHardcopy(false) + setIsCitationPrompt(false) + setMediaNoteKind(null) + setMediaUrl('') + setMediaImetaTags([]) + const draft = postEditorCache.getThreadDraft() + if (draft) { + setThreadTitle(draft.title) + setText(draft.content) + setThreadSelectedTopic(draft.topic) + const predefined = DISCUSSION_TOPICS.find((x) => x.id === draft.topic) + const dyn = discussionDynamicTopics?.allTopics.find((x) => x.id === draft.topic) + setThreadTopicInput(predefined?.label ?? dyn?.label ?? draft.topic) + } else { + setThreadTitle('') + setThreadSelectedTopic('general') + const row = DISCUSSION_TOPICS.find((x) => x.id === 'general') + setThreadTopicInput(row?.label ?? 'general') + } + setThreadErrors({}) + setIsDiscussionThread(true) + } else { + setIsDiscussionThread(false) + setThreadErrors({}) + } + } + const handleUploadStart = (file: File, cancel: () => void) => { setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }]) // Track file for media upload @@ -1076,7 +1425,7 @@ export default function PostContent({ } // Note: URL will be inserted when upload completes in handleMediaUploadSuccess } - } else { + } else if (!isDiscussionThread) { // For new posts, detect the kind from the file (async) getMediaKindFromFile(file, false) .then((kind) => setMediaNoteKind(kind)) @@ -1306,6 +1655,18 @@ export default function PostContent({ return } + if (isDiscussionThread && !parentEvent) { + setTimeout(() => { + const ed = textareaRef.current + if (ed && !ed.getText().includes(url)) { + ed.appendText(url, true) + } + }, 100) + uploadedMediaFileMap.current.delete(`${uploadingFile.name}-${uploadingFile.size}-${uploadingFile.lastModified}`) + handleUploadEnd(uploadingFile) + return + } + // Determine media kind from file // For replies, only audio comments are supported (kind 1244) // For new PMs, audio messages are supported (kind 1222) @@ -1421,6 +1782,7 @@ export default function PostContent({ setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) + setIsDiscussionThread(false) // Clear uploaded file from map and picture accumulation ref uploadedMediaFileMap.current.clear() @@ -1444,6 +1806,7 @@ export default function PostContent({ setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) + setIsDiscussionThread(false) // Clear article metadata when switching off article mode if (type === null) { @@ -1484,6 +1847,7 @@ export default function PostContent({ setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) + setIsDiscussionThread(false) // Set default accessedOn if not already set if (!citationAccessedOn && (type === 'external' || type === 'hardcopy' || type === 'prompt')) { @@ -1492,6 +1856,7 @@ export default function PostContent({ } const handleClear = () => { + const wasDiscussion = isDiscussionThread // Clear the post editor cache postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) @@ -1516,6 +1881,21 @@ export default function PostContent({ setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) + setIsDiscussionThread(false) + setThreadTitle('') + setThreadSelectedTopic('general') + const gRow = DISCUSSION_TOPICS.find((x) => x.id === 'general') + setThreadTopicInput(gRow?.label ?? 'general') + setThreadSelectedGroup('') + setThreadIsReadingGroup(false) + setThreadReadingAuthor('') + setThreadReadingSubject('') + setThreadShowReadingsPanel(false) + setThreadErrors({}) + if (wasDiscussion) { + postEditorCache.clearThreadDraft() + postEditorCache.clearPostCache(discussionThreadDraftKindParams()) + } // Clear citation fields setCitationInternalCTag('') setCitationInternalRelayHint('') @@ -1580,6 +1960,8 @@ export default function PostContent({ return t('New Public Message') } else if (determinedKind === kinds.Highlights) { return t('New Highlight') + } else if (determinedKind === ExtendedKind.DISCUSSION) { + return t('New Discussion') } else if (determinedKind === kinds.LongFormArticle) { return t('New Long-form Article') } else if (determinedKind === ExtendedKind.WIKI_ARTICLE) { @@ -1609,24 +1991,237 @@ export default function PostContent({ )} + + {isDiscussionThread && !parentEvent && ( +
+
+ +
+ setThreadTopicInput(e.target.value)} + onBlur={() => { + const r = resolveTopicFromInput(threadTopicInput, allAvailableTopics) + if (r) { + setThreadSelectedTopic(r) + setThreadTopicInput(displayTopicLabel(r, allAvailableTopics)) + } + }} + placeholder={t('Type a topic or pick from the list')} + autoComplete="off" + className={cn('min-w-0 flex-1 bg-background', threadErrors.topic && 'border-destructive')} + /> + + + + + +

{t('Suggested topics')}

+
+ {allAvailableTopics.map((topic, index) => { + const Icon = topic.icon + return ( +
{ + setThreadSelectedTopic(topic.id) + setThreadTopicInput(topic.label) + setThreadTopicPopoverOpen(false) + }} + > + + + {topic.label} +
+ ) + })} +
+
+
+
+ {threadErrors.topic &&

{threadErrors.topic}

} +

+ {t( + 'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).' + )} +

+
+ + {threadTopicResolved === 'groups' && ( +
+ + + + + + +
+ {userGroups.length === 0 ? ( +
+ {t('No groups available. Join some groups first.')} +
+ ) : ( + userGroups.map((groupId) => ( +
{ + setThreadSelectedGroup(groupId) + setThreadGroupPopoverOpen(false) + }} + > + + + {groupId} +
+ )) + )} +
+
+
+ {threadErrors.group &&

{threadErrors.group}

} +

+ {t('Select the group where you want to create this discussion.')} +

+
+ )} + +
+ + setThreadTitle(e.target.value)} + placeholder={t('Enter a descriptive title for your thread')} + maxLength={100} + className={cn('bg-background', threadErrors.title && 'border-destructive')} + /> + {threadErrors.title &&

{threadErrors.title}

} +

+ {threadTitle.length}/100 {t('characters')} +

+
+ + {threadTopicResolved === 'literature' && ( +
+
+ + + +
+ + {threadShowReadingsPanel && ( +
+
+
+ + +
+ +
+ + {threadIsReadingGroup && ( +
+
+ + setThreadReadingAuthor(e.target.value)} + placeholder={t('Enter the author name')} + className={threadErrors.author ? 'border-destructive' : ''} + /> + {threadErrors.author &&

{threadErrors.author}

} +
+ +
+ + setThreadReadingSubject(e.target.value)} + placeholder={t('Enter the book title')} + className={threadErrors.subject ? 'border-destructive' : ''} + /> + {threadErrors.subject &&

{threadErrors.subject}

} +
+ +

+ {t( + 'This will add additional tags for author and subject to help organize reading group discussions.' + )} +

+
+ )} +
+ )} +
+ )} +
+ )} {/* Article metadata fields */} {(isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent) && (
setArticleDTag(e.target.value)} placeholder={t('e.g., my-article-title')} - className={!articleDTag.trim() ? 'border-destructive' : ''} /> -

- {t('Unique identifier for this article (required)')} -

+

{t('articleDTagDefaultHint')}

@@ -2047,9 +2642,12 @@ export default function PostContent({ text={text} setText={setText} defaultContent={defaultContent} - parentEvent={parentEvent} + parentEvent={isDiscussionThread && !parentEvent ? THREAD_POST_EDITOR_PARENT : parentEvent} onSubmit={() => post()} - className={isPoll ? 'min-h-20' : 'min-h-52'} + className={cn( + isPoll ? 'min-h-20' : 'min-h-52', + isDiscussionThread && threadErrors.content && 'border-destructive' + )} onUploadStart={handleUploadStart} onUploadProgress={handleUploadProgress} onUploadEnd={handleUploadEnd} @@ -2057,30 +2655,45 @@ export default function PostContent({ highlightData={isHighlight ? highlightData : undefined} pollCreateData={isPoll ? pollCreateData : undefined} getDraftEventJson={getDraftEventJson} - extraPreviewTags={rssReplyExtraPreviewTags} + extraPreviewTags={ + isDiscussionThread && !parentEvent ? discussionPreviewExtraTags : rssReplyExtraPreviewTags + } mediaImetaTags={mediaImetaTags} mediaUrl={mediaUrl} headerActions={ <> {/* Media button - show for new posts only (replies have audio button at bottom) */} {!parentEvent && ( - - - + + + + )} {/* Note creation buttons - only show when not replying */} {!parentEvent && ( @@ -2116,7 +2729,8 @@ export default function PostContent({ variant="ghost" size="icon" title={t('Create Thread')} - onClick={() => checkLogin(() => setCreateThreadOpen(true))} + className={isDiscussionThread ? 'bg-accent' : ''} + onClick={() => checkLogin(() => handleDiscussionThreadToggle())} > @@ -2220,6 +2834,14 @@ export default function PostContent({ } /> + {isDiscussionThread && !parentEvent && ( +
+ {threadErrors.content &&

{threadErrors.content}

} +

+ {text.length}/5000 {t('characters')} +

+
+ )} {isPoll && ( ))} {!isPoll && ( - +
+ + {isDiscussionThread && threadErrors.relay && ( +

{threadErrors.relay}

+ )} +
)}
@@ -2388,7 +3020,13 @@ export default function PostContent({ {posting && ( )} - {parentEvent ? t('Reply') : isPublicMessage ? t('Send Public Message') : t('Post')} + {parentEvent + ? t('Reply') + : isPublicMessage + ? t('Send Public Message') + : isDiscussionThread + ? t('Create Thread') + : t('Post')}
@@ -2428,7 +3066,7 @@ export default function PostContent({ {posting && ( )} - {parentEvent ? t('Reply') : t('Post')} + {parentEvent ? t('Reply') : isDiscussionThread ? t('Create Thread') : t('Post')}
@@ -2514,16 +3152,6 @@ export default function PostContent({ - {createThreadOpen && ( - setCreateThreadOpen(false)} - onThreadCreated={() => { - discussionFeedCache.clearDiscussionsListCache() - setCreateThreadOpen(false) - close() - }} - /> - )}
) } diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 7b1cf771..1002d726 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -85,29 +85,45 @@ const PostTextarea = forwardRef< ref ) => { const { t } = useTranslation() - const [activeTab, setActiveTab] = useState('edit') + const [activeTab, setActiveTab] = useState('preview') const [draftEventJson, setDraftEventJson] = useState('') const [isLoadingJson, setIsLoadingJson] = useState(false) const kindDescription = useMemo(() => getKindDescription(kind), [kind]) useEffect(() => { - if (activeTab === 'json' && getDraftEventJson) { - setIsLoadingJson(true) - getDraftEventJson() - .then((json) => { - setDraftEventJson(json) - setIsLoadingJson(false) - }) - .catch((error) => { - setDraftEventJson(`Error generating JSON: ${error.message}`) - setIsLoadingJson(false) - }) - } else if (activeTab === 'preview') { - // Clear JSON when switching away from JSON tab + if (activeTab === 'preview') { setDraftEventJson('') + setIsLoadingJson(false) + return } - }, [activeTab, getDraftEventJson, kind]) + + if (activeTab !== 'json' || !getDraftEventJson) { + return + } + + let cancelled = false + setIsLoadingJson(true) + + void Promise.resolve(getDraftEventJson()) + .then((json) => { + if (cancelled) return + setDraftEventJson(json) + setIsLoadingJson(false) + }) + .catch((error: unknown) => { + if (cancelled) return + const msg = error instanceof Error ? error.message : String(error) + setDraftEventJson(`Error generating JSON: ${msg}`) + setIsLoadingJson(false) + }) + + return () => { + cancelled = true + } + // `text` is included so JSON refreshes when the parent memoizes `getDraftEventJson` too narrowly; + // `kind` catches compose-mode switches even if callback identity were ever stable across them. + }, [activeTab, getDraftEventJson, kind, text]) const editor = useEditor({ // TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle. immediatelyRender: false, @@ -229,7 +245,6 @@ const PostTextarea = forwardRef<
- {t('Edit')} {t('Preview')} {t('Json')} @@ -239,10 +254,8 @@ const PostTextarea = forwardRef<
)} - {/* Keep editor mounted: remounting EditorContent after Preview/Json triggers TipTap flushSync under React 18. */} - - - + {/* Editor always visible (no Edit tab). Keep mounted; only Preview/Json swap panels below. */} +
diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index 633d9b41..44b6fb11 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -19,6 +19,7 @@ import { pubkeyToNpub } from '@/lib/pubkey' import postEditor from '@/services/post-editor.service' import { Event } from 'nostr-tools' import { Dispatch, useMemo } from 'react' +import type { TDiscussionDynamicTopics } from '@/lib/discussion-thread-composer' import PostContent from './PostContent' export default function PostEditor({ @@ -29,7 +30,8 @@ export default function PostEditor({ openFrom, initialHighlightData, initialPublicMessageTo, - onPublishSuccess + onPublishSuccess, + discussionDynamicTopics }: { defaultContent?: string parentEvent?: Event @@ -41,6 +43,8 @@ export default function PostEditor({ initialPublicMessageTo?: string /** Called after a reply/post is successfully published, before closing. */ onPublishSuccess?: () => void + /** Hot topics for the discussion (kind 11) composer when integrated in this editor. */ + discussionDynamicTopics?: TDiscussionDynamicTopics | null }) { const { isSmallScreen } = useScreenSize() @@ -63,9 +67,19 @@ export default function PostEditor({ initialHighlightData={initialHighlightData} initialPublicMessageTo={initialPublicMessageTo} onPublishSuccess={onPublishSuccess} + discussionDynamicTopics={discussionDynamicTopics} /> ) - }, [effectiveDefaultContent, parentEvent, openFrom, setOpen, initialHighlightData, initialPublicMessageTo, onPublishSuccess]) + }, [ + effectiveDefaultContent, + parentEvent, + openFrom, + setOpen, + initialHighlightData, + initialPublicMessageTo, + onPublishSuccess, + discussionDynamicTopics + ]) if (isSmallScreen) { return ( diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 315977a2..967dc3cd 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1098,6 +1098,8 @@ export default { 'Article exported as AsciiDoc': 'Article exported as AsciiDoc', 'Article exported as Markdown': 'Article exported as Markdown', 'Article title (optional)': 'Article title (optional)', + articleDTagDefaultHint: + 'Optional. Wenn leer: automatischer d-Tag mit typischem PrΓ€fix und Unix-Zeitstempel (Sekunden), z. B. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….', Audio: 'Audio', Author: 'Author', 'Author is required for reading groups': 'Author is required for reading groups', @@ -1160,6 +1162,7 @@ export default { 'Create New Thread': 'Create New Thread', 'Create Poll': 'Create Poll', 'Create Thread': 'Create Thread', + composeModeKind1: 'Kurznotiz (Kind 1) β€” andere Beitragsarten abschalten', 'Create a Spell': 'Zauberspruch anlegen', 'Creating...': 'Creating...', 'D-Tag': 'D-Tag', @@ -1323,6 +1326,7 @@ export default { 'New Internal Citation': 'New Internal Citation', 'New Long-form Article': 'New Long-form Article', 'New Poll': 'New Poll', + 'New Discussion': 'Neue Diskussion', 'New Prompt Citation': 'New Prompt Citation', 'New Public Message': 'New Public Message', 'New Wiki Article': 'New Wiki Article', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c5685fa5..8a46a3b1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1133,6 +1133,8 @@ export default { 'Article exported as AsciiDoc': 'Article exported as AsciiDoc', 'Article exported as Markdown': 'Article exported as Markdown', 'Article title (optional)': 'Article title (optional)', + articleDTagDefaultHint: + 'Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….', Audio: 'Audio', Author: 'Author', 'Author is required for reading groups': 'Author is required for reading groups', @@ -1195,6 +1197,7 @@ export default { 'Create New Thread': 'Create New Thread', 'Create Poll': 'Create Poll', 'Create Thread': 'Create Thread', + composeModeKind1: 'Short note (kind 1) β€” turn off other compose types', 'Create a Spell': 'Create a Spell', 'Creating...': 'Creating...', 'D-Tag': 'D-Tag', @@ -1360,6 +1363,7 @@ export default { 'New Internal Citation': 'New Internal Citation', 'New Long-form Article': 'New Long-form Article', 'New Poll': 'New Poll', + 'New Discussion': 'New Discussion', 'New Prompt Citation': 'New Prompt Citation', 'New Public Message': 'New Public Message', 'New Wiki Article': 'New Wiki Article', diff --git a/src/lib/discussion-thread-composer.ts b/src/lib/discussion-thread-composer.ts new file mode 100644 index 00000000..c77b0c52 --- /dev/null +++ b/src/lib/discussion-thread-composer.ts @@ -0,0 +1,249 @@ +import { ExtendedKind } from '@/constants' +import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' +import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics' +import { Event } from 'nostr-tools' +import type { LucideIcon } from 'lucide-react' +import { Hash } from 'lucide-react' + +/** Isolates TipTap post cache from the main note composer (see postEditorCache.generateCacheKey). */ +export const THREAD_POST_EDITOR_PARENT = { id: '__jumble_thread_post_editor__' } as Event + +export type TDiscussionDynamicTopics = { + mainTopics: { + id: string + label: string + count: number + isMainTopic: boolean + isSubtopic: boolean + parentTopic?: string + }[] + subtopics: { + id: string + label: string + count: number + isMainTopic: boolean + isSubtopic: boolean + parentTopic?: string + }[] + allTopics: { + id: string + label: string + count: number + isMainTopic: boolean + isSubtopic: boolean + parentTopic?: string + }[] +} + +export type TTopicRow = { id: string; label: string; icon: LucideIcon } + +type TopicListEntry = { id: string; label: string } + +export function extractImagesFromContent(content: string): string[] { + const imageRegex = /(https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?)/gi + return content.match(imageRegex) || [] +} + +export function generateImetaTagsFromUrls(imageUrls: string[]): string[][] { + return imageUrls.map((url) => ['imeta', 'url', url]) +} + +export function buildDiscussionNsfwTag(): string[] { + return ['content-warning', ''] +} + +/** Match preset/dynamic list by id or exact label (case-insensitive); otherwise normalize as a new topic slug. */ +export function resolveTopicFromInput(raw: string, topics: TopicListEntry[]): string { + const trimmed = raw.trim() + if (!trimmed) return '' + const lower = trimmed.toLowerCase() + const byId = topics.find((x) => x.id === lower) + if (byId) return byId.id + const byLabel = topics.find((x) => x.label.toLowerCase() === lower) + if (byLabel) return byLabel.id + return normalizeTopic(trimmed) +} + +export function displayTopicLabel(topicId: string, topics: TopicListEntry[]): string { + const row = topics.find((x) => x.id === topicId) + return row?.label ?? topicId +} + +export function buildAllAvailableTopics(dynamicTopics?: TDiscussionDynamicTopics | null): TTopicRow[] { + const combined: TTopicRow[] = [...DISCUSSION_TOPICS] + + if (dynamicTopics) { + dynamicTopics.mainTopics.forEach((dynamicTopic) => { + const isGroupsTopic = dynamicTopic.id === 'groups' + combined.push({ + id: dynamicTopic.id, + label: `${dynamicTopic.label} (${dynamicTopic.count}) ${isGroupsTopic ? 'πŸ‘₯' : 'πŸ”₯'}`, + icon: Hash + }) + }) + + dynamicTopics.subtopics.forEach((dynamicTopic) => { + const predefinedMainTopic = DISCUSSION_TOPICS.find( + (pt) => + dynamicTopic.id.toLowerCase().includes(pt.id.toLowerCase()) || + pt.id.toLowerCase().includes(dynamicTopic.id.toLowerCase()) + ) + + const relatedDynamicMainTopic = dynamicTopics.mainTopics.find( + (dt) => + dynamicTopic.id.toLowerCase().includes(dt.id.toLowerCase()) || + dt.id.toLowerCase().includes(dynamicTopic.id.toLowerCase()) + ) + + const parentTopic = predefinedMainTopic?.id || relatedDynamicMainTopic?.id + + if (parentTopic) { + const parentIndex = combined.findIndex((topic) => topic.id === parentTopic) + if (parentIndex !== -1) { + combined.splice(parentIndex + 1, 0, { + id: dynamicTopic.id, + label: ` └─ ${dynamicTopic.label} (${dynamicTopic.count}) πŸ“Œ`, + icon: Hash + }) + } else { + combined.push({ + id: dynamicTopic.id, + label: `${dynamicTopic.label} (${dynamicTopic.count}) πŸ“Œ`, + icon: Hash + }) + } + } else { + const generalIndex = combined.findIndex((topic) => topic.id === 'general') + if (generalIndex !== -1) { + combined.splice(generalIndex + 1, 0, { + id: dynamicTopic.id, + label: ` └─ ${dynamicTopic.label} (${dynamicTopic.count}) πŸ“Œ`, + icon: Hash + }) + } else { + combined.push({ + id: dynamicTopic.id, + label: `${dynamicTopic.label} (${dynamicTopic.count}) πŸ“Œ`, + icon: Hash + }) + } + } + }) + } + + return combined +} + +export function collectDiscussionThreadTags(params: { + processedContent: string + topicForTags: string + title: string + selectedGroup: string + dynamicTopics?: TDiscussionDynamicTopics | null + isReadingGroup: boolean + author: string + subject: string + isNsfw: boolean +}): string[][] { + const { + processedContent, + topicForTags, + title, + selectedGroup, + dynamicTopics, + isReadingGroup, + author, + subject, + isNsfw + } = params + const images = extractImagesFromContent(processedContent) + const hashtags = extractHashtagsFromContent(processedContent) + const tags: string[][] = [['title', title.trim()], ['-']] + + if (topicForTags === 'groups' && selectedGroup) { + tags.push(['h', selectedGroup]) + } + + if (topicForTags !== 'all' && topicForTags !== 'general' && topicForTags !== 'groups') { + const selectedDynamicTopic = dynamicTopics?.allTopics.find((dt) => dt.id === topicForTags) + + if (selectedDynamicTopic?.isSubtopic) { + const predefinedMainTopic = DISCUSSION_TOPICS.find( + (pt) => + topicForTags.toLowerCase().includes(pt.id.toLowerCase()) || + pt.id.toLowerCase().includes(topicForTags.toLowerCase()) + ) + + if (predefinedMainTopic) { + tags.push(['t', normalizeTopic(predefinedMainTopic.id)]) + tags.push(['t', normalizeTopic(topicForTags)]) + } else { + const relatedDynamicMainTopic = dynamicTopics?.mainTopics.find( + (dt) => + topicForTags.toLowerCase().includes(dt.id.toLowerCase()) || + dt.id.toLowerCase().includes(topicForTags.toLowerCase()) + ) + + if (relatedDynamicMainTopic) { + tags.push(['t', normalizeTopic(relatedDynamicMainTopic.id)]) + tags.push(['t', normalizeTopic(topicForTags)]) + } else { + tags.push(['t', normalizeTopic(topicForTags)]) + } + } + } else { + tags.push(['t', normalizeTopic(topicForTags)]) + } + } + + let uniqueHashtags = hashtags + if (topicForTags !== 'all' && topicForTags !== 'general') { + const selectedDynamicTopic = dynamicTopics?.allTopics.find((dt) => dt.id === topicForTags) + + if (selectedDynamicTopic?.isSubtopic) { + const predefinedMainTopic = DISCUSSION_TOPICS.find( + (pt) => + topicForTags.toLowerCase().includes(pt.id.toLowerCase()) || + pt.id.toLowerCase().includes(topicForTags.toLowerCase()) + ) + const relatedDynamicMainTopic = dynamicTopics?.mainTopics.find( + (dt) => + topicForTags.toLowerCase().includes(dt.id.toLowerCase()) || + dt.id.toLowerCase().includes(topicForTags.toLowerCase()) + ) + + const parentTopic = predefinedMainTopic?.id || relatedDynamicMainTopic?.id + uniqueHashtags = hashtags.filter( + (hashtag) => + hashtag !== normalizeTopic(topicForTags) && (parentTopic ? hashtag !== normalizeTopic(parentTopic) : true) + ) + } else { + uniqueHashtags = hashtags.filter((hashtag) => hashtag !== normalizeTopic(topicForTags)) + } + } + for (const hashtag of uniqueHashtags) { + tags.push(['t', hashtag]) + } + + if (isReadingGroup) { + if (!uniqueHashtags.includes('readings')) { + tags.push(['t', 'readings']) + } + tags.push(['author', author.trim()]) + tags.push(['subject', subject.trim()]) + } + + if (images && images.length > 0) { + tags.push(...generateImetaTagsFromUrls(images)) + } + + if (isNsfw) { + tags.push(buildDiscussionNsfwTag()) + } + + return tags +} + +export function discussionThreadDraftKindParams() { + return { kind: ExtendedKind.DISCUSSION, parentEvent: THREAD_POST_EDITOR_PARENT } +} diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx deleted file mode 100644 index 2b151902..00000000 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ /dev/null @@ -1,1018 +0,0 @@ -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle -} from '@/components/ui/sheet' -import { Switch } from '@/components/ui/switch' -import { Slider } from '@/components/ui/slider' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Hash, X, Users, Film, Laugh, Image, Zap, Settings, Book, ChevronDown, Check, Smile, Upload } from 'lucide-react' -import { useState, useEffect, useMemo, useRef, useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { useNostr } from '@/providers/NostrProvider' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useGroupList } from '@/providers/GroupListProvider' -import { TDraftEvent } from '@/types' -import { NostrEvent } from 'nostr-tools' -import { prefixNostrAddresses } from '@/lib/nostr-address' -import { showPublishingError, showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' -import dayjs from 'dayjs' -import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' -import { DISCUSSION_TOPICS } from './discussionTopics' -import PostRelaySelector from '@/components/PostEditor/PostRelaySelector' -import PostTextarea, { type TPostTextareaHandle } from '@/components/PostEditor/PostTextarea' -import GifPicker from '@/components/GifPicker' -import MemePicker from '@/components/MemePicker' -import EmojiPickerDialog from '@/components/EmojiPickerDialog' -import Uploader from '@/components/PostEditor/Uploader' -import { NeventPickerProvider } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog' -import { MentionAndEventToolbarButtons } from '@/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons' -import logger from '@/lib/logger' -import postEditorCache from '@/services/post-editor-cache.service' -import postEditor from '@/services/post-editor.service' -import { cn } from '@/lib/utils' -import { ExtendedKind } from '@/constants' -import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { Event } from 'nostr-tools' - -/** Isolates TipTap post cache from the main note composer (see postEditorCache.generateCacheKey). */ -const THREAD_POST_EDITOR_PARENT = { id: '__jumble_thread_post_editor__' } as Event - -// Utility functions for thread creation -function extractImagesFromContent(content: string): string[] { - const imageRegex = /(https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?)/gi - return content.match(imageRegex) || [] -} - -function generateImetaTags(imageUrls: string[]): string[][] { - return imageUrls.map(url => ['imeta', 'url', url]) -} - -function buildNsfwTag(): string[] { - return ['content-warning', ''] -} - -type TopicListEntry = { id: string; label: string } - -/** Match preset/dynamic list by id or exact label (case-insensitive); otherwise normalize as a new topic slug. */ -function resolveTopicFromInput(raw: string, topics: TopicListEntry[]): string { - const trimmed = raw.trim() - if (!trimmed) return '' - const lower = trimmed.toLowerCase() - const byId = topics.find((x) => x.id === lower) - if (byId) return byId.id - const byLabel = topics.find((x) => x.label.toLowerCase() === lower) - if (byLabel) return byLabel.id - return normalizeTopic(trimmed) -} - -function displayTopicLabel(topicId: string, topics: TopicListEntry[]): string { - const row = topics.find((x) => x.id === topicId) - return row?.label ?? topicId -} - -interface DynamicTopic { - id: string - label: string - count: number - isMainTopic: boolean - isSubtopic: boolean - parentTopic?: string -} - -interface CreateThreadDialogProps { - /** Default topic id from the preset list; defaults to `general`. */ - topic?: string - /** Relay set id or single relay URL to seed selection (same as PostEditor `openFrom`). */ - selectedRelay?: string | null - dynamicTopics?: { - mainTopics: DynamicTopic[] - subtopics: DynamicTopic[] - allTopics: DynamicTopic[] - } - onClose: () => void - onThreadCreated: (publishedEvent?: NostrEvent) => void -} - -export default function CreateThreadDialog({ - topic: initialTopic = 'general', - selectedRelay: initialRelay = null, - dynamicTopics, - onClose, - onThreadCreated -}: CreateThreadDialogProps) { - const { t } = useTranslation() - const { isSmallScreen } = useScreenSize() - const { pubkey, publish } = useNostr() - const { relaySets } = useFavoriteRelays() - const { userGroups } = useGroupList() - const [hydrated, setHydrated] = useState(false) - const [title, setTitle] = useState('') - const [content, setContent] = useState('') - const [selectedTopic, setSelectedTopic] = useState(initialTopic) - const [topicInput, setTopicInput] = useState(() => { - const row = DISCUSSION_TOPICS.find((x) => x.id === initialTopic) - return row?.label ?? initialTopic - }) - const [, setIsProtectedEvent] = useState(false) - const [additionalRelayUrls, setAdditionalRelayUrls] = useState([]) - const [isSubmitting, setIsSubmitting] = useState(false) - const [errors, setErrors] = useState<{ - title?: string - content?: string - topic?: string - relay?: string - author?: string - subject?: string - group?: string - }>({}) - const [isNsfw, setIsNsfw] = useState(false) - const [addClientTag, setAddClientTag] = useState(true) - const [minPow, setMinPow] = useState(0) - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) - const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false) - const [pickerPortalContainer, setPickerPortalContainer] = useState(null) - - // Readings options state - const [isReadingGroup, setIsReadingGroup] = useState(false) - const [author, setAuthor] = useState('') - const [subject, setSubject] = useState('') - const [showReadingsPanel, setShowReadingsPanel] = useState(false) - - // Group options state - const [selectedGroup, setSelectedGroup] = useState('') - const [isGroupSelectorOpen, setIsGroupSelectorOpen] = useState(false) - - const postTextareaRef = useRef(null) - const advancedOptionsRef = useRef(null) - - const insertAtCursor = useCallback((text: string) => { - postTextareaRef.current?.insertText(text) - }, []) - - // Create combined topics list (predefined + dynamic) with hierarchy - const allAvailableTopics = useMemo(() => { - const combined = [...DISCUSSION_TOPICS] - - if (dynamicTopics) { - // Add dynamic main topics first - dynamicTopics.mainTopics.forEach(dynamicTopic => { - const isGroupsTopic = dynamicTopic.id === 'groups' - combined.push({ - id: dynamicTopic.id, - label: `${dynamicTopic.label} (${dynamicTopic.count}) ${isGroupsTopic ? 'πŸ‘₯' : 'πŸ”₯'}`, - icon: Hash // Use Hash icon for dynamic topics - }) - }) - - // Add dynamic subtopics grouped under their main topics - dynamicTopics.subtopics.forEach(dynamicTopic => { - // Try to find a related main topic - const predefinedMainTopic = DISCUSSION_TOPICS.find(pt => - dynamicTopic.id.toLowerCase().includes(pt.id.toLowerCase()) || - pt.id.toLowerCase().includes(dynamicTopic.id.toLowerCase()) - ) - - const relatedDynamicMainTopic = dynamicTopics.mainTopics.find(dt => - dynamicTopic.id.toLowerCase().includes(dt.id.toLowerCase()) || - dt.id.toLowerCase().includes(dynamicTopic.id.toLowerCase()) - ) - - const parentTopic = predefinedMainTopic?.id || relatedDynamicMainTopic?.id - - if (parentTopic) { - // Find the index of the parent topic and insert after it - const parentIndex = combined.findIndex(topic => topic.id === parentTopic) - if (parentIndex !== -1) { - combined.splice(parentIndex + 1, 0, { - id: dynamicTopic.id, - label: ` └─ ${dynamicTopic.label} (${dynamicTopic.count}) πŸ“Œ`, - icon: Hash // Use Hash icon for dynamic topics - }) - } else { - // Fallback: add at the end if parent not found - combined.push({ - id: dynamicTopic.id, - label: `${dynamicTopic.label} (${dynamicTopic.count}) πŸ“Œ`, - icon: Hash // Use Hash icon for dynamic topics - }) - } - } else { - // No parent found, group under "General" - const generalIndex = combined.findIndex(topic => topic.id === 'general') - if (generalIndex !== -1) { - combined.splice(generalIndex + 1, 0, { - id: dynamicTopic.id, - label: ` └─ ${dynamicTopic.label} (${dynamicTopic.count}) πŸ“Œ`, - icon: Hash // Use Hash icon for dynamic topics - }) - } else { - // Fallback: add at the end if General not found - combined.push({ - id: dynamicTopic.id, - label: `${dynamicTopic.label} (${dynamicTopic.count}) πŸ“Œ`, - icon: Hash // Use Hash icon for dynamic topics - }) - } - } - }) - } - - return combined - }, [dynamicTopics]) - - const effectiveTopic = useMemo( - () => resolveTopicFromInput(topicInput, allAvailableTopics), - [topicInput, allAvailableTopics] - ) - - /** Same `openFrom` semantics as PostEditor / PostRelaySelector. */ - const openFrom = useMemo(() => { - if (!initialRelay) return undefined - const relaySet = relaySets.find((set) => set.id === initialRelay) - if (relaySet?.relayUrls?.length) return relaySet.relayUrls - return [initialRelay] - }, [initialRelay, relaySets]) - - // Load cached thread draft when dialog opens (then mount PostTextarea once) - useEffect(() => { - const draft = postEditorCache.getThreadDraft() - if (draft) { - setTitle(draft.title) - setContent(draft.content) - setSelectedTopic(draft.topic) - const predefined = DISCUSSION_TOPICS.find((x) => x.id === draft.topic) - const dyn = dynamicTopics?.allTopics.find((x) => x.id === draft.topic) - setTopicInput(predefined?.label ?? dyn?.label ?? draft.topic) - } - setHydrated(true) - }, [dynamicTopics]) - - // Persist draft when title, content, or topic change (debounced) - useEffect(() => { - if (!title && !content.trim()) return - const t = setTimeout(() => { - const tr = resolveTopicFromInput(topicInput, allAvailableTopics) - postEditorCache.setThreadDraft({ - title, - content, - topic: tr || selectedTopic - }) - }, 500) - return () => clearTimeout(t) - }, [title, content, topicInput, selectedTopic, allAvailableTopics]) - - useEffect(() => { - if (!showAdvancedOptions) return - const el = advancedOptionsRef.current - if (!el) return - const id = requestAnimationFrame(() => { - el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) - }) - return () => cancelAnimationFrame(id) - }, [showAdvancedOptions]) - - const handleClearDraft = useCallback(() => { - setTitle('') - setContent('') - setSelectedTopic('general') - setTopicInput(displayTopicLabel('general', DISCUSSION_TOPICS)) - setErrors({}) - postEditorCache.clearThreadDraft() - postEditorCache.clearPostCache({ kind: ExtendedKind.DISCUSSION, parentEvent: THREAD_POST_EDITOR_PARENT }) - postTextareaRef.current?.clear() - }, []) - - const collectThreadTags = useCallback( - (processedContent: string, topicForTags: string) => { - const images = extractImagesFromContent(processedContent) - const hashtags = extractHashtagsFromContent(processedContent) - const tags: string[][] = [['title', title.trim()], ['-']] - - if (topicForTags === 'groups' && selectedGroup) { - tags.push(['h', selectedGroup]) - } - - if (topicForTags !== 'all' && topicForTags !== 'general' && topicForTags !== 'groups') { - const selectedDynamicTopic = dynamicTopics?.allTopics.find((dt) => dt.id === topicForTags) - - if (selectedDynamicTopic?.isSubtopic) { - const predefinedMainTopic = DISCUSSION_TOPICS.find( - (pt) => - topicForTags.toLowerCase().includes(pt.id.toLowerCase()) || - pt.id.toLowerCase().includes(topicForTags.toLowerCase()) - ) - - if (predefinedMainTopic) { - tags.push(['t', normalizeTopic(predefinedMainTopic.id)]) - tags.push(['t', normalizeTopic(topicForTags)]) - } else { - const relatedDynamicMainTopic = dynamicTopics?.mainTopics.find( - (dt) => - topicForTags.toLowerCase().includes(dt.id.toLowerCase()) || - dt.id.toLowerCase().includes(topicForTags.toLowerCase()) - ) - - if (relatedDynamicMainTopic) { - tags.push(['t', normalizeTopic(relatedDynamicMainTopic.id)]) - tags.push(['t', normalizeTopic(topicForTags)]) - } else { - tags.push(['t', normalizeTopic(topicForTags)]) - } - } - } else { - tags.push(['t', normalizeTopic(topicForTags)]) - } - } - - let uniqueHashtags = hashtags - if (topicForTags !== 'all' && topicForTags !== 'general') { - const selectedDynamicTopic = dynamicTopics?.allTopics.find((dt) => dt.id === topicForTags) - - if (selectedDynamicTopic?.isSubtopic) { - const predefinedMainTopic = DISCUSSION_TOPICS.find( - (pt) => - topicForTags.toLowerCase().includes(pt.id.toLowerCase()) || - pt.id.toLowerCase().includes(topicForTags.toLowerCase()) - ) - const relatedDynamicMainTopic = dynamicTopics?.mainTopics.find( - (dt) => - topicForTags.toLowerCase().includes(dt.id.toLowerCase()) || - dt.id.toLowerCase().includes(topicForTags.toLowerCase()) - ) - - const parentTopic = predefinedMainTopic?.id || relatedDynamicMainTopic?.id - uniqueHashtags = hashtags.filter( - (hashtag) => - hashtag !== normalizeTopic(topicForTags) && - (parentTopic ? hashtag !== normalizeTopic(parentTopic) : true) - ) - } else { - uniqueHashtags = hashtags.filter((hashtag) => hashtag !== normalizeTopic(topicForTags)) - } - } - for (const hashtag of uniqueHashtags) { - tags.push(['t', hashtag]) - } - - if (isReadingGroup) { - if (!uniqueHashtags.includes('readings')) { - tags.push(['t', 'readings']) - } - tags.push(['author', author.trim()]) - tags.push(['subject', subject.trim()]) - } - - if (images && images.length > 0) { - tags.push(...generateImetaTags(images)) - } - - if (isNsfw) { - tags.push(buildNsfwTag()) - } - - return tags - }, - [title, selectedGroup, dynamicTopics, isReadingGroup, author, subject, isNsfw] - ) - - const previewExtraTags = useMemo(() => { - if (!hydrated) return [] as string[][] - const resolved = resolveTopicFromInput(topicInput, allAvailableTopics) - if (!resolved) return [] as string[][] - return collectThreadTags(prefixNostrAddresses(content.trim()), resolved) - }, [hydrated, content, topicInput, allAvailableTopics, collectThreadTags]) - - const handleThreadMediaUploadSuccess = useCallback(({ url }: { url: string }) => { - setTimeout(() => { - const ed = postTextareaRef.current - if (ed && !ed.getText().includes(url)) { - ed.appendText(url, true) - } - }, 100) - }, []) - - const getDraftEventJson = useCallback(async () => { - const processed = prefixNostrAddresses(content.trim()) - const topicResolved = resolveTopicFromInput(topicInput, allAvailableTopics) || selectedTopic - const tags = collectThreadTags(processed, topicResolved) - return JSON.stringify( - { - kind: ExtendedKind.DISCUSSION, - content: processed, - tags, - created_at: dayjs().unix(), - pubkey: pubkey || '(your pubkey)' - }, - null, - 2 - ) - }, [content, topicInput, allAvailableTopics, selectedTopic, collectThreadTags, pubkey]) - - const validateForm = () => { - const newErrors: { - title?: string - content?: string - topic?: string - relay?: string - author?: string - subject?: string - group?: string - } = {} - - const topicResolved = resolveTopicFromInput(topicInput, allAvailableTopics) - - if (!title.trim()) { - newErrors.title = t('Title is required') - } else if (title.length > 100) { - newErrors.title = t('Title must be 100 characters or less') - } - - if (!topicResolved) { - newErrors.topic = t('Topic is required') - } - - if (!content.trim()) { - newErrors.content = t('Content is required') - } else if (content.length > 5000) { - newErrors.content = t('Content must be 5000 characters or less') - } - - if (additionalRelayUrls.length === 0) { - newErrors.relay = t('Please select at least one relay') - } - - if (isReadingGroup) { - if (!author.trim()) { - newErrors.author = t('Author is required for reading groups') - } - if (!subject.trim()) { - newErrors.subject = t('Subject (book title) is required for reading groups') - } - } - - if (topicResolved === 'groups') { - if (!selectedGroup.trim()) { - newErrors.group = t('Please select a group') - } - } - - setErrors(newErrors) - return Object.keys(newErrors).length === 0 - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!pubkey) { - showPublishingError(t('You must be logged in to create a thread')) - return - } - - if (!validateForm()) { - return - } - - setIsSubmitting(true) - - try { - const topicResolved = resolveTopicFromInput(topicInput, allAvailableTopics) - if (!topicResolved) { - setIsSubmitting(false) - return - } - setSelectedTopic(topicResolved) - - const processedContent = prefixNostrAddresses(content.trim()) - const tags = collectThreadTags(processedContent, topicResolved) - - // Create the thread event (kind 11) - const threadEvent: TDraftEvent = { - kind: 11, - content: processedContent, - tags, - created_at: dayjs().unix() - } - - // Debug: Log the event before publishing - logger.debug('[CreateThreadDialog] About to publish thread event:', { - kind: threadEvent.kind, - content: threadEvent.content, - tags: threadEvent.tags, - created_at: threadEvent.created_at, - contentLength: threadEvent.content.length, - tagsCount: threadEvent.tags.length - }) - - - // Publish to all selected relays - const publishedEvent = await publish(threadEvent, { - specifiedRelayUrls: additionalRelayUrls, - minPow, - addClientTag, - disableFallbacks: additionalRelayUrls.length > 0 - }) - - - if (publishedEvent) { - // Show publishing feedback with relay messages - if ((publishedEvent as any).relayStatuses) { - showPublishingFeedback({ - success: true, - relayStatuses: (publishedEvent as any).relayStatuses, - successCount: (publishedEvent as any).relayStatuses.filter((s: any) => s.success).length, - totalCount: (publishedEvent as any).relayStatuses.length - }, { - message: t('Thread published'), - duration: 6000 - }) - } else { - showSimplePublishSuccess(t('Thread published')) - } - - postEditorCache.clearThreadDraft() - postEditorCache.clearPostCache({ kind: ExtendedKind.DISCUSSION, parentEvent: THREAD_POST_EDITOR_PARENT }) - onThreadCreated(publishedEvent) - onClose() - } else { - throw new Error(t('Failed to publish thread')) - } - } catch (error) { - logger.error('[CreateThreadDialog] Error creating thread:', error) - logger.error('[CreateThreadDialog] Error details:', { - name: error instanceof Error ? error.name : 'Unknown', - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }) - - let errorMessage = t('Failed to create thread') - if (error instanceof Error) { - if (error.message.includes('timeout')) { - errorMessage = t('Thread creation timed out. Please try again.') - } else if (error.message.includes('auth-required') || error.message.includes('auth required')) { - errorMessage = t('Relay requires authentication for write access. Please try a different relay or contact the relay operator.') - } else if (error.message.includes('blocked')) { - errorMessage = t('Your account is blocked from posting to this relay.') - } else if (error.message.includes('rate limit')) { - errorMessage = t('Rate limited. Please wait before trying again.') - } else if (error.message.includes('writes disabled')) { - errorMessage = t('Some relays have temporarily disabled writes.') - } else if (error.message && error.message.trim()) { - errorMessage = `${t('Failed to create thread')}: ${error.message}` - } else { - errorMessage = t('Failed to create thread. Please try a different relay.') - } - } else if (error instanceof AggregateError) { - errorMessage = t('Failed to publish to some relays. Please try again or use different relays.') - } - - showPublishingError(errorMessage) - } finally { - setIsSubmitting(false) - } - } - - const triggerSubmit = () => { - void handleSubmit({ preventDefault: () => {} } as React.FormEvent) - } - - const escapeDialog = (e: { preventDefault: () => void }) => { - if (postEditor.isSuggestionPopupOpen) { - e.preventDefault() - postEditor.closeSuggestionPopup() - } - } - - const formBody = ( - -
-
-
-
{t('New Discussion')}
- -
- -
-
- -
- setTopicInput(e.target.value)} - onBlur={() => { - const r = resolveTopicFromInput(topicInput, allAvailableTopics) - if (r) { - setSelectedTopic(r) - setTopicInput(displayTopicLabel(r, allAvailableTopics)) - } - }} - placeholder={t('Type a topic or pick from the list')} - autoComplete="off" - className={cn('min-w-0 flex-1 bg-background', errors.topic && 'border-destructive')} - /> - - - - - -

{t('Suggested topics')}

-
- {allAvailableTopics.map((topic, index) => { - const Icon = topic.icon - return ( -
{ - setSelectedTopic(topic.id) - setTopicInput(topic.label) - setIsTopicSelectorOpen(false) - }} - > - - - {topic.label} -
- ) - })} -
-
-
-
- {errors.topic &&

{errors.topic}

} -

- {t( - 'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).' - )} -

-
- - {effectiveTopic === 'groups' && ( -
- - - - - - -
- {userGroups.length === 0 ? ( -
- {t('No groups available. Join some groups first.')} -
- ) : ( - userGroups.map((groupId) => ( -
{ - setSelectedGroup(groupId) - setIsGroupSelectorOpen(false) - }} - > - - - {groupId} -
- )) - )} -
-
-
- {errors.group &&

{errors.group}

} -

- {t('Select the group where you want to create this discussion.')} -

-
- )} - -
- - setTitle(e.target.value)} - placeholder={t('Enter a descriptive title for your thread')} - maxLength={100} - className={cn('bg-background', errors.title && 'border-destructive')} - /> - {errors.title &&

{errors.title}

} -

- {title.length}/100 {t('characters')} -

-
-
- -
- {hydrated ? ( - - - - - insertAtCursor(gifUrl + ' ')} - portalContainer={pickerPortalContainer ?? undefined} - > - - - insertAtCursor(memeUrl + ' ')} - portalContainer={pickerPortalContainer ?? undefined} - > - - - { - if (emoji == null) return - const char = - typeof emoji === 'string' - ? emoji - : (emoji as { native?: string }).native ?? String(emoji) - insertAtCursor(char) - }} - > - - - - - } - /> - ) : null} - {errors.content &&

{errors.content}

} -

- {content.length}/5000 {t('characters')} -

-
- - {effectiveTopic === 'literature' && ( -
-
- - - -
- - {showReadingsPanel && ( -
-
-
- - -
- -
- - {isReadingGroup && ( -
-
- - setAuthor(e.target.value)} - placeholder={t('Enter the author name')} - className={errors.author ? 'border-destructive' : ''} - /> - {errors.author &&

{errors.author}

} -
- -
- - setSubject(e.target.value)} - placeholder={t('Enter the book title')} - className={errors.subject ? 'border-destructive' : ''} - /> - {errors.subject &&

{errors.subject}

} -
- -

- {t('This will add additional tags for author and subject to help organize reading group discussions.')} -

-
- )} -
- )} -
- )} - -
- - {errors.relay &&

{errors.relay}

} -
- -
- - - {showAdvancedOptions && ( -
-
-
- - -
- -
- -
-
- - -
- -
- -
-
- - -
-
- setMinPow(value[0])} - max={20} - min={0} - step={1} - className="w-full" - /> -
- {t('No PoW')} - {t('High PoW')} -
-
-

- {t('Higher values make your thread harder to mine but more unique.')} -

-
-
- )} -
- -
- - - -
-
- - ) - - if (isSmallScreen) { - return ( - !open && onClose()}> - -
-
- - {t('New Discussion')} - {t('Create a discussion thread')} - - {formBody} -
-
-
-
- ) - } - - return ( - !open && onClose()}> - -
-
- - {t('New Discussion')} - {t('Create a discussion thread')} - - {formBody} -
-
-
-
- ) -}