import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Code2, Download, Eye, Pencil, Printer, Save, Share2, X } from "lucide-react"; import { confirmDialog, Dialog } from "./dialog"; import { formatSize } from "@/lib/format"; import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview"; import { isMarkdown, renderMarkdown } from "@/lib/markdown"; import { canShareFiles, shareFile } from "@/lib/share"; import { t, tc } from "@/lib/i18n"; /** * One blob, described the way both callers can describe it. The URLs are built * by the caller so this stays a presentational component: nothing in `ui/` * reaches for the JMAP client, and this is not the file to break that with. */ export interface PreviewFile { name: string; type: string; size?: number | null; /** Plain download -- the server sends it as an attachment. */ url: string; /** The same blob asked for inline. Only the allowlisted types come back that way. */ inlineUrl: string; } type Mode = "rendered" | "source" | "edit"; /** * Shows a file without downloading it: pictures, PDFs, and anything text. * * Grown out of the attachment preview in MessageView, which is still one of its * two callers -- the other is Files, which could only hand you the bytes. * * `onSave` is what makes it an editor. Files passes one; mail does not, because * a message part is not a thing that can be written back. */ export function FilePreviewDialog({ file, onClose, caption, onSave, startInEdit, }: { file: PreviewFile | null; onClose: () => void; caption?: ReactNode; /** Write the text back. Rejecting with a message is how a conflict is reported. */ onSave?: (text: string) => Promise; /** Open straight into the editor -- what the row menu's Edit asks for. */ startInEdit?: boolean; }) { const kind = file ? previewKind(file.type, file.name) : null; const tooBig = kind === "text" && typeof file?.size === "number" && file.size > TEXT_PREVIEW_MAX; const markdown = Boolean(file) && kind === "text" && isMarkdown(file!.type, file!.name); const pdfRef = useRef(null); const loaded = useTextFile(kind === "text" && !tooBig ? file?.url ?? null : null); const [mode, setMode] = useState("source"); const [draft, setDraft] = useState(""); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [pendingEdit, setPendingEdit] = useState(false); /* A new file starts over: Markdown as the document it is, everything else as what it says, and never in the editor. */ useEffect(() => { setMode(markdown ? "rendered" : "source"); setDraft(""); setSaveError(null); setPendingEdit(Boolean(startInEdit)); }, [file?.url, markdown, startInEdit]); /* * Three reasons not to offer editing, and each of them would lose data: * * - the file was truncated for display, so saving would write the tail away; * - it did not decode as UTF-8 (the replacement character is the giveaway), * so saving would write mojibake over whatever encoding it really is; * - the caller has no way to save it, or the reader has no right to. */ const lossy = loaded.text?.includes("�") ?? false; const editable = Boolean(onSave) && kind === "text" && !tooBig && !loaded.truncated && !lossy && loaded.text !== null && !loaded.failed; const editing = mode === "edit"; const dirty = editing && draft !== (loaded.text ?? ""); /* Opening straight into the editor has to wait for the text to arrive, and may still land in the read-only view: whether a file can be edited is not known until it has been read (truncated? not UTF-8?), and the row menu could only guess from its name. The note under the pane says why. */ useEffect(() => { if (!pendingEdit || loaded.text === null) return; setPendingEdit(false); if (!editable) return; setDraft(loaded.text); setMode("edit"); }, [pendingEdit, editable, loaded.text]); const startEditing = () => { setDraft(loaded.text ?? ""); setSaveError(null); setMode("edit"); }; const stopEditing = async () => { if (dirty && !(await confirmDialog({ title: t("Throw away your changes?"), confirmLabel: t("Discard"), danger: true }))) return; setMode(markdown ? "rendered" : "source"); setSaveError(null); }; const save = useCallback(async () => { if (!onSave || saving) return; setSaving(true); setSaveError(null); try { await onSave(draft); loaded.replace(draft); setMode(markdown ? "rendered" : "source"); } catch (err) { setSaveError((err as Error).message); } finally { setSaving(false); } }, [onSave, saving, draft, loaded, markdown]); /* The shortcut everyone's hands already know. Only while the editor is open, so it does not shadow the browser's own Save anywhere else. */ useEffect(() => { if (!editing) return; const onKey = (ev: KeyboardEvent) => { if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "s") { ev.preventDefault(); void save(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [editing, save]); /* Closing with unsaved work asks first -- Escape and the backdrop both come through here. */ const requestClose = () => { if (!dirty) { onClose(); return; } void confirmDialog({ title: t("Close without saving?"), confirmLabel: t("Discard"), danger: true }).then((yes) => yes && onClose()); }; /* * Hand the file to another app rather than to the filesystem. * * This is the surface where it matters most on a phone: opening an * attachment lands here, and until now the only way onward was Download, * which on Android and iOS means "put it somewhere and go and find it". * * The bytes have to be fetched rather than the URL passed along, because the * share sheet takes a File. `same-origin` credentials because both URLs are * ihasmail's own blob proxy and it is the session cookie that authorises the * read -- which is also why this does not break the rule about `ui/` not * reaching for the JMAP client: it is a plain fetch of a URL the caller * already handed over. * * Anything that goes wrong, including a share the browser turned out not to * support, falls through to the download. That is the button that was here * before, so the worst case costs a tap rather than the file. */ const shareIt = useCallback(async () => { if (!file) return; const download = () => { const l = document.createElement("a"); l.href = file.url; l.download = file.name; l.click(); }; try { const res = await fetch(file.url, { credentials: "same-origin" }); if (!res.ok) throw new Error(String(res.status)); const blob = await res.blob(); const out = await shareFile(new File([blob], file.name, { type: file.type || blob.type || "application/octet-stream" })); if (out === "unsupported") download(); } catch { download(); } }, [file]); /* * Print what is on screen, not the mail or the file list behind it. * * A PDF is its own document inside an iframe, and the page around it cannot * paginate it -- printing the page yields the first screenful of the viewer * and nothing else. Same origin, so we can ask the iframe to print itself, * which is the browser's own PDF print. Chrome sometimes refuses while the * viewer is still loading; opening it in a tab leaves the reader somewhere * they can print from, which is better than a silent no-op. * * Pictures and text are ours to lay out, so those go through the page with * the dialog marked and everything else dropped -- see `printing-preview` in * the print block of app.css. */ const print = () => { if (kind === "pdf") { const frame = pdfRef.current; try { if (!frame?.contentWindow) throw new Error("no frame"); frame.contentWindow.focus(); frame.contentWindow.print(); } catch { if (file) window.open(file.inlineUrl, "_blank", "noopener"); } return; } const root = document.documentElement; const clear = () => { root.classList.remove("printing-preview"); window.removeEventListener("afterprint", clear); }; window.addEventListener("afterprint", clear); root.classList.add("printing-preview"); try { window.print(); } finally { clear(); } }; return ( {editing ? ( <> {dirty && {t("Unsaved changes")}} ) : ( <> {markdown && !tooBig && (
)} {editable && } {canShareFiles() && } {kind && !tooBig && } {t("Download")} )} ) } > {file && ( <> {saveError &&
{saveError}
} {tooBig ? (

{t("This file is too big to show here ({size}) — download it to read it.", { size: formatSize(file.size ?? 0) })}

) : kind === "image" ? ( {file.name} ) : kind === "pdf" ? (