Merge pull request #191 from Coffey-Labs/feat/file-preview

Look at a file without downloading it first
This commit is contained in:
Coffey Labs
2026-09-01 20:21:48 -07:00
committed by GitHub
6 changed files with 370 additions and 19 deletions
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { openableInTab, previewKind } from "@/lib/preview";
describe("previewKind", () => {
it("goes by the declared type when there is one", () => {
expect(previewKind("image/png", "photo.png")).toBe("image");
expect(previewKind("application/pdf", "invoice.pdf")).toBe("pdf");
expect(previewKind("text/plain", "notes.txt")).toBe("text");
expect(previewKind("text/markdown", "README.md")).toBe("text");
expect(previewKind("application/json", "data.json")).toBe("text");
expect(previewKind("image/png; charset=binary", "photo.png")).toBe("image");
});
it("falls back to the name when the type is a generic wrapper", () => {
// What an upload gets when the browser cannot guess -- files.ts stores
// `f.type || "application/octet-stream"`, so this is the common case for
// anything unusual, and it is what made the old exact-type check useless
// on real uploads.
expect(previewKind("application/octet-stream", "README.md")).toBe("text");
expect(previewKind("application/octet-stream", "shot.PNG")).toBe("image");
expect(previewKind("application/octet-stream", "report.pdf")).toBe("pdf");
expect(previewKind("", "notes.txt")).toBe("text");
expect(previewKind(null, "deploy.sh")).toBe("text");
expect(previewKind(undefined, undefined)).toBeNull();
});
it("does not let the name override a type the server was specific about", () => {
// A .txt served as a zip is a zip. Guessing from the name here would be
// taking the sender's word for the extension over the server's for the
// bytes.
expect(previewKind("application/zip", "archive.txt")).toBeNull();
expect(previewKind("video/mp4", "clip.txt")).toBeNull();
});
it("leaves SVG alone", () => {
// It carries script and the server refuses to serve it inline; it stays a
// download until that is decided deliberately.
expect(previewKind("image/svg+xml", "logo.svg")).toBeNull();
expect(previewKind("application/octet-stream", "logo.svg")).toBeNull();
});
it("has nothing to show for the rest", () => {
expect(previewKind("application/zip", "backup.zip")).toBeNull();
expect(previewKind("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "letter.docx")).toBeNull();
});
});
describe("openableInTab", () => {
/*
* This mirrors `isInlineSafe` in server/src/app.ts. If the two drift, the
* "open in a new tab" button silently starts downloading instead, because
* the server sends Content-Disposition: attachment for anything not on its
* list. These cases are the list.
*/
it("matches what the server will serve inline", () => {
expect(openableInTab("image/png")).toBe(true);
expect(openableInTab("video/mp4")).toBe(true);
expect(openableInTab("audio/mpeg")).toBe(true);
expect(openableInTab("application/pdf")).toBe(true);
expect(openableInTab("text/plain; charset=utf-8")).toBe(true);
expect(openableInTab("text/calendar")).toBe(true);
expect(openableInTab("text/vcard")).toBe(true);
});
it("refuses what the server will not", () => {
expect(openableInTab("image/svg+xml")).toBe(false);
expect(openableInTab("text/html")).toBe(false);
expect(openableInTab("text/markdown")).toBe(false);
expect(openableInTab("application/json")).toBe(false);
expect(openableInTab("application/octet-stream")).toBe(false);
expect(openableInTab(null)).toBe(false);
});
it("is narrower than what we can show ourselves", () => {
// Markdown is the case that proves the two questions are different: the
// dialog reads it with fetch, which ignores Content-Disposition.
expect(previewKind("text/markdown", "README.md")).toBe("text");
expect(openableInTab("text/markdown")).toBe(false);
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* What, if anything, we can show of a file without downloading it.
*
* Two questions, deliberately kept apart:
*
* - `previewKind` — can the app render it in a dialog? Text is answered with
* `fetch`, which ignores Content-Disposition, so this is free to say yes to
* anything text-shaped.
* - `openableInTab` — will the *server* hand it back inline? That mirrors
* `isInlineSafe` in `server/src/app.ts`, which is the security boundary:
* everything else is served as an attachment with a sandbox CSP. Navigating
* to a blob the server will not inline just starts a download, so the
* "open in a new tab" affordance has to ask this and not the other one.
*
* Keep the two in step by hand. They answer different questions and neither
* can be derived from the other.
*/
export type PreviewKind = "image" | "pdf" | "text";
/**
* Uploads arrive with whatever type the browser guessed, which for anything
* unusual is one of these -- `files.ts` stores `f.type || "application/octet-stream"`.
* A generic type is not evidence about the file, so fall through to the name.
*/
const GENERIC = new Set(["", "application/octet-stream", "binary/octet-stream", "application/unknown", "unknown/unknown"]);
const BY_EXTENSION: Array<[RegExp, PreviewKind]> = [
[/\.(png|jpe?g|gif|webp|avif|bmp|ico|heic|heif)$/i, "image"],
[/\.pdf$/i, "pdf"],
[/\.(txt|text|md|markdown|log|csv|tsv|json|ya?ml|toml|ini|cfg|conf|env|sh|bash|zsh|fish|ps1|bat|js|mjs|cjs|jsx|ts|tsx|css|scss|less|html?|xhtml|xml|sql|py|rb|rs|go|c|h|cc|cpp|hpp|java|kt|swift|php|pl|lua|r|diff|patch|gitignore|dockerfile|makefile)$/i, "text"],
];
function textish(type: string): boolean {
return (
type.startsWith("text/") ||
type.endsWith("+json") ||
type.endsWith("+xml") ||
/^application\/(json|xml|javascript|ecmascript|sql|toml|x-yaml|yaml|x-sh|x-shellscript|x-httpd-php)$/.test(type)
);
}
/**
* SVG is excluded on purpose, and stays excluded. It is a script carrier, the
* server refuses to serve it inline, and deciding how to show one safely is a
* question of its own rather than something to settle inside a file lister.
* An SVG falls through to a download, which is what it did before.
*/
export function previewKind(type: string | null | undefined, name: string | null | undefined): PreviewKind | null {
const t = (type ?? "").split(";")[0]!.trim().toLowerCase();
if (t && !GENERIC.has(t)) {
if (t === "image/svg+xml") return null;
if (t.startsWith("image/")) return "image";
if (t === "application/pdf") return "pdf";
if (textish(t)) return "text";
// The server was specific and it is not something we show. Guessing from
// the extension here would override a type the sender actually declared.
return null;
}
const n = name ?? "";
for (const [re, kind] of BY_EXTENSION) if (re.test(n)) return kind;
return null;
}
/** Mirrors `isInlineSafe` in `server/src/app.ts`; see the note at the top. */
export function openableInTab(type: string | null | undefined): boolean {
const t = (type ?? "").split(";")[0]!.trim().toLowerCase();
return (
(t.startsWith("image/") && t !== "image/svg+xml") ||
t.startsWith("video/") ||
t.startsWith("audio/") ||
t === "application/pdf" ||
t === "text/plain" ||
t === "text/calendar" ||
t === "text/vcard"
);
}
/**
* Past this, a text file is not read in a dialog -- it is downloaded and opened
* in something built for it. The number is about the browser, not the network:
* laying out a few million characters in one `<pre>` locks the tab up.
*/
export const TEXT_PREVIEW_MAX = 2 * 1024 * 1024;
/** A second guard for when the size was not known ahead of the fetch. */
export const TEXT_PREVIEW_CHARS = 400_000;
+18
View File
@@ -1244,6 +1244,24 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
*/
.printing-one .message:not(.print-target) { display: none !important; }
.printing-one .thread-subject > .muted { display: none !important; }
/*
* Printing from the file viewer. The dialog is the document: the mail or the
* file listing behind it is not what was asked for, and the chrome of the
* dialog itself -- title bar, Print and Download buttons -- is not part of
* the file. A PDF never comes through here; it prints itself from its own
* iframe (see ui/filepreview.tsx).
*/
/* The dialog is portalled to <body>, so the whole app goes -- hiding `.app`
alone left `#root` holding its `height: 100%` and printing a blank first
page, the same way `break-inside` did on a message. */
.printing-preview #root { display: none !important; }
.printing-preview body { height: auto !important; }
.printing-preview .dialog-backdrop { position: static !important; display: block !important; padding: 0 !important; background: none !important; backdrop-filter: none !important; animation: none !important; }
.printing-preview .dialog { max-width: none !important; max-height: none !important; border: 0 !important; box-shadow: none !important; animation: none !important; background: none !important; }
.printing-preview .dialog-head, .printing-preview .dialog-foot { display: none !important; }
.printing-preview .dialog-body { padding: 0 !important; overflow: visible !important; }
.printing-preview .dialog-body .code { max-height: none !important; overflow: visible !important; border: 0 !important; padding: 0 !important; }
.printing-preview .dialog-body img { max-height: none !important; }
.print-only { display: block; }
body { background: #fff; color: #000; }
}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Download, Printer } from "lucide-react";
import { Dialog } from "./dialog";
import { formatSize } from "@/lib/format";
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
import { t } 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;
}
/**
* Shows a file without downloading it: pictures, PDFs, and anything text.
*
* Grown out of the attachment preview in MessageView, which is where it still
* has one of its two callers -- the other is Files, which until now could only
* hand you the bytes.
*/
export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFile | null; onClose: () => void; caption?: ReactNode }) {
const kind = file ? previewKind(file.type, file.name) : null;
const tooBig = kind === "text" && typeof file?.size === "number" && file.size > TEXT_PREVIEW_MAX;
const pdfRef = useRef<HTMLIFrameElement>(null);
/*
* 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 (
<Dialog
open={Boolean(file)}
onClose={onClose}
title={file?.name ?? t("Preview")}
size="xl"
footer={file && (
<>
{kind && !tooBig && <button className="btn" onClick={print}><Printer size={16} /> {t("Print")}</button>}
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a>
</>
)}
>
{file && (
<>
{tooBig ? (
<p className="hint">{t("This file is too big to show here ({size}) — download it to read it.", { size: formatSize(file.size ?? 0) })}</p>
) : kind === "image" ? (
<img src={file.inlineUrl} alt={file.name} style={{ maxWidth: "100%", maxHeight: "70vh", display: "block", margin: "0 auto" }} />
) : kind === "pdf" ? (
<iframe ref={pdfRef} title={file.name} src={file.inlineUrl} style={{ width: "100%", height: "70vh", border: 0 }} />
) : kind === "text" ? (
/* `url`, not `inlineUrl`: fetch pays no attention to
Content-Disposition, so this works for the text types the server
will not serve inline -- Markdown among them. */
<TextPreview url={file.url} />
) : (
<p className="hint">{t("There is no preview for this kind of file.")}</p>
)}
{caption}
</>
)}
</Dialog>
);
}
function TextPreview({ url }: { url: string }) {
const [text, setText] = useState<string | null>(null);
const [truncated, setTruncated] = useState(false);
useEffect(() => {
let live = true;
setText(null);
setTruncated(false);
fetch(url, { credentials: "same-origin" })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((body) => {
if (!live) return;
setTruncated(body.length > TEXT_PREVIEW_CHARS);
setText(body.slice(0, TEXT_PREVIEW_CHARS));
})
.catch(() => live && setText(t("Could not load this file.")));
return () => {
live = false;
};
}, [url]);
return (
<>
{/* Someone else's file: not ours to translate, and not ours to reflow. */}
<pre className="code notranslate" translate="no" style={{ maxHeight: "65vh", whiteSpace: "pre-wrap" }}>
{text ?? t("Loading…")}
</pre>
{truncated && <p className="hint">{t("Only the beginning is shown — download the file for the rest.")}</p>}
</>
);
}
+30 -4
View File
@@ -1,17 +1,19 @@
import { useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react";
import { ChevronRight, Download, Eye, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react";
import { useFiles } from "@/store/files";
import { client } from "@/jmap/client";
import type { FileNode } from "@/jmap/types";
import { formatSize, formatListDate } from "@/lib/format";
import { canDropFileNode, isShared } from "@/lib/filenode";
import { previewKind } from "@/lib/preview";
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
import { NODE_MIME } from "./FilesTree";
import { ShareDialog } from "../settings/ShareDialog";
import { Empty, Spinner } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
import { FilePreviewDialog, type PreviewFile } from "@/ui/filepreview";
import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
@@ -25,6 +27,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
const [shareNode, setShareNode] = useState<FileNode | null>(null);
const [preview, setPreview] = useState<PreviewFile | null>(null);
/* Shared with the sidebar tree, so a row dragged onto a folder there is
recognised. See the note on `draggingId` in the store. */
const draggingId = files.draggingId;
@@ -100,14 +103,31 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
const onDrop = (e: React.DragEvent) => dropOnto(parentId, e);
const blobUrl = (n: FileNode, inline: boolean) =>
client.downloadUrl(files.accountId!, n.blobId!, n.name, n.type ?? "application/octet-stream", inline);
const download = (n: FileNode) => {
if (!n.blobId) return;
const a = document.createElement("a");
a.href = client.downloadUrl(files.accountId!, n.blobId, n.name, n.type ?? "application/octet-stream");
a.href = blobUrl(n, false);
a.download = n.name;
a.click();
};
/* A file with nothing to show still does what it always did. */
const canPreview = (n: FileNode) => Boolean(n.blobId) && n.nodeType !== "directory" && previewKind(n.type, n.name) !== null;
const openPreview = (n: FileNode) => setPreview({ name: n.name, type: n.type ?? "application/octet-stream", size: n.size, url: blobUrl(n, false), inlineUrl: blobUrl(n, true) });
/* Double-clicking a file used to download it, which is a decision made for
you: to look at a picture you had to put it on disk first. Now it opens
what can be opened and downloads the rest. */
const activate = (n: FileNode) => {
if (n.nodeType === "directory") navigate(`/files/${n.id}`);
else if (canPreview(n)) openPreview(n);
else download(n);
};
return (
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } else if (e.dataTransfer.types.includes(NODE_MIME) && canDropFileNode(files.nodes, draggingId ?? "", parentId)) { e.preventDefault(); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
<div className="files-toolbar">
@@ -162,7 +182,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
e.dataTransfer.dropEffect = node ? "move" : "copy";
}}
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }}
onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
onClick={() => setSelected(n.id)} onDoubleClick={() => activate(n)} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label={t("Shared")} />}</div></td>
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
@@ -182,7 +202,12 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
)}
{menuNode && (
<>
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label={t("Download")} onClick={() => download(menuNode)} />}
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : (
<>
{canPreview(menuNode) && <MenuItem icon={<Eye size={16} />} label={t("Preview")} onClick={() => openPreview(menuNode)} />}
<MenuItem icon={<Download size={16} />} label={t("Download")} onClick={() => download(menuNode)} />
</>
)}
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: t("Rename"), defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={() => setMoveNode(menuNode)} />
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
@@ -192,6 +217,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
)}
</Popover>
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
<FilePreviewDialog file={preview} onClose={() => setPreview(null)} />
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
</div>
);
+17 -15
View File
@@ -13,6 +13,8 @@ import { client } from "@/jmap/client";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html";
import { openableInTab, previewKind } from "@/lib/preview";
import { FilePreviewDialog } from "@/ui/filepreview";
import { findQuoteStart, textToHtml } from "@/lib/text";
import { Avatar } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
@@ -569,7 +571,9 @@ export function attachmentIcon(type: string, name?: string | null) {
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
const [preview, setPreview] = useState<EmailBodyPart | null>(null);
const viewable = (a: EmailBodyPart) => (a.type.startsWith("image/") && a.type !== "image/svg+xml") || a.type === "application/pdf" || a.type === "text/plain";
/* Whether we can show it, and whether the server will serve it inline, are
different questions -- see the note in lib/preview.ts. */
const viewable = (a: EmailBodyPart) => Boolean(a.blobId) && previewKind(a.type, a.name) !== null;
return (
<>
<div className="attachments">
@@ -584,7 +588,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
<span className="att-size">{formatSize(a.size)}</span>
<span className="att-actions">
<button className="icon-btn xs" title={translate("Download")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
{viewable(a) && <button className="icon-btn xs" title={translate("Open in new tab")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
{openableInTab(a.type) && a.blobId && <button className="icon-btn xs" title={translate("Open in new tab")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
</span>
</span>
</a>
@@ -596,20 +600,18 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
</button>
)}
</div>
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? translate("Preview")} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> {translate("Download")}</a>}>
{preview?.type.startsWith("image/") && <img src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "image", preview.type, true)} alt={preview.name ?? ""} style={{ maxHeight: "70vh", display: "block", margin: "0 auto" }} />}
{preview?.type === "application/pdf" && <iframe title={translate("PDF")} src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
<p className="hint" style={{ marginTop: 8 }}>{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}</p>
</Dialog>
<FilePreviewDialog
file={preview && preview.blobId ? {
name: preview.name ?? translate("file"),
type: preview.type,
size: preview.size,
url: client.downloadUrl(accountId, preview.blobId, preview.name ?? "file", preview.type),
inlineUrl: client.downloadUrl(accountId, preview.blobId, preview.name ?? "file", preview.type, true),
} : null}
onClose={() => setPreview(null)}
caption={<p className="hint" style={{ marginTop: 8 }}>{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}</p>}
/>
</>
);
}
function TextAttachment({ url }: { url: string }) {
const [text, setText] = useState<string | null>(null);
useEffect(() => {
fetch(url, { credentials: "same-origin" }).then((r) => r.text()).then(setText).catch(() => setText("Could not load."));
}, [url]);
return <pre className="code notranslate" translate="no" style={{ maxHeight: "65vh" }}>{text ?? "Loading…"}</pre>;
}