You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

3641 lines
143 KiB

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<TPostTextareaHandle>(null)
const labTagOverrideRef = useRef<string[][] | null>(null)
const [advancedLabOpen, setAdvancedLabOpen] = useState(false)
const advancedLabOpenRef = useRef(false)
useEffect(() => {
advancedLabOpenRef.current = advancedLabOpen
}, [advancedLabOpen])
const advancedLabBodyApiRef = useRef<AdvancedLabBodyHandle | null>(null)
const getActiveComposerBody = () =>
advancedLabOpenRef.current && advancedLabBodyApiRef.current
? advancedLabBodyApiRef.current
: textareaRef.current
const [advancedLabInitial, setAdvancedLabInitial] = useState<AdvancedEventLabSlice | null>(null)
const mediaUploaderBtnRef = useRef<HTMLButtonElement>(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<string[]>([])
const [isNsfw, setIsNsfw] = useState(false)
const [isPoll, setIsPoll] = useState(false)
const [isPublicMessage, setIsPublicMessage] = useState(!!initialPublicMessageTo)
const [extractedMentions, setExtractedMentions] = useState<string[]>(
initialPublicMessageTo ? [initialPublicMessageTo] : []
)
const [isProtectedEvent, setIsProtectedEvent] = useState(false)
const [additionalRelayUrls, setAdditionalRelayUrls] = useState<string[]>([])
/** 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<HighlightData>(
initialHighlightData || {
sourceType: 'nostr',
sourceValue: ''
}
)
const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({
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<number | null>(null)
const [mediaImetaTags, setMediaImetaTags] = useState<string[][]>([])
const [mediaUrl, setMediaUrl] = useState<string>('')
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<Map<string, File>>(new Map())
/** Accumulates imeta tags across uploads (short note or multi-attachment) so files are not dropped. */
const composerImetaTagsRef = useRef<string[][]>([])
const mediaNoteKindRef = useRef<number | null>(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<any> => {
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<string> => {
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<PostEditorFormatToolbarUploadHandlers>(
() => ({
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 (
<div className="space-y-2 min-w-0">
{/* Dynamic Title based on mode */}
<div className="text-lg font-semibold">
{(() => {
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')
}
})()}
</div>
{parentEvent && (
<ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40">
<div className="p-2 sm:p-3 pointer-events-none">
<Note size="small" event={parentEvent} hideParentNotePreview />
</div>
</ScrollArea>
)}
{isDiscussionThread && !parentEvent && (
<div className="shrink-0 space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="space-y-2">
<Label htmlFor="discussion-topic-input" className="text-sm font-medium">
{t('Topic')} <span className="text-destructive">*</span>
</Label>
<div className="flex min-w-0 gap-2">
<Input
id="discussion-topic-input"
value={threadTopicInput}
onChange={(e) => 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')}
/>
<Popover open={threadTopicPopoverOpen} onOpenChange={setThreadTopicPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
title={t('Suggested topics')}
aria-expanded={threadTopicPopoverOpen}
>
<ChevronDown className="h-4 w-4 opacity-70" />
</Button>
</PopoverTrigger>
<PopoverContent className="z-[10000] w-72 p-2" align="end" side="bottom" sideOffset={4}>
<p className="text-muted-foreground mb-2 px-1 text-xs font-medium">{t('Suggested topics')}</p>
<div className="max-h-60 overflow-y-auto">
{allAvailableTopics.map((topic, index) => {
const Icon = topic.icon
return (
<div
key={`topic-${index}-${topic.id}`}
className="flex cursor-pointer items-center rounded p-2 hover:bg-accent"
onClick={() => {
setThreadSelectedTopic(topic.id)
setThreadTopicInput(topic.label)
setThreadTopicPopoverOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${threadTopicResolved === topic.id ? 'opacity-100' : 'opacity-0'}`}
/>
<Icon className="mr-2 h-4 w-4 shrink-0" />
<span className="min-w-0 truncate text-sm">{topic.label}</span>
</div>
)
})}
</div>
</PopoverContent>
</Popover>
</div>
{threadErrors.topic && <p className="text-sm text-destructive">{threadErrors.topic}</p>}
<p className="text-xs text-muted-foreground">
{t(
'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).'
)}
</p>
</div>
{threadTopicResolved === 'groups' && (
<div className="space-y-2">
<Label htmlFor="discussion-group" className="text-sm font-medium">
{t('Select Group')}
</Label>
<Popover open={threadGroupPopoverOpen} onOpenChange={setThreadGroupPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={threadGroupPopoverOpen}
title={t('Select group...')}
className="h-9 w-full justify-between bg-background font-normal"
>
{threadSelectedGroup ? threadSelectedGroup : t('Select group...')}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="z-[10000] w-[--radix-popover-trigger-width] p-2"
align="start"
side="bottom"
sideOffset={4}
>
<div className="max-h-60 overflow-y-auto">
{userGroups.length === 0 ? (
<div className="p-2 text-center text-sm text-muted-foreground">
{t('No groups available. Join some groups first.')}
</div>
) : (
userGroups.map((groupId) => (
<div
key={groupId}
className="flex cursor-pointer items-center rounded p-2 hover:bg-accent"
onClick={() => {
setThreadSelectedGroup(groupId)
setThreadGroupPopoverOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${threadSelectedGroup === groupId ? 'opacity-100' : 'opacity-0'}`}
/>
<Users className="mr-2 h-4 w-4" />
{groupId}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
{threadErrors.group && <p className="text-sm text-destructive">{threadErrors.group}</p>}
<p className="text-xs text-muted-foreground">
{t('Select the group where you want to create this discussion.')}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="discussion-thread-title" className="text-sm font-medium">
{t('Title')} <span className="text-destructive">*</span>
</Label>
<Input
id="discussion-thread-title"
value={threadTitle}
onChange={(e) => 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 && <p className="text-sm text-destructive">{threadErrors.title}</p>}
<p className="text-xs text-muted-foreground">
{threadTitle.length}/100 {t('characters')}
</p>
</div>
{threadTopicResolved === 'literature' && (
<div className="shrink-0 space-y-2">
<div className="flex items-center gap-2">
<Book className="h-4 w-4" />
<Label className="text-sm font-medium">{t('Readings Options')}</Label>
<Button
type="button"
variant="ghost"
size="sm"
title={threadShowReadingsPanel ? t('Hide') : t('Configure')}
onClick={() => setThreadShowReadingsPanel(!threadShowReadingsPanel)}
className="ml-auto"
>
{threadShowReadingsPanel ? t('Hide') : t('Configure')}
</Button>
</div>
{threadShowReadingsPanel && (
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Book className="h-4 w-4 text-primary" />
<Label htmlFor="discussion-reading-group" className="text-sm">
{t('Reading group entry')}
</Label>
</div>
<Switch
id="discussion-reading-group"
checked={threadIsReadingGroup}
onCheckedChange={setThreadIsReadingGroup}
/>
</div>
{threadIsReadingGroup && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="discussion-reading-author">{t('Author')}</Label>
<Input
id="discussion-reading-author"
value={threadReadingAuthor}
onChange={(e) => setThreadReadingAuthor(e.target.value)}
placeholder={t('Enter the author name')}
className={threadErrors.author ? 'border-destructive' : ''}
/>
{threadErrors.author && <p className="text-sm text-destructive">{threadErrors.author}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="discussion-reading-subject">{t('Subject (Book Title)')}</Label>
<Input
id="discussion-reading-subject"
value={threadReadingSubject}
onChange={(e) => setThreadReadingSubject(e.target.value)}
placeholder={t('Enter the book title')}
className={threadErrors.subject ? 'border-destructive' : ''}
/>
{threadErrors.subject && <p className="text-sm text-destructive">{threadErrors.subject}</p>}
</div>
<p className="text-xs text-muted-foreground">
{t(
'This will add additional tags for author and subject to help organize reading group discussions.'
)}
</p>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Article metadata fields */}
{(isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent) && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="space-y-2">
<Label htmlFor="article-dtag" className="text-sm font-medium">
{t('D-Tag')}
</Label>
<Input
id="article-dtag"
value={articleDTag}
onChange={(e) => setArticleDTag(e.target.value)}
placeholder={t('e.g., my-article-title')}
/>
<p className="text-xs text-muted-foreground">{t('articleDTagDefaultHint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="article-title" className="text-sm font-medium">
{t('Title')}
</Label>
<Input
id="article-title"
value={articleTitle}
onChange={(e) => setArticleTitle(e.target.value)}
placeholder={t('Article title (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="article-image" className="text-sm font-medium">
{t('Image URL')}
</Label>
<Input
id="article-image"
value={articleImage}
onChange={(e) => setArticleImage(e.target.value)}
placeholder={t('https://example.com/image.jpg')}
/>
<p className="text-xs text-muted-foreground">
{t('URL of the article cover image (optional)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="article-subject" className="text-sm font-medium">
{t('Subject / Topics')}
</Label>
<Input
id="article-subject"
value={articleSubject}
onChange={(e) => setArticleSubject(e.target.value)}
placeholder={t('topic1, topic2, topic3')}
/>
<p className="text-xs text-muted-foreground">
{t('Comma or space-separated topics (will be added as t-tags)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="article-summary" className="text-sm font-medium">
{t('Summary')}
</Label>
<Textarea
id="article-summary"
value={articleSummary}
onChange={(e) => setArticleSummary(e.target.value)}
placeholder={t('Brief summary of the article (optional)')}
rows={3}
/>
<p className="text-xs text-muted-foreground">
{t('A short description of the article content')}
</p>
</div>
</div>
)}
{/* Citation metadata fields */}
{(isCitationInternal ||
isCitationExternal ||
isCitationHardcopy ||
isCitationPrompt) && (
<div className="p-4 border rounded-lg bg-muted/30">
<div className="text-sm font-medium mb-3">
{isCitationInternal
? t('Internal Citation Settings')
: isCitationExternal
? t('External Citation Settings')
: isCitationHardcopy
? t('Hardcopy Citation Settings')
: t('Prompt Citation Settings')}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Prompt Citation specific fields - shown first if prompt */}
{isCitationPrompt && (
<>
<div className="space-y-2">
<Label htmlFor="citation-prompt-llm" className="text-sm font-medium">
{t('Language Model')} <span className="text-destructive">*</span>
</Label>
<Input
id="citation-prompt-llm"
value={citationPromptLlm}
onChange={(e) => setCitationPromptLlm(e.target.value)}
placeholder={t('e.g., GPT-4, Claude, etc. (required)')}
className={!citationPromptLlm.trim() ? 'border-destructive' : ''}
/>
<p className="text-xs text-muted-foreground">
{t('Name of the language model used')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="citation-external-url" className="text-sm font-medium">
{t('URL')}
</Label>
<Input
id="citation-external-url"
value={citationExternalUrl}
onChange={(e) => setCitationExternalUrl(e.target.value)}
placeholder={t('Website where LLM was accessed (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-version" className="text-sm font-medium">
{t('Version')}
</Label>
<Input
id="citation-version"
value={citationVersion}
onChange={(e) => setCitationVersion(e.target.value)}
placeholder={t('Version number (optional)')}
/>
</div>
</>
)}
{/* Shared fields - not shown for prompt citations */}
{!isCitationPrompt && (
<>
<div className="space-y-2">
<Label htmlFor="citation-title" className="text-sm font-medium">
{t('Title')}
</Label>
<Input
id="citation-title"
value={citationTitle}
onChange={(e) => setCitationTitle(e.target.value)}
placeholder={t('Citation title (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-author" className="text-sm font-medium">
{t('Author')}
</Label>
<Input
id="citation-author"
value={citationAuthor}
onChange={(e) => setCitationAuthor(e.target.value)}
placeholder={t('Author name (optional)')}
/>
</div>
</>
)}
{/* Internal Citation specific fields */}
{isCitationInternal && (
<>
<div className="space-y-2">
<Label htmlFor="citation-internal-ctag" className="text-sm font-medium">
{t('C-Tag')} <span className="text-destructive">*</span>
</Label>
<Input
id="citation-internal-ctag"
value={citationInternalCTag}
onChange={(e) => setCitationInternalCTag(e.target.value)}
placeholder={t('kind:pubkey:hex format (required)')}
className={!citationInternalCTag.trim() ? 'border-destructive' : ''}
/>
<p className="text-xs text-muted-foreground">
{t('Reference to the cited Nostr event in kind:pubkey:hex format')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="citation-internal-relay-hint" className="text-sm font-medium">
{t('Relay Hint')}
</Label>
<Input
id="citation-internal-relay-hint"
value={citationInternalRelayHint}
onChange={(e) => setCitationInternalRelayHint(e.target.value)}
placeholder={t('Relay URL (optional)')}
/>
</div>
</>
)}
{/* External Citation specific fields */}
{isCitationExternal && (
<>
<div className="space-y-2">
<Label htmlFor="citation-external-url" className="text-sm font-medium">
{t('URL')} <span className="text-destructive">*</span>
</Label>
<Input
id="citation-external-url"
value={citationExternalUrl}
onChange={(e) => setCitationExternalUrl(e.target.value)}
placeholder={t('https://example.com (required)')}
className={!citationExternalUrl.trim() ? 'border-destructive' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-external-open-timestamp" className="text-sm font-medium">
{t('Open Timestamp')}
</Label>
<Input
id="citation-external-open-timestamp"
value={citationExternalOpenTimestamp}
onChange={(e) => setCitationExternalOpenTimestamp(e.target.value)}
placeholder={t('e tag of kind 1040 event (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-published-by" className="text-sm font-medium">
{t('Published By')}
</Label>
<Input
id="citation-published-by"
value={citationPublishedBy}
onChange={(e) => setCitationPublishedBy(e.target.value)}
placeholder={t('Publisher name (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-version" className="text-sm font-medium">
{t('Version')}
</Label>
<Input
id="citation-version"
value={citationVersion}
onChange={(e) => setCitationVersion(e.target.value)}
placeholder={t('Version number (optional)')}
/>
</div>
</>
)}
{/* Hardcopy Citation specific fields */}
{isCitationHardcopy && (
<>
<div className="space-y-2">
<Label htmlFor="citation-hardcopy-page-range" className="text-sm font-medium">
{t('Page Range')}
</Label>
<Input
id="citation-hardcopy-page-range"
value={citationHardcopyPageRange}
onChange={(e) => setCitationHardcopyPageRange(e.target.value)}
placeholder={t('e.g., 123-145 (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-hardcopy-chapter-title" className="text-sm font-medium">
{t('Chapter Title')}
</Label>
<Input
id="citation-hardcopy-chapter-title"
value={citationHardcopyChapterTitle}
onChange={(e) => setCitationHardcopyChapterTitle(e.target.value)}
placeholder={t('Chapter title (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-hardcopy-editor" className="text-sm font-medium">
{t('Editor')}
</Label>
<Input
id="citation-hardcopy-editor"
value={citationHardcopyEditor}
onChange={(e) => setCitationHardcopyEditor(e.target.value)}
placeholder={t('Editor name (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-hardcopy-published-in" className="text-sm font-medium">
{t('Published In')}
</Label>
<Input
id="citation-hardcopy-published-in"
value={citationHardcopyPublishedIn}
onChange={(e) => setCitationHardcopyPublishedIn(e.target.value)}
placeholder={t('Journal/Publication name (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-hardcopy-volume" className="text-sm font-medium">
{t('Volume')}
</Label>
<Input
id="citation-hardcopy-volume"
value={citationHardcopyVolume}
onChange={(e) => setCitationHardcopyVolume(e.target.value)}
placeholder={t('Volume number (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-hardcopy-doi" className="text-sm font-medium">
{t('DOI')}
</Label>
<Input
id="citation-hardcopy-doi"
value={citationHardcopyDoi}
onChange={(e) => setCitationHardcopyDoi(e.target.value)}
placeholder={t('Digital Object Identifier (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-published-by" className="text-sm font-medium">
{t('Published By')}
</Label>
<Input
id="citation-published-by"
value={citationPublishedBy}
onChange={(e) => setCitationPublishedBy(e.target.value)}
placeholder={t('Publisher name (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-version" className="text-sm font-medium">
{t('Version')}
</Label>
<Input
id="citation-version"
value={citationVersion}
onChange={(e) => setCitationVersion(e.target.value)}
placeholder={t('Version number (optional)')}
/>
</div>
</>
)}
{/* Shared date fields - not shown for prompt citations */}
{!isCitationPrompt && (
<div className="space-y-2">
<Label htmlFor="citation-published-on" className="text-sm font-medium">
{t('Published On')}
</Label>
<Input
id="citation-published-on"
type="date"
value={citationPublishedOn}
onChange={(e) => setCitationPublishedOn(e.target.value)}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="citation-accessed-on" className="text-sm font-medium">
{t('Accessed On')} {(isCitationExternal || isCitationHardcopy || isCitationPrompt) && <span className="text-destructive">*</span>}
</Label>
<Input
id="citation-accessed-on"
type="date"
value={citationAccessedOn}
onChange={(e) => setCitationAccessedOn(e.target.value)}
className={(isCitationExternal || isCitationHardcopy || isCitationPrompt) && !citationAccessedOn.trim() ? 'border-destructive' : ''}
/>
</div>
{/* Summary field - different label for prompt citations - spans full width on desktop */}
<div className="space-y-2 md:col-span-2">
<Label htmlFor="citation-summary" className="text-sm font-medium">
{isCitationPrompt ? t('Prompt Conversation Script') : t('Summary')}
</Label>
<Textarea
id="citation-summary"
value={citationSummary}
onChange={(e) => setCitationSummary(e.target.value)}
placeholder={isCitationPrompt ? t('The full prompt conversation (optional)') : t('Brief summary (optional)')}
rows={3}
/>
</div>
{/* Shared optional fields - not shown for prompt citations */}
{!isCitationPrompt && (
<>
<div className="space-y-2">
<Label htmlFor="citation-location" className="text-sm font-medium">
{t('Location')}
</Label>
<Input
id="citation-location"
value={citationLocation}
onChange={(e) => setCitationLocation(e.target.value)}
placeholder={t('Location (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="citation-geohash" className="text-sm font-medium">
{t('Geohash')}
</Label>
<Input
id="citation-geohash"
value={citationGeohash}
onChange={(e) => setCitationGeohash(e.target.value)}
placeholder={t('Geohash (optional)')}
/>
</div>
</>
)}
</div>
</div>
)}
<NeventPickerProvider>
<PostTextarea
ref={textareaRef}
text={text}
setText={setText}
defaultContent={defaultContent}
parentEvent={isDiscussionThread && !parentEvent ? THREAD_POST_EDITOR_PARENT : parentEvent}
onSubmit={() => post()}
className={cn(
isPoll ? 'min-h-20' : 'min-h-52',
isDiscussionThread && threadErrors.content && 'border-destructive'
)}
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd}
onUploadSuccess={handleMediaUploadSuccess}
onUploadCompressPhase={handleUploadCompressPhase}
onUploadCompressProgress={handleUploadCompressProgress}
kind={getDeterminedKind}
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
getDraftEventJson={getDraftEventJson}
expectedDraftKind={pubkey ? getDeterminedKind : undefined}
onApplyComposerDraftJson={pubkey ? applyComposerDraftJson : undefined}
extraPreviewTags={
isDiscussionThread && !parentEvent ? discussionPreviewExtraTags : rssReplyExtraPreviewTags
}
addClientTag={addClientTag}
mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl}
headerActions={(() => {
const ActiveIcon =
isLongFormArticle ? FileText :
isWikiArticle ? FileText :
isWikiArticleMarkdown ? FileText :
isPublicationContent ? Book :
isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt ? Quote :
isHighlight ? Highlighter :
isPublicMessage ? MessageCircle :
isPoll ? ListTodo :
isDiscussionThread ? MessagesSquare :
mediaNoteKind !== null ? Upload :
StickyNote
const activeLabel =
isLongFormArticle ? t('Long-form Article') :
isWikiArticle ? t('Wiki Article (AsciiDoc)') :
isWikiArticleMarkdown ? t('Wiki Article (Markdown)') :
isPublicationContent ? t('Publication Note') :
isCitationInternal ? t('Internal Citation') :
isCitationExternal ? t('External Citation') :
isCitationHardcopy ? t('Hardcopy Citation') :
isCitationPrompt ? t('Prompt Citation') :
isHighlight ? t('Highlight') :
isPublicMessage ? t('Public Message') :
isPoll ? t('Poll') :
isDiscussionThread ? t('Thread') :
mediaNoteKind !== null ? t('Media Note') :
t('Short Note')
return (
<div className="flex flex-wrap items-center justify-end gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-sm font-normal shrink-0"
onClick={() => void handleOpenAdvancedLab()}
title={t('Advanced event lab')}
>
<Code2 className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline max-w-[9rem] truncate">{t('Advanced event lab')}</span>
</Button>
{!parentEvent ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-sm font-normal shrink-0"
disabled={!canUseMediaKindFromUrlButton}
title={
canUseMediaKindFromUrlButton
? t('Use image/audio/video note kind for the media URL in the editor')
: t('Media kind (disabled): add imeta tags, a media URL, or upload media first')
}
onClick={handleUseMediaNoteKindFromUrl}
>
<Upload className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline max-w-[7.5rem] truncate">{t('Media kind')}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5 h-8 text-sm font-normal">
<ActiveIcon className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[120px] truncate">{activeLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground px-2 py-1">
{t('Note type')}
</DropdownMenuLabel>
<DropdownMenuItem onClick={handlePlainNoteMode} className="gap-3 py-2 cursor-pointer">
<StickyNote className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Short Note')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Plain text note (kind 1)')}</span>
</div>
{isPlainShortNoteToolbar && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => mediaUploaderBtnRef.current?.click()} className="gap-3 py-2 cursor-pointer">
<Upload className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Media Note')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Attach image, audio, or video')}</span>
</div>
{mediaNoteKind !== null && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleHighlightToggle} className="gap-3 py-2 cursor-pointer">
<Highlighter className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Highlight')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Save a quote or passage')}</span>
</div>
{isHighlight && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePublicMessageToggle} className="gap-3 py-2 cursor-pointer">
<MessageCircle className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Public Message')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Public direct message (kind 4)')}</span>
</div>
{isPublicMessage && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePollToggle} className="gap-3 py-2 cursor-pointer">
<ListTodo className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Poll')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Create a voting poll')}</span>
</div>
{isPoll && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => checkLogin(() => handleDiscussionThreadToggle())} className="gap-3 py-2 cursor-pointer">
<MessagesSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Thread')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Start a discussion thread')}</span>
</div>
{isDiscussionThread && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground px-2 py-1">
{t('Articles')}
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => handleArticleToggle('longform')} className="gap-3 py-2 cursor-pointer">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Long-form Article')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Markdown article (NIP-23)')}</span>
</div>
{isLongFormArticle && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArticleToggle('wiki')} className="gap-3 py-2 cursor-pointer">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Wiki Article (AsciiDoc)')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('AsciiDoc wiki contribution')}</span>
</div>
{isWikiArticle && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArticleToggle('wiki-markdown')} className="gap-3 py-2 cursor-pointer">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Wiki Article (Markdown)')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Markdown wiki contribution')}</span>
</div>
{isWikiArticleMarkdown && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
{hasPrivateRelaysAvailable && (
<DropdownMenuItem onClick={() => handleArticleToggle('publication')} className="gap-3 py-2 cursor-pointer">
<Book className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Publication Note')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Private relay publication')}</span>
</div>
{isPublicationContent && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground px-2 py-1">
{t('Citations')}
</DropdownMenuLabel>
{hasPrivateRelaysAvailable ? (
<>
<DropdownMenuItem onClick={() => handleCitationToggle('internal')} className="gap-3 py-2 cursor-pointer">
<Quote className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Internal Citation')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Cite from private relay')}</span>
</div>
{isCitationInternal && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCitationToggle('external')} className="gap-3 py-2 cursor-pointer">
<Quote className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('External Citation')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Cite from external source')}</span>
</div>
{isCitationExternal && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCitationToggle('hardcopy')} className="gap-3 py-2 cursor-pointer">
<Quote className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Hardcopy Citation')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Physical source citation')}</span>
</div>
{isCitationHardcopy && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCitationToggle('prompt')} className="gap-3 py-2 cursor-pointer">
<Quote className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Prompt Citation')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('AI / LLM prompt citation')}</span>
</div>
{isCitationPrompt && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
</>
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('Citations require private relays (NIP-65).')}
</div>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => checkLogin(() => setCreateCustomEventOpen(true))} className="gap-3 py-2 cursor-pointer">
<HelpCircle className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Custom Event')}</span>
<span className="text-xs text-muted-foreground mt-0.5">{t('Create event with custom kind')}</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : null}
</div>
)
})()
}
/>
{isDiscussionThread && !parentEvent && (
<div className="flex min-w-0 flex-col gap-1">
{threadErrors.content && <p className="text-sm text-destructive">{threadErrors.content}</p>}
<p className="text-xs text-muted-foreground">
{text.length}/5000 {t('characters')}
</p>
</div>
)}
{isPoll && (
<PollEditor
pollCreateData={pollCreateData}
setPollCreateData={setPollCreateData}
setIsPoll={setIsPoll}
content={text}
/>
)}
{isHighlight && (
<HighlightEditor
highlightData={highlightData}
setHighlightData={setHighlightData}
setIsHighlight={setIsHighlight}
/>
)}
{isPublicMessage && (
<div className="rounded-lg border bg-muted/40 p-3">
<div className="mb-2 text-sm font-medium">{t('Recipients')}</div>
<div className="space-y-2">
<Mentions
content={text}
parentEvent={undefined}
mentions={extractedMentions}
setMentions={setExtractedMentions}
/>
{extractedMentions.length > 0 ? (
<div className="text-sm text-muted-foreground">
{t('Recipients detected from your message:')} {extractedMentions.length}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t('Add recipients using nostr: mentions (e.g., nostr:npub1...) or the recipient selector above')}
</div>
)}
</div>
</div>
)}
{uploadProgresses.length > 0 &&
uploadProgresses.map(({ file, progress, cancel, phase }, index) => (
<div key={`${file.name}-${index}`} className="mt-2 flex items-end gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-muted-foreground mb-0.5">
{file.name ?? t('Uploading...')}
</div>
<div className="text-[11px] text-muted-foreground mb-1 leading-snug">
{phase === 'compressing'
? t('Compressing on your device before upload (large videos can take several minutes)…')
: t('Uploading to media server…')}
</div>
<div className="h-0.5 w-full rounded-full bg-muted overflow-hidden">
{phase === 'compressing' && progress <= 0 ? (
<div
className="h-full w-1/3 max-w-[45%] animate-pulse rounded-full bg-primary motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-60"
aria-hidden
/>
) : (
<div
className="h-full bg-primary transition-[width] duration-200 ease-out"
style={{
width: `${phase === 'compressing' ? Math.max(2, progress) : progress}%`
}}
/>
)}
</div>
</div>
<button
type="button"
onClick={() => {
cancel?.()
handleUploadEnd(file)
}}
className="text-muted-foreground hover:text-foreground"
title={t('Cancel')}
>
<X className="h-4 w-4" />
</button>
</div>
))}
{!isPoll && (
<div
className={cn(
'shrink-0',
isDiscussionThread && threadErrors.relay && 'rounded-md ring-1 ring-destructive'
)}
>
<PostRelaySelector
setIsProtectedEvent={setIsProtectedEvent}
setAdditionalRelayUrls={setAdditionalRelayUrls}
onRelayPublishCapChange={handleRelayPublishCapChange}
parentEvent={parentEvent}
openFrom={openFrom}
content={text}
isPublicMessage={isPublicMessage}
mentions={extractedMentions}
/>
{relayCapBlockInfo && (
<p className="mt-2 text-sm text-amber-600 dark:text-amber-500" role="alert">
{relayCapBlockInfo.outboxSlotsInPublish > 0
? t('Publish relay cap hint with outbox first', {
max: MAX_PUBLISH_RELAYS,
reservedSlots: relayCapBlockInfo.outboxSlotsInPublish,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})
: t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})}
</p>
)}
{isDiscussionThread && threadErrors.relay && (
<p className="mt-1 text-sm text-destructive">{threadErrors.relay}</p>
)}
</div>
)}
{/* Hidden uploader for the "Media Note" dropdown item */}
{!parentEvent && (
<Uploader
onUploadSuccess={handleMediaUploadSuccess}
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase}
onUploadCompressProgress={handleUploadCompressProgress}
accept="image/*,audio/*,video/*,.mkv,.mka,video/x-matroska,audio/x-matroska"
className="sr-only"
>
<button ref={mediaUploaderBtnRef} type="button" aria-hidden="true" tabIndex={-1} />
</Uploader>
)}
<div className="flex flex-wrap items-center justify-between gap-2 min-w-0">
<div className="flex gap-2 items-center min-w-0 shrink-0">
<PostEditorFormatToolbar
insertText={(txt) => textareaRef.current?.insertText(txt)}
insertEmoji={(em) => textareaRef.current?.insertEmoji(em)}
upload={toolbarUploadHandlers}
showAudioUpload={Boolean(parentEvent || isPublicMessage)}
audioUploadTitle={parentEvent ? t('Upload Audio Comment') : t('Upload Audio Message')}
audioButtonHighlighted={
mediaNoteKind === ExtendedKind.VOICE_COMMENT ||
(isPublicMessage && mediaNoteKind === ExtendedKind.VOICE)
}
showMoreOptions={showMoreOptions}
onToggleMoreOptions={() => setShowMoreOptions((pre) => !pre)}
/>
</div>
<div className="flex gap-2 items-center shrink-0">
<Mentions
content={text}
parentEvent={parentEvent}
mentions={mentions}
setMentions={setMentions}
/>
<div className="flex gap-2 items-center max-sm:hidden">
<Button
type="button"
variant="outline"
title={t('Clear')}
onClick={(e) => {
e.stopPropagation()
handleClear()
}}
>
{t('Clear')}
</Button>
<Button
type="button"
variant="secondary"
title={t('Cancel')}
onClick={(e) => {
e.stopPropagation()
close()
}}
>
{t('Cancel')}
</Button>
<Button
type="submit"
title={
parentEvent
? t('Reply')
: isPublicMessage
? t('Send Public Message')
: isDiscussionThread
? t('Create Thread')
: t('Post')
}
disabled={!canPost}
onClick={post}
>
{posting && (
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />
)}
{parentEvent
? t('Reply')
: isPublicMessage
? t('Send Public Message')
: isDiscussionThread
? t('Create Thread')
: t('Post')}
</Button>
</div>
</div>
</div>
<PostOptions
posting={posting}
show={showMoreOptions}
addClientTag={addClientTag}
setAddClientTag={setAddClientTag}
isNsfw={isNsfw}
setIsNsfw={setIsNsfw}
minPow={minPow}
setMinPow={setMinPow}
/>
<div className="flex gap-2 items-center justify-around sm:hidden">
<Button
type="button"
className="w-full"
variant="outline"
title={t('Clear')}
onClick={(e) => {
e.stopPropagation()
handleClear()
}}
>
{t('Clear')}
</Button>
<Button
type="button"
className="w-full"
variant="secondary"
title={t('Cancel')}
onClick={(e) => {
e.stopPropagation()
close()
}}
>
{t('Cancel')}
</Button>
<Button
className="w-full"
type="submit"
title={
parentEvent
? t('Reply')
: isPublicMessage
? t('Send Public Message')
: isDiscussionThread
? t('Create Thread')
: t('Post')
}
disabled={!canPost}
onClick={post}
>
{posting && (
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />
)}
{parentEvent ? t('Reply') : isDiscussionThread ? t('Create Thread') : t('Post')}
</Button>
</div>
{/* Media Kind Selection Dialog */}
<Dialog open={showMediaKindDialog} onOpenChange={setShowMediaKindDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Select Media Type')}</DialogTitle>
<DialogDescription>
{pendingMediaUpload && (
<>
{t('This file could be either audio or video. Please select the correct type:')}
<br />
<span className="text-xs text-muted-foreground mt-2 block">
{pendingMediaUpload.file.name}
</span>
</>
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-4">
<Button
variant="outline"
className="flex items-center justify-start gap-3 h-auto p-4"
onClick={() => {
// User selected audio - always use VOICE (kind 1222)
handleMediaKindSelection(ExtendedKind.VOICE)
}}
>
<Music className="h-5 w-5" />
<div className="flex flex-col items-start">
<span className="font-medium">{t('Audio')}</span>
<span className="text-xs text-muted-foreground">{t('Voice note or audio file')}</span>
</div>
</Button>
<Button
variant="outline"
className="flex items-center justify-start gap-3 h-auto p-4"
onClick={() => {
// Get duration to determine if it should be VIDEO (kind 21) or SHORT_VIDEO (kind 22)
const file = pendingMediaUpload?.file
if (file) {
// Create a temporary media element to get duration
const url = URL.createObjectURL(file)
const media = document.createElement('video')
media.onloadedmetadata = () => {
const duration = media.duration || 0
URL.revokeObjectURL(url)
// Video files longer than 10 minutes (600 seconds) are long videos (kind 21)
// Otherwise use short video (kind 22)
const selectedKind = duration > 600 ? ExtendedKind.VIDEO : ExtendedKind.SHORT_VIDEO
handleMediaKindSelection(selectedKind)
}
media.onerror = () => {
URL.revokeObjectURL(url)
// Fallback to SHORT_VIDEO if we can't determine duration
handleMediaKindSelection(ExtendedKind.SHORT_VIDEO)
}
media.src = url
media.load()
// Timeout after 3 seconds
setTimeout(() => {
URL.revokeObjectURL(url)
handleMediaKindSelection(ExtendedKind.SHORT_VIDEO)
}, 3000)
} else {
// Fallback to SHORT_VIDEO if no file
handleMediaKindSelection(ExtendedKind.SHORT_VIDEO)
}
}}
>
<Video className="h-5 w-5" />
<div className="flex flex-col items-start">
<span className="font-medium">{t('Video')}</span>
<span className="text-xs text-muted-foreground">{t('Video file')}</span>
</div>
</Button>
</div>
</DialogContent>
</Dialog>
<AdvancedEventLabDialog
open={advancedLabOpen}
onOpenChange={(o) => {
setAdvancedLabOpen(o)
if (!o) setAdvancedLabInitial(null)
}}
initial={advancedLabInitial}
kindEditable={false}
markupMode={isAsciidocMarkupKind(getDeterminedKind) ? 'asciidoc' : 'markdown'}
i18nLanguage={i18n.language}
contextEventId={parentEvent?.id ?? null}
draftPersistenceKey={advancedLabOpen ? advancedLabPersistenceKey : null}
bodyApiRef={advancedLabBodyApiRef}
formatToolbar={
<PostEditorFormatToolbar
insertText={(txt) => {
const lab = advancedLabBodyApiRef.current
if (!lab) return
if (labInsertShouldBecomeMarkupImage(txt)) {
lab.insertText(
formatMarkupImageAtCursor(
txt,
isAsciidocMarkupKind(getDeterminedKindRef.current)
)
)
} else {
lab.insertText(txt)
}
}}
insertEmoji={(em) => advancedLabBodyApiRef.current?.insertEmoji(em)}
upload={toolbarUploadHandlers}
showAudioUpload={Boolean(parentEvent || isPublicMessage)}
audioUploadTitle={parentEvent ? t('Upload Audio Comment') : t('Upload Audio Message')}
audioButtonHighlighted={
mediaNoteKind === ExtendedKind.VOICE_COMMENT ||
(isPublicMessage && mediaNoteKind === ExtendedKind.VOICE)
}
showMoreOptions={showMoreOptions}
onToggleMoreOptions={() => setShowMoreOptions((pre) => !pre)}
/>
}
onApply={(payload) => {
labTagOverrideRef.current = payload.tags.map((r) => [...r])
textareaRef.current?.setDocumentFromPlainText(payload.content)
}}
/>
<EditOrCloneEventDialog
open={createCustomEventOpen}
onOpenChange={setCreateCustomEventOpen}
mode="create"
/>
</NeventPickerProvider>
</div>
)
}