"use client"; /** * The message composer. * * Formatting is deliberately NOT parsed here — the user types `*bold*` and the * BFF turns markers into clean text plus spans. Keeping one parser on the * server means every client renders identically and no client can dictate how * its text is decorated in someone else's thread. * * Link previews and attachments layer onto this in the next phase; nothing here * needs to change to accommodate them. */ import { useCallback, useEffect, useRef, useState } from "react"; import { Paperclip, SendHorizonal, X } from "lucide-react"; import { LinkPreviewCard } from "@/components/chat/link-preview-card"; import { ComposerAttachments, type StagedAttachment, } from "@/components/chat/composer-attachments"; import { compressImage } from "@/lib/compress-image"; import { cn } from "@/lib/utils"; import { ACCEPTED_ATTACHMENTS, chat, firstUrl, uploadAttachment, type ChatMessage, type LinkPreview, type UploadedAttachment, } from "@/lib/chat"; const MAX_ROWS_PX = 140; export function ChatComposer({ replyTo, onCancelReply, onSend, conversationId, disabled = false, placeholder = "Message…", replyToName, }: { conversationId: string; replyTo: ChatMessage | null; onCancelReply: () => void; onSend: ( body: string, replyToId?: string, previewUrl?: string, attachments?: UploadedAttachment[], ) => void; disabled?: boolean; placeholder?: string; /** Display name of whoever wrote the message being replied to. */ replyToName: string; }) { const [value, setValue] = useState(""); const [preview, setPreview] = useState(null); // URLs the sender dismissed. Kept so re-typing doesn't resurrect a card // they've already said no to. const [dismissed, setDismissed] = useState>(new Set()); const [staged, setStaged] = useState([]); const ref = useRef(null); const fileRef = useRef(null); const lookupSeq = useRef(0); // Grow with the content, up to a cap. useEffect(() => { const el = ref.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`; }, [value]); // Which link, if any, the draft currently wants a card for. const activeUrl = firstUrl(value); const wantsPreview = !!activeUrl && !dismissed.has(activeUrl); // Whether to SHOW a card is derived during render rather than cleared from // an effect: a stale card for a link the user has since edited away simply // stops matching, so there is no state to reset. const visiblePreview = wantsPreview && preview?.url === activeUrl ? preview : null; // The effect only fetches. Requests are sequenced so a slow earlier response // cannot overwrite a newer one, and debounced so a lookup doesn't fire on // every keystroke. useEffect(() => { if (!wantsPreview || !activeUrl) return; const seq = ++lookupSeq.current; const timer = setTimeout(() => { chat .linkPreview(activeUrl) .then((p) => { if (seq === lookupSeq.current) setPreview(p); }) // No card is a normal outcome — many pages carry no metadata. .catch(() => {}); }, 500); return () => clearTimeout(timer); }, [activeUrl, wantsPreview]); const dismissPreview = useCallback(() => { if (activeUrl) setDismissed((prev) => new Set(prev).add(activeUrl)); setPreview(null); }, [activeUrl]); // Starting a reply should put the cursor where the user is about to type. useEffect(() => { if (replyTo) ref.current?.focus(); }, [replyTo]); /** * Uploads each pick straight to the private bucket, so by the time the user * hits send the bytes are already there and sending is just metadata. * Images are compressed first, which also strips EXIF — location data * shouldn't ride along into someone else's chat. */ const addFiles = useCallback( async (files: FileList) => { for (const original of Array.from(files)) { const id = crypto.randomUUID(); const file = original.type.startsWith("image/") ? await compressImage(original).catch(() => original) : original; const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined; setStaged((prev) => [ ...prev, { id, file, previewUrl, status: "uploading" }, ]); uploadAttachment(conversationId, file) .then((uploaded) => { setStaged((prev) => prev.map((s) => s.id === id ? { ...s, status: "ready", uploaded } : s, ), ); }) .catch(() => { // One failure shouldn't discard the others — mark it and let the // user remove it. setStaged((prev) => prev.map((s) => (s.id === id ? { ...s, status: "failed" } : s)), ); }); } }, [conversationId], ); const removeStaged = useCallback((id: string) => { setStaged((prev) => { const target = prev.find((s) => s.id === id); if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl); return prev.filter((s) => s.id !== id); }); }, []); // Object URLs are a leak if the composer unmounts mid-compose. useEffect(() => { return () => { staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl)); }; // Intentionally on unmount only; removeStaged handles the per-chip case. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const uploading = staged.some((s) => s.status === "uploading"); const ready = staged.filter((s) => s.status === "ready" && s.uploaded); const canSend = !disabled && !uploading && (value.trim() !== "" || ready.length > 0); const submit = () => { if (!canSend) return; const body = value.trim(); onSend( body, replyTo?.id, visiblePreview?.url, ready.map((s) => s.uploaded as UploadedAttachment), ); staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl)); setStaged([]); setValue(""); setPreview(null); setDismissed(new Set()); onCancelReply(); }; return (
{replyTo && (

Replying to {replyToName}

{replyTo.body}

)} {visiblePreview && (
)}
{ if (e.target.files?.length) void addFiles(e.target.files); // Reset so picking the same file twice still fires a change. e.target.value = ""; }} />