From 984f0474e3c118da2a795c63bfcd4ef470d5a9e3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 1 Sep 2026 20:18:51 -0700 Subject: [PATCH] Look at a file without downloading it first Files could only hand you the bytes: double-clicking a picture put it on disk and left you to find it. The viewer for this already existed -- images, PDFs and text, in the attachment preview in MessageView -- it was just wired to one screen. It is now a component both screens use. Two things it needed before it was any use on Files. The type detection falls back to the file name: an upload carries whatever the browser guessed, which for anything unusual is application/octet-stream, so the old exact-type check saw nothing to show in a .md that had just been uploaded. And text is read with fetch, which ignores Content-Disposition, so Markdown previews even though the server will not serve it inline. Whether we can show a file and whether the server will serve it inline are separate questions, and lib/preview.ts answers them separately: `openableInTab` mirrors the isInlineSafe allowlist in the blob route, because navigating to a blob the server will not inline just starts a download. SVG is left out of both -- it carries script, and how to show one safely is its own question, not a detail of a file lister. Printing goes with it. A picture or a text file prints from the dialog with everything else dropped; a PDF prints itself from its own iframe, since the page around it cannot paginate someone else's document. Hiding `.app` alone was not enough there -- `#root` kept its height and printed a blank first page, the same trap as the message card. --- web/src/lib/__tests__/preview.test.ts | 80 +++++++++++++++ web/src/lib/preview.ts | 87 ++++++++++++++++ web/src/styles/app.css | 18 ++++ web/src/ui/filepreview.tsx | 138 ++++++++++++++++++++++++++ web/src/views/files/FilesView.tsx | 34 ++++++- web/src/views/mail/MessageView.tsx | 32 +++--- 6 files changed, 370 insertions(+), 19 deletions(-) create mode 100644 web/src/lib/__tests__/preview.test.ts create mode 100644 web/src/lib/preview.ts create mode 100644 web/src/ui/filepreview.tsx diff --git a/web/src/lib/__tests__/preview.test.ts b/web/src/lib/__tests__/preview.test.ts new file mode 100644 index 0000000..1e68c70 --- /dev/null +++ b/web/src/lib/__tests__/preview.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/preview.ts b/web/src/lib/preview.ts new file mode 100644 index 0000000..7dbfe2b --- /dev/null +++ b/web/src/lib/preview.ts @@ -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 `
` 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;
diff --git a/web/src/styles/app.css b/web/src/styles/app.css
index 8a6fcb7..eb7a961 100644
--- a/web/src/styles/app.css
+++ b/web/src/styles/app.css
@@ -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 , 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; }
 }
diff --git a/web/src/ui/filepreview.tsx b/web/src/ui/filepreview.tsx
new file mode 100644
index 0000000..9882751
--- /dev/null
+++ b/web/src/ui/filepreview.tsx
@@ -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(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 (
+    
+          {kind && !tooBig && }
+            {t("Download")}
+        
+      )}
+    >
+      {file && (
+        <>
+          {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" ? ( +