Files
edr-platform/apps/edr-passenger-web/backoffice/src/features/support/useAttachmentDraft.ts

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,
};
}