Files
eranos/src/components/ComposeBox.tsx
T
2026-02-19 16:42:25 -06:00

684 lines
26 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 { Paperclip, Smile, AlertTriangle, X, Loader2, Eye, EyeOff } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
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 { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { LinkPreview } from '@/components/LinkPreview';
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';
const MAX_CHARS = 5000;
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 } = useCurrentUser();
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[][]>([]);
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]);
// Check if content has any previewable content (link previews, images, or videos)
const hasPreviewableContent = useMemo(() => {
return visibleEmbeds.length > 0 || previewImages.length > 0 || previewVideos.length > 0;
}, [visibleEmbeds, previewImages, previewVideos]);
// 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 handleFileUpload = useCallback(async (file: File) => {
try {
const tags = await uploadFile(file);
const [[, url]] = tags;
// Store the full NIP-94 tags for later use in imeta
setUploadedFileTags((prev) => [...prev, ...tags]);
setContent((prev) => (prev ? prev + '\n' + url : url));
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-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
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
if (user.pubkey) pPubkeys.delete(user.pubkey);
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)(\?[^\s]*)?/gi);
const processedUrls = new Set<string>();
for (const match of mediaUrlMatches) {
const url = match[0];
if (processedUrls.has(url)) continue;
processedUrls.add(url);
// Check if this URL was uploaded (has full metadata)
const uploadedTag = uploadedFileTags.find(tag => tag[0] === 'url' && tag[1] === url);
if (uploadedTag) {
// Use the full NIP-94 metadata from the upload
const imetaFields = uploadedFileTags
.filter(tag => {
// Get all tags for this URL
const urlTag = uploadedFileTags.find(t => t[0] === 'url' && t[1] === url);
return urlTag && uploadedFileTags.indexOf(tag) >= uploadedFileTags.indexOf(urlTag);
})
.map(tag => `${tag[0]} ${tag[1]}`);
tags.push(['imeta', ...imetaFields]);
} else {
// Fallback: basic imeta tag with URL and inferred mime type
const ext = match[1].toLowerCase();
const mimeType = 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';
tags.push(['imeta', `url ${url}`, `m ${mimeType}`]);
}
}
await createEvent({
kind: 1,
content: finalContent,
tags,
created_at: Math.floor(Date.now() / 1000),
});
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
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 && (
<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">
{user ? (metadata?.name?.[0] || '?').toUpperCase() : '?'}
</AvatarFallback>
</Avatar>
)}
<div className="flex-1 min-w-0">
{!previewMode ? (
/* Edit mode - Textarea */
<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',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={isExpanded ? 4 : 1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
) : (
/* Preview mode - Show how post will look */
mockEvent && (
<div className="pt-2.5 pb-2 min-h-[100px] overflow-hidden">
<div className="whitespace-pre-wrap break-words 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/*"
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>
);
}