import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import { encode as blurhashEncode } from 'blurhash'; import type { NostrEvent } from '@nostrify/nostrify'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useCustomEmojis } from '@/hooks/useCustomEmojis'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { GifPicker } from '@/components/GifPicker'; import { EmbeddedNote } from '@/components/EmbeddedNote'; import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { MentionAutocomplete } from '@/components/MentionAutocomplete'; import { CustomEmojiImg } from '@/components/CustomEmoji'; import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete'; import { NoteContent } from '@/components/NoteContent'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { usePostComment } from '@/hooks/usePostComment'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useQueryClient } from '@tanstack/react-query'; import { useToast } from '@/hooks/useToast'; import { useAppContext } from '@/hooks/useAppContext'; import type { EventStats } from '@/hooks/useTrending'; import { cn } from '@/lib/utils'; import { notificationSuccess } from '@/lib/haptics'; import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls'; /** Lazy-loaded EmojiPicker — keeps emoji-mart + its data out of the main bundle. */ const LazyEmojiPicker = lazy(() => import('@/components/EmojiPicker').then(m => ({ default: m.EmojiPicker }))); import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useInsertText } from '@/hooks/useInsertText'; import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'; import { formatTime } from '@/lib/formatTime'; import { genUserName } from '@/lib/genUserName'; import { DITTO_RELAY } from '@/lib/appRelays'; import { resizeImage } from '@/lib/resizeImage'; const MAX_CHARS = 5000; /** Short random ID for poll options. */ function pollOptionId(): string { return Math.random().toString(36).slice(2, 8); } /** * For an image File, returns `{ dim: "WxH", blurhash: "..." }`. * Decodes to a small canvas (max 64px wide) for speed — large enough * for a good blurhash sample but cheap to compute. * Returns an empty object for non-image files or if anything fails. */ async function getImageMeta(file: File): Promise<{ dim?: string; blurhash?: string }> { if (!file.type.startsWith('image/')) return {}; try { const url = URL.createObjectURL(file); try { const img = await new Promise((resolve, reject) => { const el = new Image(); el.onload = () => resolve(el); el.onerror = reject; el.src = url; }); const naturalWidth = img.naturalWidth; const naturalHeight = img.naturalHeight; if (!naturalWidth || !naturalHeight) return {}; const dim = `${naturalWidth}x${naturalHeight}`; // Downsample for blurhash encoding — 64px wide keeps it fast const SAMPLE_W = 64; const scale = SAMPLE_W / naturalWidth; const sampleW = SAMPLE_W; const sampleH = Math.max(1, Math.round(naturalHeight * scale)); const canvas = document.createElement('canvas'); canvas.width = sampleW; canvas.height = sampleH; const ctx = canvas.getContext('2d'); if (!ctx) return { dim }; ctx.drawImage(img, 0, 0, sampleW, sampleH); const { data } = ctx.getImageData(0, 0, sampleW, sampleH); // componentX/Y: 4x3 gives a good balance of detail vs hash length const blurhash = blurhashEncode(data, sampleW, sampleH, 4, 3); return { dim, blurhash }; } finally { URL.revokeObjectURL(url); } } catch { return {}; } } interface ComposeBoxProps { onSuccess?: () => void; placeholder?: string; compact?: boolean; /** Event being replied to – adds NIP-10 reply tags when set. A URL triggers NIP-22 comment mode. */ replyTo?: NostrEvent | URL; /** Event being quoted – shows embedded preview and adds quote tags. */ quotedEvent?: NostrEvent; /** If true, the compose area is always expanded (e.g. inside a modal). */ forceExpanded?: boolean; /** If true, hides the avatar (useful inside modals with their own layout). */ hideAvatar?: boolean; /** Controlled preview mode (for modal usage). */ previewMode?: boolean; /** Callback to notify parent of previewable content changes. */ onHasPreviewableContentChange?: (hasContent: boolean) => void; /** Pre-filled content for the compose box. */ initialContent?: string; /** Open directly in poll mode. */ initialMode?: 'post' | 'poll'; } /** Circular progress ring for character count. */ function CharRing({ count, max }: { count: number; max: number }) { const radius = 10; const circumference = 2 * Math.PI * radius; const ratio = Math.min(count / max, 1); const offset = circumference * (1 - ratio); const overLimit = count > max; const nearLimit = count > max * 0.9; return (
{/* Background ring */} {/* Progress ring */}
); } export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", compact = false, replyTo, quotedEvent, forceExpanded = false, hideAvatar = false, previewMode: controlledPreviewMode, onHasPreviewableContentChange, initialContent = '', initialMode = 'post', }: ComposeBoxProps) { const { user, metadata, isLoading: isProfileLoading } = useCurrentUser(); const avatarShape = getAvatarShape(metadata); const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata); const { mutateAsync: createEvent, isPending, isPending: isPollPending } = useNostrPublish(); const { mutateAsync: postComment, isPending: isCommentPending } = usePostComment(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { feedSettings } = useFeedSettings(); const customEmojisEnabled = feedSettings.showCustomEmojis !== false; const { emojis: allCustomEmojis } = useCustomEmojis(); const customEmojis = useMemo(() => customEmojisEnabled ? allCustomEmojis : [], [customEmojisEnabled, allCustomEmojis]); const queryClient = useQueryClient(); const { toast } = useToast(); const { config } = useAppContext(); const imageQuality = config.imageQuality; const [content, setContent] = useState(initialContent); const [expanded, setExpanded] = useState(false); const [cwEnabled, setCwEnabled] = useState(false); const [cwText, setCwText] = useState(''); const [pickerOpen, setPickerOpen] = useState(false); const [pickerTab, setPickerTab] = useState<'emoji' | 'gif' | 'stickers'>('emoji'); const [trayOpen, setTrayOpen] = useState(false); const [internalPreviewMode, setInternalPreviewMode] = useState(false); // Poll mode state const [mode, setMode] = useState<'post' | 'poll'>(initialMode); const [pollOptions, setPollOptions] = useState([ { id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }, ]); const [pollType, setPollType] = useState<'singlechoice' | 'multiplechoice'>('singlechoice'); const [pollDuration, setPollDuration] = useState<7 | 3 | 1 | 0>(7); const [removedEmbeds, setRemovedEmbeds] = useState>(new Set()); const [_uploadedFileTags, setUploadedFileTags] = useState([]); /** Maps uploaded file URLs to their NIP-94 tags (grouped per upload). */ const [uploadedFileGroups, setUploadedFileGroups] = useState>(new Map()); /** Maps .xdc URLs to their generated webxdc UUIDs. */ const [webxdcUuids, setWebxdcUuids] = useState>(new Map()); /** Maps .xdc URLs to extracted metadata (name + icon URL). */ const [webxdcMetas, setWebxdcMetas] = useState>(new Map()); const textareaRef = useRef(null); const { insertAtCursor, insertEmoji: insertEmojiAtCursor } = useInsertText(textareaRef, content, setContent); const fileInputRef = useRef(null); // Voice recording const voiceRecorder = useVoiceRecorder(); const [isPublishingVoice, setIsPublishingVoice] = useState(false); const resetComposeState = useCallback(() => { setContent(''); setCwEnabled(false); setCwText(''); setExpanded(false); setPickerOpen(false); setTrayOpen(false); setInternalPreviewMode(false); setMode(initialMode); setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]); setPollType('singlechoice'); setPollDuration(7); setRemovedEmbeds(new Set()); setUploadedFileTags([]); setUploadedFileGroups(new Map()); setWebxdcUuids(new Map()); setWebxdcMetas(new Map()); }, [initialMode]); // Use controlled preview mode if provided, otherwise use internal state const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode; // Auto-expand when quotedEvent is provided useEffect(() => { if (quotedEvent) { setExpanded(true); } }, [quotedEvent]); // Auto-resize textarea height as content grows/shrinks useEffect(() => { const el = textareaRef.current; if (!el) return; // Reset to auto so shrinking is detected correctly el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; }, [content]); const charCount = content.length; const remaining = MAX_CHARS - charCount; const expand = useCallback(() => { if (!expanded) setExpanded(true); }, [expanded]); // Detect embeds in content (nevent, note, naddr, URLs) with their positions const detectedEmbeds = useMemo(() => { const embeds: Array<{ type: 'nevent' | 'note' | 'naddr' | 'link'; value: string; index: number; eventId?: string; addr?: { kind: number; pubkey: string; identifier: string } }> = []; // Detect nostr: URIs const nostrMatches = content.matchAll(/nostr:(nevent1|note1|naddr1)[023456789acdefghjklmnpqrstuvwxyz]+/g); for (const match of nostrMatches) { const bech32 = match[0].slice('nostr:'.length); try { const decoded = nip19.decode(bech32); if (decoded.type === 'nevent') { embeds.push({ type: 'nevent', value: match[0], index: match.index!, eventId: decoded.data.id }); } else if (decoded.type === 'note') { embeds.push({ type: 'note', value: match[0], index: match.index!, eventId: decoded.data }); } else if (decoded.type === 'naddr') { embeds.push({ type: 'naddr', value: match[0], index: match.index!, addr: { kind: decoded.data.kind, pubkey: decoded.data.pubkey, identifier: decoded.data.identifier } }); } } catch { // Invalid bech32, skip } } // Detect raw NIP-19 identifiers (without nostr: prefix) const rawNip19Matches = content.matchAll(/\b(nevent1|note1|naddr1)[023456789acdefghjklmnpqrstuvwxyz]+\b/g); for (const match of rawNip19Matches) { const bech32 = match[0]; // Skip if it's already prefixed with nostr: (already handled above) const beforeIndex = match.index! - 6; const before = content.substring(Math.max(0, beforeIndex), match.index); if (before.endsWith('nostr:')) continue; try { const decoded = nip19.decode(bech32); if (decoded.type === 'nevent') { embeds.push({ type: 'nevent', value: match[0], index: match.index!, eventId: decoded.data.id }); } else if (decoded.type === 'note') { embeds.push({ type: 'note', value: match[0], index: match.index!, eventId: decoded.data }); } else if (decoded.type === 'naddr') { embeds.push({ type: 'naddr', value: match[0], index: match.index!, addr: { kind: decoded.data.kind, pubkey: decoded.data.pubkey, identifier: decoded.data.identifier } }); } } catch { // Invalid bech32, skip } } // Detect regular URLs (but not image/video URLs that will be rendered inline) const urlMatches = content.matchAll(/https?:\/\/[^\s]+/g); for (const match of urlMatches) { const url = match[0]; // Skip media URLs that render inline // Note: SVGs not excluded - LinkPreview checks content-type and handles both cases if (!IMETA_MEDIA_URL_TEST_REGEX.test(url)) { embeds.push({ type: 'link', value: url, index: match.index! }); } } // Sort by position in content return embeds.sort((a, b) => a.index - b.index); }, [content]); // Filter out removed embeds const visibleEmbeds = useMemo(() => detectedEmbeds.filter(embed => !removedEmbeds.has(embed.value)), [detectedEmbeds, removedEmbeds] ); // Extract videos for preview mode const previewVideos = useMemo(() => content ? extractVideoUrls(content) : [], [content]); // Extract audio for preview mode const previewAudios = useMemo(() => content ? extractAudioUrls(content) : [], [content]); // Detect inline images for preview mode const hasPreviewImages = useMemo(() => { if (!content) return false; return /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|avif)(\?[^\s]*)?/i.test(content); }, [content]); // Detect nostr:npub/nprofile mentions in content const hasMentions = useMemo(() => { return /nostr:(npub1|nprofile1)[023456789acdefghjklmnpqrstuvwxyz]+/.test(content); }, [content]); // Detect custom emojis in content for preview mode const hasCustomEmojis = useMemo(() => { if (customEmojis.length === 0 || !content) return false; const emojiSet = new Set(customEmojis.map((e) => e.shortcode)); const matches = content.matchAll(/:([a-zA-Z0-9_-]+):/g); for (const match of matches) { if (emojiSet.has(match[1])) return true; } return false; }, [content, customEmojis]); // Detect webxdc attachments for preview mode const hasWebxdc = useMemo(() => webxdcUuids.size > 0, [webxdcUuids]); // Check if content has any previewable content (link previews, images, videos, audio, webxdc, mentions, or custom emojis) const hasPreviewableContent = useMemo(() => { return visibleEmbeds.length > 0 || hasPreviewImages || previewVideos.length > 0 || previewAudios.length > 0 || hasWebxdc || hasMentions || hasCustomEmojis; }, [visibleEmbeds, hasPreviewImages, previewVideos, previewAudios, hasWebxdc, hasMentions, hasCustomEmojis]); // Notify parent of previewable content changes useEffect(() => { if (onHasPreviewableContentChange) { onHasPreviewableContentChange(hasPreviewableContent); } }, [hasPreviewableContent, onHasPreviewableContentChange]); // Include quoted event if provided and not removed. // Use naddr for addressable events (kinds 30000-39999) so the reference // stays stable across event updates; use nevent for everything else. const quotedEventNip19 = useMemo(() => { if (!quotedEvent) return null; if (quotedEvent.kind >= 30000 && quotedEvent.kind < 40000) { const dTag = quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? ''; return nip19.naddrEncode({ kind: quotedEvent.kind, pubkey: quotedEvent.pubkey, identifier: dTag, relays: [DITTO_RELAY], }); } return nip19.neventEncode({ id: quotedEvent.id, author: quotedEvent.pubkey, relays: [DITTO_RELAY] }); }, [quotedEvent]); const quotedEventKey = quotedEventNip19 ? `nostr:${quotedEventNip19}` : null; const showQuotedEvent = quotedEvent && quotedEventKey && !removedEmbeds.has(quotedEventKey); // Create mock event for preview const mockEvent = useMemo(() => { if (!user || !content) return null; const hashtags = content.match(/#[\p{L}\p{N}_]+/gu)?.map((t) => t.slice(1)) || []; const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]); // NIP-30: Add emoji tags for custom emojis referenced in content if (customEmojis.length > 0) { const emojiMap = new Map(customEmojis.map((e) => [e.shortcode, e.url])); const shortcodeRegex = /:([a-zA-Z0-9_-]+):/g; const usedEmojis = new Set(); let match; while ((match = shortcodeRegex.exec(content)) !== null) { const shortcode = match[1]; if (emojiMap.has(shortcode) && !usedEmojis.has(shortcode)) { usedEmojis.add(shortcode); tags.push(['emoji', shortcode, emojiMap.get(shortcode)!]); } } } // NIP-92: Build imeta tags for uploaded media so preview can render them const mediaUrlMatches = content.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi')); const processedUrls = new Set(); for (const m of mediaUrlMatches) { const url = m[0]; if (processedUrls.has(url)) continue; processedUrls.add(url); const ext = m[1].toLowerCase(); const isWebxdc = ext === 'xdc'; const fileTags = uploadedFileGroups.get(url); if (fileTags) { const imetaFields = fileTags.map(tag => `${tag[0]} ${tag[1]}`); if (isWebxdc) { const filtered = imetaFields.filter(f => !f.startsWith('m ')); filtered.push('m application/x-webxdc'); const uuid = webxdcUuids.get(url); if (uuid) filtered.push(`webxdc ${uuid}`); const meta = webxdcMetas.get(url); if (meta?.name) filtered.push(`summary ${meta.name}`); if (meta?.iconUrl) filtered.push(`image ${meta.iconUrl}`); tags.push(['imeta', ...filtered]); } else { tags.push(['imeta', ...imetaFields]); } } else { const mimeType = mimeFromExt(ext); const imetaTag = ['imeta', `url ${url}`, `m ${mimeType}`]; if (isWebxdc) { const uuid = webxdcUuids.get(url); if (uuid) imetaTag.push(`webxdc ${uuid}`); const meta = webxdcMetas.get(url); if (meta?.name) imetaTag.push(`summary ${meta.name}`); if (meta?.iconUrl) imetaTag.push(`image ${meta.iconUrl}`); } tags.push(imetaTag); } } return { id: 'preview', pubkey: user.pubkey, content: content.trim(), created_at: Math.floor(Date.now() / 1000), kind: 1, tags, sig: '', }; }, [user, content, customEmojis, uploadedFileGroups, webxdcUuids, webxdcMetas]); const insertEmoji = useCallback((emoji: string) => { insertEmojiAtCursor(emoji); expand(); }, [insertEmojiAtCursor, expand]); const handleInsertMention = insertAtCursor; const handleInsertShortcodeEmoji = insertAtCursor; const handleFileUpload = useCallback(async (file: File) => { try { // .xdc files are ZIP archives; browsers don't know their MIME type so file.type is ''. // Blossom servers may reject uploads with an empty Content-Type, so we re-wrap the file // with the correct MIME type before uploading. const isXdc = file.name.endsWith('.xdc'); const isImage = file.type.startsWith('image/'); let uploadableFile: File; let resizedDim: string | undefined; if (isXdc && !file.type) { uploadableFile = new File([file], file.name, { type: 'application/x-webxdc' }); } else if (isImage && imageQuality === 'compressed') { // Resize & optimize images before uploading for better performance. const resized = await resizeImage(file); uploadableFile = resized.file; resizedDim = resized.dimensions; } else { uploadableFile = file; } const tags = await uploadFile(uploadableFile); let [[, url]] = tags; // Blossom returns hash-based URLs that may lack the original file extension. // Append the extension so downstream media-URL detection and imeta generation work. if (isXdc && !url.endsWith('.xdc')) { url = url + '.xdc'; // Update the url tag in the NIP-94 tags to match const urlTag = tags.find(t => t[0] === 'url'); if (urlTag) urlTag[1] = url; } // Compute dim + blurhash and inject into NIP-94 tags if (!isXdc && isImage) { // Use dimensions from resizeImage; compute blurhash from the resized file if (resizedDim) tags.push(['dim', resizedDim]); const { blurhash } = await getImageMeta(uploadableFile); if (blurhash) tags.push(['blurhash', blurhash]); } // Store the full NIP-94 tags for later use in imeta setUploadedFileTags((prev) => [...prev, ...tags]); setUploadedFileGroups((prev) => new Map(prev).set(url, tags)); setContent((prev) => (prev ? prev + '\n' + url : url)); // For .xdc files, generate a UUID and extract manifest metadata if (isXdc) { const uuid = crypto.randomUUID(); setWebxdcUuids((prev) => new Map(prev).set(url, uuid)); // Extract name and icon from the .xdc archive try { const { extractWebxdcMeta } = await import('@/lib/webxdcMeta'); const meta = await extractWebxdcMeta(file); const metaEntry: { name?: string; iconUrl?: string } = { name: meta.name }; // Upload the icon to Blossom if present if (meta.iconFile) { try { const iconTags = await uploadFile(meta.iconFile); const [[, iconUrl]] = iconTags; metaEntry.iconUrl = iconUrl; } catch { // Icon upload failed — continue without it } } setWebxdcMetas((prev) => new Map(prev).set(url, metaEntry)); } catch { // Metadata extraction failed — continue without it } } expand(); } catch { toast({ title: 'Upload failed', description: 'Could not upload file.', variant: 'destructive' }); } }, [uploadFile, expand, toast, imageQuality]); const handlePaste = useCallback(async (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; // Check for image files in clipboard for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith('image/')) { e.preventDefault(); // Prevent default paste behavior for images const file = item.getAsFile(); if (file) { await handleFileUpload(file); } break; } } }, [handleFileUpload]); /** Start voice recording. */ const handleStartRecording = useCallback(async () => { try { await voiceRecorder.startRecording(); expand(); } catch { toast({ title: 'Microphone access denied', description: 'Please allow microphone access to record voice messages.', variant: 'destructive' }); } }, [voiceRecorder, expand, toast]); /** Stop recording, upload, and publish as kind 1222 or 1244. */ const handleStopAndPublishVoice = useCallback(async () => { if (!user) return; setIsPublishingVoice(true); try { const recording = await voiceRecorder.stopRecording(); if (!recording) return; // Determine file extension from MIME type const extMap: Record = { 'audio/mp4': '.m4a', 'audio/mp4;codecs=aac': '.m4a', 'audio/aac': '.aac', 'audio/webm;codecs=opus': '.webm', 'audio/webm': '.webm', 'audio/ogg;codecs=opus': '.ogg', }; const ext = extMap[recording.mimeType] ?? '.webm'; const fileName = `voice-message-${Date.now()}${ext}`; const file = new File([recording.blob], fileName, { type: recording.mimeType }); // Upload to Blossom const tags = await uploadFile(file); const [[, audioUrl]] = tags; // Build NIP-A0 imeta tag with waveform and duration const imetaFields = [ `url ${audioUrl}`, `m ${recording.mimeType}`, `waveform ${recording.waveform.join(' ')}`, `duration ${Math.round(recording.duration)}`, ]; const imetaTag = ['imeta', ...imetaFields]; // Determine kind: 1244 for NIP-22 replies, 1222 for root messages const isNip22Reply = replyTo && (replyTo instanceof URL || replyTo.kind !== 1); const isKind1Reply = replyTo && !(replyTo instanceof URL) && replyTo.kind === 1; if (isNip22Reply) { // NIP-22 voice reply (kind 1244) — use postComment infrastructure // but we need to publish kind 1244 directly since postComment uses kind 1111 // Build NIP-22 tags manually const voiceTags: string[][] = [imetaTag]; if (replyTo instanceof URL) { voiceTags.push(['I', replyTo.toString()]); voiceTags.push(['K', replyTo.protocol === 'http:' || replyTo.protocol === 'https:' ? 'web' : replyTo.protocol.replace(/:$/, '')]); // lowercase reply tags pointing to same root voiceTags.push(['i', replyTo.toString()]); voiceTags.push(['k', replyTo.protocol === 'http:' || replyTo.protocol === 'https:' ? 'web' : replyTo.protocol.replace(/:$/, '')]); } else { voiceTags.push(['E', replyTo.id]); voiceTags.push(['K', replyTo.kind.toString()]); voiceTags.push(['P', replyTo.pubkey]); // lowercase reply tags voiceTags.push(['e', replyTo.id]); voiceTags.push(['k', replyTo.kind.toString()]); voiceTags.push(['p', replyTo.pubkey]); } await createEvent({ kind: 1244, content: audioUrl, tags: voiceTags, }); } else if (isKind1Reply && !(replyTo instanceof URL)) { // NIP-10 voice reply to a kind 1 note — still publish as kind 1222 with reply tags const voiceTags: string[][] = [imetaTag]; const rootTag = replyTo.tags.find(([name, , , marker]) => name === 'e' && marker === 'root'); if (rootTag) { voiceTags.push(['e', rootTag[1], rootTag[2] || DITTO_RELAY, 'root', ...(rootTag[4] ? [rootTag[4]] : [])]); voiceTags.push(['e', replyTo.id, DITTO_RELAY, 'reply', replyTo.pubkey]); } else { voiceTags.push(['e', replyTo.id, DITTO_RELAY, 'root', replyTo.pubkey]); } voiceTags.push(['p', replyTo.pubkey]); await createEvent({ kind: 1222, content: audioUrl, tags: voiceTags, }); } else { // Root voice message (kind 1222) await createEvent({ kind: 1222, content: audioUrl, tags: [imetaTag], }); } // Reset state queryClient.invalidateQueries({ queryKey: ['feed'] }); if (replyTo) { if (replyTo instanceof URL) { queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] }); } else { queryClient.invalidateQueries({ queryKey: ['replies', replyTo.id] }); if (replyTo.kind !== 1) { queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] }); } } } notificationSuccess(); toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' }); onSuccess?.(); } catch { toast({ title: 'Error', description: 'Failed to send voice message.', variant: 'destructive' }); } finally { setIsPublishingVoice(false); } }, [user, voiceRecorder, uploadFile, createEvent, replyTo, queryClient, toast, onSuccess]); const handleSubmit = async () => { if (!content.trim() || !user || charCount > MAX_CHARS) return; try { const hashtags = content.match(/#[\p{L}\p{N}_]+/gu)?.map((t) => t.slice(1)) || []; const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]); // NIP-27 mention p tags — extract nostr:npub1... from content const mentionMatches = content.matchAll(/nostr:(npub1[023456789acdefghjklmnpqrstuvwxyz]+)/g); const mentionedPubkeys = new Set(); for (const match of mentionMatches) { try { const decoded = nip19.decode(match[1]); if (decoded.type === 'npub') { mentionedPubkeys.add(decoded.data); } } catch { // Invalid bech32, skip } } // Don't include ourselves mentionedPubkeys.delete(user.pubkey); for (const pk of mentionedPubkeys) { tags.push(['p', pk]); } // Reply tags: NIP-10 for kind 1 targets, NIP-22 for non-kind-1 targets and URLs const isNip22Reply = replyTo && (replyTo instanceof URL || replyTo.kind !== 1); if (replyTo && !isNip22Reply && !(replyTo instanceof URL)) { // NIP-10 reply tags (kind 1 targets only) const rootTag = replyTo.tags.find(([name, , , marker]) => name === 'e' && marker === 'root'); if (rootTag) { // replyTo is itself a reply – preserve the root and mark replyTo as reply tags.push(['e', rootTag[1], rootTag[2] || DITTO_RELAY, 'root', ...(rootTag[4] ? [rootTag[4]] : [])]); tags.push(['e', replyTo.id, DITTO_RELAY, 'reply', replyTo.pubkey]); } else { // replyTo is a top-level note – it becomes the root tags.push(['e', replyTo.id, DITTO_RELAY, 'root', replyTo.pubkey]); } // Add p tags: original author + all existing p tags from the parent // Skip pubkeys already added by mention detection above const pPubkeys = new Set(); pPubkeys.add(replyTo.pubkey); for (const tag of replyTo.tags) { if (tag[0] === 'p' && tag[1]) pPubkeys.add(tag[1]); } // Don't include ourselves or already-mentioned pubkeys if (user.pubkey) pPubkeys.delete(user.pubkey); for (const pk of mentionedPubkeys) pPubkeys.delete(pk); for (const pk of pPubkeys) { tags.push(['p', pk]); } } // Quote tags (if quoted event and not removed) // Per NIP-18, quotes should use the q tag and include the nostr: URI in content. // For addressable events (kinds 30000-39999), use event address coordinates // so the reference stays stable across event updates. let finalContent = content.trim(); if (showQuotedEvent && quotedEvent && quotedEventNip19) { if (quotedEvent.kind >= 30000 && quotedEvent.kind < 40000) { const dTag = quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? ''; tags.push(['q', `${quotedEvent.kind}:${quotedEvent.pubkey}:${dTag}`, DITTO_RELAY]); } else { tags.push(['q', quotedEvent.id, DITTO_RELAY, quotedEvent.pubkey]); } // Add the nostr: URI to the content if not already present const quoteUri = `nostr:${quotedEventNip19}`; if (!finalContent.includes(quoteUri)) { finalContent = finalContent + '\n\n' + quoteUri; } } // NIP-36: content warning if (cwEnabled) { tags.push(['content-warning', cwText || '']); tags.push(['L', 'content-warning']); if (cwText) { tags.push(['l', cwText, 'content-warning']); } } // NIP-30: Add emoji tags for custom emojis referenced in content if (customEmojis.length > 0) { const emojiMap = new Map(customEmojis.map((e) => [e.shortcode, e.url])); const shortcodeRegex = /:([a-zA-Z0-9_-]+):/g; const usedEmojis = new Set(); let emojiMatch; while ((emojiMatch = shortcodeRegex.exec(finalContent)) !== null) { const shortcode = emojiMatch[1]; if (emojiMap.has(shortcode) && !usedEmojis.has(shortcode)) { usedEmojis.add(shortcode); tags.push(['emoji', shortcode, emojiMap.get(shortcode)!]); } } } // NIP-92: Add imeta tags for media URLs in content const mediaUrlMatches = finalContent.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi')); const processedUrls = new Set(); for (const match of mediaUrlMatches) { const url = match[0]; if (processedUrls.has(url)) continue; processedUrls.add(url); const ext = match[1].toLowerCase(); const isWebxdc = ext === 'xdc'; // Build imeta from grouped upload tags if available, otherwise infer const fileTags = uploadedFileGroups.get(url); if (fileTags) { const imetaFields = fileTags.map(tag => `${tag[0]} ${tag[1]}`); if (isWebxdc) { // Override MIME type for .xdc files and add webxdc UUID + metadata const filtered = imetaFields.filter(f => !f.startsWith('m ')); filtered.push('m application/x-webxdc'); const uuid = webxdcUuids.get(url); if (uuid) filtered.push(`webxdc ${uuid}`); const meta = webxdcMetas.get(url); if (meta?.name) filtered.push(`summary ${meta.name}`); if (meta?.iconUrl) filtered.push(`image ${meta.iconUrl}`); tags.push(['imeta', ...filtered]); } else { tags.push(['imeta', ...imetaFields]); } } else { // Fallback: basic imeta tag with URL and inferred mime type const mimeType = mimeFromExt(ext); const imetaTag = ['imeta', `url ${url}`, `m ${mimeType}`]; if (isWebxdc) { const uuid = webxdcUuids.get(url); if (uuid) imetaTag.push(`webxdc ${uuid}`); const meta = webxdcMetas.get(url); if (meta?.name) imetaTag.push(`summary ${meta.name}`); if (meta?.iconUrl) imetaTag.push(`image ${meta.iconUrl}`); } tags.push(imetaTag); } } if (isNip22Reply) { // NIP-22: use usePostComment for non-kind-1 targets and URL roots // Determine root and reply params for the comment hook let root: NostrEvent | URL | `#${string}`; let reply: NostrEvent | undefined; if (replyTo instanceof URL) { // External content root — the URL is the root directly root = replyTo; } else if (replyTo.kind === 1111) { // Replying to a comment: replyTo is the parent, root is derived from its uppercase tags reply = replyTo; // Reconstruct the original root from the comment's uppercase tags const K = replyTo.tags.find(([n]) => n === 'K')?.[1]; const P = replyTo.tags.find(([n]) => n === 'P')?.[1]; const A = replyTo.tags.find(([n]) => n === 'A')?.[1]; const E = replyTo.tags.find(([n]) => n === 'E')?.[1]; const I = replyTo.tags.find(([n]) => n === 'I')?.[1]; // External content root (URL or hashtag identifier) if (I) { if (K === '#') { root = I as `#${string}`; } else { try { root = new URL(I); } catch { root = I as `#${string}`; } } } else { const rootKind = K ? parseInt(K, 10) : 0; const rootPubkey = P ?? ''; if (A) { // Addressable/replaceable root: extract d-tag from the A value const parts = A.split(':'); const dValue = parts.length >= 3 ? parts.slice(2).join(':') : ''; root = { id: E ?? '', kind: rootKind, pubkey: rootPubkey, content: '', created_at: 0, sig: '', tags: [['d', dValue]], }; } else { root = { id: E ?? '', kind: rootKind, pubkey: rootPubkey, content: '', created_at: 0, sig: '', tags: [], }; } } } else { // Replying directly to a non-kind-1 event: it is the root root = replyTo; } await postComment({ root, reply, content: finalContent, tags }); } else { await createEvent({ kind: 1, content: finalContent, tags, created_at: Math.floor(Date.now() / 1000), }); } resetComposeState(); // Optimistically bump the reply count on the parent event if (replyTo && !(replyTo instanceof URL)) { queryClient.setQueryData(['event-stats', replyTo.id], (prev) => prev ? { ...prev, replies: prev.replies + 1 } : prev, ); } queryClient.invalidateQueries({ queryKey: ['feed'] }); if (replyTo) { if (replyTo instanceof URL) { queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] }); } else { queryClient.invalidateQueries({ queryKey: ['replies', replyTo.id] }); // Invalidate the event-comments cache used by CommentsSheet if (replyTo.kind !== 1) { const dTag = replyTo.tags.find(([n]) => n === 'd')?.[1] ?? ''; const aTag = `${replyTo.kind}:${replyTo.pubkey}:${dTag}`; queryClient.invalidateQueries({ queryKey: ['event-comments', aTag] }); } } } if (quotedEvent) { queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] }); queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] }); } notificationSuccess(); toast({ title: 'Posted!', description: replyTo ? 'Your reply has been published.' : quotedEvent ? 'Your quote has been published.' : 'Your note has been published.' }); onSuccess?.(); } catch { toast({ title: 'Error', description: 'Failed to publish note.', variant: 'destructive' }); } }; const handlePollSubmit = async () => { const filledOptions = pollOptions.filter((o) => o.label.trim()); const finalContent = content.trim(); if (!finalContent || filledOptions.length < 2 || !user || isPollPending) return; const tags: string[][] = []; for (const opt of filledOptions) { tags.push(['option', opt.id, opt.label.trim()]); } tags.push(['polltype', pollType]); if (pollDuration > 0) { tags.push(['endsAt', String(Math.floor(Date.now() / 1000) + pollDuration * 86_400)]); } // NIP-92: Add imeta tags for media URLs in content const mediaUrlMatches = finalContent.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi')); const processedUrls = new Set(); for (const match of mediaUrlMatches) { const url = match[0]; if (processedUrls.has(url)) continue; processedUrls.add(url); const fileTags = uploadedFileGroups.get(url); if (fileTags) { tags.push(['imeta', ...fileTags.map(tag => `${tag[0]} ${tag[1]}`)]); } else { const ext = match[1].toLowerCase(); tags.push(['imeta', `url ${url}`, `m ${mimeFromExt(ext)}`]); } } tags.push(['alt', `Poll: ${finalContent}`]); // Country-scoped polls: attach NIP-73 iso3166 tags so the poll appears in // the country feed (matches Pathos buildPollTags geo-scoping behavior). if (replyTo instanceof URL && replyTo.protocol === 'iso3166:') { const countryIdentifier = replyTo.toString(); tags.push(['I', countryIdentifier]); tags.push(['K', 'iso3166']); tags.push(['i', countryIdentifier]); tags.push(['k', 'iso3166']); } try { await createEvent({ kind: 1068, content: finalContent, tags }); resetComposeState(); queryClient.invalidateQueries({ queryKey: ['feed'] }); notificationSuccess(); toast({ title: 'Poll published!' }); onSuccess?.(); } catch { toast({ title: 'Error', description: 'Failed to publish poll.', variant: 'destructive' }); } }; const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length; const isPollValid = content.trim().length > 0 && pollFilledCount >= 2; const isExpanded = forceExpanded || expanded || content.length > 0 || !compact; // Early return after all hooks to avoid violating Rules of Hooks if (!user && compact) return null; return (
{/* Preview toggle at top when not controlled and has previewable content */} {hasPreviewableContent && controlledPreviewMode === undefined && (
)}
{!hideAvatar && user && ( isProfileLoading ? ( ) : ( {(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'} ) )}
{!previewMode ? ( /* ── Edit mode — Textarea ────────────────────────────── */