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.
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 .message:not(.print-target) { display: none !important; }
|
||||||
.printing-one .thread-subject > .muted { 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; }
|
.print-only { display: block; }
|
||||||
body { background: #fff; color: #000; }
|
body { background: #fff; color: #000; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
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 { useFiles } from "@/store/files";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import type { FileNode } from "@/jmap/types";
|
import type { FileNode } from "@/jmap/types";
|
||||||
import { formatSize, formatListDate } from "@/lib/format";
|
import { formatSize, formatListDate } from "@/lib/format";
|
||||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
import { canDropFileNode, isShared } from "@/lib/filenode";
|
||||||
|
import { previewKind } from "@/lib/preview";
|
||||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||||
import { NODE_MIME } from "./FilesTree";
|
import { NODE_MIME } from "./FilesTree";
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
import { ShareDialog } from "../settings/ShareDialog";
|
||||||
import { Empty, Spinner } from "@/ui/misc";
|
import { Empty, Spinner } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||||
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
|
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
|
||||||
|
import { FilePreviewDialog, type PreviewFile } from "@/ui/filepreview";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||||
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
||||||
const [shareNode, setShareNode] = 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
|
/* Shared with the sidebar tree, so a row dragged onto a folder there is
|
||||||
recognised. See the note on `draggingId` in the store. */
|
recognised. See the note on `draggingId` in the store. */
|
||||||
const draggingId = files.draggingId;
|
const draggingId = files.draggingId;
|
||||||
@@ -100,14 +103,31 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
|
|
||||||
const onDrop = (e: React.DragEvent) => dropOnto(parentId, e);
|
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) => {
|
const download = (n: FileNode) => {
|
||||||
if (!n.blobId) return;
|
if (!n.blobId) return;
|
||||||
const a = document.createElement("a");
|
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.download = n.name;
|
||||||
a.click();
|
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 (
|
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-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">
|
<div className="files-toolbar">
|
||||||
@@ -162,7 +182,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
e.dataTransfer.dropEffect = node ? "move" : "copy";
|
e.dataTransfer.dropEffect = node ? "move" : "copy";
|
||||||
}}
|
}}
|
||||||
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }}
|
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><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">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
|
||||||
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
|
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
|
||||||
@@ -182,7 +202,12 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
)}
|
)}
|
||||||
{menuNode && (
|
{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={<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={<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)} />
|
<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>
|
</Popover>
|
||||||
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
|
{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)} />}
|
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { client } from "@/jmap/client";
|
|||||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||||
import { displayName, formatAddress } from "@/lib/address";
|
import { displayName, formatAddress } from "@/lib/address";
|
||||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html";
|
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 { findQuoteStart, textToHtml } from "@/lib/text";
|
||||||
import { Avatar } from "@/ui/misc";
|
import { Avatar } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
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 }) {
|
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
|
||||||
const [preview, setPreview] = useState<EmailBodyPart | null>(null);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="attachments">
|
<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-size">{formatSize(a.size)}</span>
|
||||||
<span className="att-actions">
|
<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>
|
<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>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -596,20 +600,18 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>}>
|
<FilePreviewDialog
|
||||||
{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" }} />}
|
file={preview && preview.blobId ? {
|
||||||
{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 }} />}
|
name: preview.name ?? translate("file"),
|
||||||
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
|
type: preview.type,
|
||||||
<p className="hint" style={{ marginTop: 8 }}>{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}</p>
|
size: preview.size,
|
||||||
</Dialog>
|
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>;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user