Files
webmail/components/files/preview-shell.tsx
T
Linus Rath e80f5ade81 feat: let admins turn off downloads, export, printing and the install prompt, and add a watermark
Organisation > Devices now sets policy.json `organisation.devices`, each
switch off by default and scoped per domain or group:

- No downloads: attachments and files only preview (reader, conversation
  view, Pro tab, list chips, .eml previews, the Files app). Download,
  Download all, Open in new tab, Save to Files, drag-out, the browser's
  PDF viewer (pdf.js instead) and media download controls go away.
- No export: no .eml export, View source, message drag-out, or folder and
  account export; a running export stops and its file is deleted when
  the switch turns on. Import stays.
- No printing: no Print buttons in mail, calendar and contacts; Ctrl+P
  prints a note; the reader frame prints blank.
- No install prompt: no card, and the browser's own banner is held back.
- Watermark: the user's address and the date over the reader,
  previews and every printout (mail, calendar, contacts, plugins).

A message follows its own account's policy; what isn't about one message
follows the strictest signed-in account. Every user path hides its
button, and the anchor and new-tab helpers refuse as a backstop
(lib/download-guard.ts), never the blob readers. The plugin APIs
`ui.downloadFile` and `ui.print` refuse too, and the office editor frame
loses `allow-downloads`.

Two assertions in components/mail/__tests__/blob-routing.test.tsx now
expect `{ kind: 'export' }` as the fifth argument of downloadBlob for an
.eml export: the export says what it is, so the backstop checks "No
export" rather than "No downloads" for it.
2026-11-01 08:52:34 +01:00

115 lines
4.4 KiB
TypeScript

"use client";
import { useEffect, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, Download, X } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
import { useFocusTrap } from "@/hooks/use-focus-trap";
export interface PreviewNeighbours {
/** The previous and next previewable file of the folder, if any. */
previous?: string | null;
next?: string | null;
onGo: (name: string) => void;
}
interface PreviewShellProps {
name: string;
onClose: () => void;
/** Left out: no download button (a device policy turned downloads off). */
onDownload?: () => void;
/** Extra header buttons before Download (e.g. "Open in new tab"). */
actions?: ReactNode;
neighbours?: PreviewNeighbours;
/** Click on the dark area around the content closes the preview. */
closeOnBackdrop?: boolean;
children: ReactNode;
}
/**
* The one frame both file previews use (images and everything else): the
* same dim backdrop, a header with the name, Download and Close, ←/→ to the
* previous and next file of the folder, Escape to close.
*/
export function PreviewShell({ name, onClose, onDownload, actions, neighbours, closeOnBackdrop = true, children }: PreviewShellProps) {
const t = useTranslations("files");
// Tab stays in the preview and the focus goes back to the file that was
// opened when it closes. Escape is the listener below.
const shellRef = useFocusTrap({ isActive: true, restoreFocus: true });
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
};
// The arrows after the content's own keys: a zoomed image pans with them
// and takes the key (preventDefault), the frame goes to the next file.
const onArrow = (e: KeyboardEvent) => {
if (e.defaultPrevented || !neighbours || e.ctrlKey || e.metaKey || e.altKey) return;
const target = e.target instanceof HTMLElement ? e.target : null;
if (target?.closest("input, textarea, [contenteditable=true]")) return;
const rtl = document.dir === "rtl";
const forward = rtl ? "ArrowLeft" : "ArrowRight";
const back = rtl ? "ArrowRight" : "ArrowLeft";
if (e.key === forward && neighbours.next) {
e.preventDefault();
neighbours.onGo(neighbours.next);
} else if (e.key === back && neighbours.previous) {
e.preventDefault();
neighbours.onGo(neighbours.previous);
}
};
window.addEventListener("keydown", onKey, { capture: true });
window.addEventListener("keydown", onArrow);
return () => {
window.removeEventListener("keydown", onKey, { capture: true });
window.removeEventListener("keydown", onArrow);
};
}, [onClose, neighbours]);
return (
<div
ref={shellRef}
role="dialog"
aria-modal="true"
aria-label={name}
className="fixed inset-0 z-50 flex flex-col bg-black/80"
onClick={closeOnBackdrop ? onClose : undefined}
>
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
the iOS status bar (#936). The insets are physical, so pl/pr. */}
<div
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-background/90 backdrop-blur border-b border-border"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-medium truncate" title={name}>{name}</h3>
<div className="flex items-center gap-1 shrink-0">
{neighbours && (
<>
<IconButton label={t("previous_file")} disabled={!neighbours.previous} onClick={() => neighbours.previous && neighbours.onGo(neighbours.previous)}>
<ChevronLeft />
</IconButton>
<IconButton label={t("next_file")} disabled={!neighbours.next} onClick={() => neighbours.next && neighbours.onGo(neighbours.next)}>
<ChevronRight />
</IconButton>
</>
)}
{actions}
{onDownload && (
<IconButton label={t("download")} onClick={onDownload}>
<Download />
</IconButton>
)}
<IconButton label={t("close_preview")} onClick={onClose}>
<X />
</IconButton>
</div>
</div>
{children}
</div>
);
}