You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1063 lines
44 KiB

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<string[]>([])
const [selectableRelays, setSelectableRelays] = useState<string[]>([])
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<HTMLElement | null>(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<string>('')
const [isGroupSelectorOpen, setIsGroupSelectorOpen] = useState(false)
const contentTextareaRef = useRef<HTMLTextAreaElement | null>(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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4">
{/* Portal target for GIF/emoji pickers so they render as children of this modal */}
<div
ref={setPickerPortalContainer}
className="absolute inset-0 pointer-events-none"
aria-hidden
/>
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto relative bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-xl font-semibold">{t('Create New Thread')}</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Topic Selection */}
<div className="space-y-2">
<Label htmlFor="topic">{t('Topic')}</Label>
<Popover open={isTopicSelectorOpen} onOpenChange={setIsTopicSelectorOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isTopicSelectorOpen}
className="w-full justify-between"
>
{selectedTopicInfo?.label || t('Select topic...')}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-2 z-[10000]"
align="start"
side="bottom"
sideOffset={4}
>
<div className="max-h-60 overflow-y-auto">
{allAvailableTopics.map((topic) => {
const Icon = topic.icon
return (
<div
key={topic.id}
className="flex items-center p-2 hover:bg-accent cursor-pointer rounded"
onClick={() => {
setSelectedTopic(topic.id)
setIsTopicSelectorOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedTopic === topic.id ? 'opacity-100' : 'opacity-0'
}`}
/>
<Icon className="mr-2 h-4 w-4" />
{topic.label}
</div>
)
})}
</div>
</PopoverContent>
</Popover>
<p className="text-sm text-muted-foreground">
{t('Threads are organized by topics. Choose a topic that best fits your discussion.')}
</p>
</div>
{/* Group Selection - Only show when Groups topic is selected */}
{selectedTopic === 'groups' && (
<div className="space-y-2">
<Label htmlFor="group">{t('Select Group')}</Label>
<Popover open={isGroupSelectorOpen} onOpenChange={setIsGroupSelectorOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isGroupSelectorOpen}
className="w-full justify-between"
>
{selectedGroup ? selectedGroup : t('Select group...')}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-2 z-[10000]"
align="start"
side="bottom"
sideOffset={4}
>
<div className="max-h-60 overflow-y-auto">
{userGroups.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
{t('No groups available. Join some groups first.')}
</div>
) : (
userGroups.map((groupId) => (
<div
key={groupId}
className="flex items-center p-2 hover:bg-accent cursor-pointer rounded"
onClick={() => {
setSelectedGroup(groupId)
setIsGroupSelectorOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedGroup === groupId ? 'opacity-100' : 'opacity-0'
}`}
/>
<Users className="mr-2 h-4 w-4" />
{groupId}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
{errors.group && (
<p className="text-sm text-destructive">{errors.group}</p>
)}
<p className="text-sm text-muted-foreground">
{t('Select the group where you want to create this discussion.')}
</p>
</div>
)}
{/* Title Input */}
<div className="space-y-2">
<Label htmlFor="title">{t('Thread Title')}</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('Enter a descriptive title for your thread')}
maxLength={100}
className={errors.title ? 'border-destructive' : ''}
/>
{errors.title && (
<p className="text-sm text-destructive">{errors.title}</p>
)}
<p className="text-sm text-muted-foreground">
{title.length}/100 {t('characters')}
</p>
</div>
{/* Content Input with Preview */}
<div className="space-y-2">
<Label htmlFor="content">{t('Thread Content')}</Label>
<Tabs defaultValue="edit" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="edit" className="flex items-center gap-2">
<Edit3 className="w-4 h-4" />
{t('Edit')}
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="w-4 h-4" />
{t('Preview')}
</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="space-y-2">
<div className="flex items-center gap-1 mb-1 flex-wrap">
<Uploader
onUploadSuccess={({ url }) => insertAtCursor(url)}
accept="image/*"
>
<Button type="button" variant="outline" size="sm">
<ImageUp className="h-4 w-4 mr-1" />
{t('Upload Image')}
</Button>
</Uploader>
<GifPicker onSelect={(gifUrl) => insertAtCursor(gifUrl)} portalContainer={pickerPortalContainer}>
<Button type="button" variant="outline" size="sm">
<Film className="h-4 w-4 mr-1" />
{t('Insert GIF')}
</Button>
</GifPicker>
<EmojiPickerDialog
portalContainer={pickerPortalContainer}
onEmojiClick={(emoji) => {
if (emoji == null) return
const char = typeof emoji === 'string' ? emoji : (emoji as { native?: string }).native ?? String(emoji)
insertAtCursor(char)
}}
>
<Button type="button" variant="outline" size="sm">
<Smile className="h-4 w-4 mr-1" />
{t('Insert emoji')}
</Button>
</EmojiPickerDialog>
</div>
<TextareaWithMentionAutocomplete
ref={contentTextareaRef}
id="content"
value={content}
onChange={setContent}
placeholder={t('Share your thoughts, ask questions, or start a discussion...')}
rows={8}
maxLength={5000}
className={errors.content ? 'border-destructive' : ''}
/>
{errors.content && (
<p className="text-sm text-destructive">{errors.content}</p>
)}
<p className="text-sm text-muted-foreground">
{content.length}/5000 {t('characters')}
</p>
</TabsContent>
<TabsContent value="preview" className="space-y-2">
<div className="border rounded-lg p-4 bg-muted/30 min-h-[200px]">
{content.trim() ? (
<div className="space-y-4">
{/* Preview of the thread */}
<div className="border-b pb-2">
<h3 className="text-lg font-semibold">{title || t('Untitled')}</h3>
<div className="flex items-center gap-2 mt-1">
<selectedTopicInfo.icon className="w-4 h-4" />
<Badge variant="secondary" className="text-xs">
{selectedTopicInfo.label}
</Badge>
{isReadingGroup && (
<>
<Badge variant="outline" className="text-xs">
<Hash className="w-3 h-3 mr-1" />
Readings
</Badge>
{author && (
<span className="text-xs text-muted-foreground">
{t('Author')}: {author}
</span>
)}
{subject && (
<span className="text-xs text-muted-foreground">
{t('Book')}: {subject}
</span>
)}
</>
)}
</div>
</div>
{/* Preview of the content */}
<MarkdownArticle
event={{
id: 'preview',
pubkey: pubkey || '',
created_at: Math.floor(Date.now() / 1000),
kind: 11,
tags: [
['title', title],
['t', selectedTopic],
...(isReadingGroup ? [['t', 'readings']] : []),
...(author ? [['author', author]] : []),
...(subject ? [['subject', subject]] : [])
],
content: content,
sig: ''
}}
hideMetadata={true}
/>
</div>
) : (
<div className="text-center text-muted-foreground py-8">
<Edit3 className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>{t('Start typing to see a preview...')}</p>
</div>
)}
</div>
<p className="text-sm text-muted-foreground">
{content.length}/5000 {t('characters')}
</p>
</TabsContent>
</Tabs>
</div>
{/* Readings Options - Only show for literature topic */}
{selectedTopic === 'literature' && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Book className="w-4 h-4" />
<Label className="text-sm font-medium">{t('Readings Options')}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowReadingsPanel(!showReadingsPanel)}
className="ml-auto"
>
{showReadingsPanel ? t('Hide') : t('Configure')}
</Button>
</div>
{showReadingsPanel && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Book className="w-4 h-4 text-primary" />
<Label htmlFor="reading-group" className="text-sm">
{t('Reading group entry')}
</Label>
</div>
<Switch
id="reading-group"
checked={isReadingGroup}
onCheckedChange={setIsReadingGroup}
/>
</div>
{isReadingGroup && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="author">{t('Author')}</Label>
<Input
id="author"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder={t('Enter the author name')}
className={errors.author ? 'border-destructive' : ''}
/>
{errors.author && (
<p className="text-sm text-destructive">{errors.author}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="subject">{t('Subject (Book Title)')}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t('Enter the book title')}
className={errors.subject ? 'border-destructive' : ''}
/>
{errors.subject && (
<p className="text-sm text-destructive">{errors.subject}</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{t('This will add additional tags for author and subject to help organize reading group discussions.')}
</p>
</div>
)}
</div>
)}
</div>
)}
{/* Relay Selection */}
<div className="space-y-2">
<Label>{t('Publish to Relays')}</Label>
<ScrollArea className={`max-h-64 rounded-md border p-4 ${errors.relay ? 'border-destructive' : ''}`}>
{isLoadingRelays ? (
<div className="text-sm text-muted-foreground text-center py-4">
{t('Loading relays...')}
</div>
) : selectableRelays.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-4">
{t('No relays available. Please configure relays in settings.')}
</div>
) : (
<div className="space-y-3">
{selectableRelays.map(relay => {
const isChecked = selectedRelayUrls.includes(relay)
return (
<div key={relay} className="flex items-center space-x-3">
<Checkbox
id={`relay-${relay}`}
checked={isChecked}
onCheckedChange={(checked: boolean | 'indeterminate') => handleRelayCheckedChange(!!checked, relay)}
disabled={isLoadingRelays}
/>
<label
htmlFor={`relay-${relay}`}
className="flex items-center gap-2 text-sm font-medium leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex-1"
>
<RelayIcon url={relay} className="w-4 h-4" />
<span className="truncate">{simplifyUrl(relay)}</span>
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
{errors.relay && (
<p className="text-sm text-destructive">{errors.relay}</p>
)}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{selectedRelayUrls.length === 0
? t('No relays selected')
: t('{{count}} relay(s) selected', { count: selectedRelayUrls.length })}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleSelectAll}
disabled={isLoadingRelays}
>
{t('Select All')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearAll}
disabled={isLoadingRelays}
>
{t('Clear All')}
</Button>
</div>
</div>
</div>
{/* Advanced Options Toggle */}
<div className="border-t pt-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<Settings className="w-4 h-4" />
{t('Advanced Options')}
</Button>
{showAdvancedOptions && (
<div className="space-y-4 mt-4">
{/* NSFW Toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Hash className="w-4 h-4 text-foreground" />
<Label htmlFor="nsfw" className="text-sm">
{t('Mark as NSFW')}
</Label>
</div>
<Switch
id="nsfw"
checked={isNsfw}
onCheckedChange={setIsNsfw}
/>
</div>
{/* Client Tag Toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Image className="w-4 h-4 text-foreground" />
<Label htmlFor="client-tag" className="text-sm">
{t('Add client identifier')}
</Label>
</div>
<Switch
id="client-tag"
checked={addClientTag}
onCheckedChange={setAddClientTag}
/>
</div>
{/* PoW Setting */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-foreground" />
<Label className="text-sm">
{t('Proof of Work')}: {minPow}
</Label>
</div>
<div className="px-2">
<Slider
value={[minPow]}
onValueChange={(value: number[]) => setMinPow(value[0])}
max={20}
min={0}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{t('No PoW')}</span>
<span>{t('High PoW')}</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('Higher values make your thread harder to mine but more unique.')}
</p>
</div>
</div>
)}
</div>
{/* Form Actions */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
className="flex-1"
>
{t('Cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? t('Creating...') : t('Create Thread')}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}