import { useEffect, useRef, useState } from "octane";
import { Button } from "octane-kumo/components/button";
import {
ATTACHMENT_ACCEPT,
MAX_ATTACHMENTS,
MAX_ATTACHMENT_BYTES,
MAX_IMAGE_BYTES,
attachmentUrl,
type Attachment,
} from "../../shared/attachments";
export function AttachmentCards({
conversationId,
files,
onRemove,
}: {
conversationId: string;
files: Attachment[];
onRemove?: (file: Attachment) => void;
}) {
return (
{files.map((file) => (
{file.mediaType.startsWith("image/") ? (

) : (
PDF
)}
{file.name}
{(file.size / 1024).toFixed(0)} KiB
{onRemove ? (
) : null}
))}
);
}
export function AttachmentUpload({
conversationId,
files,
disabled,
onChange,
onBusy,
}: {
conversationId: string;
files: Attachment[];
disabled: boolean;
onChange: (files: Attachment[]) => void;
onBusy: (busy: boolean) => void;
}) {
const [error, setError] = useState("");
const [progress, setProgress] = useState(null);
const input = useRef(null);
const lifetime = useRef(null);
useEffect(() => {
lifetime.current = new AbortController();
return () => lifetime.current?.abort();
}, []);
const upload = async (selected: File[]) => {
if (disabled || progress || !selected.length) return;
if (selected.length + files.length > MAX_ATTACHMENTS) {
setError("Attach up to four files per message.");
return;
}
for (const file of selected) {
if (
!ATTACHMENT_ACCEPT.split(",").includes(file.type) ||
!file.size ||
file.size >
(file.type === "application/pdf"
? MAX_ATTACHMENT_BYTES
: MAX_IMAGE_BYTES)
) {
setError(
"Choose PNG, JPEG or WebP up to 3.5 MiB, or PDF up to 20 MiB.",
);
return;
}
}
const signal = lifetime.current!.signal;
setError("");
onBusy(true);
const next = [...files];
try {
for (const file of selected) {
setProgress(`Uploading ${file.name}…`);
const result = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
const cleanup = () => signal.removeEventListener("abort", abort);
xhr.open("POST", `/api/conversations/${conversationId}/attachments`);
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (!signal.aborted && event.lengthComputable)
setProgress(
`Uploading ${file.name}: ${Math.round((event.loaded / event.total) * 100)}%`,
);
};
xhr.onload = () => {
cleanup();
if (xhr.status === 201) {
try {
resolve(JSON.parse(xhr.responseText));
} catch {
reject(new Error("Invalid upload response"));
}
} else
reject(
new Error(
xhr.status === 401 || xhr.status === 403
? "Sign in again to upload files."
: "Upload failed. Try again.",
),
);
};
xhr.onerror = xhr.onabort = () => {
cleanup();
reject(new Error("Upload interrupted. Try again."));
};
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) {
cleanup();
reject(new Error("Upload cancelled"));
return;
}
xhr.send(file);
});
next.push(result);
if (!signal.aborted) onChange([...next]);
}
} catch (error) {
if (!signal.aborted)
setError(
error instanceof Error ? error.message : "Upload failed. Try again.",
);
} finally {
if (!signal.aborted) {
setProgress(null);
onBusy(false);
}
}
};
return (
{
const selected = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = "";
void upload(selected);
}}
/>
{progress ?? "Images and PDFs · up to 4 files"}
{error ?
{error}
: null}
);
}