import Note from '@/components/Note' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { createCommentDraftEvent, createPollDraftEvent, createPublicMessageDraftEvent, createPublicMessageReplyDraftEvent, createShortTextNoteDraftEvent, createHighlightDraftEvent, deleteDraftEventCache, createVoiceDraftEvent, createVoiceCommentDraftEvent, createPictureDraftEvent, createVideoDraftEvent, createLongFormArticleDraftEvent, createWikiArticleDraftEvent, createWikiArticleMarkdownDraftEvent, createPublicationContentDraftEvent, createCitationInternalDraftEvent, createCitationExternalDraftEvent, createCitationHardcopyDraftEvent, createCitationPromptDraftEvent } from '@/lib/draft-event' import { ExtendedKind } from '@/constants' import { isTouchDevice } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useFeed } from '@/providers/FeedProvider' import { useReply } from '@/providers/ReplyProvider' import { normalizeUrl, cleanUrl } from '@/lib/url' import logger from '@/lib/logger' import postEditorCache from '@/services/post-editor-cache.service' import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload } from 'lucide-react' import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls, hasCacheRelays, getCacheRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' import { StorageKey } from '@/constants' import { isProtectedEvent as isEventProtected, isReplyNoteEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import EmojiPickerDialog from '../EmojiPickerDialog' import Mentions, { extractMentions } from './Mentions' import PollEditor from './PollEditor' import PostOptions from './PostOptions' import PostRelaySelector from './PostRelaySelector' import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import Uploader from './Uploader' import HighlightEditor, { HighlightData } from './HighlightEditor' export default function PostContent({ defaultContent = '', parentEvent, close, openFrom, initialHighlightData }: { defaultContent?: string parentEvent?: Event close: () => void openFrom?: string[] initialHighlightData?: HighlightData }) { const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() const { feedInfo } = useFeed() const { addReplies } = useReply() const [text, setText] = useState('') const textareaRef = useRef(null) const [posting, setPosting] = useState(false) const [uploadProgresses, setUploadProgresses] = useState< { file: File; progress: number; cancel: () => void }[] >([]) const [showMoreOptions, setShowMoreOptions] = useState(false) const [addClientTag, setAddClientTag] = useState(true) // Default to true to always add client tag const [mentions, setMentions] = useState([]) const [isNsfw, setIsNsfw] = useState(false) const [isPoll, setIsPoll] = useState(false) const [isPublicMessage, setIsPublicMessage] = useState(false) const [extractedMentions, setExtractedMentions] = useState([]) const [isProtectedEvent, setIsProtectedEvent] = useState(false) const [additionalRelayUrls, setAdditionalRelayUrls] = useState([]) const [isHighlight, setIsHighlight] = useState(!!initialHighlightData) const [highlightData, setHighlightData] = useState( initialHighlightData || { sourceType: 'nostr', sourceValue: '' } ) const [pollCreateData, setPollCreateData] = useState({ isMultipleChoice: false, options: ['', ''], endsAt: undefined, relays: [] }) const [minPow, setMinPow] = useState(0) const [mediaNoteKind, setMediaNoteKind] = useState(null) const [mediaImetaTags, setMediaImetaTags] = useState([]) const [mediaUrl, setMediaUrl] = useState('') const [isLongFormArticle, setIsLongFormArticle] = useState(false) const [isWikiArticle, setIsWikiArticle] = useState(false) const [isWikiArticleMarkdown, setIsWikiArticleMarkdown] = useState(false) const [isPublicationContent, setIsPublicationContent] = useState(false) const [isCitationInternal, setIsCitationInternal] = useState(false) const [isCitationExternal, setIsCitationExternal] = useState(false) const [isCitationHardcopy, setIsCitationHardcopy] = useState(false) const [isCitationPrompt, setIsCitationPrompt] = useState(false) const [hasPrivateRelaysAvailable, setHasPrivateRelaysAvailable] = useState(false) const [hasCacheRelaysAvailable, setHasCacheRelaysAvailable] = useState(false) const [useCacheOnlyForPrivateNotes, setUseCacheOnlyForPrivateNotes] = useState(true) // Default ON const uploadedMediaFileMap = useRef>(new Map()) const isFirstRender = useRef(true) const canPost = useMemo(() => { const result = ( !!pubkey && !posting && !uploadProgresses.length && // For media notes, text is optional - just need media ((mediaNoteKind !== null && mediaUrl) || !!text) && (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && (!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && (!isProtectedEvent || additionalRelayUrls.length > 0) && (!isHighlight || highlightData.sourceValue.trim() !== '') ) return result }, [ pubkey, text, posting, uploadProgresses, mediaNoteKind, mediaUrl, isPoll, pollCreateData, isPublicMessage, extractedMentions, parentEvent?.kind, isProtectedEvent, additionalRelayUrls, isHighlight, highlightData ]) // 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]) useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false const cachedSettings = postEditorCache.getPostSettingsCache({ defaultContent, parentEvent }) if (cachedSettings) { setIsNsfw(cachedSettings.isNsfw ?? false) setIsPoll(cachedSettings.isPoll ?? false) setPollCreateData( cachedSettings.pollCreateData ?? { isMultipleChoice: false, options: ['', ''], endsAt: undefined, relays: [] } ) setAddClientTag(cachedSettings.addClientTag ?? true) // Default to true } return } postEditorCache.setPostSettingsCache( { defaultContent, parentEvent }, { isNsfw, isPoll, pollCreateData, addClientTag } ) }, [defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag]) // 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) { setExtractedMentions([]) return } // Debounce the mention extraction for all posts (not just public messages) const timeoutId = setTimeout(() => { extractMentionsFromContent(text) }, 300) return () => { clearTimeout(timeoutId) } }, [text, extractMentionsFromContent]) // Check for private relays availability useEffect(() => { if (!pubkey) { setHasPrivateRelaysAvailable(false) setHasCacheRelaysAvailable(false) return } hasPrivateRelays(pubkey).then(setHasPrivateRelaysAvailable).catch(() => { setHasPrivateRelaysAvailable(false) }) hasCacheRelays(pubkey).then(setHasCacheRelaysAvailable).catch(() => { setHasCacheRelaysAvailable(false) }) }, [pubkey]) // Load cache-only preference from localStorage useEffect(() => { const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES) // Default to true (ON) if not set setUseCacheOnlyForPrivateNotes(stored === null ? true : stored === 'true') }, []) const post = async (e?: React.MouseEvent) => { e?.stopPropagation() checkLogin(async () => { if (!canPost) { logger.warn('Attempted to post while canPost is false') return } // console.log('🚀 Starting post process:', { // isPublicMessage, // parentEventKind: parentEvent?.kind, // parentEventId: parentEvent?.id, // text: text.substring(0, 50) + '...', // mentions: mentions.length, // canPost // }) setPosting(true) let draftEvent: any = null let newEvent: any = null try { // Clean tracking parameters from URLs in the post content const cleanedText = text.replace( /(https?:\/\/[^\s]+)/g, (url) => { try { return cleanUrl(url) } catch { return url } } ) // Get expiration and quiet settings // Only add expiration tags to chatting kinds: 1, 1111, 1222, 1244 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 // Only use it when replying to an OP event that also has the "-" tag let shouldUseProtectedEvent = false if (parentEvent) { // Check if parent event is an OP (not a reply itself) and has the "-" tag const isParentOP = !isReplyNoteEvent(parentEvent) const parentHasProtectedTag = isEventProtected(parentEvent) shouldUseProtectedEvent = isParentOP && parentHasProtectedTag } // Determine relay URLs for private events let privateRelayUrls: string[] = [] const isPrivateEvent = isPublicationContent || isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt if (isPrivateEvent) { if (useCacheOnlyForPrivateNotes && hasCacheRelaysAvailable) { // Use only cache relays if toggle is ON privateRelayUrls = await getCacheRelayUrls(pubkey!) } else { // Use all private relays (outbox + cache) privateRelayUrls = await getPrivateRelayUrls(pubkey!) } } if (mediaNoteKind !== null && mediaUrl) { // Media notes if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) { // Voice comment draftEvent = await createVoiceCommentDraftEvent( cleanedText, parentEvent, mediaUrl, mediaImetaTags, mentions, { addClientTag, protectedEvent: shouldUseProtectedEvent, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE_COMMENT), expirationMonths, addQuietTag, quietDays } ) } else if (mediaNoteKind === ExtendedKind.VOICE) { // Voice note draftEvent = await createVoiceDraftEvent( cleanedText, mediaUrl, mediaImetaTags, mentions, { addClientTag, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE), expirationMonths, addQuietTag, quietDays } ) } else if (mediaNoteKind === ExtendedKind.PICTURE) { // Picture note draftEvent = await createPictureDraftEvent( cleanedText, mediaImetaTags, mentions, { addClientTag, isNsfw, addExpirationTag: false, // Picture notes are not chatting kinds expirationMonths, addQuietTag, quietDays } ) } else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) { // Video note draftEvent = await createVideoDraftEvent( cleanedText, mediaImetaTags, mentions, mediaNoteKind, { addClientTag, isNsfw, addExpirationTag: false, // Video notes are not chatting kinds expirationMonths, addQuietTag, quietDays } ) } } else if (isLongFormArticle) { draftEvent = await createLongFormArticleDraftEvent(cleanedText, mentions, { addClientTag, isNsfw, addExpirationTag: false, // Articles are not chatting kinds expirationMonths, addQuietTag, quietDays }) } else if (isWikiArticle) { draftEvent = await createWikiArticleDraftEvent(cleanedText, mentions, { dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content addClientTag, isNsfw, addExpirationTag: false, // Wiki articles are not chatting kinds expirationMonths, addQuietTag, quietDays }) } else if (isWikiArticleMarkdown) { draftEvent = await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, { dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content addClientTag, isNsfw, addExpirationTag: false, // Wiki articles are not chatting kinds expirationMonths, addQuietTag, quietDays }) } else if (isPublicationContent) { draftEvent = await createPublicationContentDraftEvent(cleanedText, mentions, { dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content addClientTag, isNsfw, addExpirationTag: false, // Publication content is not a chatting kind expirationMonths, addQuietTag, quietDays }) } else if (isCitationInternal) { // For now, use a simple format - in a real implementation, this would have a form draftEvent = createCitationInternalDraftEvent(cleanedText, { cTag: '', // Would need to be filled from a form title: cleanedText.substring(0, 100) }) } else if (isCitationExternal) { draftEvent = createCitationExternalDraftEvent(cleanedText, { url: '', // Would need to be filled from a form accessedOn: new Date().toISOString(), title: cleanedText.substring(0, 100) }) } else if (isCitationHardcopy) { draftEvent = createCitationHardcopyDraftEvent(cleanedText, { accessedOn: new Date().toISOString(), title: cleanedText.substring(0, 100) }) } else if (isCitationPrompt) { draftEvent = createCitationPromptDraftEvent(cleanedText, { llm: '', // Would need to be filled from a form accessedOn: new Date().toISOString() }) } else if (isHighlight) { // For highlights, pass the original sourceValue which contains the full identifier // The createHighlightDraftEvent function will parse it correctly draftEvent = await createHighlightDraftEvent( cleanedText, highlightData.sourceType, highlightData.sourceValue, highlightData.context, undefined, // description parameter (not used) { addClientTag, isNsfw, addExpirationTag: false, // Highlights are not chatting kinds expirationMonths, addQuietTag, quietDays } ) } else if (isPublicMessage) { draftEvent = await createPublicMessageDraftEvent(cleanedText, extractedMentions, { addClientTag, isNsfw, addExpirationTag: false, // Public messages are not chatting kinds expirationMonths, addQuietTag, quietDays }) } else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { draftEvent = await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, { addClientTag, isNsfw, addExpirationTag: false, // Public messages are not chatting kinds expirationMonths, addQuietTag, quietDays }) } else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) { draftEvent = await createCommentDraftEvent(cleanedText, parentEvent, mentions, { addClientTag, protectedEvent: shouldUseProtectedEvent, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.COMMENT), expirationMonths, addQuietTag, quietDays }) } else if (isPoll) { draftEvent = await createPollDraftEvent(pubkey!, cleanedText, mentions, pollCreateData, { addClientTag, isNsfw, addExpirationTag: false, // Polls are not chatting kinds expirationMonths, addQuietTag, quietDays }) } else { // For regular kind 1 note OPs (no parentEvent), never use protectedEvent // protectedEvent should only be used when replying to an OP that has it draftEvent = await createShortTextNoteDraftEvent(cleanedText, mentions, { parentEvent, addClientTag, protectedEvent: shouldUseProtectedEvent, isNsfw, addExpirationTag: addExpirationTag && isChattingKind(kinds.ShortTextNote), expirationMonths, addQuietTag, quietDays }) } // 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 }) // console.log('Published event:', newEvent) // Check if we need to refresh the current relay view if (feedInfo.feedType === 'relay' && feedInfo.id) { const currentRelayUrl = normalizeUrl(feedInfo.id) const publishedRelays = additionalRelayUrls // If we published to the current relay being viewed, trigger a refresh after a short delay if (publishedRelays.some(url => normalizeUrl(url) === currentRelayUrl)) { setTimeout(() => { // Trigger a page refresh by dispatching a custom event that the relay view can listen to window.dispatchEvent(new CustomEvent('relay-refresh-needed', { detail: { relayUrl: currentRelayUrl } })) }, 1000) // 1 second delay to allow the event to propagate } } // Show publishing feedback if ((newEvent as any).relayStatuses) { showPublishingFeedback({ success: true, relayStatuses: (newEvent as any).relayStatuses, successCount: (newEvent as any).relayStatuses.filter((s: any) => s.success).length, totalCount: (newEvent as any).relayStatuses.length }, { message: parentEvent ? t('Reply published') : t('Post published'), duration: 6000 }) } else { showSimplePublishSuccess(parentEvent ? t('Reply published') : t('Post published')) } // Full success - clean up and close postEditorCache.clearPostCache({ defaultContent, parentEvent }) deleteDraftEventCache(draftEvent) // Remove relayStatuses before storing the event (it's only for UI feedback) const cleanEvent = { ...newEvent } delete (cleanEvent as any).relayStatuses addReplies([cleanEvent]) close() } catch (error) { 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 if (successCount > 0) { // Clean up and close on partial success postEditorCache.clearPostCache({ defaultContent, parentEvent }) if (draftEvent) deleteDraftEventCache(draftEvent) if (newEvent) addReplies([newEvent]) close() } } else { // Use standard publishing error feedback for cases without relay statuses if (error instanceof AggregateError) { const errorMessages = error.errors.map((err: any) => err.message).join('; ') showPublishingError(`Failed to publish to relays: ${errorMessages}`) } else if (error instanceof Error) { showPublishingError(error.message) } else { showPublishingError('Failed to publish') } // Don't close form on complete failure - let user try again } } finally { setPosting(false) } }) } const handlePollToggle = () => { if (parentEvent) return setIsPoll((prev) => !prev) if (!isPoll) { // When enabling poll mode, clear other modes setIsPublicMessage(false) setIsHighlight(false) } } const handlePublicMessageToggle = () => { if (parentEvent) return setIsPublicMessage((prev) => !prev) if (!isPublicMessage) { // When enabling public message mode, clear other modes setIsPoll(false) setIsHighlight(false) } } const handleHighlightToggle = () => { if (parentEvent) return setIsHighlight((prev) => !prev) if (!isHighlight) { // When enabling highlight mode, clear other modes and set client tag to true setIsPoll(false) setIsPublicMessage(false) setAddClientTag(true) } } const handleUploadStart = (file: File, cancel: () => void) => { setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }]) // Track file for media upload if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type.startsWith('video/')) { uploadedMediaFileMap.current.set(file.name, file) } } 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 } const handleMediaUploadSuccess = async ({ url, tags }: { url: string; tags: string[][] }) => { // Find the file from the map - try to match by URL or get the most recent let uploadingFile: File | undefined // Try to find by matching URL pattern or get the first available for (const [, file] of uploadedMediaFileMap.current.entries()) { uploadingFile = file break // Get first available } if (!uploadingFile) { // Try to get from uploadProgresses as fallback const progressItem = uploadProgresses.find(p => p.file) uploadingFile = progressItem?.file } if (!uploadingFile) { logger.warn('Media upload succeeded but file not found') return } // Determine media kind from file // For replies, only audio comments are supported (kind 1244) // For new posts, all media types are supported if (parentEvent) { // For replies, only allow audio comments const fileType = uploadingFile.type const fileName = uploadingFile.name.toLowerCase() const isAudio = fileType.startsWith('audio/') || /\.(mp3|m4a|ogg|wav|webm|opus|aac|flac)$/i.test(fileName) if (isAudio) { // For replies, always create voice comments, regardless of duration setMediaNoteKind(ExtendedKind.VOICE_COMMENT) } else { // Non-audio media in replies - don't set mediaNoteKind, will be handled as regular comment // Clear any existing media note kind setMediaNoteKind(null) setMediaUrl('') setMediaImetaTags([]) // Just add the media URL to the text content textareaRef.current?.appendText(url, true) return // Don't set media note kind for non-audio in replies } } else { // For new posts, use the detected kind (which handles audio > 60s → video) const kind = await getMediaKindFromFile(uploadingFile, false) setMediaNoteKind(kind) } setMediaUrl(url) // Get imeta tag from media upload service const imetaTag = mediaUpload.getImetaTagByUrl(url) if (imetaTag) { // imetaTag is already a string[] like ['imeta', 'url https://...', 'm image/jpeg'] // We need it as string[][] for the draft event functions setMediaImetaTags([imetaTag]) } else if (tags && tags.length > 0) { // Use tags from upload result - they should already be in the right format setMediaImetaTags(tags) } else { // Create a basic imeta tag if none exists const basicImetaTag: string[] = ['imeta', `url ${url}`] if (uploadingFile.type) { basicImetaTag.push(`m ${uploadingFile.type}`) } setMediaImetaTags([basicImetaTag]) } // Clear other note types when media is selected setIsPoll(false) setIsPublicMessage(false) setIsHighlight(false) setIsLongFormArticle(false) setIsWikiArticle(false) setIsWikiArticleMarkdown(false) setIsPublicationContent(false) setIsCitationInternal(false) setIsCitationExternal(false) setIsCitationHardcopy(false) setIsCitationPrompt(false) // Clear uploaded file from map uploadedMediaFileMap.current.clear() } 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) } 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) } return (
{/* Dynamic Title based on mode */}
{parentEvent ? (
{parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE ? t('Reply to Public Message') : mediaNoteKind === ExtendedKind.VOICE_COMMENT ? t('Voice Comment') : t('Reply to') }
) : mediaNoteKind === ExtendedKind.VOICE ? ( t('Voice Note') ) : mediaNoteKind === ExtendedKind.PICTURE ? ( t('Picture Note') ) : mediaNoteKind === ExtendedKind.VIDEO ? ( t('Video Note') ) : mediaNoteKind === ExtendedKind.SHORT_VIDEO ? ( t('Short Video Note') ) : isPoll ? ( t('New Poll') ) : isPublicMessage ? ( t('New Public Message') ) : isHighlight ? ( t('New Highlight') ) : isLongFormArticle ? ( t('New Long-form Article') ) : isWikiArticle ? ( t('New Wiki Article') ) : isWikiArticleMarkdown ? ( t('New Wiki Article (Markdown)') ) : isPublicationContent ? ( t('Take a note') ) : isCitationInternal ? ( t('New Internal Citation') ) : isCitationExternal ? ( t('New External Citation') ) : isCitationHardcopy ? ( t('New Hardcopy Citation') ) : isCitationPrompt ? ( t('New Prompt Citation') ) : ( t('New Note') )}
{parentEvent && (
)} post()} className={isPoll ? 'min-h-20' : 'min-h-52'} onUploadStart={handleUploadStart} onUploadProgress={handleUploadProgress} onUploadEnd={handleUploadEnd} kind={ mediaNoteKind !== null ? mediaNoteKind : isHighlight ? kinds.Highlights : isPublicMessage ? ExtendedKind.PUBLIC_MESSAGE : isPoll ? ExtendedKind.POLL : isLongFormArticle ? kinds.LongFormArticle : isWikiArticle ? ExtendedKind.WIKI_ARTICLE : isWikiArticleMarkdown ? ExtendedKind.WIKI_ARTICLE_MARKDOWN : isPublicationContent ? ExtendedKind.PUBLICATION_CONTENT : kinds.ShortTextNote } highlightData={isHighlight ? highlightData : undefined} pollCreateData={isPoll ? pollCreateData : undefined} headerActions={ <> {/* Media button */} {/* Note creation buttons - only show when not replying */} {!parentEvent && ( <> {/* Article dropdown - only show if has private relays for publication content */} {(hasPrivateRelaysAvailable || !isPublicationContent) && ( handleArticleToggle('longform')}> {t('Long-form Article')} handleArticleToggle('wiki')}> {t('Wiki Article (AsciiDoc)')} handleArticleToggle('wiki-markdown')}> {t('Wiki Article (Markdown)')} {hasPrivateRelaysAvailable && ( handleArticleToggle('publication')}> {t('Take a note')} )} )} {/* Citation dropdown - only show if has private relays */} {hasPrivateRelaysAvailable && ( handleCitationToggle('internal')}> {t('Internal Citation')} handleCitationToggle('external')}> {t('External Citation')} handleCitationToggle('hardcopy')}> {t('Hardcopy Citation')} handleCitationToggle('prompt')}> {t('Prompt Citation')} )} )} } /> {isPoll && ( )} {isHighlight && ( )} {isPublicMessage && (
{t('Recipients')}
{extractedMentions.length > 0 ? (
{t('Recipients detected from your message:')} {extractedMentions.length}
) : (
{t('Add recipients using nostr: mentions (e.g., nostr:npub1...) or the recipient selector above')}
)}
)} {uploadProgresses.length > 0 && uploadProgresses.map(({ file, progress, cancel }, index) => (
{file.name ?? t('Uploading...')}
))} {!isPoll && ( )}
{ textareaRef.current?.appendText(url, true) }} onUploadStart={handleUploadStart} onUploadEnd={handleUploadEnd} onProgress={handleUploadProgress} accept="image/*" > {/* I'm not sure why, but after triggering the virtual keyboard, opening the emoji picker drawer causes an issue, the emoji I tap isn't the one that gets inserted. */} {!isTouchDevice() && ( { if (!emoji) return textareaRef.current?.insertEmoji(emoji) }} > )}
) }