'use client'; import { useState, useEffect, useRef } from 'react'; import AutoTextarea from '@/components/AutoTextarea'; import { Post, Attachment } from '@/lib/types'; import { AlertTriangle, Music2, Paperclip } from 'lucide-react'; import { VideoEmbed } from '@/components/VideoEmbed'; import { useFormattedHandle } from '@/lib/utils/handle'; import { useAuth } from '@/lib/contexts/AuthContext'; import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload'; import { getMediaKind } from '@/lib/media/upload-policy'; interface MediaAttachment extends Attachment { mimeType?: string; filename?: string; } interface ComposeProps { onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void | boolean | Promise; onPosted?: () => void; replyingTo?: Post | null; onCancelReply?: () => void; placeholder?: string; isReply?: boolean; autoFocus?: boolean; } export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placeholder = "What's happening?", isReply, autoFocus = false }: ComposeProps) { const { isIdentityUnlocked } = useAuth(); const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : ''; const [content, setContent] = useState(''); const [isPosting, setIsPosting] = useState(false); const [attachments, setAttachments] = useState([]); const [isUploading, setIsUploading] = useState(false); const [isCheckingStorage, setIsCheckingStorage] = useState(false); const [uploadError, setUploadError] = useState(null); const [storageNotice, setStorageNotice] = useState(null); const [pendingStorageFiles, setPendingStorageFiles] = useState([]); const [showStorageConfiguration, setShowStorageConfiguration] = useState(false); const [linkPreview, setLinkPreview] = useState(null); const [fetchingPreview, setFetchingPreview] = useState(false); const [lastDetectedUrl, setLastDetectedUrl] = useState(null); const [isNsfw, setIsNsfw] = useState(false); const [canPostNsfw, setCanPostNsfw] = useState(false); const [isNsfwNode, setIsNsfwNode] = useState(false); const mediaInputRef = useRef(null); const maxLength = 600; const remaining = maxLength - content.length; const previewMedia = linkPreview?.media?.length ? linkPreview.media : linkPreview?.image ? [{ url: linkPreview.image }] : []; const previewImage = previewMedia[0]?.url || linkPreview?.image || null; const isEmbeddedVideo = Boolean(linkPreview?.url?.match(/(youtube\.com|youtu\.be|vimeo\.com)/)); // Check if user can post NSFW content and if node is NSFW useEffect(() => { fetch('/api/settings/nsfw') .then(res => res.ok ? res.json() : null) .then(data => { if (data?.nsfwEnabled) { setCanPostNsfw(true); } }) .catch(() => { }); fetch('/api/node') .then(res => res.ok ? res.json() : null) .then(data => { if (data?.isNsfw) { setIsNsfwNode(true); } }) .catch(() => { }); }, []); // Detect URLs in content useEffect(() => { const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi; const matches = content.match(urlRegex); if (matches && matches[0]) { const url = matches[0]; if (url !== lastDetectedUrl) { setLastDetectedUrl(url); fetchPreview(url); } } else if (!content.trim()) { setLinkPreview(null); setLastDetectedUrl(null); } }, [content, lastDetectedUrl]); const fetchPreview = async (url: string) => { setFetchingPreview(true); try { const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`); if (res.ok) { const data = await res.json(); setLinkPreview(data); } } catch (err) { console.error('Preview error', err); } finally { setFetchingPreview(false); } }; const handleSubmit = async () => { if (!content.trim() || isPosting || isUploading) return; // With persistence, identity should be unlocked. If not, user needs to re-login if (!isIdentityUnlocked) { alert('Your session has expired. Please log in again.'); return; } setIsPosting(true); try { const posted = await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw); if (posted === false) { setIsPosting(false); return; } setContent(''); setAttachments([]); setLinkPreview(null); setLastDetectedUrl(null); setIsNsfw(false); setIsPosting(false); onPosted?.(); } catch (error) { setIsPosting(false); throw error; } }; const uploadMediaFiles = async (files: File[]) => { const remainingSlots = Math.max(0, 4 - attachments.length); const selectedFiles = files.slice(0, remainingSlots); if (selectedFiles.length === 0) return; setUploadError(null); setIsUploading(true); const uploaded: MediaAttachment[] = []; for (const file of selectedFiles) { try { const media = await uploadMediaFile(file); uploaded.push({ id: media.id, url: media.url, altText: media.altText ?? null, mimeType: media.mimeType ?? file.type, filename: file.name, }); } catch (error) { if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED') { setAttachments((prev) => [...prev, ...uploaded].slice(0, 4)); setPendingStorageFiles(selectedFiles.slice(uploaded.length)); setShowStorageConfiguration(true); setIsUploading(false); return; } console.error('Upload failed', error); setUploadError('One or more uploads failed. Try again.'); } } setAttachments((prev) => [...prev, ...uploaded].slice(0, 4)); setIsUploading(false); }; const handleMediaSelect = async (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); event.target.value = ''; if (files.length === 0) return; await uploadMediaFiles(files); }; const handleAddMedia = async () => { setUploadError(null); setStorageNotice(null); setIsCheckingStorage(true); try { const provider = await getStorageProvider(); if (!provider) { setShowStorageConfiguration(true); return; } mediaInputRef.current?.click(); } catch (error) { setUploadError(error instanceof Error ? error.message : 'Unable to check media storage'); } finally { setIsCheckingStorage(false); } }; const handleRemoveAttachment = (id: string) => { setAttachments((prev) => prev.filter((item) => item.id !== id)); }; return (
{ setShowStorageConfiguration(false); const files = pendingStorageFiles; setPendingStorageFiles([]); if (files.length > 0) { await uploadMediaFiles(files); return; } setStorageNotice('Stuffbox connected. Choose your media to continue.'); mediaInputRef.current?.click(); }} onCancel={() => { setShowStorageConfiguration(false); setPendingStorageFiles([]); }} /> {replyingTo && !isReply && (
Replying to {replyToHandle}
)} setContent(e.target.value)} maxLength={maxLength + 50} // Allow some overflow for better UX autoFocus={autoFocus} /> {attachments.length > 0 && (
{attachments.map((item) => { const mediaKind = getMediaKind(item.mimeType); return (
{mediaKind === 'video' ? (
); })}
)} {linkPreview && (
{!isEmbeddedVideo && (
{linkPreview.type === 'video' && linkPreview.videoUrl ? (
) : linkPreview.type === 'gallery' && previewMedia.length > 0 ? (
{previewMedia.slice(0, 3).map((item: { url: string }, index: number) => (
{index === Math.min(previewMedia.length, 3) - 1 && previewMedia.length > 3 && ( +{previewMedia.length - 3} )}
))}
) : previewImage && (
)}
{linkPreview.title}
{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}
)}
)} {uploadError && (
{uploadError}
)} {storageNotice && (
{storageNotice}
)}
{remaining} {canPostNsfw && !isNsfwNode && ( )}
= 4} className="compose-media-input" />
); }