import Note from '@/components/Note' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { createCommentDraftEvent, createPollDraftEvent, createPublicMessageDraftEvent, createPublicMessageReplyDraftEvent, createShortTextNoteDraftEvent, createHighlightDraftEvent, deleteDraftEventCache, createVoiceDraftEvent, createVoiceCommentDraftEvent, createPictureDraftEvent, createVideoDraftEvent, createLongFormArticleDraftEvent, createWikiArticleDraftEvent, createWikiArticleMarkdownDraftEvent, createPublicationContentDraftEvent, createCitationInternalDraftEvent, createCitationExternalDraftEvent, createCitationHardcopyDraftEvent, createCitationPromptDraftEvent, applyImwaldAttributionTags, collectUploadImetaTagsForContentUrls, mergeUploadImetaTagsInto } from '@/lib/draft-event' import { ExtendedKind, MAX_PUBLISH_RELAYS } from '@/constants' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useFeed } from '@/providers/FeedProvider' import { useReply } from '@/providers/ReplyProvider' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' import { cleanUrl, normalizeUrl, rewritePlainTextHttpUrls } from '@/lib/url' import logger from '@/lib/logger' import { LoginRequiredError } from '@/lib/nostr-errors' import postEditorCache from '@/services/post-editor-cache.service' import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' import { Book, Check, ChevronDown, ListTodo, MessageCircle, MessagesSquare, Users, X, Highlighter, FileText, HelpCircle, Quote, StickyNote, Upload, Music, Video, Code2 } from 'lucide-react' import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media' import { nip94PairsToImetaTag } from '@/lib/upload-nip94-imeta' import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' import type { TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap' import { successfulPublishRelayUrls, type TRelayPublishStatus } from '@/lib/publish-relay-urls' import client, { eventService } from '@/services/client.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import noteStatsService from '@/services/note-stats.service' import { buildAllAvailableTopics, collectDiscussionThreadTags, discussionThreadDraftKindParams, displayTopicLabel, resolveTopicFromInput, THREAD_POST_EDITOR_PARENT, type TDiscussionDynamicTopics } from '@/lib/discussion-thread-composer' import { prefixNostrAddresses } from '@/lib/nostr-address' import dayjs from 'dayjs' import { TDraftEvent } from '@/types' import { useGroupList } from '@/providers/group-list-context' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Switch } from '@/components/ui/switch' import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics' import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import Mentions, { extractMentions } from './Mentions' import PollEditor from './PollEditor' import PostOptions from './PostOptions' import PostRelaySelector from './PostRelaySelector' import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog' import Uploader from './Uploader' import HighlightEditor, { HighlightData } from './HighlightEditor' import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog' import AdvancedEventLabDialog, { type AdvancedLabBodyHandle } from '@/components/AdvancedEventLab/AdvancedEventLabDialog' import { PostEditorFormatToolbar, type PostEditorFormatToolbarUploadHandlers } from './PostEditorFormatToolbar' import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds' function stripUrlForImageExtensionCheck(url: string): string { return url.trim().split(/[#?]/)[0].toLowerCase() } function imageUrlLooksLikeHttpImage(url: string): boolean { return /\.(gif|jpe?g|png|webp|avif|bmp|svg)$/i.test(stripUrlForImageExtensionCheck(url)) } function labInsertShouldBecomeMarkupImage(txt: string): boolean { const t = txt.trim() if (!/^https?:\/\//i.test(t)) return false if (/^\s*!\[/.test(t)) return false if (/^\s*image::/i.test(t)) return false if (imageUrlLooksLikeHttpImage(t)) return true try { const host = new URL(t).hostname.toLowerCase() if (host.endsWith('tenor.com') || host.endsWith('giphy.com')) return true } catch { /* ignore */ } return false } function formatMarkupImageAppend(url: string, asciidoc: boolean): string { const safe = url.trim() if (asciidoc) return `\nimage::${safe}[Image]\n` return `\n![image](${safe})\n` } function formatMarkupImageAtCursor(url: string, asciidoc: boolean): string { const safe = url.trim() if (asciidoc) return `image::${safe}[Image]` return `![image](${safe})` } export default function PostContent({ defaultContent = '', parentEvent, close, openFrom, initialHighlightData, initialPublicMessageTo, onPublishSuccess, discussionDynamicTopics }: { defaultContent?: string parentEvent?: Event close: () => void openFrom?: string[] initialHighlightData?: HighlightData /** When set, opens in public message mode with this pubkey in the mention list. */ initialPublicMessageTo?: string /** Called after a reply/post is successfully published, before closing. */ onPublishSuccess?: () => void /** Optional hot/discussion topics (e.g. from Discussions spell) for the thread composer. */ discussionDynamicTopics?: TDiscussionDynamicTopics | null }) { const { t, i18n } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() const { userGroups } = useGroupList() const { feedInfo } = useFeed() const { addReplies } = useReply() const mergePublishedReplyIntoThread = useCallback( (reply: Event, relayStatuses?: TRelayPublishStatus[]) => { if (!parentEvent) return const clean = { ...reply } as Event delete (clean as any).relayStatuses addReplies([clean]) const isQuotePost = clean.tags.some((t) => t[0] === 'q' && t[1]) noteStatsService.updateNoteStatsByEvents( [clean], undefined, isQuotePost ? undefined : { replyParentNoteId: parentEvent.id } ) const rootInfo = parentEvent.kind === ExtendedKind.RSS_THREAD_ROOT ? (() => { const articleUrl = getArticleUrlFromCommentITags(parentEvent) if (articleUrl) { return { type: 'I' as const, id: canonicalizeRssArticleUrl(articleUrl) } } return { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey } })() : !isReplaceableEvent(parentEvent.kind) ? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey } : { type: 'A' as const, id: getReplaceableCoordinateFromEvent(parentEvent), eventId: parentEvent.id, pubkey: parentEvent.pubkey, relay: client.getEventHint(parentEvent.id) } const cached = discussionFeedCache.getCachedReplies(rootInfo) ?? [] const next = cached.filter((r) => r.id !== clean.id).concat([clean]) discussionFeedCache.setCachedReplies(rootInfo, next) const urls = successfulPublishRelayUrls(relayStatuses) if (!clean.id || urls.length === 0) return const delayMs = 1600 setTimeout(() => { void eventService.fetchEventWithExternalRelays(clean.id, urls).then((fresh) => { if (!fresh || fresh.id !== clean.id) return addReplies([fresh]) const merged = (discussionFeedCache.getCachedReplies(rootInfo) ?? []).filter((r) => r.id !== fresh.id) discussionFeedCache.setCachedReplies(rootInfo, [...merged, fresh]) client.addEventToCache(fresh) }) }, delayMs) }, [addReplies, parentEvent] ) const [text, setText] = useState('') const textareaRef = useRef(null) const labTagOverrideRef = useRef(null) const [advancedLabOpen, setAdvancedLabOpen] = useState(false) const advancedLabOpenRef = useRef(false) useEffect(() => { advancedLabOpenRef.current = advancedLabOpen }, [advancedLabOpen]) const advancedLabBodyApiRef = useRef(null) const getActiveComposerBody = () => advancedLabOpenRef.current && advancedLabBodyApiRef.current ? advancedLabBodyApiRef.current : textareaRef.current const [advancedLabInitial, setAdvancedLabInitial] = useState(null) const mediaUploaderBtnRef = useRef(null) const [posting, setPosting] = useState(false) const [uploadProgresses, setUploadProgresses] = useState< { file: File; progress: number; cancel: () => void; phase: 'compressing' | 'uploading' }[] >([]) const [showMoreOptions, setShowMoreOptions] = useState(false) const [createCustomEventOpen, setCreateCustomEventOpen] = useState(false) const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag()) const [mentions, setMentions] = useState([]) const [isNsfw, setIsNsfw] = useState(false) const [isPoll, setIsPoll] = useState(false) const [isPublicMessage, setIsPublicMessage] = useState(!!initialPublicMessageTo) const [extractedMentions, setExtractedMentions] = useState( initialPublicMessageTo ? [initialPublicMessageTo] : [] ) const [isProtectedEvent, setIsProtectedEvent] = useState(false) const [additionalRelayUrls, setAdditionalRelayUrls] = useState([]) /** When set, too many relays are checked vs the per-publish cap; publish stays disabled until unchecking. */ const [relayCapBlockInfo, setRelayCapBlockInfo] = useState<{ outboxSlotsInPublish: number selectedContacted: number selectedTotal: number } | null>(null) const [isHighlight, setIsHighlight] = useState(!!initialHighlightData) const [highlightData, setHighlightData] = useState( initialHighlightData || { sourceType: 'nostr', sourceValue: '' } ) const [pollCreateData, setPollCreateData] = useState({ isMultipleChoice: false, options: ['', ''], endsAt: undefined, relays: [] }) const [minPow, setMinPow] = useState(0) const [isDiscussionThread, setIsDiscussionThread] = useState(false) const [threadTitle, setThreadTitle] = useState('') const [threadTopicInput, setThreadTopicInput] = useState(() => { const row = DISCUSSION_TOPICS.find((x) => x.id === 'general') return row?.label ?? 'general' }) const [threadSelectedTopic, setThreadSelectedTopic] = useState('general') const [threadSelectedGroup, setThreadSelectedGroup] = useState('') const [threadIsReadingGroup, setThreadIsReadingGroup] = useState(false) const [threadReadingAuthor, setThreadReadingAuthor] = useState('') const [threadReadingSubject, setThreadReadingSubject] = useState('') const [threadShowReadingsPanel, setThreadShowReadingsPanel] = useState(false) const [threadTopicPopoverOpen, setThreadTopicPopoverOpen] = useState(false) const [threadGroupPopoverOpen, setThreadGroupPopoverOpen] = useState(false) const [threadErrors, setThreadErrors] = useState<{ title?: string content?: string topic?: string relay?: string author?: string subject?: string group?: string }>({}) const [mediaNoteKind, setMediaNoteKind] = useState(null) const [mediaImetaTags, setMediaImetaTags] = useState([]) const [mediaUrl, setMediaUrl] = useState('') const [isLongFormArticle, setIsLongFormArticle] = useState(false) const [isWikiArticle, setIsWikiArticle] = useState(false) const [isWikiArticleMarkdown, setIsWikiArticleMarkdown] = useState(false) const [isPublicationContent, setIsPublicationContent] = useState(false) const [articleTitle, setArticleTitle] = useState('') const [articleDTag, setArticleDTag] = useState('') const [articleImage, setArticleImage] = useState('') const [articleSubject, setArticleSubject] = useState('') const [articleSummary, setArticleSummary] = useState('') const [isCitationInternal, setIsCitationInternal] = useState(false) const [isCitationExternal, setIsCitationExternal] = useState(false) const [isCitationHardcopy, setIsCitationHardcopy] = useState(false) const [isCitationPrompt, setIsCitationPrompt] = useState(false) // Citation metadata fields // Internal Citation (30) const [citationInternalCTag, setCitationInternalCTag] = useState('') const [citationInternalRelayHint, setCitationInternalRelayHint] = useState('') // External Citation (31) const [citationExternalUrl, setCitationExternalUrl] = useState('') const [citationExternalOpenTimestamp, setCitationExternalOpenTimestamp] = useState('') // Hardcopy Citation (32) const [citationHardcopyPageRange, setCitationHardcopyPageRange] = useState('') const [citationHardcopyChapterTitle, setCitationHardcopyChapterTitle] = useState('') const [citationHardcopyEditor, setCitationHardcopyEditor] = useState('') const [citationHardcopyPublishedIn, setCitationHardcopyPublishedIn] = useState('') const [citationHardcopyVolume, setCitationHardcopyVolume] = useState('') const [citationHardcopyDoi, setCitationHardcopyDoi] = useState('') // Prompt Citation (33) const [citationPromptLlm, setCitationPromptLlm] = useState('') // Shared citation fields const [citationTitle, setCitationTitle] = useState('') const [citationAuthor, setCitationAuthor] = useState('') const [citationPublishedOn, setCitationPublishedOn] = useState('') const [citationPublishedBy, setCitationPublishedBy] = useState('') const [citationAccessedOn, setCitationAccessedOn] = useState('') const [citationLocation, setCitationLocation] = useState('') const [citationGeohash, setCitationGeohash] = useState('') const [citationVersion, setCitationVersion] = useState('') const [citationSummary, setCitationSummary] = useState('') const [hasPrivateRelaysAvailable, setHasPrivateRelaysAvailable] = useState(false) const [showMediaKindDialog, setShowMediaKindDialog] = useState(false) const [pendingMediaUpload, setPendingMediaUpload] = useState<{ url: string tags: string[][] file: File urlAlreadyInEditor?: boolean } | null>(null) const uploadedMediaFileMap = useRef>(new Map()) /** Accumulates imeta tags across uploads (short note or multi-attachment) so files are not dropped. */ const composerImetaTagsRef = useRef([]) const mediaNoteKindRef = useRef(null) /** Stable auto d-tag when the field is left empty; `{ slug, value }` resets when article subtype changes. */ const articleDTagFallbackRef = useRef<{ slug: string; value: string } | null>(null) useEffect(() => { if (articleDTag.trim()) { articleDTagFallbackRef.current = null } }, [articleDTag]) useEffect(() => { const isArticle = isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent if (!isArticle) { articleDTagFallbackRef.current = null } }, [isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent]) useEffect(() => { mediaNoteKindRef.current = mediaNoteKind }, [mediaNoteKind]) const appendComposerImetaTag = useCallback((newTag: string[]) => { const urlItem = newTag.find((x) => typeof x === 'string' && x.startsWith('url ')) const rawUrl = urlItem?.slice(4)?.trim() const normalized = rawUrl ? cleanUrl(rawUrl) || rawUrl : '' const exists = normalized && composerImetaTagsRef.current.some((tag) => { const u = tag.find((x) => typeof x === 'string' && x.startsWith('url ')) if (!u) return false const r = u.slice(4).trim() return (cleanUrl(r) || r) === normalized }) if (exists) return composerImetaTagsRef.current = [...composerImetaTagsRef.current, newTag] setMediaImetaTags([...composerImetaTagsRef.current]) }, []) const isFirstRender = useRef(true) const allAvailableTopics = useMemo( () => buildAllAvailableTopics(discussionDynamicTopics), [discussionDynamicTopics] ) const threadTopicResolved = useMemo( () => (isDiscussionThread ? resolveTopicFromInput(threadTopicInput, allAvailableTopics) : ''), [isDiscussionThread, threadTopicInput, allAvailableTopics] ) const discussionPreviewExtraTags = useMemo((): string[][] | undefined => { if (!isDiscussionThread) return undefined const resolved = resolveTopicFromInput(threadTopicInput, allAvailableTopics) if (!resolved) return [] return collectDiscussionThreadTags({ processedContent: prefixNostrAddresses(text.trim()), topicForTags: resolved, title: threadTitle, selectedGroup: threadSelectedGroup, dynamicTopics: discussionDynamicTopics, isReadingGroup: threadIsReadingGroup, author: threadReadingAuthor, subject: threadReadingSubject, isNsfw }) }, [ isDiscussionThread, threadTopicInput, allAvailableTopics, text, threadTitle, threadSelectedGroup, discussionDynamicTopics, threadIsReadingGroup, threadReadingAuthor, threadReadingSubject, isNsfw ]) const handleRelayPublishCapChange = useCallback((preview: TPrePublishRelayCapPreview) => { if (preview.blocksPublish) { setRelayCapBlockInfo({ outboxSlotsInPublish: preview.outboxSlotsInPublish, selectedContacted: preview.selectedContacted, selectedTotal: preview.selectedTotal }) } else { setRelayCapBlockInfo(null) } }, []) useEffect(() => { if (isPoll) setRelayCapBlockInfo(null) }, [isPoll]) const canPost = useMemo(() => { const discussionOk = !isDiscussionThread || !!parentEvent || (!!threadTitle.trim() && threadTitle.length <= 100 && !!threadTopicResolved && !!text.trim() && text.length <= 5000 && additionalRelayUrls.length > 0 && (!threadIsReadingGroup || (!!threadReadingAuthor.trim() && !!threadReadingSubject.trim())) && (threadTopicResolved !== 'groups' || !!threadSelectedGroup.trim())) const result = ( !!pubkey && !posting && !uploadProgresses.length && discussionOk && // For media notes, text is optional - just need media ((mediaNoteKind !== null && mediaUrl) || !!text) && (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && (!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && (!isProtectedEvent || additionalRelayUrls.length > 0) && (!isHighlight || highlightData.sourceValue.trim() !== '') && // For citations, required fields must be filled (!isCitationInternal || !!citationInternalCTag.trim()) && (!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) && (!isCitationHardcopy || !!citationAccessedOn.trim()) && (!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) && relayCapBlockInfo === null ) return result }, [ pubkey, text, posting, uploadProgresses, mediaNoteKind, mediaUrl, isPoll, pollCreateData, isPublicMessage, extractedMentions, parentEvent, isProtectedEvent, additionalRelayUrls, isHighlight, highlightData, isCitationInternal, citationInternalCTag, isCitationExternal, citationExternalUrl, citationAccessedOn, isCitationHardcopy, isCitationPrompt, citationPromptLlm, isDiscussionThread, threadTitle, threadTopicResolved, threadIsReadingGroup, threadReadingAuthor, threadReadingSubject, threadSelectedGroup, relayCapBlockInfo ]) // Clear highlight data when initialHighlightData changes or is removed useEffect(() => { if (initialHighlightData) { // Set highlight mode and data when provided setIsHighlight(true) setHighlightData(initialHighlightData) } else { // Clear highlight mode and data when not provided setIsHighlight(false) setHighlightData({ sourceType: 'nostr', sourceValue: '' }) } }, [initialHighlightData]) // Extract mentions from content for public messages const extractMentionsFromContent = useCallback(async (content: string) => { try { // Extract nostr: protocol mentions const { pubkeys: nostrPubkeys } = await extractMentions(content, undefined) // For now, we'll use the nostr mentions // In a real implementation, you'd also resolve @ mentions to pubkeys setExtractedMentions(nostrPubkeys) } catch (error) { logger.error('Error extracting mentions', { error }) setExtractedMentions([]) } }, []) useEffect(() => { if (!text) { if (!initialPublicMessageTo) setExtractedMentions([]) return } // Debounce the mention extraction for all posts (not just public messages) const timeoutId = setTimeout(() => { extractMentionsFromContent(text) }, 300) return () => { clearTimeout(timeoutId) } }, [text, extractMentionsFromContent, initialPublicMessageTo]) // Check for private relays availability useEffect(() => { if (!pubkey) { setHasPrivateRelaysAvailable(false) return } hasPrivateRelays(pubkey).then(setHasPrivateRelaysAvailable).catch(() => { setHasPrivateRelaysAvailable(false) }) }, [pubkey]) useEffect(() => { if (!isDiscussionThread || parentEvent) return if (!threadTitle && !text.trim()) return const h = setTimeout(() => { const tr = resolveTopicFromInput(threadTopicInput, allAvailableTopics) postEditorCache.setThreadDraft({ title: threadTitle, content: text, topic: tr || threadSelectedTopic }) }, 500) return () => clearTimeout(h) }, [ isDiscussionThread, parentEvent, threadTitle, text, threadTopicInput, threadSelectedTopic, allAvailableTopics ]) // Helper function to determine the kind that will be created const getDeterminedKind = useMemo((): number => { // Public messages always take priority - even with media, they stay as PMs if (isPublicMessage) { return ExtendedKind.PUBLIC_MESSAGE } else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { return ExtendedKind.PUBLIC_MESSAGE } // For voice comments in replies, check mediaNoteKind even if mediaUrl is not set yet (for preview) if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) { return ExtendedKind.VOICE_COMMENT } else if (isDiscussionThread && !parentEvent) { return ExtendedKind.DISCUSSION } else if (mediaNoteKind !== null && mediaUrl) { return mediaNoteKind } else if (isLongFormArticle) { return kinds.LongFormArticle } else if (isWikiArticle) { return ExtendedKind.WIKI_ARTICLE } else if (isWikiArticleMarkdown) { return ExtendedKind.WIKI_ARTICLE_MARKDOWN } else if (isPublicationContent) { return ExtendedKind.PUBLICATION_CONTENT } else if (isCitationInternal) { return ExtendedKind.CITATION_INTERNAL } else if (isCitationExternal) { return ExtendedKind.CITATION_EXTERNAL } else if (isCitationHardcopy) { return ExtendedKind.CITATION_HARDCOPY } else if (isCitationPrompt) { return ExtendedKind.CITATION_PROMPT } else if (isHighlight) { return kinds.Highlights } else if (isPoll) { return ExtendedKind.POLL } else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) { return ExtendedKind.COMMENT } else { return kinds.ShortTextNote } }, [ mediaNoteKind, mediaUrl, isDiscussionThread, isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent, isCitationInternal, isCitationExternal, isCitationHardcopy, isCitationPrompt, isHighlight, isPublicMessage, isPoll, parentEvent ]) const getDeterminedKindRef = useRef(getDeterminedKind) getDeterminedKindRef.current = getDeterminedKind const appendUploadedUrlToComposer = (url: string, treatAsImage: boolean) => { const ed = getActiveComposerBody() if (!ed || ed.getText().includes(url)) return if (ed === advancedLabBodyApiRef.current && treatAsImage) { ed.appendText( formatMarkupImageAppend(url, isAsciidocMarkupKind(getDeterminedKindRef.current)), false ) return } ed.appendText(url, true) } useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false const cachedSettings = postEditorCache.getPostSettingsCache({ kind: getDeterminedKind, defaultContent, parentEvent }) if (cachedSettings) { setIsNsfw(cachedSettings.isNsfw ?? false) setIsPoll(cachedSettings.isPoll ?? false) setPollCreateData( cachedSettings.pollCreateData ?? { isMultipleChoice: false, options: ['', ''], endsAt: undefined, relays: [] } ) setAddClientTag(cachedSettings.addClientTag ?? storage.getAddClientTag()) } return } postEditorCache.setPostSettingsCache( { kind: getDeterminedKind, defaultContent, parentEvent }, { isNsfw, isPoll, pollCreateData, addClientTag } ) }, [getDeterminedKind, defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag]) const rssReplyExtraPreviewTags = useMemo((): string[][] | undefined => { if (!parentEvent || parentEvent.kind !== ExtendedKind.RSS_THREAD_ROOT) return undefined const raw = parentEvent.tags.find((t) => t[0] === 'I')?.[1] ?? parentEvent.tags.find((t) => t[0] === 'i')?.[1] if (!raw) return undefined const c = canonicalizeRssArticleUrl(raw) return [['i', c], ['I', c]] }, [parentEvent]) // Shared function to create draft event - used by both preview and posting const createDraftEvent = useCallback(async (cleanedText: string): Promise => { const uploadImetaTagsOpt = mediaImetaTags.length > 0 ? mediaImetaTags : undefined // Get expiration and quiet settings const isChattingKind = (kind: number) => kind === kinds.ShortTextNote || kind === ExtendedKind.COMMENT || kind === ExtendedKind.VOICE || kind === ExtendedKind.VOICE_COMMENT const addExpirationTag = storage.getDefaultExpirationEnabled() const expirationMonths = storage.getDefaultExpirationMonths() const addQuietTag = storage.getDefaultQuietEnabled() const quietDays = storage.getDefaultQuietDays() // Determine if we should use protected event tag let shouldUseProtectedEvent = false if (parentEvent) { const isParentOP = !isReplyNoteEvent(parentEvent) const parentHasProtectedTag = isEventProtected(parentEvent) shouldUseProtectedEvent = isParentOP && parentHasProtectedTag } // Public messages - check BEFORE media notes to ensure PMs with media stay as PMs if (isPublicMessage) { return await createPublicMessageDraftEvent(cleanedText, extractedMentions, { addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt }) } else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { // For PM replies, always create PM even if there's media return await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, { addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt }) } if (isDiscussionThread && !parentEvent) { const processed = prefixNostrAddresses(cleanedText.trim()) const topicResolved = resolveTopicFromInput(threadTopicInput, allAvailableTopics) || threadSelectedTopic const tags = collectDiscussionThreadTags({ processedContent: processed, topicForTags: topicResolved, title: threadTitle, selectedGroup: threadSelectedGroup, dynamicTopics: discussionDynamicTopics, isReadingGroup: threadIsReadingGroup, author: threadReadingAuthor, subject: threadReadingSubject, isNsfw }) const draft: TDraftEvent = { kind: ExtendedKind.DISCUSSION, content: processed, tags, created_at: dayjs().unix() } mergeUploadImetaTagsInto(draft.tags, uploadImetaTagsOpt) return draft } // Check for voice comments (only for non-PM replies) if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) { const url = mediaUrl || 'placeholder://audio' const voiceImetaRows = mediaImetaTags.length > 0 ? [] : [['imeta', `url ${url}`, 'm audio/mpeg']] return await createVoiceCommentDraftEvent( cleanedText, parentEvent, url, voiceImetaRows, mentions, { addClientTag, protectedEvent: shouldUseProtectedEvent, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE_COMMENT), expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt } ) } // Media notes if (mediaNoteKind !== null && mediaUrl) { if (mediaNoteKind === ExtendedKind.VOICE) { const voiceImetaRows = mediaImetaTags.length > 0 ? [] : [['imeta', `url ${mediaUrl}`, 'm audio/mpeg']] return await createVoiceDraftEvent( cleanedText, mediaUrl, voiceImetaRows, mentions, { addClientTag, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE), expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt } ) } else if (mediaNoteKind === ExtendedKind.PICTURE) { return await createPictureDraftEvent( cleanedText, mediaImetaTags, mentions, { addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt } ) } else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) { return await createVideoDraftEvent( cleanedText, mediaImetaTags, mentions, mediaNoteKind, { addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt } ) } } // Parse topics from subject field for articles const topics = articleSubject.trim() ? articleSubject.split(/[,\s]+/).filter(s => s.trim()) : [] // Articles const isArticleDraft = isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent let effectiveArticleDTag = '' if (isArticleDraft) { const trimmedDTag = articleDTag.trim() if (trimmedDTag) { effectiveArticleDTag = trimmedDTag } else { const slug = isLongFormArticle ? 'longform-article' : isWikiArticle ? 'wiki-article' : isWikiArticleMarkdown ? 'wiki-markdown' : 'publication-content' const prev = articleDTagFallbackRef.current if (!prev || prev.slug !== slug) { articleDTagFallbackRef.current = { slug, value: `${slug}-${Math.floor(Date.now() / 1000)}` } } effectiveArticleDTag = articleDTagFallbackRef.current!.value } } if (isLongFormArticle) { return await createLongFormArticleDraftEvent(cleanedText, mentions, { dTag: effectiveArticleDTag, title: articleTitle.trim() || undefined, summary: articleSummary.trim() || undefined, image: articleImage.trim() || undefined, topics: topics.length > 0 ? topics : undefined, addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays }) } else if (isWikiArticle) { return await createWikiArticleDraftEvent(cleanedText, mentions, { dTag: effectiveArticleDTag, title: articleTitle.trim() || undefined, summary: articleSummary.trim() || undefined, image: articleImage.trim() || undefined, topics: topics.length > 0 ? topics : undefined, addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays }) } else if (isWikiArticleMarkdown) { return await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, { dTag: effectiveArticleDTag, title: articleTitle.trim() || undefined, summary: articleSummary.trim() || undefined, image: articleImage.trim() || undefined, topics: topics.length > 0 ? topics : undefined, addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays }) } else if (isPublicationContent) { return await createPublicationContentDraftEvent(cleanedText, mentions, { dTag: effectiveArticleDTag, title: articleTitle.trim() || undefined, summary: articleSummary.trim() || undefined, image: articleImage.trim() || undefined, topics: topics.length > 0 ? topics : undefined, addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays }) } // Citations if (isCitationInternal) { return createCitationInternalDraftEvent(cleanedText, { cTag: citationInternalCTag.trim(), relayHint: citationInternalRelayHint.trim() || undefined, title: citationTitle.trim() || undefined, author: citationAuthor.trim() || undefined, publishedOn: citationPublishedOn.trim() || undefined, accessedOn: citationAccessedOn.trim() || undefined, location: citationLocation.trim() || undefined, geohash: citationGeohash.trim() || undefined, summary: citationSummary.trim() || undefined }) } else if (isCitationExternal) { return createCitationExternalDraftEvent(cleanedText, { url: citationExternalUrl.trim(), accessedOn: citationAccessedOn.trim() || new Date().toISOString(), title: citationTitle.trim() || undefined, author: citationAuthor.trim() || undefined, publishedOn: citationPublishedOn.trim() || undefined, publishedBy: citationPublishedBy.trim() || undefined, version: citationVersion.trim() || undefined, location: citationLocation.trim() || undefined, geohash: citationGeohash.trim() || undefined, openTimestamp: citationExternalOpenTimestamp.trim() || undefined, summary: citationSummary.trim() || undefined }) } else if (isCitationHardcopy) { // Convert date strings to ISO 8601 format if they exist const formatDateToISO = (dateStr: string): string => { if (!dateStr || !dateStr.trim()) return '' // If already in ISO format, return as is if (dateStr.includes('T')) return dateStr // If in YYYY-MM-DD format, convert to ISO if (dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) { return new Date(dateStr + 'T00:00:00Z').toISOString() } return dateStr } const hardcopyOptions = { accessedOn: formatDateToISO(citationAccessedOn.trim()) || new Date().toISOString(), title: citationTitle.trim() || undefined, author: citationAuthor.trim() || undefined, pageRange: citationHardcopyPageRange.trim() || undefined, chapterTitle: citationHardcopyChapterTitle.trim() || undefined, editor: citationHardcopyEditor.trim() || undefined, publishedOn: citationPublishedOn.trim() ? formatDateToISO(citationPublishedOn.trim()) : undefined, publishedBy: citationPublishedBy.trim() || undefined, publishedIn: citationHardcopyPublishedIn.trim() || undefined, volume: citationHardcopyVolume.trim() || undefined, doi: citationHardcopyDoi.trim() || undefined, version: citationVersion.trim() || undefined, location: citationLocation.trim() || undefined, geohash: citationGeohash.trim() || undefined, summary: citationSummary.trim() || undefined } return createCitationHardcopyDraftEvent(cleanedText, hardcopyOptions) } else if (isCitationPrompt) { return createCitationPromptDraftEvent(cleanedText, { llm: citationPromptLlm.trim(), accessedOn: citationAccessedOn.trim() || new Date().toISOString(), version: citationVersion.trim() || undefined, summary: citationSummary.trim() || undefined, url: citationExternalUrl.trim() || undefined }) } // Highlights if (isHighlight) { return await createHighlightDraftEvent( cleanedText, highlightData.sourceType, highlightData.sourceValue, highlightData.context, undefined, { addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt } ) } // Comments and replies if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) { return await createCommentDraftEvent(cleanedText, parentEvent, mentions, { addClientTag, protectedEvent: shouldUseProtectedEvent, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.COMMENT), expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt }) } // Polls if (isPoll) { return await createPollDraftEvent(pubkey!, cleanedText, mentions, pollCreateData, { addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt }) } // Default: Short text note (kind 1), with optional NIP-94 imeta from uploads while still in "short note" mode return await createShortTextNoteDraftEvent(cleanedText, mentions, { parentEvent, addClientTag, protectedEvent: shouldUseProtectedEvent, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(kinds.ShortTextNote), expirationMonths, addQuietTag, quietDays, mediaImetaTags: uploadImetaTagsOpt }) }, [ parentEvent, mediaNoteKind, mediaUrl, mediaImetaTags, mentions, isDiscussionThread, threadTopicInput, allAvailableTopics, threadSelectedTopic, threadTitle, threadSelectedGroup, discussionDynamicTopics, threadIsReadingGroup, threadReadingAuthor, threadReadingSubject, isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent, isCitationInternal, isCitationExternal, isCitationHardcopy, isCitationPrompt, isHighlight, highlightData, isPublicMessage, extractedMentions, isPoll, pollCreateData, addClientTag, isNsfw, articleDTag, articleTitle, articleImage, articleSubject, articleSummary, pubkey, t ]) const applyLabTagOverrideToDraft = useCallback((draft: TDraftEvent): TDraftEvent => { if (!labTagOverrideRef.current) return draft const tags = labTagOverrideRef.current.map((r) => [...r]) labTagOverrideRef.current = null mergeUploadImetaTagsInto(tags, collectUploadImetaTagsForContentUrls(draft.content)) return { ...draft, tags } }, []) // Function to generate draft event JSON for preview const getDraftEventJson = useCallback(async (): Promise => { if (!pubkey) { return JSON.stringify({ error: 'Not logged in' }, null, 2) } try { // Clean tracking parameters from URLs in the post content const body = textareaRef.current?.getText() ?? text const cleanedText = rewritePlainTextHttpUrls(body) let draftEvent = await createDraftEvent(cleanedText) draftEvent = applyLabTagOverrideToDraft(draftEvent) return JSON.stringify(applyImwaldAttributionTags(draftEvent, { addClientTag }), null, 2) } catch (error) { return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) } }, [text, pubkey, isDiscussionThread, createDraftEvent, addClientTag, applyLabTagOverrideToDraft]) const applyComposerDraftJson = useCallback( (raw: string) => { const parsed = parseLabSlice(raw.trim()) if (!parsed.ok) { toast.error(parsed.error) return false } if (parsed.value.kind !== getDeterminedKind) { toast.error( t('composerJsonKindMismatch', { expected: String(getDeterminedKind), got: String(parsed.value.kind) }) ) return false } labTagOverrideRef.current = parsed.value.tags.map((r) => [...r]) textareaRef.current?.setDocumentFromPlainText(parsed.value.content) toast.success(t('composerJsonApplySuccess')) return true }, [getDeterminedKind, t] ) const advancedLabPersistenceKey = useMemo( () => postEditorCache.generateCacheKey({ kind: getDeterminedKind, defaultContent, parentEvent }), [getDeterminedKind, defaultContent, parentEvent] ) const handleOpenAdvancedLab = useCallback(async () => { await checkLogin(async () => { if (!pubkey) { toast.error(t('Log in to publish')) return } try { const body = textareaRef.current?.getText() ?? text const cleanedText = rewritePlainTextHttpUrls(body) let d = await createDraftEvent(cleanedText) d = applyLabTagOverrideToDraft(d) const labKey = advancedLabPersistenceKey const saved = postEditorCache.getAdvancedLabDraft(labKey) if (saved && saved.kind === d.kind) { setAdvancedLabInitial({ kind: saved.kind, content: saved.content, tags: saved.tags.map((row: string[]) => [...row]) }) } else { setAdvancedLabInitial({ kind: d.kind, content: d.content, tags: (d.tags ?? []).map((row: string[]) => [...row]) }) } setAdvancedLabOpen(true) } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } }) }, [ checkLogin, pubkey, text, createDraftEvent, applyLabTagOverrideToDraft, advancedLabPersistenceKey, t ]) const post = async (e?: React.MouseEvent) => { e?.stopPropagation() checkLogin(async () => { if (!canPost) { logger.warn('Attempted to post while canPost is false') return } if (isDiscussionThread && !parentEvent) { const newErrors: typeof threadErrors = {} const topicResolved = resolveTopicFromInput(threadTopicInput, allAvailableTopics) if (!threadTitle.trim()) { newErrors.title = t('Title is required') } else if (threadTitle.length > 100) { newErrors.title = t('Title must be 100 characters or less') } if (!topicResolved) { newErrors.topic = t('Topic is required') } if (!text.trim()) { newErrors.content = t('Content is required') } else if (text.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 (threadIsReadingGroup) { if (!threadReadingAuthor.trim()) { newErrors.author = t('Author is required for reading groups') } if (!threadReadingSubject.trim()) { newErrors.subject = t('Subject (book title) is required for reading groups') } } if (topicResolved === 'groups' && !threadSelectedGroup.trim()) { newErrors.group = t('Please select a group') } setThreadErrors(newErrors) if (Object.keys(newErrors).length > 0) { return } } // console.log('🚀 Starting post process:', { // isPublicMessage, // parentEventKind: parentEvent?.kind, // parentEventId: parentEvent?.id, // text: text.substring(0, 50) + '...', // mentions: mentions.length, // canPost // }) setPosting(true) let newEvent: any = null let draftEvent: any = null try { // Clean tracking parameters from URLs in the post content const cleanedText = rewritePlainTextHttpUrls(text) // Determine relay URLs for private events let privateRelayUrls: string[] = [] const isPrivateEvent = isPublicationContent || isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt if (isPrivateEvent) { // Use all private relays (outbox + cache) privateRelayUrls = await getPrivateRelayUrls(pubkey!) } // Create draft event using shared function draftEvent = await createDraftEvent(cleanedText) draftEvent = applyLabTagOverrideToDraft(draftEvent) const publishSuccessMessage = parentEvent ? t('Reply published') : isDiscussionThread && !parentEvent ? t('Thread published') : t('Post published') // console.log('Publishing draft event:', draftEvent) // For private events, only publish to private relays const relayUrls = isPrivateEvent && privateRelayUrls.length > 0 ? privateRelayUrls : (additionalRelayUrls.length > 0 ? additionalRelayUrls : undefined) newEvent = await publish(draftEvent, { specifiedRelayUrls: relayUrls, additionalRelayUrls: isPoll ? pollCreateData.relays : (isPrivateEvent ? privateRelayUrls : additionalRelayUrls), minPow, disableFallbacks: additionalRelayUrls.length > 0 || isPrivateEvent, // Don't use fallbacks if user explicitly selected relays or for private events addClientTag }) // console.log('Published event:', newEvent) // Check if we need to refresh the current relay view if (feedInfo.feedType === 'relay' && feedInfo.id) { const currentRelayUrl = normalizeUrl(feedInfo.id) const publishedRelays = additionalRelayUrls // If we published to the current relay being viewed, trigger a refresh after a short delay if (publishedRelays.some(url => normalizeUrl(url) === currentRelayUrl)) { setTimeout(() => { // Trigger a page refresh by dispatching a custom event that the relay view can listen to window.dispatchEvent(new CustomEvent('relay-refresh-needed', { detail: { relayUrl: currentRelayUrl } })) }, 1000) // 1 second delay to allow the event to propagate } } // Show publishing feedback if ((newEvent as any).relayStatuses) { showPublishingFeedback({ success: true, relayStatuses: (newEvent as any).relayStatuses, successCount: (newEvent as any).relayStatuses.filter((s: any) => s.success).length, totalCount: (newEvent as any).relayStatuses.length }, { message: publishSuccessMessage, duration: 6000 }) } else { showSimplePublishSuccess(publishSuccessMessage) } // Full success - clean up and close postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) if (isDiscussionThread && !parentEvent) { postEditorCache.clearPostCache(discussionThreadDraftKindParams()) postEditorCache.clearThreadDraft() discussionFeedCache.clearDiscussionsListCache() } deleteDraftEventCache(draftEvent) const relayStatuses = (newEvent as any).relayStatuses as TRelayPublishStatus[] | undefined const cleanEvent = { ...newEvent } delete (cleanEvent as any).relayStatuses if (parentEvent) { mergePublishedReplyIntoThread(cleanEvent, relayStatuses) } onPublishSuccess?.() close() } catch (error) { if (error instanceof LoginRequiredError) { return } // AggregateError = "Failed to publish to any relay" is already logged in NostrProvider with relayStatuses; avoid duplicate noise if (!(error instanceof AggregateError && error.message === 'Failed to publish to any relay')) { logger.error('Publishing error', { error }) logger.error('Publishing error details', { name: error instanceof Error ? error.name : 'Unknown', message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }) } // Check if we have relay statuses to display (even if publishing failed) if (error instanceof AggregateError && (error as any).relayStatuses) { const relayStatuses = (error as any).relayStatuses const successCount = relayStatuses.filter((s: any) => s.success).length const totalCount = relayStatuses.length // Show proper relay status feedback showPublishingFeedback({ success: successCount > 0, relayStatuses, successCount, totalCount }, { message: successCount > 0 ? (parentEvent ? t('Reply published to some relays') : t('Post published to some relays')) : (parentEvent ? t('Failed to publish reply') : t('Failed to publish post')), duration: 6000 }) // Handle partial success: show reply immediately (event already emitted by NostrProvider) if (successCount > 0) { const partialEvent = (error as any).event ?? newEvent if (parentEvent && partialEvent) { const clean = { ...partialEvent } delete (clean as any).relayStatuses mergePublishedReplyIntoThread(clean, (error as any).relayStatuses) } postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) if (isDiscussionThread && !parentEvent) { postEditorCache.clearPostCache(discussionThreadDraftKindParams()) postEditorCache.clearThreadDraft() discussionFeedCache.clearDiscussionsListCache() } if (draftEvent) deleteDraftEventCache(draftEvent) onPublishSuccess?.() close() } } else { // Use standard publishing error feedback for cases without relay statuses if (error instanceof AggregateError) { const errorMessages = error.errors.map((err: any) => err.message).join('; ') showPublishingError(`Failed to publish to relays: ${errorMessages}`) } else if (error instanceof Error) { showPublishingError(error.message) } else { showPublishingError('Failed to publish') } // Don't close form on complete failure - let user try again } } finally { setPosting(false) } }) } const handlePollToggle = () => { if (parentEvent) return setIsPoll((prev) => !prev) if (!isPoll) { // When enabling poll mode, clear other modes setIsPublicMessage(false) setIsHighlight(false) setIsLongFormArticle(false) setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) setIsDiscussionThread(false) setMediaNoteKind(null) setMediaUrl('') setMediaImetaTags([]) composerImetaTagsRef.current = [] } } const handlePublicMessageToggle = () => { if (parentEvent) return setIsPublicMessage((prev) => !prev) if (!isPublicMessage) { // When enabling public message mode, clear other modes setIsPoll(false) setIsHighlight(false) setIsLongFormArticle(false) setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) setIsDiscussionThread(false) setMediaNoteKind(null) setMediaUrl('') setMediaImetaTags([]) composerImetaTagsRef.current = [] } } const handlePlainNoteMode = () => { if (parentEvent) return setIsPoll(false) setIsPublicMessage(false) setIsHighlight(false) setIsLongFormArticle(false) setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) setIsDiscussionThread(false) // Short note (kind 1) still supports NIP-94 imeta; only Clear should drop uploads/tags. setMediaNoteKind(null) } const inferKindFromEditorMediaUrl = (url: string): number | null => { const path = url.split(/[?#]/)[0].toLowerCase() if (/\.(jpg|jpeg|png|gif|webp|heic|avif|apng)$/i.test(path)) return ExtendedKind.PICTURE if (/\.(mp3|m4a|mka|ogg|opus|wav|aac|flac)$/i.test(path)) return ExtendedKind.VOICE if (/\.(mp4|webm|mov|mkv|m4v|ogv|avi|mpeg|mpg|3gp|3g2)$/i.test(path)) return ExtendedKind.SHORT_VIDEO return null } const mimeFromUrlPathForKind = (url: string, kind: number): string => { const path = url.split(/[?#]/)[0].toLowerCase() if (kind === ExtendedKind.PICTURE) { if (path.endsWith('.png')) return 'image/png' if (path.endsWith('.webp')) return 'image/webp' if (path.endsWith('.gif')) return 'image/gif' return 'image/jpeg' } if (kind === ExtendedKind.VOICE) { if (path.endsWith('.mka')) return 'audio/x-matroska' if (path.endsWith('.ogg')) return 'audio/ogg' if (path.endsWith('.webm')) return 'audio/webm' return 'audio/mpeg' } if (path.endsWith('.mkv')) return 'video/x-matroska' if (path.endsWith('.webm')) return 'video/webm' if (path.endsWith('.3gp')) return 'video/3gpp' if (path.endsWith('.3g2')) return 'video/3gpp2' return 'video/mp4' } const textLooksLikeImetaWithUrl = (s: string): boolean => /\bimeta\b[\s\S]{0,400}?\burl\s+https?:\/\//i.test(s) const firstHttpUrlInNoteText = (s: string): string | undefined => { const m = s.match(/https?:\/\/[^\s<>\])}'"]+/) return m?.[0] } const canUseMediaKindFromUrlButton = useMemo(() => { if (parentEvent || isDiscussionThread || isPublicMessage) return false if (mediaNoteKind !== null && mediaUrl) return false if (mediaImetaTags.length > 0) return true if (mediaUrl) return true if (textLooksLikeImetaWithUrl(text)) return true const u = firstHttpUrlInNoteText(text) return !!(u && inferKindFromEditorMediaUrl(u) !== null) }, [ parentEvent, isDiscussionThread, isPublicMessage, mediaNoteKind, mediaUrl, mediaImetaTags, text ]) /** When the editor already contains a media URL (e.g. after drop/paste) but kind stayed 1. */ const handleUseMediaNoteKindFromUrl = () => { if (parentEvent || isDiscussionThread || isPublicMessage) return if (mediaNoteKind !== null && mediaUrl) { toast.info(t('Already publishing as a media note')) return } const raw = textareaRef.current?.getText() ?? text const m = raw.match(/https?:\/\/[^\s<>\])}'"]+/) const found = m?.[0] if (!found) { toast.info(t('No media URL in note — upload or paste a link first')) return } const kind = inferKindFromEditorMediaUrl(found) if (kind === null) { toast.info(t('Cannot infer media type from URL — use Note type → Media Note to upload')) return } setIsPoll(false) setIsHighlight(false) setIsLongFormArticle(false) setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) setIsDiscussionThread(false) setMediaUrl(found) setMediaNoteKind(kind) const mime = mimeFromUrlPathForKind(found, kind) const synth: string[] = ['imeta', `url ${found}`, `m ${mime}`] const foundNorm = cleanUrl(found) || found setMediaImetaTags((prev) => { const has = prev.some((tag) => { const u = tag.find((x) => typeof x === 'string' && x.startsWith('url ')) if (!u) return false const r = u.slice(4).trim() return (cleanUrl(r) || r) === foundNorm }) const next = has ? prev : [...prev, synth] composerImetaTagsRef.current = next return next }) } const isPlainShortNoteToolbar = useMemo( () => !parentEvent && !isPoll && !isPublicMessage && !isHighlight && !isLongFormArticle && !isWikiArticle && !isWikiArticleMarkdown && !isPublicationContent && !isCitationInternal && !isCitationExternal && !isCitationHardcopy && !isCitationPrompt && !isDiscussionThread && mediaNoteKind === null, [ parentEvent, isPoll, isPublicMessage, isHighlight, isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent, isCitationInternal, isCitationExternal, isCitationHardcopy, isCitationPrompt, isDiscussionThread, mediaNoteKind ] ) const handleHighlightToggle = () => { if (parentEvent) return setIsHighlight((prev) => !prev) if (!isHighlight) { // When enabling highlight mode, clear other modes and set client tag to true setIsPoll(false) setIsPublicMessage(false) setIsLongFormArticle(false) setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) setIsDiscussionThread(false) setMediaNoteKind(null) setMediaUrl('') setMediaImetaTags([]) composerImetaTagsRef.current = [] setAddClientTag(true) } } const handleDiscussionThreadToggle = () => { if (parentEvent) return if (!isDiscussionThread) { setIsPoll(false) setIsPublicMessage(false) setIsHighlight(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) setMediaNoteKind(null) setMediaUrl('') setMediaImetaTags([]) composerImetaTagsRef.current = [] const draft = postEditorCache.getThreadDraft() if (draft) { setThreadTitle(draft.title) setText(draft.content) setThreadSelectedTopic(draft.topic) const predefined = DISCUSSION_TOPICS.find((x) => x.id === draft.topic) const dyn = discussionDynamicTopics?.allTopics.find((x) => x.id === draft.topic) setThreadTopicInput(predefined?.label ?? dyn?.label ?? draft.topic) } else { setThreadTitle('') setThreadSelectedTopic('general') const row = DISCUSSION_TOPICS.find((x) => x.id === 'general') setThreadTopicInput(row?.label ?? 'general') } setThreadErrors({}) setIsDiscussionThread(true) } else { setIsDiscussionThread(false) setThreadErrors({}) } } const handleUploadCompressPhase = useCallback((file: File, phase: 'compressing' | 'uploading') => { setUploadProgresses((prev) => prev.map((row) => row.file === file ? { ...row, phase, progress: phase === 'uploading' ? 0 : row.progress } : row ) ) }, []) const handleUploadCompressProgress = useCallback((file: File, percent: number) => { const p = Math.max(0, Math.min(100, Math.round(percent))) setUploadProgresses((prev) => prev.map((row) => row.file === file && row.phase === 'compressing' ? { ...row, progress: p } : row ) ) }, []) const handleUploadStart = (file: File, cancel: () => void) => { setUploadProgresses((prev) => [ ...prev, { file, progress: 0, cancel, phase: fileLooksLikeUploadableMedia(file) ? 'compressing' : 'uploading' } ]) // Track file for media upload if (fileLooksLikeUploadableMedia(file)) { const mapKey = `${file.name}-${file.size}-${file.lastModified}` uploadedMediaFileMap.current.set(mapKey, file) // For replies and PMs, if it's an audio file, set mediaNoteKind immediately for preview if (parentEvent || isPublicMessage) { const fileType = file.type const fileName = file.name.toLowerCase() // Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4 const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg' const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName) // For replies/PMs, webm/ogg/mp3/m4a files should be treated as audio since the microphone button only accepts audio/* // Even if the MIME type is incorrect, if it came through the audio uploader, it's audio const isWebmFile = /\.webm$/i.test(fileName) const isOggFile = /\.ogg$/i.test(fileName) const isMp3File = /\.mp3$/i.test(fileName) // m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this) const isM4aFile = /\.m4a$/i.test(fileName) const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime // For replies/PMs, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files) // m4a files are always audio, even if MIME type is wrong const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File if (isAudio) { // For PM replies, don't set mediaNoteKind - let PM reply handle it with imeta tags if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { // Don't set mediaNoteKind - PM replies stay as kind 24 with imeta tags } else if (parentEvent) { setMediaNoteKind(ExtendedKind.VOICE_COMMENT) } else if (isPublicMessage) { setMediaNoteKind(ExtendedKind.VOICE) } // Note: URL will be inserted when upload completes in handleMediaUploadSuccess } } // Root composer: video/voice kinds are set in processMediaUpload; images stay kind 1 with imeta (ambiguous types use the dialog). } } const handleUploadProgress = (file: File, progress: number) => { setUploadProgresses((prev) => prev.map((item) => item.file === file ? { ...item, progress } : item ) ) } const handleUploadEnd = (file: File) => { setUploadProgresses((prev) => prev.filter((item) => item.file !== file)) // Keep file in map until upload success is called } // Helper function to check if a file could be either audio or video const isAmbiguousMediaFile = (file: File): boolean => { if (parentEvent) { // For replies, we don't show the dialog - audio button only accepts audio/* return false } const fileType = file.type const fileName = file.name.toLowerCase() // Check if it's a webm or mp4 file that could be either audio or video const isWebm = /\.webm$/i.test(fileName) const isMp4 = /\.mp4$/i.test(fileName) if (isWebm || isMp4) { // If MIME type is missing, it's ambiguous if (!fileType || fileType === 'application/octet-stream') { return true } const isAudioMime = fileType.startsWith('audio/') const isVideoMime = fileType.startsWith('video/') // If MIME type doesn't clearly indicate one or the other, it's ambiguous // Some browsers report video/webm for audio-only webm files, so we show the dialog // to let the user choose if (isWebm) { // WebM files are often misreported, so show dialog return true } if (isMp4) { // MP4 files can be audio or video - if MIME type is video/mp4 but could be audio, // or if it's unclear, show dialog // Only show if MIME type suggests it could be either if (!isAudioMime && !isVideoMime) { return true } // If it's video/mp4, it could still be audio-only, so show dialog if (isVideoMime) { return true } } } return false } const handleMediaKindSelection = (selectedKind: number) => { if (!pendingMediaUpload) return const { url, tags, file, urlAlreadyInEditor } = pendingMediaUpload setShowMediaKindDialog(false) setPendingMediaUpload(null) // Process the upload with the selected kind processMediaUpload(url, tags, file, selectedKind, { skipComposerUrlAppend: urlAlreadyInEditor === true }) } const processMediaUpload = async ( url: string, tags: string[][], uploadingFile: File, selectedKind?: number, opts?: { skipComposerUrlAppend?: boolean } ) => { try { let resolvedKind: number if (selectedKind !== undefined) { resolvedKind = selectedKind } else { resolvedKind = await getMediaKindFromFile(uploadingFile, false) } // New-post composer: images stay kind 1 (short text + imeta + URL), not kind 20 picture notes. if (resolvedKind === ExtendedKind.PICTURE) { setMediaNoteKind(null) setMediaUrl('') } else { setMediaNoteKind(resolvedKind) setMediaUrl(url) } const imetaTag = mediaUpload.getImetaTagByUrl(url) let newImetaTag: string[] if (imetaTag) { newImetaTag = imetaTag } else if (tags && tags.length > 0) { newImetaTag = nip94PairsToImetaTag(tags) } else { newImetaTag = ['imeta', `url ${url}`] let mimeType = uploadingFile.type const kindHint = selectedKind ?? resolvedKind if (kindHint === ExtendedKind.VOICE || kindHint === ExtendedKind.VOICE_COMMENT) { const fileName = uploadingFile.name.toLowerCase() if (/\.webm$/i.test(fileName)) { mimeType = 'audio/webm' } else if (/\.mka$/i.test(fileName)) { mimeType = 'audio/x-matroska' } else if (/\.mp4$/i.test(fileName)) { mimeType = 'audio/mp4' } } else if (kindHint === ExtendedKind.VIDEO || kindHint === ExtendedKind.SHORT_VIDEO) { const fileName = uploadingFile.name.toLowerCase() if (/\.webm$/i.test(fileName)) { mimeType = 'video/webm' } else if (/\.mkv$/i.test(fileName)) { mimeType = 'video/x-matroska' } else if (/\.mp4$/i.test(fileName)) { mimeType = 'video/mp4' } } if (mimeType) { newImetaTag.push(`m ${mimeType}`) } } appendComposerImetaTag(newImetaTag) if (!opts?.skipComposerUrlAppend) { const treatAsImage = resolvedKind === ExtendedKind.PICTURE || (uploadingFile.type?.startsWith('image/') ?? false) || imageUrlLooksLikeHttpImage(url) setTimeout(() => { appendUploadedUrlToComposer(url, treatAsImage) }, 100) } } catch (error) { logger.error('Error processing media upload', { error, file: uploadingFile.name }) const imetaTag = mediaUpload.getImetaTagByUrl(url) const tagToAdd = imetaTag ?? (() => { const basic: string[] = ['imeta', `url ${url}`] if (uploadingFile.type) basic.push(`m ${uploadingFile.type}`) return basic })() appendComposerImetaTag(tagToAdd) if (mediaNoteKindRef.current !== null) { setMediaUrl((prev) => prev || url) } } } const handleMediaUploadSuccess = async ({ url, tags, file: fileFromCallback, urlAlreadyInEditor }: { url: string tags: string[][] file?: File urlAlreadyInEditor?: boolean }) => { try { let uploadingFile: File | undefined = fileFromCallback if (!uploadingFile) { for (const [, file] of uploadedMediaFileMap.current.entries()) { uploadingFile = file break } } if (!uploadingFile) { const progressItem = uploadProgresses.find((p) => p.file) uploadingFile = progressItem?.file } if (!uploadingFile) { logger.warn('Media upload succeeded but file not found') return } if (isDiscussionThread && !parentEvent) { if (!urlAlreadyInEditor) { setTimeout(() => { appendUploadedUrlToComposer(url, imageUrlLooksLikeHttpImage(url)) }, 100) } uploadedMediaFileMap.current.delete(`${uploadingFile.name}-${uploadingFile.size}-${uploadingFile.lastModified}`) handleUploadEnd(uploadingFile) return } // Determine media kind from file // For replies, only audio comments are supported (kind 1244) // For new PMs, audio messages are supported (kind 1222) // For new posts, all media types are supported if (parentEvent || isPublicMessage) { // For replies and PMs, only allow audio const fileType = uploadingFile.type const fileName = uploadingFile.name.toLowerCase() // Check for audio files - including mp4/m4a/webm/ogg/mp3 which can be audio // mp4/m4a/webm/ogg/mp3 files can be audio if MIME type is audio/* // For replies/PMs, webm/ogg/mp3 files should be treated as audio since the microphone button only accepts audio/* // Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4 const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg' const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName) // m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this) const isM4aFile = /\.m4a$/i.test(fileName) const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime const isWebmFile = /\.webm$/i.test(fileName) const isOggFile = /\.ogg$/i.test(fileName) const isMp3File = /\.mp3$/i.test(fileName) // For replies/PMs, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files) // m4a files are always audio, even if MIME type is wrong const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File if (isAudio) { // For PM replies, don't set mediaNoteKind - let PM reply handle it with imeta tags if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { // Don't set mediaNoteKind - PM replies stay as kind 24 with imeta tags // Just set the URL and imeta tags } else if (parentEvent) { // For regular replies, always create voice comments (kind 1244), regardless of duration setMediaNoteKind(ExtendedKind.VOICE_COMMENT) } else if (isPublicMessage) { // For new PMs, create voice notes (kind 1222) setMediaNoteKind(ExtendedKind.VOICE) } setMediaUrl(url) // Get imeta tag from media upload service const imetaTag = mediaUpload.getImetaTagByUrl(url) if (imetaTag) { setMediaImetaTags([imetaTag]) composerImetaTagsRef.current = [imetaTag] } else if (tags && tags.length > 0) { const nipRow = nip94PairsToImetaTag(tags) setMediaImetaTags([nipRow]) composerImetaTagsRef.current = [nipRow] } else { const basicImetaTag: string[] = ['imeta', `url ${url}`] // For webm/ogg/mp3/m4a files uploaded via microphone, ensure MIME type is set to audio/* // even if the browser reports video/webm or video/mp4 (mobile browsers sometimes do this) let mimeType = uploadingFile.type const fileName = uploadingFile.name.toLowerCase() if (/\.m4a$/i.test(fileName)) { // m4a files are always audio, use audio/mp4 or audio/x-m4a mimeType = 'audio/mp4' } else if (/\.mka$/i.test(fileName)) { mimeType = 'audio/x-matroska' } else if (/\.webm$/i.test(fileName) && !mimeType.startsWith('audio/')) { mimeType = 'audio/webm' } else if (/\.ogg$/i.test(fileName) && !mimeType.startsWith('audio/')) { mimeType = 'audio/ogg' } else if (/\.mp3$/i.test(fileName) && !mimeType.startsWith('audio/')) { mimeType = 'audio/mpeg' } if (mimeType) { basicImetaTag.push(`m ${mimeType}`) } setMediaImetaTags([basicImetaTag]) composerImetaTagsRef.current = [basicImetaTag] } // Insert the URL into the editor content so it shows in the edit pane // Use setTimeout to ensure the state has updated and editor is ready if (!urlAlreadyInEditor) { setTimeout(() => { appendUploadedUrlToComposer(url, false) }, 100) } } else { // Non-audio media in replies/PMs - don't set mediaNoteKind, will be handled as regular comment/PM // Clear any existing media note kind setMediaNoteKind(null) setMediaUrl('') setMediaImetaTags([]) composerImetaTagsRef.current = [] if (!urlAlreadyInEditor) { appendUploadedUrlToComposer(url, imageUrlLooksLikeHttpImage(url)) } return // Don't set media note kind for non-audio in replies/PMs } } else { // For new posts, check if file is ambiguous (could be audio or video) if (isAmbiguousMediaFile(uploadingFile)) { // Show dialog to let user choose setPendingMediaUpload({ url, tags, file: uploadingFile, urlAlreadyInEditor: urlAlreadyInEditor === true }) setShowMediaKindDialog(true) return } // Not ambiguous, auto-detect and process await processMediaUpload(url, tags, uploadingFile, undefined, { skipComposerUrlAppend: urlAlreadyInEditor === true }) } } catch (error) { logger.error('Error in handleMediaUploadSuccess', { error }) // Don't throw - just log the error so the upload doesn't fail completely } // Clear other note types when media is selected setIsPoll(false) setIsPublicMessage(false) setIsHighlight(false) setIsLongFormArticle(false) setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) setIsDiscussionThread(false) // Clear uploaded file map (upload finished). Keep composerImetaTagsRef in sync with mediaImetaTags — do not wipe here. uploadedMediaFileMap.current.clear() } const toolbarUploadHandlers = useMemo( () => ({ onUploadSuccess: handleMediaUploadSuccess, onUploadStart: handleUploadStart, onUploadEnd: handleUploadEnd, onProgress: handleUploadProgress, onUploadCompressPhase: handleUploadCompressPhase, onUploadCompressProgress: handleUploadCompressProgress }), [ handleMediaUploadSuccess, handleUploadStart, handleUploadEnd, handleUploadProgress, handleUploadCompressPhase, handleUploadCompressProgress ] ) const handleArticleToggle = (type: 'longform' | 'wiki' | 'wiki-markdown' | 'publication') => { if (parentEvent) return // Can't create articles as replies setIsLongFormArticle(type === 'longform') setIsWikiArticle(type === 'wiki') setIsWikiArticleMarkdown(type === 'wiki-markdown') setIsPublicationContent(type === 'publication') // Clear other types setIsPoll(false) setIsPublicMessage(false) setIsHighlight(false) setMediaNoteKind(null) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) setIsDiscussionThread(false) // Clear article metadata when switching off article mode if (type === null) { setArticleTitle('') setArticleDTag('') setArticleImage('') setArticleSubject('') setArticleSummary('') setArticleSummary('') } // Clear article fields when toggling off if (type === 'longform' || type === 'wiki' || type === 'wiki-markdown' || type === 'publication') { // Keep fields when switching between article types } else { setArticleTitle('') setArticleDTag('') setArticleImage('') setArticleSubject('') setArticleSummary('') } } const handleCitationToggle = (type: 'internal' | 'external' | 'hardcopy' | 'prompt') => { if (parentEvent) return // Can't create citations as replies setIsCitationInternal(type === 'internal') setIsCitationExternal(type === 'external') setIsCitationHardcopy(type === 'hardcopy') setIsCitationPrompt(type === 'prompt') // Clear other types setIsPoll(false) setIsPublicMessage(false) setIsHighlight(false) setMediaNoteKind(null) setIsLongFormArticle(false) setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) setIsDiscussionThread(false) // Set default accessedOn if not already set if (!citationAccessedOn && (type === 'external' || type === 'hardcopy' || type === 'prompt')) { setCitationAccessedOn(new Date().toISOString().split('T')[0]) // ISO date format YYYY-MM-DD } } const handleClear = () => { const wasDiscussion = isDiscussionThread // Clear the post editor cache postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) // Clear the editor content textareaRef.current?.clear() // Reset all state setText('') setMediaNoteKind(null) setMediaUrl('') setMediaImetaTags([]) setMentions([]) setExtractedMentions([]) setIsPoll(false) setIsPublicMessage(false) setIsHighlight(false) setIsLongFormArticle(false) setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) setIsDiscussionThread(false) setThreadTitle('') setThreadSelectedTopic('general') const gRow = DISCUSSION_TOPICS.find((x) => x.id === 'general') setThreadTopicInput(gRow?.label ?? 'general') setThreadSelectedGroup('') setThreadIsReadingGroup(false) setThreadReadingAuthor('') setThreadReadingSubject('') setThreadShowReadingsPanel(false) setThreadErrors({}) if (wasDiscussion) { postEditorCache.clearThreadDraft() postEditorCache.clearPostCache(discussionThreadDraftKindParams()) } // Clear citation fields setCitationInternalCTag('') setCitationInternalRelayHint('') setCitationExternalUrl('') setCitationExternalOpenTimestamp('') setCitationHardcopyPageRange('') setCitationHardcopyChapterTitle('') setCitationHardcopyEditor('') setCitationHardcopyPublishedIn('') setCitationHardcopyVolume('') setCitationHardcopyDoi('') setCitationTitle('') setCitationAuthor('') setCitationPublishedOn('') setCitationPublishedBy('') setCitationAccessedOn('') setCitationLocation('') setCitationGeohash('') setCitationVersion('') setCitationSummary('') setCitationPromptLlm('') setPollCreateData({ isMultipleChoice: false, options: ['', ''], endsAt: undefined, relays: [] }) setHighlightData({ sourceType: 'nostr', sourceValue: '' }) uploadedMediaFileMap.current.clear() composerImetaTagsRef.current = [] setUploadProgresses([]) } return (
{/* Dynamic Title based on mode */}
{(() => { const determinedKind = getDeterminedKind if (parentEvent) { if (parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { return t('Reply to Public Message') } else if (determinedKind === ExtendedKind.VOICE_COMMENT) { return t('Voice Comment') } else { return t('Reply to') } } else if (determinedKind === ExtendedKind.VOICE) { return t('Voice Note') } else if (determinedKind === ExtendedKind.PICTURE) { return t('Picture Note') } else if (determinedKind === ExtendedKind.VIDEO) { return t('Video Note') } else if (determinedKind === ExtendedKind.SHORT_VIDEO) { return t('Short Video Note') } else if (determinedKind === ExtendedKind.POLL) { return t('New Poll') } else if (determinedKind === ExtendedKind.PUBLIC_MESSAGE) { return t('New Public Message') } else if (determinedKind === kinds.Highlights) { return t('New Highlight') } else if (determinedKind === ExtendedKind.DISCUSSION) { return t('New Discussion') } else if (determinedKind === kinds.LongFormArticle) { return t('New Long-form Article') } else if (determinedKind === ExtendedKind.WIKI_ARTICLE) { return t('New Wiki Article') } else if (determinedKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { return t('New Wiki Article (Markdown)') } else if (determinedKind === ExtendedKind.PUBLICATION_CONTENT) { return t('Take a note') } else if (determinedKind === ExtendedKind.CITATION_INTERNAL) { return t('New Internal Citation') } else if (determinedKind === ExtendedKind.CITATION_EXTERNAL) { return t('New External Citation') } else if (determinedKind === ExtendedKind.CITATION_HARDCOPY) { return t('New Hardcopy Citation') } else if (determinedKind === ExtendedKind.CITATION_PROMPT) { return t('New Prompt Citation') } else { return t('New Note') } })()}
{parentEvent && (
)} {isDiscussionThread && !parentEvent && (
setThreadTopicInput(e.target.value)} onBlur={() => { const r = resolveTopicFromInput(threadTopicInput, allAvailableTopics) if (r) { setThreadSelectedTopic(r) setThreadTopicInput(displayTopicLabel(r, allAvailableTopics)) } }} placeholder={t('Type a topic or pick from the list')} autoComplete="off" className={cn('min-w-0 flex-1 bg-background', threadErrors.topic && 'border-destructive')} />

{t('Suggested topics')}

{allAvailableTopics.map((topic, index) => { const Icon = topic.icon return (
{ setThreadSelectedTopic(topic.id) setThreadTopicInput(topic.label) setThreadTopicPopoverOpen(false) }} > {topic.label}
) })}
{threadErrors.topic &&

{threadErrors.topic}

}

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

{threadTopicResolved === 'groups' && (
{userGroups.length === 0 ? (
{t('No groups available. Join some groups first.')}
) : ( userGroups.map((groupId) => (
{ setThreadSelectedGroup(groupId) setThreadGroupPopoverOpen(false) }} > {groupId}
)) )}
{threadErrors.group &&

{threadErrors.group}

}

{t('Select the group where you want to create this discussion.')}

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

{threadErrors.title}

}

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

{threadTopicResolved === 'literature' && (
{threadShowReadingsPanel && (
{threadIsReadingGroup && (
setThreadReadingAuthor(e.target.value)} placeholder={t('Enter the author name')} className={threadErrors.author ? 'border-destructive' : ''} /> {threadErrors.author &&

{threadErrors.author}

}
setThreadReadingSubject(e.target.value)} placeholder={t('Enter the book title')} className={threadErrors.subject ? 'border-destructive' : ''} /> {threadErrors.subject &&

{threadErrors.subject}

}

{t( 'This will add additional tags for author and subject to help organize reading group discussions.' )}

)}
)}
)}
)} {/* Article metadata fields */} {(isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent) && (
setArticleDTag(e.target.value)} placeholder={t('e.g., my-article-title')} />

{t('articleDTagDefaultHint')}

setArticleTitle(e.target.value)} placeholder={t('Article title (optional)')} />
setArticleImage(e.target.value)} placeholder={t('https://example.com/image.jpg')} />

{t('URL of the article cover image (optional)')}

setArticleSubject(e.target.value)} placeholder={t('topic1, topic2, topic3')} />

{t('Comma or space-separated topics (will be added as t-tags)')}