Files
eranos/src/components/ComposeBox.tsx
T
Chad Curtis 984bada0a4 Add middle-click to open posts in new tab, fix word breaking in compose box
- Add useOpenPost hook to centralize left/middle-click navigation logic
- Apply middle-click (aux click) support to NoteCard, hot posts sidebar, and streams feed
- Fix ComposeBox textarea using break-all instead of break-words
2026-02-24 20:57:11 -06:00

891 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Paperclip, Smile, AlertTriangle, X, Loader2 } 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 { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { EmojiPicker } from '@/components/EmojiPicker';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
import { NoteContent } from '@/components/NoteContent';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { extractWebxdcMeta } from '@/lib/webxdcMeta';
import { useProfileUrl } from '@/hooks/useProfileUrl';
const MAX_CHARS = 5000;
/**
* 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<HTMLImageElement>((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. */
replyTo?: NostrEvent;
/** 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;
}
/** 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 (
<div className="relative flex items-center justify-center size-7">
<svg width="28" height="28" viewBox="0 0 28 28" className="-rotate-90">
{/* Background ring */}
<circle
cx="14" cy="14" r={radius}
fill="none"
strokeWidth="2.5"
className="stroke-secondary"
/>
{/* Progress ring */}
<circle
cx="14" cy="14" r={radius}
fill="none"
strokeWidth="2.5"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className={cn(
'transition-all duration-150',
overLimit ? 'stroke-destructive' : nearLimit ? 'stroke-amber-500' : 'stroke-primary',
)}
/>
</svg>
</div>
);
}
export function ComposeBox({
onSuccess,
placeholder = "What's on your mind?",
compact = false,
replyTo,
quotedEvent,
forceExpanded = false,
hideAvatar = false,
previewMode: controlledPreviewMode,
onHasPreviewableContentChange,
}: ComposeBoxProps) {
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const { mutateAsync: createEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const queryClient = useQueryClient();
const { toast } = useToast();
const [content, setContent] = useState('');
const [expanded, setExpanded] = useState(false);
const [cwEnabled, setCwEnabled] = useState(false);
const [cwText, setCwText] = useState('');
const [emojiOpen, setEmojiOpen] = useState(false);
const [internalPreviewMode, setInternalPreviewMode] = useState(false);
const [removedEmbeds, setRemovedEmbeds] = useState<Set<string>>(new Set());
const [_uploadedFileTags, setUploadedFileTags] = useState<string[][]>([]);
/** Maps uploaded file URLs to their NIP-94 tags (grouped per upload). */
const [uploadedFileGroups, setUploadedFileGroups] = useState<Map<string, string[][]>>(new Map());
/** Maps .xdc URLs to their generated webxdc UUIDs. */
const [webxdcUuids, setWebxdcUuids] = useState<Map<string, string>>(new Map());
/** Maps .xdc URLs to extracted metadata (name + icon URL). */
const [webxdcMetas, setWebxdcMetas] = useState<Map<string, { name?: string; iconUrl?: string }>>(new Map());
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 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]);
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 (!/\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|avi|mkv|flv)(\?[^\s]*)?$/i.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 images and videos for preview mode
const previewImages = useMemo(() => {
if (!content) return [];
const urlRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi;
return content.match(urlRegex) || [];
}, [content]);
const previewVideos = useMemo(() => {
if (!content) return [];
const urlRegex = /https?:\/\/[^\s]+\.(mp4|webm|mov)(\?[^\s]*)?/gi;
return content.match(urlRegex) || [];
}, [content]);
// Detect nostr:npub/nprofile mentions in content
const hasMentions = useMemo(() => {
return /nostr:(npub1|nprofile1)[023456789acdefghjklmnpqrstuvwxyz]+/.test(content);
}, [content]);
// Check if content has any previewable content (link previews, images, videos, or mentions)
const hasPreviewableContent = useMemo(() => {
return visibleEmbeds.length > 0 || previewImages.length > 0 || previewVideos.length > 0 || hasMentions;
}, [visibleEmbeds, previewImages, previewVideos, hasMentions]);
// Notify parent of previewable content changes
useEffect(() => {
if (onHasPreviewableContentChange) {
onHasPreviewableContentChange(hasPreviewableContent);
}
}, [hasPreviewableContent, onHasPreviewableContentChange]);
// Include quoted event if provided and not removed
const quotedEventId = quotedEvent ? nip19.neventEncode({ id: quotedEvent.id, author: quotedEvent.pubkey }) : null;
const quotedEventKey = quotedEventId ? `nostr:${quotedEventId}` : 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(/#\w+/g)?.map((t) => t.slice(1)) || [];
const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]);
return {
id: 'preview',
pubkey: user.pubkey,
content: content.trim(),
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags,
sig: '',
};
}, [user, content]);
const insertEmoji = useCallback((emoji: string) => {
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = content.slice(0, start) + emoji + content.slice(end);
setContent(newContent);
// Restore cursor position after the inserted emoji
requestAnimationFrame(() => {
textarea.focus();
const pos = start + emoji.length;
textarea.setSelectionRange(pos, pos);
});
} else {
setContent((prev) => prev + emoji);
}
expand();
}, [content, expand]);
const handleInsertMention = useCallback(({ start, end, replacement }: { start: number; end: number; replacement: string }) => {
const newContent = content.slice(0, start) + replacement + content.slice(end);
setContent(newContent);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
const pos = start + replacement.length;
textarea.setSelectionRange(pos, pos);
}
});
}, [content]);
const handleInsertShortcodeEmoji = useCallback(({ start, end, replacement }: { start: number; end: number; replacement: string }) => {
const newContent = content.slice(0, start) + replacement + content.slice(end);
setContent(newContent);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
const pos = start + replacement.length;
textarea.setSelectionRange(pos, pos);
}
});
}, [content]);
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 uploadableFile = isXdc && !file.type
? new File([file], file.name, { type: 'application/x-webxdc' })
: 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 from the original file and inject into NIP-94 tags
if (!isXdc) {
const { dim, blurhash } = await getImageMeta(uploadableFile);
if (dim) tags.push(['dim', dim]);
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 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]);
const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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]);
const handleSubmit = async () => {
if (!content.trim() || !user || charCount > MAX_CHARS) return;
try {
const hashtags = content.match(/#\w+/g)?.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<string>();
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]);
}
// NIP-10 reply tags
if (replyTo) {
// Determine root of the thread
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] || '', 'root', rootTag[4] || '']);
tags.push(['e', replyTo.id, '', 'reply', replyTo.pubkey]);
} else {
// replyTo is a top-level note it becomes the root
tags.push(['e', replyTo.id, '', '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<string>();
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
let finalContent = content.trim();
if (showQuotedEvent && quotedEvent) {
tags.push(['q', quotedEvent.id]);
// Add the nostr: URI to the content if not already present
const neventUri = `nostr:${nip19.neventEncode({ id: quotedEvent.id, author: quotedEvent.pubkey })}`;
if (!finalContent.includes(neventUri)) {
finalContent = finalContent + '\n\n' + neventUri;
}
}
// 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-92: Add imeta tags for media URLs in content
const mediaUrlMatches = finalContent.matchAll(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|avi|mkv|flv|xdc)(\?[^\s]*)?/gi);
const processedUrls = new Set<string>();
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 = isWebxdc ? 'application/x-webxdc'
: ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
: ext === 'png' ? 'image/png'
: ext === 'gif' ? 'image/gif'
: ext === 'webp' ? 'image/webp'
: ext === 'svg' ? 'image/svg+xml'
: ext === 'mp4' ? 'video/mp4'
: ext === 'webm' ? 'video/webm'
: ext === 'mov' ? 'video/quicktime'
: ext === 'avi' ? 'video/x-msvideo'
: ext === 'mkv' ? 'video/x-matroska'
: ext === 'flv' ? 'video/x-flv'
: 'application/octet-stream';
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);
}
}
await createEvent({
kind: 1,
content: finalContent,
tags,
created_at: Math.floor(Date.now() / 1000),
});
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
queryClient.invalidateQueries({ queryKey: ['feed'] });
if (replyTo) {
queryClient.invalidateQueries({ queryKey: ['replies', replyTo.id] });
}
if (quotedEvent) {
queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] });
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
}
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 isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
// Early return after all hooks to avoid violating Rules of Hooks
if (!user && compact) return null;
return (
<div className={cn("px-4 py-3", !forceExpanded && "border-b border-border")}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
<div className="flex items-center justify-end mb-3">
<div className="inline-flex items-center gap-0.5 p-1 bg-muted/50 rounded-lg">
<button
onClick={() => setInternalPreviewMode(false)}
className={cn(
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
!previewMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Edit
</button>
<button
onClick={() => setInternalPreviewMode(true)}
className={cn(
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
previewMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Preview
</button>
</div>
</div>
)}
<div className="flex gap-3">
{!hideAvatar && user && (
isProfileLoading ? (
<Skeleton className="size-12 shrink-0 mt-0.5 rounded-full" />
) : (
<Link to={userProfileUrl} className="shrink-0">
<Avatar className="size-12 shrink-0 mt-0.5">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{(metadata?.name?.[0] || '?').toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
)
)}
<div className="flex-1 min-w-0">
{!previewMode ? (
/* Edit mode - Textarea */
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={isExpanded ? 4 : 1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={content}
onInsertEmoji={handleInsertShortcodeEmoji}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
{/* Render images */}
{previewImages.map((url, i) => (
<div key={i} className="mt-3 rounded-2xl overflow-hidden border border-border">
<img
src={url}
alt=""
className="w-full h-auto max-h-[500px] object-contain bg-muted"
loading="lazy"
/>
</div>
))}
{/* Render videos */}
{previewVideos.map((url, i) => (
<div key={i} className="mt-3 rounded-2xl overflow-hidden border border-border">
<video
src={url}
controls
className="w-full h-auto max-h-[500px] bg-muted"
/>
</div>
))}
</div>
)
)}
{/* Content warning input */}
{cwEnabled && (
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="size-4 text-amber-500 shrink-0" />
<Input
value={cwText}
onChange={(e) => setCwText(e.target.value)}
placeholder="Content warning reason (optional)"
className="h-8 text-sm bg-secondary/50 border-0 rounded-lg"
/>
<button
onClick={() => { setCwEnabled(false); setCwText(''); }}
className="p-1 rounded-full text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<X className="size-4" />
</button>
</div>
)}
{/* Quoted event preview */}
{showQuotedEvent && quotedEvent && quotedEventKey && (
<div className="mt-4 mb-3">
<EmbeddedNote eventId={quotedEvent.id} />
</div>
)}
{/* Toolbar + post button */}
{isExpanded && (
<div className="flex items-center justify-between mt-3">
{/* Left: action icons */}
<div className="flex items-center gap-1 -ml-2">
{/* File upload */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading || !user}
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors disabled:opacity-40"
>
{isUploading ? <Loader2 className="size-[18px] animate-spin" /> : <Paperclip className="size-[18px]" />}
</button>
</TooltipTrigger>
<TooltipContent>Attach file</TooltipContent>
</Tooltip>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*,.xdc"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
e.target.value = '';
}}
/>
{/* Emoji picker */}
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'p-2 rounded-full transition-colors',
emojiOpen
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<Smile className="size-[18px]" />
</button>
</PopoverTrigger>
</TooltipTrigger>
{!emojiOpen && <TooltipContent>Emoji</TooltipContent>}
</Tooltip>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<EmojiPicker onSelect={(emoji) => {
insertEmoji(emoji);
}} />
</PopoverContent>
</Popover>
{/* Content warning (NIP-36) */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCwEnabled(!cwEnabled)}
className={cn(
'p-2 rounded-full transition-colors',
cwEnabled
? 'text-amber-500 bg-amber-500/10'
: 'text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10',
)}
>
<AlertTriangle className="size-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>Content warning (NIP-36)</TooltipContent>
</Tooltip>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right: char count + post button */}
<div className="flex items-center gap-3">
{charCount > 0 && (
<div className="flex items-center gap-1.5">
<CharRing count={charCount} max={MAX_CHARS} />
<span className={cn(
'text-xs tabular-nums',
remaining < 0 ? 'text-destructive font-semibold' : remaining < 500 ? 'text-amber-500' : 'text-muted-foreground',
)}>
{remaining}
</span>
</div>
)}
<Button
onClick={handleSubmit}
disabled={!content.trim() || isPending || !user || charCount > MAX_CHARS}
className="rounded-full px-5 font-bold"
size="sm"
>
{isPending ? 'Posting...' : 'Post!'}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}