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.
1009 lines
39 KiB
1009 lines
39 KiB
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<string[]>([]) |
|
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<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 postTextareaRef = useRef<TPostTextareaHandle | null>(null) |
|
const advancedOptionsRef = useRef<HTMLDivElement | null>(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<HTMLFormElement>) |
|
} |
|
|
|
const escapeDialog = (e: { preventDefault: () => void }) => { |
|
if (postEditor.isSuggestionPopupOpen) { |
|
e.preventDefault() |
|
postEditor.closeSuggestionPopup() |
|
} |
|
} |
|
|
|
const formBody = ( |
|
<NeventPickerProvider> |
|
<div |
|
ref={setPickerPortalContainer} |
|
className="pointer-events-none absolute inset-0" |
|
aria-hidden |
|
/> |
|
<form |
|
id="create-thread-form" |
|
onSubmit={handleSubmit} |
|
className="relative flex min-w-0 flex-col gap-4" |
|
> |
|
<div className="flex min-w-0 items-center justify-between gap-2"> |
|
<div className="text-lg font-semibold">{t('New Discussion')}</div> |
|
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="h-8 w-8 shrink-0"> |
|
<X className="h-4 w-4" /> |
|
</Button> |
|
</div> |
|
|
|
<div className="shrink-0 space-y-3 rounded-lg border bg-muted/30 p-4"> |
|
<div className="space-y-2"> |
|
<Label htmlFor="topic-input" className="text-sm font-medium"> |
|
{t('Topic')} <span className="text-destructive">*</span> |
|
</Label> |
|
<div className="flex min-w-0 gap-2"> |
|
<Input |
|
id="topic-input" |
|
value={topicInput} |
|
onChange={(e) => 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')} |
|
/> |
|
<Popover open={isTopicSelectorOpen} onOpenChange={setIsTopicSelectorOpen}> |
|
<PopoverTrigger asChild> |
|
<Button |
|
type="button" |
|
variant="outline" |
|
size="icon" |
|
className="h-9 w-9 shrink-0" |
|
title={t('Suggested topics')} |
|
aria-expanded={isTopicSelectorOpen} |
|
> |
|
<ChevronDown className="h-4 w-4 opacity-70" /> |
|
</Button> |
|
</PopoverTrigger> |
|
<PopoverContent className="z-[10000] w-72 p-2" align="end" side="bottom" sideOffset={4}> |
|
<p className="text-muted-foreground mb-2 px-1 text-xs font-medium">{t('Suggested topics')}</p> |
|
<div className="max-h-60 overflow-y-auto"> |
|
{allAvailableTopics.map((topic, index) => { |
|
const Icon = topic.icon |
|
return ( |
|
<div |
|
key={`topic-${index}-${topic.id}`} |
|
className="flex cursor-pointer items-center rounded p-2 hover:bg-accent" |
|
onClick={() => { |
|
setSelectedTopic(topic.id) |
|
setTopicInput(topic.label) |
|
setIsTopicSelectorOpen(false) |
|
}} |
|
> |
|
<Check |
|
className={`mr-2 h-4 w-4 ${effectiveTopic === topic.id ? 'opacity-100' : 'opacity-0'}`} |
|
/> |
|
<Icon className="mr-2 h-4 w-4 shrink-0" /> |
|
<span className="min-w-0 truncate text-sm">{topic.label}</span> |
|
</div> |
|
) |
|
})} |
|
</div> |
|
</PopoverContent> |
|
</Popover> |
|
</div> |
|
{errors.topic && <p className="text-sm text-destructive">{errors.topic}</p>} |
|
<p className="text-xs text-muted-foreground"> |
|
{t( |
|
'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).' |
|
)} |
|
</p> |
|
</div> |
|
|
|
{effectiveTopic === 'groups' && ( |
|
<div className="space-y-2"> |
|
<Label htmlFor="group" className="text-sm font-medium"> |
|
{t('Select Group')} |
|
</Label> |
|
<Popover open={isGroupSelectorOpen} onOpenChange={setIsGroupSelectorOpen}> |
|
<PopoverTrigger asChild> |
|
<Button |
|
variant="outline" |
|
role="combobox" |
|
aria-expanded={isGroupSelectorOpen} |
|
className="h-9 w-full justify-between bg-background font-normal" |
|
> |
|
{selectedGroup ? selectedGroup : t('Select group...')} |
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> |
|
</Button> |
|
</PopoverTrigger> |
|
<PopoverContent |
|
className="z-[10000] w-[--radix-popover-trigger-width] p-2" |
|
align="start" |
|
side="bottom" |
|
sideOffset={4} |
|
> |
|
<div className="max-h-60 overflow-y-auto"> |
|
{userGroups.length === 0 ? ( |
|
<div className="p-2 text-center text-sm text-muted-foreground"> |
|
{t('No groups available. Join some groups first.')} |
|
</div> |
|
) : ( |
|
userGroups.map((groupId) => ( |
|
<div |
|
key={groupId} |
|
className="flex cursor-pointer items-center rounded p-2 hover:bg-accent" |
|
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-xs text-muted-foreground"> |
|
{t('Select the group where you want to create this discussion.')} |
|
</p> |
|
</div> |
|
)} |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="title" className="text-sm font-medium"> |
|
{t('Title')} <span className="text-destructive">*</span> |
|
</Label> |
|
<Input |
|
id="title" |
|
value={title} |
|
onChange={(e) => 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 && <p className="text-sm text-destructive">{errors.title}</p>} |
|
<p className="text-xs text-muted-foreground"> |
|
{title.length}/100 {t('characters')} |
|
</p> |
|
</div> |
|
</div> |
|
|
|
<div className="flex min-w-0 flex-col gap-1 min-h-52"> |
|
{hydrated ? ( |
|
<PostTextarea |
|
ref={postTextareaRef} |
|
text={content} |
|
setText={setContent} |
|
defaultContent={content} |
|
parentEvent={THREAD_POST_EDITOR_PARENT} |
|
onSubmit={triggerSubmit} |
|
className={cn('min-h-52', errors.content && 'border-destructive')} |
|
kind={ExtendedKind.DISCUSSION} |
|
getDraftEventJson={getDraftEventJson} |
|
extraPreviewTags={previewExtraTags} |
|
headerActions={ |
|
<> |
|
<Uploader onUploadSuccess={handleThreadMediaUploadSuccess} accept="image/*,audio/*,video/*"> |
|
<Button type="button" variant="ghost" size="icon" title={t('Upload Media')}> |
|
<Upload className="h-4 w-4" /> |
|
</Button> |
|
</Uploader> |
|
<GifPicker |
|
onSelect={(gifUrl) => insertAtCursor(gifUrl + ' ')} |
|
portalContainer={pickerPortalContainer ?? undefined} |
|
> |
|
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}> |
|
<Film className="h-4 w-4" /> |
|
</Button> |
|
</GifPicker> |
|
<EmojiPickerDialog |
|
portalContainer={pickerPortalContainer ?? undefined} |
|
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="ghost" size="icon" title={t('Insert emoji')}> |
|
<Smile className="h-4 w-4" /> |
|
</Button> |
|
</EmojiPickerDialog> |
|
<MentionAndEventToolbarButtons insertAtCursor={insertAtCursor} variant="ghost" /> |
|
</> |
|
} |
|
/> |
|
) : null} |
|
{errors.content && <p className="text-sm text-destructive">{errors.content}</p>} |
|
<p className="text-xs text-muted-foreground"> |
|
{content.length}/5000 {t('characters')} |
|
</p> |
|
</div> |
|
|
|
{effectiveTopic === 'literature' && ( |
|
<div className="shrink-0 space-y-2"> |
|
<div className="flex items-center gap-2"> |
|
<Book className="h-4 w-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="space-y-4 rounded-lg border bg-muted/30 p-4"> |
|
<div className="flex items-center justify-between"> |
|
<div className="flex items-center gap-2"> |
|
<Book className="h-4 w-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> |
|
)} |
|
|
|
<div className={cn('shrink-0', errors.relay && 'rounded-md ring-1 ring-destructive')}> |
|
<PostRelaySelector |
|
setIsProtectedEvent={setIsProtectedEvent} |
|
setAdditionalRelayUrls={setAdditionalRelayUrls} |
|
openFrom={openFrom} |
|
content={content} |
|
/> |
|
{errors.relay && <p className="mt-1 text-sm text-destructive">{errors.relay}</p>} |
|
</div> |
|
|
|
<div ref={advancedOptionsRef} className="shrink-0 scroll-mt-4 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="h-4 w-4" /> |
|
{t('Advanced Options')} |
|
</Button> |
|
|
|
{showAdvancedOptions && ( |
|
<div className="mt-4 space-y-4"> |
|
<div className="flex items-center justify-between"> |
|
<div className="flex items-center gap-2"> |
|
<Hash className="h-4 w-4 text-foreground" /> |
|
<Label htmlFor="nsfw" className="text-sm"> |
|
{t('Mark as NSFW')} |
|
</Label> |
|
</div> |
|
<Switch id="nsfw" checked={isNsfw} onCheckedChange={setIsNsfw} /> |
|
</div> |
|
|
|
<div className="flex items-center justify-between"> |
|
<div className="flex items-center gap-2"> |
|
<Image className="h-4 w-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> |
|
|
|
<div className="space-y-2"> |
|
<div className="flex items-center gap-2"> |
|
<Zap className="h-4 w-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="mt-1 flex justify-between text-xs text-muted-foreground"> |
|
<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> |
|
|
|
<div className="flex shrink-0 gap-3 pt-4"> |
|
<Button type="button" variant="outline" onClick={onClose} className="flex-1"> |
|
{t('Cancel')} |
|
</Button> |
|
<Button type="button" variant="outline" onClick={handleClearDraft} disabled={isSubmitting}> |
|
{t('Clear')} |
|
</Button> |
|
<Button type="submit" disabled={isSubmitting} className="flex-1"> |
|
{isSubmitting ? t('Creating...') : t('Create Thread')} |
|
</Button> |
|
</div> |
|
</form> |
|
</NeventPickerProvider> |
|
) |
|
|
|
if (isSmallScreen) { |
|
return ( |
|
<Sheet open onOpenChange={(open) => !open && onClose()}> |
|
<SheetContent |
|
className="flex max-h-[min(92dvh,100%)] w-full max-w-full flex-col overflow-hidden border-none p-0" |
|
side="bottom" |
|
hideClose |
|
onEscapeKeyDown={escapeDialog} |
|
> |
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto overscroll-y-contain px-4 [scrollbar-gutter:stable]"> |
|
<div className="min-w-0 space-y-4 px-2 py-6 pr-4"> |
|
<SheetHeader className="sr-only"> |
|
<SheetTitle>{t('New Discussion')}</SheetTitle> |
|
<SheetDescription>{t('Create a discussion thread')}</SheetDescription> |
|
</SheetHeader> |
|
{formBody} |
|
</div> |
|
</div> |
|
</SheetContent> |
|
</Sheet> |
|
) |
|
} |
|
|
|
return ( |
|
<Dialog open onOpenChange={(open) => !open && onClose()}> |
|
<DialogContent |
|
className="z-[210] flex max-h-[min(93dvh,52rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col overflow-hidden p-0 sm:w-full" |
|
overlayClassName="z-[205]" |
|
withoutClose |
|
onEscapeKeyDown={escapeDialog} |
|
> |
|
<div className="max-h-[min(90dvh,50rem)] min-h-0 w-full overflow-x-hidden overflow-y-auto overscroll-y-contain [scrollbar-gutter:stable]"> |
|
<div className="min-w-0 space-y-4 px-2 py-6 pr-4 pl-4"> |
|
<DialogHeader className="sr-only"> |
|
<DialogTitle>{t('New Discussion')}</DialogTitle> |
|
<DialogDescription>{t('Create a discussion thread')}</DialogDescription> |
|
</DialogHeader> |
|
{formBody} |
|
</div> |
|
</div> |
|
</DialogContent> |
|
</Dialog> |
|
) |
|
}
|
|
|