import { useCallback, useEffect, useRef, useState } from "react"; import { errorMessage } from "../../lib/api-client"; import { MEDIA_MAX_ATTACHMENTS, fetchMediaObjectUrl, uploadMediaAsset } from "../../lib/media-types"; /** * One picked file on its way to POST /media. `previewUrl` is the asset's bytes * fetched with auth and rewrapped as an object URL (never a bare asset URL, * which the browser cannot authorize) — revoke it on remove/unmount. */ export interface ComposeAttachment { key: string; file: File; name: string; kind: "image" | "video"; progress: number; assetId: string | null; previewUrl: string | null; alt: string; error: string | null; } function kindOf(mime: string): "image" | "video" | null { if (mime.startsWith("image/")) return "image"; if (mime.startsWith("video/")) return "video"; return null; } /** Attach state + upload lifecycle for the compose surface. */ export function useComposeAttachments(): { attachments: ComposeAttachment[]; warning: string | null; uploading: boolean; full: boolean; mediaAssetIds: string[]; altTexts: Array; addFiles: (files: FileList | File[]) => void; setAlt: (key: string, alt: string) => void; remove: (key: string) => void; retry: (key: string) => void; } { const [attachments, setAttachments] = useState([]); const [warning, setWarning] = useState(null); const attachmentsRef = useRef(attachments); attachmentsRef.current = attachments; // Every object URL minted here must die with the page, not only on a remove click. useEffect( () => () => { attachmentsRef.current.forEach((item) => { if (item.previewUrl !== null) URL.revokeObjectURL(item.previewUrl); }); }, [], ); const patch = useCallback((key: string, changes: Partial): void => { setAttachments((current) => current.map((item) => (item.key === key ? { ...item, ...changes } : item))); }, []); const upload = useCallback( async (key: string, file: File): Promise => { try { const asset = await uploadMediaAsset(file, (percent) => patch(key, { progress: percent })); let previewUrl: string | null = null; try { previewUrl = await fetchMediaObjectUrl(asset.id); } catch { // The asset is attached and postable even if its thumbnail cannot load. } patch(key, { assetId: asset.id, previewUrl, progress: 100, error: null }); } catch (err) { patch(key, { error: errorMessage(err) }); } }, [patch], ); /** Picks files, enforcing the ≤4 / 1-video cap before anything is uploaded. */ const addFiles = useCallback( (files: FileList | File[]): void => { const picked = Array.from(files); if (picked.length === 0) return; // Failed picks do not hold a slot: they are removed or retried, not counted. const held = attachmentsRef.current.filter((item) => item.error === null); const accepted: Array<{ file: File; kind: "image" | "video" }> = []; const rejected: string[] = []; for (const file of picked) { const kind = kindOf(file.type); if (kind === null) { rejected.push(`${file.name}: only images and video can be attached`); continue; } const next = [...held.map((item) => item.kind), ...accepted.map((entry) => entry.kind)]; if (next.length + 1 > MEDIA_MAX_ATTACHMENTS) { rejected.push(`${file.name}: at most ${MEDIA_MAX_ATTACHMENTS} attachments per post`); continue; } if (next.includes("video") || (kind === "video" && next.length > 0)) { rejected.push(`${file.name}: a video must be the only attachment`); continue; } accepted.push({ file, kind }); } setWarning(rejected.length > 0 ? rejected.join(" · ") : null); if (accepted.length === 0) return; const entries: ComposeAttachment[] = accepted.map(({ file, kind }) => ({ key: `${file.name}:${file.size}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, file, name: file.name, kind, progress: 0, assetId: null, previewUrl: null, alt: "", error: null, })); setAttachments((current) => [...current, ...entries]); entries.forEach((entry) => { void upload(entry.key, entry.file); }); }, [upload], ); const setAlt = useCallback( (key: string, alt: string): void => { patch(key, { alt }); }, [patch], ); const remove = useCallback((key: string): void => { setAttachments((current) => { const victim = current.find((item) => item.key === key); if (victim !== undefined && victim.previewUrl !== null) URL.revokeObjectURL(victim.previewUrl); const rest = current.filter((item) => item.key !== key); if (rest.length === 0) setWarning(null); return rest; }); }, []); const retry = useCallback( (key: string): void => { const victim = attachmentsRef.current.find((item) => item.key === key); if (victim === undefined || victim.error === null) return; patch(key, { error: null, progress: 0 }); void upload(key, victim.file); }, [patch, upload], ); const settled = attachments.filter((item) => item.error === null && item.assetId !== null); const held = attachments.filter((item) => item.error === null); return { attachments, warning, /** True while any pick is still in flight — posting must wait for these. */ uploading: attachments.some((item) => item.error === null && item.assetId === null), full: held.length >= MEDIA_MAX_ATTACHMENTS || held.some((item) => item.kind === "video"), mediaAssetIds: settled.map((item) => item.assetId as string), altTexts: settled.map((item) => (item.alt.trim() === "" ? null : item.alt.trim())), addFiles, setAlt, remove, retry, }; } /** Thumbnail strip shared by the mobile composer and the desktop compose column. */ export function AttachmentsRow({ attachments, onAltChange, onRemove, onRetry, }: { attachments: ComposeAttachment[]; onAltChange: (key: string, alt: string) => void; onRemove: (key: string) => void; onRetry: (key: string) => void; }): React.ReactElement | null { if (attachments.length === 0) return null; return (
{attachments.map((item) => { const pending = item.assetId === null && item.error === null; return (
{item.previewUrl !== null && item.kind === "image" && ( {item.alt )} {item.previewUrl !== null && item.kind === "video" && (
onAltChange(item.key, event.target.value)} placeholder="ALT text" className="mt-1 min-h-11 w-full bg-transparent text-2xs outline-none disabled:opacity-50" /> {item.alt ? "ALT ready" : "Add ALT"}
{item.error !== null ? ( ) : ( {item.kind} )}
); })}
); }