import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import TextareaWithMentionAutocomplete from '@/components/TextareaWithMentionAutocomplete' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' import { Slider } from '@/components/ui/slider' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Checkbox } from '@/components/ui/checkbox' import { ScrollArea } from '@/components/ui/scroll-area' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Hash, X, Users, Code, Coins, Newspaper, BookOpen, Scroll, Cpu, Trophy, Film, Heart, TrendingUp, Utensils, MapPin, Home, PawPrint, Shirt, Image, Zap, Settings, Book, Network, Car, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react' import { useState, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useGroupList } from '@/providers/GroupListProvider' import { TDraftEvent, TRelaySet } from '@/types' import { NostrEvent } from 'nostr-tools' import { prefixNostrAddresses } from '@/lib/nostr-address' import { showPublishingError, showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { simplifyUrl } from '@/lib/url' import relaySelectionService from '@/services/relay-selection.service' import dayjs from 'dayjs' import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' import RelayIcon from '@/components/RelayIcon' import GifPicker from '@/components/GifPicker' import EmojiPickerDialog from '@/components/EmojiPickerDialog' import Uploader from '@/components/PostEditor/Uploader' import logger from '@/lib/logger' // 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', ''] } function buildClientTag(): string[] { return ['client', 'jumble'] } function buildAltTag(): string[] { return ['alt', 'This event was published by https://jumble.imwald.eu.'] } interface DynamicTopic { id: string label: string count: number isMainTopic: boolean isSubtopic: boolean parentTopic?: string } interface CreateThreadDialogProps { topic: string availableRelays: string[] relaySets: TRelaySet[] selectedRelay?: string | null // null = "All relays", relay set ID, or single relay URL dynamicTopics?: { mainTopics: DynamicTopic[] subtopics: DynamicTopic[] allTopics: DynamicTopic[] } onClose: () => void onThreadCreated: (publishedEvent?: NostrEvent) => void } export const DISCUSSION_TOPICS = [ { id: 'general', label: 'General', icon: Hash }, { id: 'meetups', label: 'Meetups', icon: Users }, { id: 'devs', label: 'Developers', icon: Code }, { id: 'finance', label: 'Bitcoin, Finance & Economics', icon: Coins }, { id: 'politics', label: 'Politics & Breaking News', icon: Newspaper }, { id: 'literature', label: 'Literature & Art', icon: BookOpen }, { id: 'philosophy', label: 'Philosophy & Theology', icon: Scroll }, { id: 'tech', label: 'Technology & Science', icon: Cpu }, { id: 'nostr', label: 'Nostr', icon: Network }, { id: 'automotive', label: 'Automotive', icon: Car }, { id: 'sports', label: 'Sports and Gaming', icon: Trophy }, { id: 'entertainment', label: 'Entertainment & Pop Culture', icon: Film }, { id: 'health', label: 'Health & Wellness', icon: Heart }, { id: 'lifestyle', label: 'Lifestyle & Personal Development', icon: TrendingUp }, { id: 'food', label: 'Food & Cooking', icon: Utensils }, { id: 'travel', label: 'Travel & Adventure', icon: MapPin }, { id: 'home', label: 'Home & Garden', icon: Home }, { id: 'pets', label: 'Pets & Animals', icon: PawPrint }, { id: 'fashion', label: 'Fashion & Beauty', icon: Shirt }, { id: 'groups', label: 'Groups', icon: Users } ] export default function CreateThreadDialog({ topic: initialTopic, availableRelays, relaySets, selectedRelay: initialRelay, dynamicTopics, onClose, onThreadCreated }: CreateThreadDialogProps) { const { t } = useTranslation() const { pubkey, publish, relayList } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { userGroups } = useGroupList() const [title, setTitle] = useState('') const [content, setContent] = useState('') const [selectedTopic, setSelectedTopic] = useState(initialTopic) const [selectedRelayUrls, setSelectedRelayUrls] = useState([]) const [selectableRelays, setSelectableRelays] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) const [errors, setErrors] = useState<{ title?: string; content?: 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 [isLoadingRelays, setIsLoadingRelays] = useState(true) 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 contentTextareaRef = useRef(null) const insertAtCursor = (text: string) => { const ta = contentTextareaRef.current if (ta) { const start = ta.selectionStart const end = ta.selectionEnd const before = content.slice(0, start) const after = content.slice(end) setContent(before + text + after) setTimeout(() => { ta.focus() ta.setSelectionRange(start + text.length, start + text.length) }, 0) } else { setContent((prev) => prev + 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]) // Initialize selected relays using the centralized relay selection service useEffect(() => { const initializeRelays = async () => { setIsLoadingRelays(true) try { // Determine openFrom based on initialRelay let openFrom: string[] | undefined = undefined if (initialRelay) { const relaySet = relaySets.find(set => set.id === initialRelay) if (relaySet) { openFrom = relaySet.relayUrls } else { openFrom = [initialRelay] } } const result = await relaySelectionService.selectRelays({ userWriteRelays: relayList?.write || [], userReadRelays: relayList?.read || [], favoriteRelays, blockedRelays, relaySets, openFrom, userPubkey: pubkey || undefined }) setSelectableRelays(result.selectableRelays) setSelectedRelayUrls(result.selectedRelays) } catch (error) { logger.error('[CreateThreadDialog] Failed to initialize relays:', error) // Fallback to availableRelays setSelectableRelays(availableRelays) setSelectedRelayUrls(availableRelays) } finally { setIsLoadingRelays(false) } } initializeRelays() }, [initialRelay, availableRelays, relaySets, favoriteRelays, blockedRelays, relayList, pubkey]) const handleRelayCheckedChange = (checked: boolean, url: string) => { if (checked) { setSelectedRelayUrls(prev => [...prev, url]) } else { setSelectedRelayUrls(prev => prev.filter(u => u !== url)) } } const handleSelectAll = () => { setSelectedRelayUrls([...selectableRelays]) } const handleClearAll = () => { setSelectedRelayUrls([]) } const validateForm = () => { const newErrors: { title?: string; content?: string; relay?: string; author?: string; subject?: string; group?: string } = {} 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 (!content.trim()) { newErrors.content = t('Content is required') } else if (content.length > 5000) { newErrors.content = t('Content must be 5000 characters or less') } if (selectedRelayUrls.length === 0) { newErrors.relay = t('Please select at least one relay') } // Validate readings fields if reading group is enabled 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') } } // Validate group selection if groups topic is selected if (selectedTopic === '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 { // Process content to prefix nostr addresses const processedContent = prefixNostrAddresses(content.trim()) // Extract images from processed content const images = extractImagesFromContent(processedContent) // Extract hashtags from content const hashtags = extractHashtagsFromContent(processedContent) // Build tags array const tags = [ ['title', title.trim()], ['-'] // Required tag for relay privacy ] // Add h tag for group discussions if (selectedTopic === 'groups' && selectedGroup) { tags.push(['h', selectedGroup]) } // Only add topic tag if it's a specific topic (not 'all' or 'general' or 'groups') if (selectedTopic !== 'all' && selectedTopic !== 'general' && selectedTopic !== 'groups') { // Check if this is a dynamic subtopic const selectedDynamicTopic = dynamicTopics?.allTopics.find(dt => dt.id === selectedTopic) if (selectedDynamicTopic?.isSubtopic) { // For subtopics, we need to find the parent main topic // First, try to find a predefined main topic that might be related const predefinedMainTopic = DISCUSSION_TOPICS.find(pt => selectedTopic.toLowerCase().includes(pt.id.toLowerCase()) || pt.id.toLowerCase().includes(selectedTopic.toLowerCase()) ) if (predefinedMainTopic) { // Add the predefined main topic first, then the subtopic tags.push(['t', normalizeTopic(predefinedMainTopic.id)]) tags.push(['t', normalizeTopic(selectedTopic)]) } else { // If no predefined main topic found, try to find a dynamic main topic const relatedDynamicMainTopic = dynamicTopics?.mainTopics.find(dt => selectedTopic.toLowerCase().includes(dt.id.toLowerCase()) || dt.id.toLowerCase().includes(selectedTopic.toLowerCase()) ) if (relatedDynamicMainTopic) { // Add the dynamic main topic first, then the subtopic tags.push(['t', normalizeTopic(relatedDynamicMainTopic.id)]) tags.push(['t', normalizeTopic(selectedTopic)]) } else { // Fallback: just add the subtopic and let the system categorize it under 'general' // Don't add 'general' as a t-tag since it's the default fallback tags.push(['t', normalizeTopic(selectedTopic)]) } } } else { // Regular topic (predefined or dynamic main topic) tags.push(['t', normalizeTopic(selectedTopic)]) } } // Add hashtags as t-tags (deduplicate with selectedTopic and any parent topics) let uniqueHashtags = hashtags if (selectedTopic !== 'all' && selectedTopic !== 'general') { const selectedDynamicTopic = dynamicTopics?.allTopics.find(dt => dt.id === selectedTopic) if (selectedDynamicTopic?.isSubtopic) { // For subtopics, deduplicate against both the subtopic and its potential parent const predefinedMainTopic = DISCUSSION_TOPICS.find(pt => selectedTopic.toLowerCase().includes(pt.id.toLowerCase()) || pt.id.toLowerCase().includes(selectedTopic.toLowerCase()) ) const relatedDynamicMainTopic = dynamicTopics?.mainTopics.find(dt => selectedTopic.toLowerCase().includes(dt.id.toLowerCase()) || dt.id.toLowerCase().includes(selectedTopic.toLowerCase()) ) const parentTopic = predefinedMainTopic?.id || relatedDynamicMainTopic?.id uniqueHashtags = hashtags.filter(hashtag => hashtag !== normalizeTopic(selectedTopic) && (parentTopic ? hashtag !== normalizeTopic(parentTopic) : true) ) } else { // Regular topic uniqueHashtags = hashtags.filter(hashtag => hashtag !== normalizeTopic(selectedTopic)) } } for (const hashtag of uniqueHashtags) { tags.push(['t', hashtag]) } // Add readings tags if this is a reading group if (isReadingGroup) { // Only add if not already added from hashtags if (!uniqueHashtags.includes('readings')) { tags.push(['t', 'readings']) } tags.push(['author', author.trim()]) tags.push(['subject', subject.trim()]) } // Add image metadata tags if images are found if (images && images.length > 0) { tags.push(...generateImetaTags(images)) } // Add NSFW tag if enabled if (isNsfw) { tags.push(buildNsfwTag()) } // Add client tag if enabled if (addClientTag) { tags.push(buildClientTag()) tags.push(buildAltTag()) } // 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: selectedRelayUrls, minPow }) 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')) } 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 selectedTopicInfo = allAvailableTopics.find(t => t.id === selectedTopic) || allAvailableTopics[0] return (
{/* Portal target for GIF/emoji pickers so they render as children of this modal */}
{t('Create New Thread')}
{/* Topic Selection */}
{allAvailableTopics.map((topic) => { const Icon = topic.icon return (
{ setSelectedTopic(topic.id) setIsTopicSelectorOpen(false) }} > {topic.label}
) })}

{t('Threads are organized by topics. Choose a topic that best fits your discussion.')}

{/* Group Selection - Only show when Groups topic is selected */} {selectedTopic === '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.')}

)} {/* Title Input */}
setTitle(e.target.value)} placeholder={t('Enter a descriptive title for your thread')} maxLength={100} className={errors.title ? 'border-destructive' : ''} /> {errors.title && (

{errors.title}

)}

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

{/* Content Input with Preview */}
{t('Edit')} {t('Preview')}
insertAtCursor(url)} accept="image/*" > insertAtCursor(gifUrl)} portalContainer={pickerPortalContainer}> { if (emoji == null) return const char = typeof emoji === 'string' ? emoji : (emoji as { native?: string }).native ?? String(emoji) insertAtCursor(char) }} >
{errors.content && (

{errors.content}

)}

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

{content.trim() ? (
{/* Preview of the thread */}

{title || t('Untitled')}

{selectedTopicInfo.label} {isReadingGroup && ( <> Readings {author && ( {t('Author')}: {author} )} {subject && ( {t('Book')}: {subject} )} )}
{/* Preview of the content */}
) : (

{t('Start typing to see a preview...')}

)}

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

{/* Readings Options - Only show for literature topic */} {selectedTopic === '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.')}

)}
)}
)} {/* Relay Selection */}
{isLoadingRelays ? (
{t('Loading relays...')}
) : selectableRelays.length === 0 ? (
{t('No relays available. Please configure relays in settings.')}
) : (
{selectableRelays.map(relay => { const isChecked = selectedRelayUrls.includes(relay) return (
handleRelayCheckedChange(!!checked, relay)} disabled={isLoadingRelays} />
) })}
)}
{errors.relay && (

{errors.relay}

)}

{selectedRelayUrls.length === 0 ? t('No relays selected') : t('{{count}} relay(s) selected', { count: selectedRelayUrls.length })}

{/* Advanced Options Toggle */}
{showAdvancedOptions && (
{/* NSFW Toggle */}
{/* Client Tag Toggle */}
{/* PoW Setting */}
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.')}

)}
{/* Form Actions */}
) }