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 (
+
+ );
+}
+
+function TextPreview({ url }: { url: string }) {
+ const [text, setText] = useState(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. */}
+
+ {text ?? t("Loading…")}
+
+ {truncated && {t("Only the beginning is shown — download the file for the rest.")}
}
+ >
+ );
+}
diff --git a/web/src/views/files/FilesView.tsx b/web/src/views/files/FilesView.tsx
index d3380a6..00f9f80 100644
--- a/web/src/views/files/FilesView.tsx
+++ b/web/src/views/files/FilesView.tsx
@@ -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(null);
const [moveNode, setMoveNode] = useState(null);
const [shareNode, setShareNode] = useState(null);
+ const [preview, setPreview] = useState(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 (
{ 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}>
@@ -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); }}>
{n.nodeType === "directory" ? : } { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}{isShared(n) && }
{n.nodeType === "directory" ? "—" : formatSize(n.size)}
{formatListDate(n.modified ?? n.created)}
@@ -182,7 +202,12 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
)}
{menuNode && (
<>
- {menuNode.nodeType === "directory" ? } label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : } label={t("Download")} onClick={() => download(menuNode)} />}
+ {menuNode.nodeType === "directory" ? } label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : (
+ <>
+ {canPreview(menuNode) && } label={t("Preview")} onClick={() => openPreview(menuNode)} />}
+ } label={t("Download")} onClick={() => download(menuNode)} />
+ >
+ )}
} 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); } } }} />
} label={t("Move to…")} onClick={() => setMoveNode(menuNode)} />
} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
@@ -192,6 +217,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
)}
{moveNode && setMoveNode(null)} />}
+ setPreview(null)} />
{shareNode && setShareNode(null)} />}
);
diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx
index 4c50c4d..d269827 100644
--- a/web/src/views/mail/MessageView.tsx
+++ b/web/src/views/mail/MessageView.tsx
@@ -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(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 (
<>
@@ -584,7 +588,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
{formatSize(a.size)}
- {viewable(a) && }
+ {openableInTab(a.type) && a.blobId && }
@@ -596,20 +600,18 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
)}
-
+ setPreview(null)}
+ caption={{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}
}
+ />
>
);
}
-function TextAttachment({ url }: { url: string }) {
- const [text, setText] = useState(null);
- useEffect(() => {
- fetch(url, { credentials: "same-origin" }).then((r) => r.text()).then(setText).catch(() => setText("Could not load."));
- }, [url]);
- return {text ?? "Loading…"};
-}