mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
124 lines
3.9 KiB
TypeScript
124 lines
3.9 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
isSupportAttachmentAllowed,
|
|
isSupportAttachmentImage,
|
|
SUPPORT_ATTACHMENT_MAX_BYTES,
|
|
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
|
} from '@edr/types';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
/** A file staged in the composer, not yet sent. */
|
|
export interface PendingAttachment {
|
|
/** Local-only id; the server id doesn't exist until the message is sent. */
|
|
id: string;
|
|
file: File;
|
|
/** Object URL, images only. Revoked when the entry goes away. */
|
|
previewUrl?: string;
|
|
}
|
|
|
|
let nextId = 0;
|
|
|
|
/**
|
|
* Staging area for files being attached to a message.
|
|
*
|
|
* Files are held client-side until send, then posted alongside the text in one
|
|
* multipart request — there's no upload-then-reference step, so nothing to
|
|
* garbage-collect if the agent changes their mind.
|
|
*
|
|
* Object URLs for image previews are revoked on removal and unmount; without
|
|
* that, pasting screenshots into a long-lived chat page leaks the full bytes of
|
|
* every image for the life of the tab.
|
|
*/
|
|
export function useAttachmentDraft(onReject?: (reason: string) => void) {
|
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
|
const rejectRef = useRef(onReject);
|
|
rejectRef.current = onReject;
|
|
|
|
// Read from a ref in the unmount cleanup so it doesn't re-run (and revoke
|
|
// still-live URLs) on every change to the list.
|
|
const attachmentsRef = useRef(attachments);
|
|
attachmentsRef.current = attachments;
|
|
useEffect(
|
|
() => () => {
|
|
for (const a of attachmentsRef.current) {
|
|
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const add = useCallback((files: File[]) => {
|
|
if (files.length === 0) return;
|
|
setAttachments((current) => {
|
|
const accepted: PendingAttachment[] = [];
|
|
for (const file of files) {
|
|
if (current.length + accepted.length >= SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
|
|
rejectRef.current?.(`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`);
|
|
break;
|
|
}
|
|
if (!isSupportAttachmentAllowed(file.type)) {
|
|
rejectRef.current?.(`${file.name}: that file type isn't supported.`);
|
|
continue;
|
|
}
|
|
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
|
rejectRef.current?.(
|
|
`${file.name} is over the ${SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)}MB limit.`,
|
|
);
|
|
continue;
|
|
}
|
|
accepted.push({
|
|
id: `pending-${nextId++}`,
|
|
file,
|
|
previewUrl: isSupportAttachmentImage(file.type) ? URL.createObjectURL(file) : undefined,
|
|
});
|
|
}
|
|
return accepted.length ? [...current, ...accepted] : current;
|
|
});
|
|
}, []);
|
|
|
|
const remove = useCallback((id: string) => {
|
|
setAttachments((current) => {
|
|
const target = current.find((a) => a.id === id);
|
|
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
|
return current.filter((a) => a.id !== id);
|
|
});
|
|
}, []);
|
|
|
|
const clear = useCallback(() => {
|
|
setAttachments((current) => {
|
|
for (const a of current) {
|
|
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
|
}
|
|
return [];
|
|
});
|
|
}, []);
|
|
|
|
/**
|
|
* Pull files off a paste. Returns true if anything was taken, so the caller
|
|
* can suppress the default paste — otherwise pasting a screenshot also drops
|
|
* its filename (or nothing) into the textarea.
|
|
*
|
|
* Copying an image in most apps puts BOTH the bitmap and some text/html on
|
|
* the clipboard, so check for files first and only then let the text through.
|
|
*/
|
|
const addFromPaste = useCallback(
|
|
(clipboard: DataTransfer | null): boolean => {
|
|
const files = Array.from(clipboard?.files ?? []);
|
|
if (files.length === 0) return false;
|
|
add(files);
|
|
return true;
|
|
},
|
|
[add],
|
|
);
|
|
|
|
return {
|
|
attachments,
|
|
files: attachments.map((a) => a.file),
|
|
add,
|
|
addFromPaste,
|
|
remove,
|
|
clear,
|
|
};
|
|
}
|