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, 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 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} > { 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}
) }