Files
eranos/src/components/ComposeBox.tsx
T
Alex Gleason cc702027b0 Add native haptic feedback to all key interactions
Install @capacitor/haptics and add a centralized haptics utility
(src/lib/haptics.ts) that uses the native taptic engine on iOS/Android
and falls back to navigator.vibrate() on web.

Haptics added to:
- Switch component (covers 36+ toggle switches app-wide)
- PullToRefresh threshold (covers 15+ pages)
- MobileBottomNav tab taps
- ReactionButton (like/unlike, double-click heart)
- RepostMenu (repost/undo repost)
- ZapDialog button press + payment success (NWC and WebLN)
- FollowButton and ProfilePage follow toggle
- ComposeBox (post, voice message, and poll publish success)
- NoteMoreMenu (bookmark, pin, mute)
- VinesFeedPage reaction and repost buttons
- ProfileReactionButton and ExternalReactionButton
- NoteCard share button
- BlobbiRoomShell swipe navigation

Replaces raw navigator.vibrate() calls in GameControls and
SendAnimation with the new cross-platform haptics utility, fixing
haptic feedback on iOS where the Vibration API is not available.
2026-04-14 11:06:18 -05:00

1589 lines
66 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 { 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<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. 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 (
<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,
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<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 { insertAtCursor, insertEmoji: insertEmojiAtCursor } = useInsertText(textareaRef, content, setContent);
const fileInputRef = useRef<HTMLInputElement>(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<string>();
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<string>();
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<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]);
/** 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<string, string> = {
'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<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]);
}
// 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<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.
// 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<string>();
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<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 = 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<EventStats>(['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<string>();
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}`]);
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 (
<div className={cn("px-4 py-3 bg-background/85 rounded-2xl")}>
{/* 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 shape={avatarShape} 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?.display_name || metadata?.name || genUserName(user?.pubkey))[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)}
onPointerDown={expand}
onFocus={expand}
onPaste={handlePaste}
placeholder={mode === 'poll' ? 'Ask a question…' : 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 overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={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>
</div>
)
)}
{/* Poll options + settings — shown below the normal textarea/preview */}
{mode === 'poll' && (
<div className="space-y-3 pt-1">
{/* Back to post link — hidden when poll mode is the only mode */}
{initialMode !== 'poll' && (
<button
type="button"
onClick={() => setMode('post')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-3.5" />
Back to post
</button>
)}
{/* Options */}
<div className="space-y-1.5">
{pollOptions.map((opt, idx) => (
<div key={opt.id} className="flex items-center gap-2">
<input
type="text"
value={opt.label}
onChange={(e) =>
setPollOptions((prev) =>
prev.map((o) => (o.id === opt.id ? { ...o, label: e.target.value } : o)),
)
}
placeholder={`Option ${idx + 1}`}
maxLength={100}
className="flex-1 bg-secondary/40 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-primary/40 placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={() => {
if (pollOptions.length > 2) {
setPollOptions((prev) => prev.filter((o) => o.id !== opt.id));
}
}}
disabled={pollOptions.length <= 2}
className="p-1 rounded-full text-muted-foreground hover:text-destructive transition-colors disabled:opacity-20"
>
<X className="size-3.5" />
</button>
</div>
))}
{pollOptions.length < 8 && (
<button
type="button"
onClick={() =>
setPollOptions((prev) => [...prev, { id: pollOptionId(), label: '' }])
}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors pt-0.5"
>
<Plus className="size-3" />
Add option
</button>
)}
</div>
{/* Settings row — pill toggles */}
<div className="flex flex-wrap gap-2 pt-0.5">
{(['singlechoice', 'multiplechoice'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setPollType(t)}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
pollType === t
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/30',
)}
>
{t === 'singlechoice' ? 'Single choice' : 'Multiple choice'}
</button>
))}
<div className="w-px bg-border self-stretch mx-0.5" />
{([1, 3, 7, 0] as const).map((d) => (
<button
key={d}
type="button"
onClick={() => setPollDuration(d)}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
pollDuration === d
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/30',
)}
>
{d === 0 ? <span style={{ fontSize: '15px', lineHeight: 1, position: 'relative', top: '-1px' }}></span> : `${d}d`}
</button>
))}
</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-base md: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">
{quotedEvent.kind >= 30000 && quotedEvent.kind < 40000 ? (
<EmbeddedNaddr addr={{
kind: quotedEvent.kind,
pubkey: quotedEvent.pubkey,
identifier: quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '',
}} />
) : (
<EmbeddedNote eventId={quotedEvent.id} authorHint={quotedEvent.pubkey} />
)}
</div>
)}
{/* Toolbar + post button */}
{isExpanded && (
voiceRecorder.isRecording || isPublishingVoice ? (
/* ── Voice recording UI ─────────────────────────────── */
<div className="flex items-center gap-3 mt-3 rounded-xl bg-destructive/5 border border-destructive/20 px-3 py-2.5">
{/* Recording indicator */}
<div className="flex items-center gap-2 min-w-0">
<div className="size-2.5 rounded-full bg-destructive animate-pulse shrink-0" />
<span className="text-sm font-medium tabular-nums text-destructive">
{formatTime(voiceRecorder.recordingDuration)}
</span>
</div>
{/* Live waveform preview */}
<div className="flex-1 flex items-center gap-[2px] h-6 overflow-hidden">
{voiceRecorder.liveWaveform.slice(-60).map((amp, i) => {
const h = 3 + (amp / 100) * 21;
return (
<div
key={i}
className="w-[3px] shrink-0 rounded-full bg-destructive/60"
style={{ height: `${h}px` }}
/>
);
})}
</div>
{/* Cancel button */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={voiceRecorder.cancelRecording}
disabled={isPublishingVoice}
className="p-2 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40"
>
<X className="size-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>Cancel</TooltipContent>
</Tooltip>
{/* Stop & send button */}
<Button
onClick={handleStopAndPublishVoice}
disabled={isPublishingVoice || voiceRecorder.recordingDuration < 0.5}
className="rounded-full px-4 font-bold"
size="sm"
>
{isPublishingVoice ? (
<Loader2 className="size-4 animate-spin mr-1.5" />
) : (
<Square className="size-3.5 mr-1.5" fill="currentColor" />
)}
{isPublishingVoice ? 'Sending...' : 'Send'}
</Button>
</div>
) : (
/* ── Normal toolbar ──────────────────────────────────── */
<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"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (files) {
Array.from(files).forEach((file) => handleFileUpload(file));
}
e.target.value = '';
}}
/>
{/* Voice recording */}
{voiceRecorder.isSupported && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleStartRecording}
disabled={!user}
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors disabled:opacity-40"
>
<Mic className="size-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>Voice message</TooltipContent>
</Tooltip>
)}
{/* Emoji / GIF picker */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setPickerOpen(true)}
className={cn(
'p-2 rounded-full transition-colors',
pickerOpen
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<Smile className="size-[18px]" />
</button>
</TooltipTrigger>
{!pickerOpen && <TooltipContent>Emoji / GIF</TooltipContent>}
</Tooltip>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent
className="w-auto max-w-fit p-0 gap-0 border-border rounded-xl overflow-hidden [&>button]:hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{/* Tab bar */}
<div className="flex">
<button
type="button"
onClick={() => setPickerTab('emoji')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
pickerTab === 'emoji'
? 'text-primary border-primary'
: 'text-muted-foreground hover:text-foreground border-border',
)}
>
<Smile className="size-3.5" />
Emoji
</button>
<button
type="button"
onClick={() => setPickerTab('gif')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
pickerTab === 'gif'
? 'text-primary border-primary'
: 'text-muted-foreground hover:text-foreground border-border',
)}
>
<svg width="14" height="14" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="1" y="1" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.5" fill="none"/>
<text x="9" y="9" textAnchor="middle" dominantBaseline="central" fontSize="7" fontWeight="700" fontFamily="system-ui,sans-serif" fill="currentColor" letterSpacing="0.5">GIF</text>
</svg>
GIF
</button>
{customEmojis.length > 0 && (
<button
type="button"
onClick={() => setPickerTab('stickers')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
pickerTab === 'stickers'
? 'text-primary border-primary'
: 'text-muted-foreground hover:text-foreground border-border',
)}
>
<Sticker className="size-3.5" />
Stickers
</button>
)}
</div>
{/* Picker content */}
{pickerTab === 'emoji' ? (
<Suspense fallback={<div className="w-[316px] h-[435px] flex items-center justify-center"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>}>
<LazyEmojiPicker
customEmojis={customEmojis}
onSelect={(selection) => {
if (selection.type === 'native') {
insertEmoji(selection.emoji);
} else {
insertEmoji(`:${selection.shortcode}:`);
}
}}
/>
</Suspense>
) : pickerTab === 'stickers' ? (
<div className="w-[316px] h-[435px]">
{customEmojis.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<Sticker className="size-8 opacity-40" />
<p className="text-sm">No sticker packs yet</p>
<p className="text-xs">Add emoji packs to your profile to use stickers</p>
</div>
) : (
<ScrollArea className="h-full">
<div className="grid grid-cols-4 gap-1.5 p-2">
{customEmojis.map((emoji) => (
<button
key={emoji.shortcode}
type="button"
title={emoji.shortcode}
onClick={() => {
setContent((prev) => (prev ? prev + '\n' + emoji.url : emoji.url));
setPickerOpen(false);
expand();
}}
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
>
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
/>
</button>
))}
</div>
</ScrollArea>
)}
</div>
) : (
<GifPicker onSelect={(gif) => {
setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url));
setPickerOpen(false);
expand();
}} />
)}
</DialogContent>
</Dialog>
{/* Overflow: Poll + CW */}
<Popover open={trayOpen} onOpenChange={setTrayOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
disabled={!user}
className={cn(
'p-2 rounded-full transition-colors disabled:opacity-40',
(trayOpen || mode === 'poll' || cwEnabled)
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<Plus className="size-[18px]" strokeWidth={2.5} />
</button>
</PopoverTrigger>
</TooltipTrigger>
{!trayOpen && <TooltipContent>More</TooltipContent>}
</Tooltip>
<PopoverContent side="bottom" align="start" sideOffset={6} className="w-44 p-1.5 rounded-xl border-border shadow-lg">
<div className="flex flex-col gap-0.5">
{!replyTo && (
<button
type="button"
onClick={() => { setMode((m) => m === 'poll' ? 'post' : 'poll'); setTrayOpen(false); expand(); }}
className={cn('flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors', mode === 'poll' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60')}
>
<BarChart3 className="size-4" /><span className="font-medium">Poll</span>
</button>
)}
<button
type="button"
onClick={() => { setCwEnabled((v) => !v); setTrayOpen(false); expand(); }}
className={cn('flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors', cwEnabled ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60')}
>
<AlertTriangle className="size-4" /><span className="font-medium">Spoiler</span>
</button>
</div>
</PopoverContent>
</Popover>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right: char count + post/poll button */}
<div className="flex items-center gap-3">
{mode === 'post' && 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>
)}
{mode === 'poll' ? (
<Button
onClick={handlePollSubmit}
disabled={!isPollValid || isPollPending || !user}
className="rounded-full px-5 font-bold"
size="sm"
>
{isPollPending ? 'Publishing...' : 'Publish poll'}
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
className="rounded-full px-5 font-bold"
size="sm"
>
{isPending || isCommentPending ? 'Posting...' : 'Post!'}
</Button>
)}
</div>
</div>
)
)}
</div>
</div>
</div>
);
}