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, 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 } from '@/lib/draft-event' import { ExtendedKind } from '@/constants' import { isTouchDevice } 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 { normalizeUrl, rewritePlainTextHttpUrls } from '@/lib/url' import logger from '@/lib/logger' import postEditorCache from '@/services/post-editor-cache.service' import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' import { ImageUp, ListTodo, MessageCircle, MessagesSquare, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic, Music, Video, Film } from 'lucide-react' import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' 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 CreateThreadDialog from '@/pages/primary/DiscussionsPage/CreateThreadDialog' 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 { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import EmojiPickerDialog from '../EmojiPickerDialog' import GifPicker from '../GifPicker' 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 { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAndEventToolbarButtons' import Uploader from './Uploader' import HighlightEditor, { HighlightData } from './HighlightEditor' export default function PostContent({ defaultContent = '', parentEvent, close, openFrom, initialHighlightData, initialPublicMessageTo, onPublishSuccess }: { 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 }) { const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() 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 [posting, setPosting] = useState(false) const [uploadProgresses, setUploadProgresses] = useState< { file: File; progress: number; cancel: () => void }[] >([]) const [showMoreOptions, setShowMoreOptions] = 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([]) 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 [createThreadOpen, setCreateThreadOpen] = useState(false) 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 } | null>(null) const uploadedMediaFileMap = useRef>(new Map()) /** Accumulates imeta tags for kind 20 (picture) so multiple rapid uploads don’t overwrite each other. */ const pictureImetaTagsRef = useRef([]) useEffect(() => { if (mediaNoteKind === ExtendedKind.PICTURE && mediaImetaTags.length > 0) { pictureImetaTagsRef.current = mediaImetaTags } }, [mediaNoteKind, mediaImetaTags]) const isFirstRender = useRef(true) const canPost = useMemo(() => { const isArticle = isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent const result = ( !!pubkey && !posting && !uploadProgresses.length && // 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 articles, dTag is mandatory (!isArticle || !!articleDTag.trim()) && // For citations, required fields must be filled (!isCitationInternal || !!citationInternalCTag.trim()) && (!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) && (!isCitationHardcopy || !!citationAccessedOn.trim()) && (!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) ) return result }, [ pubkey, text, posting, uploadProgresses, mediaNoteKind, mediaUrl, isPoll, pollCreateData, isPublicMessage, extractedMentions, parentEvent?.kind, isProtectedEvent, additionalRelayUrls, isHighlight, highlightData, isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent, articleDTag, isCitationInternal, citationInternalCTag, isCitationExternal, citationExternalUrl, citationAccessedOn, isCitationHardcopy, isCitationPrompt, citationPromptLlm ]) // 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]) // 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 (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, isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent, isCitationInternal, isCitationExternal, isCitationHardcopy, isCitationPrompt, isHighlight, isPublicMessage, isPoll, parentEvent ]) 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 => { // 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: mediaNoteKind !== null && mediaUrl ? mediaImetaTags : undefined }) } 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: mediaNoteKind !== null && mediaUrl ? mediaImetaTags : undefined }) } // Check for voice comments (only for non-PM replies) if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) { const url = mediaUrl || 'placeholder://audio' const tags = mediaImetaTags.length > 0 ? mediaImetaTags : [['imeta', `url ${url}`, 'm audio/mpeg']] return await createVoiceCommentDraftEvent( cleanedText, parentEvent, url, tags, mentions, { addClientTag, protectedEvent: shouldUseProtectedEvent, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE_COMMENT), expirationMonths, addQuietTag, quietDays } ) } // Media notes if (mediaNoteKind !== null && mediaUrl) { if (mediaNoteKind === ExtendedKind.VOICE) { return await createVoiceDraftEvent( cleanedText, mediaUrl, mediaImetaTags, mentions, { addClientTag, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE), expirationMonths, addQuietTag, quietDays } ) } else if (mediaNoteKind === ExtendedKind.PICTURE) { return await createPictureDraftEvent( cleanedText, mediaImetaTags, mentions, { addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays } ) } else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) { return await createVideoDraftEvent( cleanedText, mediaImetaTags, mentions, mediaNoteKind, { addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays } ) } } // Parse topics from subject field for articles const topics = articleSubject.trim() ? articleSubject.split(/[,\s]+/).filter(s => s.trim()) : [] // Articles if (isLongFormArticle) { return await createLongFormArticleDraftEvent(cleanedText, mentions, { dTag: articleDTag.trim(), 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: articleDTag.trim(), 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: articleDTag.trim(), 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: articleDTag.trim(), 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 } ) } // 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 }) } // Polls if (isPoll) { return await createPollDraftEvent(pubkey!, cleanedText, mentions, pollCreateData, { addClientTag, isNsfw, addExpirationTag: false, expirationMonths, addQuietTag, quietDays }) } // Default: Short text note return await createShortTextNoteDraftEvent(cleanedText, mentions, { parentEvent, addClientTag, protectedEvent: shouldUseProtectedEvent, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(kinds.ShortTextNote), expirationMonths, addQuietTag, quietDays }) }, [ parentEvent, mediaNoteKind, mediaUrl, mediaImetaTags, mentions, isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent, isCitationInternal, isCitationExternal, isCitationHardcopy, isCitationPrompt, isHighlight, highlightData, isPublicMessage, extractedMentions, isPoll, pollCreateData, addClientTag, isNsfw, articleDTag, articleTitle, articleImage, articleSubject, articleSummary, pubkey ]) // Function to generate draft event JSON for preview const getDraftEventJson = useCallback(async (): Promise => { // For articles, validate dTag is provided const isArticle = isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent if (isArticle && !articleDTag.trim()) { throw new Error(t('D-Tag is required for articles')) } if (!pubkey) { return JSON.stringify({ error: 'Not logged in' }, null, 2) } try { // Clean tracking parameters from URLs in the post content const cleanedText = rewritePlainTextHttpUrls(text) const draftEvent = await createDraftEvent(cleanedText) return JSON.stringify(draftEvent, null, 2) } catch (error) { return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) } }, [ text, pubkey, isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent, articleDTag, createDraftEvent, t ]) const post = async (e?: React.MouseEvent) => { e?.stopPropagation() checkLogin(async () => { if (!canPost) { logger.warn('Attempted to post while canPost is false') 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) // 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: parentEvent ? t('Reply published') : t('Post published'), duration: 6000 }) } else { showSimplePublishSuccess(parentEvent ? t('Reply published') : t('Post published')) } // Full success - clean up and close postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) 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) { // 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 (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) } } const handlePublicMessageToggle = () => { if (parentEvent) return setIsPublicMessage((prev) => !prev) if (!isPublicMessage) { // When enabling public message mode, clear other modes setIsPoll(false) setIsHighlight(false) } } 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) setAddClientTag(true) } } const handleUploadStart = (file: File, cancel: () => void) => { setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }]) // Track file for media upload if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type.startsWith('video/')) { 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|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 } } else { // For new posts, detect the kind from the file (async) getMediaKindFromFile(file, false) .then((kind) => setMediaNoteKind(kind)) .catch((error) => { logger.error('Error detecting media kind in handleUploadStart', { error, file: file.name }) }) } } } 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 } = pendingMediaUpload setShowMediaKindDialog(false) setPendingMediaUpload(null) // Process the upload with the selected kind processMediaUpload(url, tags, file, selectedKind) } const processMediaUpload = async (url: string, tags: string[][], uploadingFile: File, selectedKind?: number) => { try { let kind: number if (selectedKind !== undefined) { // Use the selected kind kind = selectedKind } else { // Auto-detect the kind kind = await getMediaKindFromFile(uploadingFile, false) } setMediaNoteKind(kind) // For picture notes, support multiple images by accumulating imeta tags if (kind === ExtendedKind.PICTURE) { // Get imeta tag from media upload service const imetaTag = mediaUpload.getImetaTagByUrl(url) let newImetaTag: string[] if (imetaTag) { newImetaTag = imetaTag } else if (tags && tags.length > 0 && tags[0]) { newImetaTag = tags[0] } else { // Create a basic imeta tag if none exists newImetaTag = ['imeta', `url ${url}`] if (uploadingFile.type) { newImetaTag.push(`m ${uploadingFile.type}`) } } // Accumulate multiple imeta tags for picture notes (use ref so rapid multi-upload doesn’t lose tags) const urlExists = pictureImetaTagsRef.current.some((tag) => { const urlItem = tag.find((item) => item.startsWith('url ')) return urlItem && urlItem.slice(4).trim() === url }) if (!urlExists) { pictureImetaTagsRef.current = [...pictureImetaTagsRef.current, newImetaTag] setMediaImetaTags([...pictureImetaTagsRef.current]) } // Set the first URL as the primary mediaUrl (for backwards compatibility) if (!mediaUrl) { setMediaUrl(url) } // 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 setTimeout(() => { if (textareaRef.current) { // Check the actual editor content, not the state variable (which might be stale) const currentText = textareaRef.current.getText() if (!currentText.includes(url)) { textareaRef.current.appendText(url, true) } } }, 100) } else { // For non-picture media, replace the existing tags (single media) pictureImetaTagsRef.current = [] setMediaUrl(url) const imetaTag = mediaUpload.getImetaTagByUrl(url) if (imetaTag) { setMediaImetaTags([imetaTag]) } else if (tags && tags.length > 0) { setMediaImetaTags(tags) } else { const basicImetaTag: string[] = ['imeta', `url ${url}`] // Update MIME type based on selected kind let mimeType = uploadingFile.type if (selectedKind === ExtendedKind.VOICE || selectedKind === ExtendedKind.VOICE_COMMENT) { // Ensure audio MIME type const fileName = uploadingFile.name.toLowerCase() if (/\.webm$/i.test(fileName)) { mimeType = 'audio/webm' } else if (/\.mp4$/i.test(fileName)) { mimeType = 'audio/mp4' } } else if (selectedKind === ExtendedKind.VIDEO || selectedKind === ExtendedKind.SHORT_VIDEO) { // Ensure video MIME type const fileName = uploadingFile.name.toLowerCase() if (/\.webm$/i.test(fileName)) { mimeType = 'video/webm' } else if (/\.mp4$/i.test(fileName)) { mimeType = 'video/mp4' } } if (mimeType) { basicImetaTag.push(`m ${mimeType}`) } setMediaImetaTags([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 setTimeout(() => { if (textareaRef.current) { // Check the actual editor content, not the state variable (which might be stale) const currentText = textareaRef.current.getText() if (!currentText.includes(url)) { textareaRef.current.appendText(url, true) } } }, 100) } } catch (error) { logger.error('Error processing media upload', { error, file: uploadingFile.name }) // Fallback to picture if processing fails setMediaNoteKind(ExtendedKind.PICTURE) const imetaTag = mediaUpload.getImetaTagByUrl(url) const tagToAdd = imetaTag ?? (() => { const basic: string[] = ['imeta', `url ${url}`] if (uploadingFile.type) basic.push(`m ${uploadingFile.type}`) return basic })() pictureImetaTagsRef.current = [...pictureImetaTagsRef.current, tagToAdd] setMediaImetaTags([...pictureImetaTagsRef.current]) if (!mediaUrl) { setMediaUrl(url) } } } const handleMediaUploadSuccess = async ({ url, tags, file: fileFromCallback }: { url: string tags: string[][] file?: File }) => { 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 } // 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|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]) } else if (tags && tags.length > 0) { setMediaImetaTags(tags) } 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 (/\.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]) } // 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 setTimeout(() => { if (textareaRef.current) { // Check if URL is already in the text const currentText = text || '' if (!currentText.includes(url)) { textareaRef.current.appendText(url, true) } } }, 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([]) // Just add the media URL to the text content textareaRef.current?.appendText(url, true) 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 }) setShowMediaKindDialog(true) return } // Not ambiguous, auto-detect and process await processMediaUpload(url, tags, uploadingFile) } } 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) // Clear uploaded file from map and picture accumulation ref uploadedMediaFileMap.current.clear() pictureImetaTagsRef.current = [] } 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) // 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) // 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 = () => { // 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) // 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() pictureImetaTagsRef.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 === 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 && (
)} {/* Article metadata fields */} {(isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent) && (
setArticleDTag(e.target.value)} placeholder={t('e.g., my-article-title')} className={!articleDTag.trim() ? 'border-destructive' : ''} />

{t('Unique identifier for this article (required)')}

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)')}