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.
 
 
 

596 lines
24 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
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 { 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 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider'
import { TDraftEvent } from '@/types'
import { NostrEvent } from 'nostr-tools'
import { prefixNostrAddresses } from '@/lib/nostr-address'
import { showPublishingError } from '@/lib/publishing-feedback'
import dayjs from 'dayjs'
import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics'
import DiscussionContent from '@/components/Note/DiscussionContent'
// 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']
}
interface CreateThreadDialogProps {
topic: string
availableRelays: string[]
selectedRelay?: string | null
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 }
]
export default function CreateThreadDialog({
topic: initialTopic,
availableRelays,
selectedRelay: initialRelay,
onClose,
onThreadCreated
}: CreateThreadDialogProps) {
const { t } = useTranslation()
const { pubkey, publish } = useNostr()
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [selectedTopic] = useState(initialTopic)
const [selectedRelay, setSelectedRelay] = useState<string>(initialRelay || '')
const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<{ title?: string; content?: string; relay?: string; author?: string; subject?: string }>({})
const [isNsfw, setIsNsfw] = useState(false)
const [addClientTag, setAddClientTag] = useState(true)
const [minPow, setMinPow] = useState(0)
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
// Readings options state
const [isReadingGroup, setIsReadingGroup] = useState(false)
const [author, setAuthor] = useState('')
const [subject, setSubject] = useState('')
const [showReadingsPanel, setShowReadingsPanel] = useState(false)
const validateForm = () => {
const newErrors: { title?: string; content?: string; relay?: string; author?: string; subject?: 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 (!selectedRelay) {
newErrors.relay = t('Please select a 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')
}
}
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()],
['t', normalizeTopic(selectedTopic)],
['-'] // Required tag for relay privacy
]
// Add hashtags as t-tags (deduplicate with selectedTopic)
const 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())
}
// Create the thread event (kind 11)
const threadEvent: TDraftEvent = {
kind: 11,
content: processedContent,
tags,
created_at: dayjs().unix()
}
// Publish to the selected relay only
const publishedEvent = await publish(threadEvent, {
specifiedRelayUrls: [selectedRelay],
minPow
})
if (publishedEvent) {
onThreadCreated(publishedEvent)
onClose()
} else {
throw new Error(t('Failed to publish thread'))
}
} catch (error) {
console.error('Error creating thread:', error)
console.error('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 = DISCUSSION_TOPICS.find(t => t.id === selectedTopic) || DISCUSSION_TOPICS[0]
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4">
<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>
<div className="flex items-center gap-2">
<selectedTopicInfo.icon className="w-4 h-4" />
<Badge variant="secondary" className="text-sm">
{selectedTopicInfo.label}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{t('Threads are organized by topics. You can change this after creation.')}
</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">
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
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 */}
<div className="prose prose-sm max-w-none dark:prose-invert">
<DiscussionContent
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: ''
}}
/>
</div>
</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 htmlFor="relay">{t('Publish to Relay')}</Label>
<Select value={selectedRelay} onValueChange={setSelectedRelay}>
<SelectTrigger className={errors.relay ? 'border-destructive' : ''}>
<SelectValue placeholder={t('Select a relay to publish to')} />
</SelectTrigger>
<SelectContent>
{availableRelays.map(relay => (
<SelectItem key={relay} value={relay}>
{relay.replace('wss://', '').replace('ws://', '')}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.relay && (
<p className="text-sm text-destructive">{errors.relay}</p>
)}
<p className="text-sm text-muted-foreground">
{t('Choose the relay where this discussion will be hosted.')}
</p>
</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) => 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>
)
}