diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index d16ca44a..dce0bf3d 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -2432,9 +2432,6 @@ export default function PostContent({ {createThreadOpen && ( setCreateThreadOpen(false)} onThreadCreated={() => { discussionFeedCache.clearDiscussionsListCache() diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 02591877..afc4cf2a 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -18,6 +18,9 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' import logger from '@/lib/logger' +/** Stable default when `mentions` is omitted — inline `= []` is a new array every render and retriggers effects. */ +const NO_MENTIONS: string[] = [] + export default function PostRelaySelector({ parentEvent: _parentEvent, openFrom, @@ -25,7 +28,7 @@ export default function PostRelaySelector({ setAdditionalRelayUrls, content: postContent = '', isPublicMessage = false, - mentions = [] + mentions = NO_MENTIONS }: { parentEvent?: NostrEvent openFrom?: string[] diff --git a/src/components/PostEditor/PostTextarea/Preview.tsx b/src/components/PostEditor/PostTextarea/Preview.tsx index 054fa9b4..d2c712ff 100644 --- a/src/components/PostEditor/PostTextarea/Preview.tsx +++ b/src/components/PostEditor/PostTextarea/Preview.tsx @@ -25,7 +25,8 @@ export default function Preview({ pollCreateData, mediaImetaTags, mediaUrl, - articleMetadata + articleMetadata, + extraPreviewTags }: { content: string className?: string @@ -41,6 +42,8 @@ export default function Preview({ dTag?: string topics?: string[] } + /** Merged into the fake event (e.g. kind 11 discussion title / topic tags). */ + extraPreviewTags?: string[][] }) { const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo( () => { @@ -148,8 +151,11 @@ export default function Preview({ tags.push(...normalizedTopics.map((topic) => ['t', topic])) } } + if (extraPreviewTags?.length) { + tags.push(...extraPreviewTags) + } return tags - }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, kind]) + }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, kind, extraPreviewTags]) const fakeEvent = useMemo(() => { // For voice comments, include the media URL in content if not already there @@ -194,6 +200,14 @@ export default function Preview({ ) } + if (kind === ExtendedKind.DISCUSSION) { + return ( + + + + ) + } + // For LongFormArticle, use MarkdownArticle if (kind === kinds.LongFormArticle) { return ( diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index ed1dea9f..73680262 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -58,6 +58,7 @@ const PostTextarea = forwardRef< dTag?: string topics?: string[] } + extraPreviewTags?: string[][] } >( ( @@ -78,7 +79,8 @@ const PostTextarea = forwardRef< getDraftEventJson, mediaImetaTags, mediaUrl, - articleMetadata + articleMetadata, + extraPreviewTags }, ref ) => { @@ -243,7 +245,17 @@ const PostTextarea = forwardRef<
kind {kindDescription.number}: {kindDescription.description}
- + diff --git a/src/components/Sidebar/AccountButton.tsx b/src/components/Sidebar/AccountButton.tsx index f15a25e6..96cd200b 100644 --- a/src/components/Sidebar/AccountButton.tsx +++ b/src/components/Sidebar/AccountButton.tsx @@ -7,11 +7,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { toWallet } from '@/lib/link' import { formatPubkey, generateImageByPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey' -import { usePrimaryPage, useSecondaryPage } from '@/PageManager' +import { usePrimaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import { ArrowDownUp, LogIn, LogOut, MoreVertical, Settings, Wallet } from 'lucide-react' +import { ArrowDownUp, LogIn, LogOut, MoreVertical, Settings } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import LoginDialog from '../LoginDialog' @@ -33,7 +32,6 @@ function ProfileButton() { const { account, profile } = useNostr() const pubkey = account?.pubkey const { navigate } = usePrimaryPage() - const { push } = useSecondaryPage() const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) if (!pubkey) return null @@ -74,10 +72,6 @@ function ProfileButton() { - push(toWallet())}> - - {t('Wallet')} - navigate('settings')}> {t('Settings')} diff --git a/src/constants.ts b/src/constants.ts index ab152c17..29d0c164 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -132,7 +132,8 @@ export const KIND_1_BLOCKED_RELAY_URLS = [ 'wss://thecitadel.nostr1.com', 'wss://hist.nostr.land', 'wss://profiles.nostr1.com', - 'wss://purplepag.es' + 'wss://purplepag.es', + 'wss://wikifreedia.xyz' ] // Optimized relay list for read operations (includes aggregator) diff --git a/src/lib/kind-description.ts b/src/lib/kind-description.ts index e96a4b77..a1f7e151 100644 --- a/src/lib/kind-description.ts +++ b/src/lib/kind-description.ts @@ -44,6 +44,8 @@ export function getKindDescription(kind: number): { number: number; description: return { number: 1068, description: 'Poll' } case ExtendedKind.PUBLIC_MESSAGE: return { number: 24, description: 'Public Message' } + case ExtendedKind.DISCUSSION: + return { number: 11, description: 'Discussion' } default: return { number: kind, description: `Event (kind ${kind})` } } diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index 765cf080..5f3fab15 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -1,53 +1,53 @@ import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import TextareaWithMentionAutocomplete from '@/components/TextareaWithMentionAutocomplete' -import { Badge } from '@/components/ui/badge' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet' 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Hash, X, Users, Film, Image, Zap, Settings, Book, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react' -import { forwardRef, useState, useEffect, useMemo, useRef, useCallback } from 'react' +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, TRelaySet } from '@/types' +import { TDraftEvent } 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, { type RelaySourceType } from '@/services/relay-selection.service' import dayjs from 'dayjs' import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' import { DISCUSSION_TOPICS } from './discussionTopics' -import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' -import RelayIcon from '@/components/RelayIcon' +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 { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/useNeventPicker' +import { MentionAndEventToolbarButtons } from '@/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons' import logger from '@/lib/logger' import postEditorCache from '@/services/post-editor-cache.service' -import { MentionAndEventToolbarButtons } from '@/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons' +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' -/** Wraps the textarea so it receives the nevent/naddr picker from context (must be rendered inside NeventPickerProvider). */ -const ThreadContentTextarea = forwardRef>( - function ThreadContentTextarea(props, ref) { - const neventPicker = useNeventPicker() - return ( - - ) - } -) +/** 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[] { @@ -63,6 +63,25 @@ 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 @@ -73,10 +92,10 @@ interface DynamicTopic { } interface CreateThreadDialogProps { - topic: string - availableRelays: string[] - relaySets: TRelaySet[] - selectedRelay?: string | null // null = "All relays", relay set ID, or single relay URL + /** 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[] @@ -86,32 +105,42 @@ interface CreateThreadDialogProps { onThreadCreated: (publishedEvent?: NostrEvent) => void } -export default function CreateThreadDialog({ - topic: initialTopic, - availableRelays, - relaySets, - selectedRelay: initialRelay, +export default function CreateThreadDialog({ + topic: initialTopic = 'general', + selectedRelay: initialRelay = null, dynamicTopics, - onClose, - onThreadCreated + onClose, + onThreadCreated }: CreateThreadDialogProps) { const { t } = useTranslation() - const { pubkey, publish, relayList } = useNostr() - const { favoriteRelays, blockedRelays } = useFavoriteRelays() + 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 [selectedRelayUrls, setSelectedRelayUrls] = useState([]) - const [selectableRelays, setSelectableRelays] = useState([]) - const [relayTypes, setRelayTypes] = useState>({}) + const [topicInput, setTopicInput] = useState(() => { + const row = DISCUSSION_TOPICS.find((x) => x.id === initialTopic) + return row?.label ?? initialTopic + }) + const [, setIsProtectedEvent] = useState(false) + const [additionalRelayUrls, setAdditionalRelayUrls] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) - const [errors, setErrors] = useState<{ title?: string; content?: string; relay?: string; author?: string; subject?: string; group?: string }>({}) + 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 [isLoadingRelays, setIsLoadingRelays] = useState(true) const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false) const [pickerPortalContainer, setPickerPortalContainer] = useState(null) @@ -125,24 +154,12 @@ export default function CreateThreadDialog({ 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) - } - } + const postTextareaRef = useRef(null) + const advancedOptionsRef = useRef(null) + + const insertAtCursor = useCallback((text: string) => { + postTextareaRef.current?.insertText(text) + }, []) // Create combined topics list (predefined + dynamic) with hierarchy const allAvailableTopics = useMemo(() => { @@ -215,122 +232,227 @@ export default function CreateThreadDialog({ return combined }, [dynamicTopics]) - // Stable refs for relay lists so we don't re-run init when parent context identity changes - const writeRelays = relayList?.write ?? [] - const readRelays = relayList?.read ?? [] - const writeKey = writeRelays.join(',') - const readKey = readRelays.join(',') - const favoriteKey = favoriteRelays.join(',') - const blockedKey = blockedRelays.join(',') - const relaySetsKey = relaySets.map(s => `${s.id}:${s.relayUrls.join(',')}`).join(';') - const availableRelaysKey = availableRelays.join(',') - - // Initialize selected relays using the centralized relay selection service (once per meaningful change) - 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: writeRelays, - userReadRelays: readRelays, - favoriteRelays, - blockedRelays, - relaySets, - openFrom, - userPubkey: pubkey || undefined - }) - - setSelectableRelays(result.selectableRelays) - setSelectedRelayUrls(result.selectedRelays) - setRelayTypes(result.relayTypes ?? {}) - } catch (error) { - logger.error('[CreateThreadDialog] Failed to initialize relays:', error) - // Fallback to availableRelays - setSelectableRelays(availableRelays) - setSelectedRelayUrls(availableRelays) - setRelayTypes({}) - } finally { - setIsLoadingRelays(false) - } - } + const effectiveTopic = useMemo( + () => resolveTopicFromInput(topicInput, allAvailableTopics), + [topicInput, allAvailableTopics] + ) - initializeRelays() - }, [initialRelay, availableRelaysKey, writeKey, readKey, favoriteKey, blockedKey, relaySetsKey, pubkey]) + /** 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 + // 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(() => { - postEditorCache.setThreadDraft({ title, content, topic: selectedTopic }) + const tr = resolveTopicFromInput(topicInput, allAvailableTopics) + postEditorCache.setThreadDraft({ + title, + content, + topic: tr || selectedTopic + }) }, 500) return () => clearTimeout(t) - }, [title, content, selectedTopic]) + }, [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(initialTopic) + setSelectedTopic('general') + setTopicInput(displayTopicLabel('general', DISCUSSION_TOPICS)) setErrors({}) postEditorCache.clearThreadDraft() - }, [initialTopic]) + postEditorCache.clearPostCache({ parentEvent: THREAD_POST_EDITOR_PARENT }) + postTextareaRef.current?.clear() + }, []) - const handleRelayCheckedChange = (checked: boolean, url: string) => { - if (checked) { - setSelectedRelayUrls(prev => [...prev, url]) - } else { - setSelectedRelayUrls(prev => prev.filter(u => u !== url)) - } - } + const collectThreadTags = useCallback( + (processedContent: string, topicForTags: string) => { + const images = extractImagesFromContent(processedContent) + const hashtags = extractHashtagsFromContent(processedContent) + const tags: string[][] = [['title', title.trim()], ['-']] - const handleSelectAll = () => { - setSelectedRelayUrls([...selectableRelays]) - } + if (topicForTags === 'groups' && selectedGroup) { + tags.push(['h', selectedGroup]) + } - const handleClearAll = () => { - setSelectedRelayUrls([]) - } + 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; relay?: string; author?: string; subject?: string; group?: string } = {} - + 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 (selectedRelayUrls.length === 0) { + + if (additionalRelayUrls.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') @@ -339,14 +461,13 @@ export default function CreateThreadDialog({ newErrors.subject = t('Subject (book title) is required for reading groups') } } - - // Validate group selection if groups topic is selected - if (selectedTopic === 'groups') { + + if (topicResolved === 'groups') { if (!selectedGroup.trim()) { newErrors.group = t('Please select a group') } } - + setErrors(newErrors) return Object.keys(newErrors).length === 0 } @@ -366,118 +487,16 @@ export default function CreateThreadDialog({ 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]) + const topicResolved = resolveTopicFromInput(topicInput, allAvailableTopics) + if (!topicResolved) { + setIsSubmitting(false) + return } - - // 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()) - } - - // Client tag is added in publish() based on user preference - + setSelectedTopic(topicResolved) + + const processedContent = prefixNostrAddresses(content.trim()) + const tags = collectThreadTags(processedContent, topicResolved) + // Create the thread event (kind 11) const threadEvent: TDraftEvent = { kind: 11, @@ -499,9 +518,10 @@ export default function CreateThreadDialog({ // Publish to all selected relays const publishedEvent = await publish(threadEvent, { - specifiedRelayUrls: selectedRelayUrls, + specifiedRelayUrls: additionalRelayUrls, minPow, - addClientTag + addClientTag, + disableFallbacks: additionalRelayUrls.length > 0 }) @@ -522,6 +542,7 @@ export default function CreateThreadDialog({ } postEditorCache.clearThreadDraft() + postEditorCache.clearPostCache({ parentEvent: THREAD_POST_EDITOR_PARENT }) onThreadCreated(publishedEvent) onClose() } else { @@ -562,546 +583,427 @@ export default function CreateThreadDialog({ } } - const selectedTopicInfo = allAvailableTopics.find(t => t.id === selectedTopic) || allAvailableTopics[0] + const triggerSubmit = () => { + void handleSubmit({ preventDefault: () => {} } as React.FormEvent) + } - return ( -
- {/* Portal target for GIF/emoji pickers so they render as children of this modal */} + const escapeDialog = (e: { preventDefault: () => void }) => { + if (postEditor.isSuggestionPopupOpen) { + e.preventDefault() + postEditor.closeSuggestionPopup() + } + } + + const formBody = ( +
- - - - {t('Create New Thread')} - - - - - - {/* Topic Selection */} -
- +
+ +
+
+ +
+ setTopicInput(e.target.value)} + onBlur={() => { + const r = resolveTopicFromInput(topicInput, allAvailableTopics) + if (r) { + setSelectedTopic(r) + setTopicInput(displayTopicLabel(r, allAvailableTopics)) + } + }} + placeholder={t('Type a topic or pick from the list')} + autoComplete="off" + className={cn('min-w-0 flex-1 bg-background', errors.topic && 'border-destructive')} + /> - + +

{t('Suggested topics')}

{allAvailableTopics.map((topic, index) => { const Icon = topic.icon return (
{ setSelectedTopic(topic.id) + setTopicInput(topic.label) setIsTopicSelectorOpen(false) }} > - - {topic.label} + + {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}

+ {errors.topic &&

{errors.topic}

} +

+ {t( + 'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).' )} -

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

-
+

+
- {/* Content Input with Preview */} + {effectiveTopic === 'groups' && (
- - - - - - {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 && ( -
-
-
- - + + +
+ {userGroups.length === 0 ? ( +
+ {t('No groups available. Join some groups first.')}
- -
- - {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' : ''} + ) : ( + userGroups.map((groupId) => ( +
{ + setSelectedGroup(groupId) + setIsGroupSelectorOpen(false) + }} + > + - {errors.subject && ( -

{errors.subject}

- )} + + {groupId}
- -

- {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) - const sourceType = relayTypes[relay] - const typeLabel = sourceType ? t(`relayType_${sourceType}`) : '' - 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 })} -

-
-
+ )} + +
+ + setTitle(e.target.value)} + placeholder={t('Enter a descriptive title for your thread')} + maxLength={100} + className={cn('bg-background', errors.title && 'border-destructive')} + /> + {errors.title &&

{errors.title}

} +

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

+
+
+ +
+ {hydrated ? ( + + + + + insertAtCursor(gifUrl + ' ')} + portalContainer={pickerPortalContainer ?? undefined} > - {t('Select All')} - - + + { + if (emoji == null) return + const char = + typeof emoji === 'string' + ? emoji + : (emoji as { native?: string }).native ?? String(emoji) + insertAtCursor(char) + }} > - {t('Clear All')} - -
-
-
- - {/* Advanced Options Toggle */} -
+ + + + + } + /> + ) : null} + {errors.content &&

{errors.content}

} +

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

+
+ + {effectiveTopic === 'literature' && ( +
+
+ + - - {showAdvancedOptions && ( -
- {/* NSFW Toggle */} -
-
- - -
- -
+
- {/* Client Tag Toggle */} -
-
- - -
- + {showReadingsPanel && ( +
+
+
+ +
+ +
- {/* PoW Setting */} -
-
- - + {isReadingGroup && ( +
+
+ + setAuthor(e.target.value)} + placeholder={t('Enter the author name')} + className={errors.author ? 'border-destructive' : ''} + /> + {errors.author &&

{errors.author}

}
-
- setMinPow(value[0])} - max={20} - min={0} - step={1} - className="w-full" + +
+ + setSubject(e.target.value)} + placeholder={t('Enter the book title')} + className={errors.subject ? 'border-destructive' : ''} /> -
- {t('No PoW')} - {t('High PoW')} -
+ {errors.subject &&

{errors.subject}

}
+

- {t('Higher values make your thread harder to mine but more unique.')} + {t('This will add additional tags for author and subject to help organize reading group discussions.')}

+ )} +
+ )} +
+ )} + +
+ + {errors.relay &&

{errors.relay}

} +
+ +
+ + + {showAdvancedOptions && ( +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ setMinPow(value[0])} + max={20} + min={0} + step={1} + className="w-full" + /> +
+ {t('No PoW')} + {t('High PoW')} +
- )} +

+ {t('Higher values make your thread harder to mine but more unique.')} +

+
+ )} +
- {/* Form Actions */} -
- - - +
+ + + +
+ + + ) + + if (isSmallScreen) { + return ( + !open && onClose()}> + +
+
+ + {t('New Discussion')} + {t('Create a discussion thread')} + + {formBody}
- - - - -
+
+ + + ) + } + + return ( + !open && onClose()}> + +
+
+ + {t('New Discussion')} + {t('Create a discussion thread')} + + {formBody} +
+
+
+
) } diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx index 0b9cebb6..b85a3ea6 100644 --- a/src/pages/primary/DiscussionsPage/index.tsx +++ b/src/pages/primary/DiscussionsPage/index.tsx @@ -1234,12 +1234,9 @@ const DiscussionsPage = forwardRef(function Di {/* Create Thread Dialog */} {showCreateDialog && ( )} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index b6f644e1..b0e540f1 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -173,8 +173,12 @@ function NoteListPageTitlebar({ )} - - + {isSmallScreen && ( + <> + + + + )}
) diff --git a/src/providers/FavoriteRelaysProvider.tsx b/src/providers/FavoriteRelaysProvider.tsx index 84788094..88a95478 100644 --- a/src/providers/FavoriteRelaysProvider.tsx +++ b/src/providers/FavoriteRelaysProvider.tsx @@ -9,34 +9,12 @@ import indexedDb from '@/services/indexed-db.service' import storage from '@/services/local-storage.service' import { TRelaySet } from '@/types' import { Event, kinds } from 'nostr-tools' -import { createContext, useContext, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' +import { FavoriteRelaysContext } from './favorite-relays-context' import { useNostr } from './NostrProvider' -type TFavoriteRelaysContext = { - favoriteRelays: string[] - addFavoriteRelays: (relayUrls: string[]) => Promise - deleteFavoriteRelays: (relayUrls: string[]) => Promise - reorderFavoriteRelays: (reorderedRelays: string[]) => Promise - blockedRelays: string[] - addBlockedRelays: (relayUrls: string[]) => Promise - deleteBlockedRelays: (relayUrls: string[]) => Promise - relaySets: TRelaySet[] - createRelaySet: (relaySetName: string, relayUrls?: string[]) => Promise - addRelaySets: (newRelaySetEvents: Event[]) => Promise - deleteRelaySet: (id: string) => Promise - updateRelaySet: (newSet: TRelaySet) => Promise - reorderRelaySets: (reorderedSets: TRelaySet[]) => Promise -} - -const FavoriteRelaysContext = createContext(undefined) - -export const useFavoriteRelays = () => { - const context = useContext(FavoriteRelaysContext) - if (!context) { - throw new Error('useFavoriteRelays must be used within a FavoriteRelaysProvider') - } - return context -} +export { useFavoriteRelays } from './favorite-relays-context' +export type { TFavoriteRelaysContext } from './favorite-relays-context' export function FavoriteRelaysProvider({ children }: { children: React.ReactNode }) { const { favoriteRelaysEvent, blockedRelaysEvent, updateFavoriteRelaysEvent, updateBlockedRelaysEvent, pubkey, relayList, publish } = useNostr() diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 8fbdf92c..d2c002e2 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -6,29 +6,13 @@ import indexedDb from '@/services/indexed-db.service' import storage from '@/services/local-storage.service' import { TFeedInfo, TFeedType } from '@/types' import { kinds } from 'nostr-tools' -import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' +import { FeedContext } from './feed-context' import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useNostr } from './NostrProvider' -type TFeedContext = { - feedInfo: TFeedInfo - relayUrls: string[] - isReady: boolean - switchFeed: ( - feedType: TFeedType, - options?: { activeRelaySetId?: string; pubkey?: string; relay?: string | null } - ) => Promise -} - -const FeedContext = createContext(undefined) - -export const useFeed = () => { - const context = useContext(FeedContext) - if (!context) { - throw new Error('useFeed must be used within a FeedProvider') - } - return context -} +export { useFeed } from './feed-context' +export type { TFeedContext } from './feed-context' export function FeedProvider({ children }: { children: React.ReactNode }) { const { pubkey, isInitialized } = useNostr() diff --git a/src/providers/favorite-relays-context.tsx b/src/providers/favorite-relays-context.tsx new file mode 100644 index 00000000..0bcc63b7 --- /dev/null +++ b/src/providers/favorite-relays-context.tsx @@ -0,0 +1,34 @@ +/** + * Standalone React context for favorite relays so HMR on `FavoriteRelaysProvider.tsx` does not + * recreate `createContext()` (which breaks `useFavoriteRelays` in InterestListProvider, + * FeedProvider, etc. after Fast Refresh). + */ +import { TRelaySet } from '@/types' +import { Event } from 'nostr-tools' +import { createContext, useContext } from 'react' + +export type TFavoriteRelaysContext = { + favoriteRelays: string[] + addFavoriteRelays: (relayUrls: string[]) => Promise + deleteFavoriteRelays: (relayUrls: string[]) => Promise + reorderFavoriteRelays: (reorderedRelays: string[]) => Promise + blockedRelays: string[] + addBlockedRelays: (relayUrls: string[]) => Promise + deleteBlockedRelays: (relayUrls: string[]) => Promise + relaySets: TRelaySet[] + createRelaySet: (relaySetName: string, relayUrls?: string[]) => Promise + addRelaySets: (newRelaySetEvents: Event[]) => Promise + deleteRelaySet: (id: string) => Promise + updateRelaySet: (newSet: TRelaySet) => Promise + reorderRelaySets: (reorderedSets: TRelaySet[]) => Promise +} + +export const FavoriteRelaysContext = createContext(undefined) + +export function useFavoriteRelays(): TFavoriteRelaysContext { + const context = useContext(FavoriteRelaysContext) + if (!context) { + throw new Error('useFavoriteRelays must be used within a FavoriteRelaysProvider') + } + return context +} diff --git a/src/providers/feed-context.tsx b/src/providers/feed-context.tsx new file mode 100644 index 00000000..b353c91f --- /dev/null +++ b/src/providers/feed-context.tsx @@ -0,0 +1,30 @@ +/** + * Standalone React context for feed state so HMR on `FeedProvider.tsx` does not recreate + * `createContext()` (which breaks `useFeed` after Fast Refresh). + */ +import { TFeedInfo, TFeedType } from '@/types' +import { createContext, useContext } from 'react' + +export type TFeedContext = { + feedInfo: TFeedInfo + relayUrls: string[] + isReady: boolean + switchFeed: ( + feedType: TFeedType, + options?: { + activeRelaySetId?: string | null + pubkey?: string | null + relay?: string | null + } + ) => Promise +} + +export const FeedContext = createContext(undefined) + +export function useFeed(): TFeedContext { + const context = useContext(FeedContext) + if (!context) { + throw new Error('useFeed must be used within a FeedProvider') + } + return context +}