import { useEffect, useRef, useState } from "react"; import { File } from "lucide-react"; import { usePhotoUploadService } from "../../services/PhotoUploadService"; import { SCAN_HOLD_WORDS, scanHoldOf } from "../../utils/uploadScanHold"; import { isSaveRetired, useSaveScope } from "../../utils/saveOperation"; import { writeThroughViewer } from "../../utils/writeThroughHold"; export type ChatFile = { name: string; size: string; type?: string; /** The uploaded file's key; it opens only through a link the server signs once its scan is clean. */ key?: string; /** The message that carries it: the file opens only for whoever may read that message. */ carrier?: string; /** Why the file stays closed: still sending, or sent before files were scanned. */ closed?: string; }; /** Said for a file this sender is still sending: it is named, never shown, until the server has checked it. */ export const SENDING_FILE_WORDS = "Sending. It opens once it has been checked for viruses."; const OPEN_FAILED = "This file could not be opened. Try again."; // One file in a message. A stored file opens only as the exact version its scan cleared; while the scan runs, or // when it failed, the reason is said in its place. Every read belongs to this file in this conversation opening, for // this viewer: a link answered after the opening moved on, closed or the session ended is never shown or opened. // Each link and notice keeps the owner it was answered for and is drawn only while that is still the owner. export const ChatAttachment = ({ file, isImage, mine, opening }: { file: ChatFile; isImage: boolean; mine: boolean; opening: string }) => { const { getPresignedDownloadUrl } = usePhotoUploadService(); const ownerNow = () => [writeThroughViewer() ?? "", opening, file.key ?? "", file.carrier ?? ""].join("|"); const owner = ownerNow(); const onScreen = useRef(owner); onScreen.current = owner; const [answered, setLink] = useState<{ owner: string; url: string }>(); const [said, setNotice] = useState<{ owner: string; words: string }>(); const link = answered?.owner === owner ? answered.url : undefined; const notice = file.closed ?? (said?.owner === owner ? said.words : undefined); const lives = useSaveScope(owner, () => { setLink(undefined); setNotice(undefined); }); const signed = async (): Promise => { if (!file.key || file.closed) return undefined; const life = lives.start(); try { const url = await getPresignedDownloadUrl(file.key, life.transport, file.carrier); life.admit(); setLink({ owner, url }); return url; } catch (error) { if (isSaveRetired(error) || !life.belongs()) return undefined; const hold = scanHoldOf(error); setNotice({ owner, words: hold ? SCAN_HOLD_WORDS[hold] : OPEN_FAILED }); return undefined; } }; // An image is shown as soon as its link is signed; a document is signed only when asked for. useEffect(() => { if (!isImage) return; void signed().catch(() => undefined); // eslint-disable-next-line react-hooks/exhaustive-deps -- a new owner is a new read; the scope retires the old one }, [owner, file.closed, isImage]); // A link already signed opens only while its owner is still the one on screen, admitted at the click itself. const open = async () => { const url = link ? (onScreen.current === owner && ownerNow() === owner ? link : undefined) : await signed().catch(() => undefined); if (!url) return; setNotice(undefined); window.open(url, "_blank", "noopener"); }; const muted = mine ? "text-white/75" : "text-muted-foreground"; if (isImage && link) { return (
{file.name} ({file.size})
); } const openable = Boolean(file.key) && !file.closed; return (
{openable ? ( ) : ( {file.name} )} {file.size} {notice && {notice}}
); };