fix: pad attachment preview overlays for the iOS PWA safe area #936

This commit is contained in:
Linus Rath
2026-08-31 21:03:08 +02:00
parent 47dac6421e
commit d60eb59575
6 changed files with 310 additions and 9 deletions
+38 -2
View File
@@ -7,6 +7,7 @@ import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { applyNewTabToAnchor, escapeHtml, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { withBasePath } from "@/lib/browser-navigation";
import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
@@ -2144,6 +2145,13 @@ export function EmailViewer({
// Iframe for rendering HTML emails true-to-life
const iframeRef = useRef<HTMLIFrameElement>(null);
// Scale-to-fit is a phone behaviour: on a desktop reading pane a wide mail is
// legible at 1:1 and scrolls. Held in a ref because the measurement runs from
// observers wired up once per document - a plain closure would keep fitting
// (or not) across a rotation or a pane resize that crossed the breakpoint.
const fitBodyToWidthRef = useRef(isMobile);
fitBodyToWidthRef.current = isMobile;
// Detect if the email HTML has native dark mode support
const emailHasNativeDarkMode = useMemo(() => {
if (!effectiveEmailContent.isHtml) return false;
@@ -2264,7 +2272,10 @@ export function EmailViewer({
overflow-x is auto on the body so an intrinsically wide table (e.g. a
20-column data table) can scroll horizontally instead of being crushed to
fit - the latter wraps header text to one character per line, which reads
as 90deg-rotated vertical headers (issue #409). */
as 90deg-rotated vertical headers (issue #409). On a phone that scroll is
the wrong answer for ordinary fixed-width mail, so fitEmailBodyWidth()
shrinks the whole body to the screen width after load and only content too
wide to stay legible when scaled keeps scrolling. */
html { overflow: hidden; height: auto !important; }
/* Some emails put height:100% on a full-bleed wrapper table/div (not html/body),
which - with body's overflow:hidden - clips the content to a sliver, and the
@@ -2316,6 +2327,12 @@ export function EmailViewer({
// be torn down. contentDocument keeps pointing at it until the browser swaps
// the new srcDoc in, so the poll skips it to avoid wiring up stale content.
const staleDocRef = useRef<Document | null>(null);
// Host-side observer on the iframe box itself: the in-document one watches
// body, whose width scale-to-fit pins to the content width, so a viewport
// change (rotation, pane resize) would otherwise never re-fit. Kept in a ref
// so each srcDoc replaces the previous one instead of stacking observers.
const frameSizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => () => frameSizeObserverRef.current?.disconnect(), []);
useLayoutEffect(() => {
setIframeReady(false);
initializedDocRef.current = null;
@@ -2349,12 +2366,31 @@ export function EmailViewer({
// documentElement.scrollHeight short while the real content lives in body.
const applyHeight = () => {
if (iframe.contentDocument !== doc) return; // navigated away; stale
const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
// Shrink desktop-width mail to fit a phone screen first: it changes
// the layout height, and the transform doesn't shrink the measured
// (layout) height, so the iframe box has to be scaled by hand.
const scale = fitEmailBodyWidth(doc, { enabled: fitBodyToWidthRef.current });
const height = Math.ceil(
Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight) * scale
);
iframe.style.height = height + 'px';
lastBodyHeightRef.current = height;
};
const resizeObserver = new ResizeObserver(applyHeight);
resizeObserver.observe(doc.body);
// Re-fit when the iframe itself changes width. Height-only changes are
// our own applyHeight writing back, so they're ignored - re-entering on
// those would spin the observer.
let lastFrameWidth = iframe.clientWidth;
frameSizeObserverRef.current?.disconnect();
const frameObserver = new ResizeObserver(() => {
if (iframe.contentDocument !== doc) return;
if (iframe.clientWidth === lastFrameWidth) return;
lastFrameWidth = iframe.clientWidth;
applyHeight();
});
frameObserver.observe(iframe);
frameSizeObserverRef.current = frameObserver;
applyHeight();
// The ResizeObserver only fires on body's border box; a content overflow
// that grows scrollHeight without resizing that box (e.g. a height:100%
+12 -3
View File
@@ -6,6 +6,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
import { Avatar } from "@/components/ui/avatar";
@@ -448,8 +449,12 @@ function EmailCard({
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>
html, body { overflow: hidden; }
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
/* overflow-y hidden keeps the scrollHeight measurement below honest. overflow-x
is auto so intrinsically wide content can pan instead of being clipped outright;
on a phone fitEmailBodyWidth() shrinks it to the screen first, so the pan is
only left for content too wide to stay legible when scaled. */
html { overflow: hidden; }
body { overflow-x: auto; overflow-y: hidden; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
@@ -471,7 +476,11 @@ function EmailCard({
hide: t('email_viewer.hide_quoted_text'),
});
const resize = () => {
iframe.style.height = doc.documentElement.scrollHeight + 'px';
// Fit desktop-width mail to the screen before measuring: the transform
// leaves the layout height untouched, so scale the box by hand.
const scale = fitEmailBodyWidth(doc);
const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
iframe.style.height = Math.ceil(height * scale) + 'px';
};
resize();
const ro = new ResizeObserver(resize);
+8 -2
View File
@@ -292,7 +292,13 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
return (
<div role="dialog" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" onClick={onClose}>
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
{/* The dialog is `fixed inset-0` under `viewport-fit=cover`, so in an
installed iOS PWA (no browser chrome above it) the header would sit
underneath the status bar and its buttons - including the close
button - became unreachable (#936). Pad the bar itself so its
background still bleeds behind the status bar / notch. The insets are
physical, so pl/pr (not ps/pe) is correct in RTL too. */}
<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">{name}</h3>
<div className="flex items-center gap-2">
{objectUrl && canOpenInNewTab && (
@@ -316,7 +322,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
</div>
</div>
<div className="flex-1 flex items-center justify-center overflow-auto p-4">
<div className="flex-1 flex items-center justify-center overflow-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))]">
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" />
+5 -2
View File
@@ -65,7 +65,10 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im
onClick={onClose}
>
{/* Header */}
<div className="absolute top-0 left-0 right-0 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/60 to-transparent z-10">
{/* Same safe-area handling as FilePreviewModal: full-screen overlay +
`viewport-fit=cover` puts this bar under the iOS status bar in an
installed PWA (#936). */}
<div className="absolute top-0 left-0 right-0 flex items-center justify-between 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-gradient-to-b from-black/60 to-transparent z-10">
<span className="text-white text-sm font-medium truncate max-w-[50%]">{name}</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.min(z + 0.25, 5)); }}>
@@ -87,7 +90,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im
</div>
{/* Image */}
<div className="flex items-center justify-center w-full h-full p-16" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center w-full h-full p-16 pt-[calc(4rem+env(safe-area-inset-top))] pb-[calc(4rem+env(safe-area-inset-bottom))]" onClick={(e) => e.stopPropagation()}>
{loading && (
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
)}
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { computeFitScale, fitEmailBodyWidth, FIT_MIN_SCALE } from '@/lib/email-fit-width';
/** jsdom does no layout, so the widths the fit reads are stubbed by hand. */
function stubWidths(opts: { viewport: number; content: number | (() => number) }) {
Object.defineProperty(document.documentElement, 'clientWidth', {
configurable: true,
get: () => opts.viewport,
});
const content = typeof opts.content === 'function' ? opts.content : () => opts.content as number;
// Only body carries the width: the helper reads both and takes the max, and a
// shared getter would advance a scripted sequence twice per measurement.
Object.defineProperty(document.body, 'scrollWidth', { configurable: true, get: content });
Object.defineProperty(document.documentElement, 'scrollWidth', { configurable: true, get: () => 0 });
}
describe('computeFitScale', () => {
it('shrinks desktop-width mail to a phone screen', () => {
expect(computeFitScale(780, 390)).toBeCloseTo(0.5);
});
it('leaves content that already fits alone', () => {
expect(computeFitScale(390, 390)).toBe(1);
// Sub-pixel rounding routinely reports a scrollWidth a hair over the client
// width on content that visually fits.
expect(computeFitScale(390.5, 390)).toBe(1);
});
it('leaves a desktop reading pane at 1:1', () => {
expect(computeFitScale(900, 720)).toBe(1);
});
it('keeps the horizontal scroll for content too wide to stay legible', () => {
// 390 / 2000 is far below the floor: scaled, this is unreadable, so panning
// at full size is the better of the two bad options.
expect(computeFitScale(2000, 390)).toBe(1);
expect(computeFitScale(390 / FIT_MIN_SCALE - 10, 390)).toBeGreaterThan(FIT_MIN_SCALE);
});
it('ignores nonsense measurements', () => {
expect(computeFitScale(0, 390)).toBe(1);
expect(computeFitScale(780, 0)).toBe(1);
});
});
describe('fitEmailBodyWidth', () => {
beforeEach(() => {
document.body.removeAttribute('style');
document.body.removeAttribute('dir');
});
it('lays the body out at its content width and scales it down', () => {
stubWidths({ viewport: 390, content: 780 });
const scale = fitEmailBodyWidth(document);
expect(scale).toBeCloseTo(0.5);
expect(document.body.style.width).toBe('780px');
expect(document.body.style.boxSizing).toBe('border-box');
expect(document.body.style.transform).toBe('scale(0.5)');
expect(document.body.style.transformOrigin).toBe('top left');
});
it('anchors the shrink at the right edge in an RTL body', () => {
stubWidths({ viewport: 390, content: 780 });
document.body.style.direction = 'rtl';
fitEmailBodyWidth(document);
expect(document.body.style.transformOrigin).toBe('top right');
});
it('touches nothing when the mail already fits', () => {
stubWidths({ viewport: 390, content: 380 });
expect(fitEmailBodyWidth(document)).toBe(1);
expect(document.body.getAttribute('style')).toBeFalsy();
});
it('undoes a previous fit when the mail reflows to fit', () => {
stubWidths({ viewport: 390, content: 780 });
fitEmailBodyWidth(document);
expect(document.body.style.transform).toBe('scale(0.5)');
// e.g. a blocked image was loaded and the layout collapsed to one column.
stubWidths({ viewport: 390, content: 300 });
expect(fitEmailBodyWidth(document)).toBe(1);
expect(document.body.style.transform).toBe('');
expect(document.body.style.width).toBe('');
expect(document.body.style.boxSizing).toBe('');
});
it('undoes a previous fit when disabled', () => {
stubWidths({ viewport: 390, content: 780 });
fitEmailBodyWidth(document);
expect(fitEmailBodyWidth(document, { enabled: false })).toBe(1);
expect(document.body.style.transform).toBe('');
expect(document.body.style.width).toBe('');
});
it('re-measures when widening the body reveals more width', () => {
// Percentage-width children can grow with the body they were just given.
const widths = [700, 900, 900];
let call = 0;
stubWidths({ viewport: 390, content: () => widths[Math.min(call++, widths.length - 1)] });
const scale = fitEmailBodyWidth(document);
expect(document.body.style.width).toBe('900px');
expect(scale).toBeCloseTo(390 / 900);
});
it('backs out of a fit that settles past the shrink floor', () => {
const widths = [700, 3000, 3000];
let call = 0;
stubWidths({ viewport: 390, content: () => widths[Math.min(call++, widths.length - 1)] });
expect(fitEmailBodyWidth(document)).toBe(1);
expect(document.body.style.width).toBe('');
expect(document.body.style.transform).toBe('');
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Scale-to-fit for email bodies rendered inside the sandboxed viewer iframes.
*
* A lot of mail is authored at a fixed desktop width (the 600-800px table is
* the newsletter default). On a phone that content is wider than the iframe,
* so the reader has to swipe sideways through every message - or, where the
* iframe clips instead of scrolling, never sees the right-hand half at all.
*
* Native mail clients solve this by shrinking the whole body to the screen
* width, which is what these helpers do: lay the body out at its intrinsic
* width and then `transform: scale()` it down. Layout is preserved exactly
* (unlike forcing everything to `width: 100%`, which crushes wide data tables
* into one-character columns - issue #409), and the reader can still pinch-zoom
* to read the fine print.
*
* Only narrow viewports are scaled. On a desktop reading pane a wide table
* still scrolls horizontally inside the body, which is the better trade-off
* there: the content is legible at 1:1 and there is a mouse to scroll with.
*/
/** Widest iframe that still counts as "a phone" for scale-to-fit purposes. */
export const FIT_VIEWPORT_MAX = 640;
/**
* Never shrink past this. An email that would need more (a 2000px data table
* on a 390px screen) is unreadable when scaled, so it keeps the horizontal
* scroll instead and the reader pans through it at a legible size.
*/
export const FIT_MIN_SCALE = 0.4;
export interface FitOptions {
/**
* Set false on a desktop layout: a wide mail there is legible at 1:1 and the
* reading pane can scroll. Any fit already applied is undone.
*/
enabled?: boolean;
/** Iframes at least this wide are left at 1:1. */
viewportMax?: number;
/** Lower bound on the scale factor. */
minScale?: number;
}
/**
* The factor a body of `contentWidth` must be scaled by to fit `viewportWidth`,
* or 1 when it should be left alone (already fits, not a narrow viewport, or
* would have to shrink past `minScale`).
*/
export function computeFitScale(
contentWidth: number,
viewportWidth: number,
{ viewportMax = FIT_VIEWPORT_MAX, minScale = FIT_MIN_SCALE }: FitOptions = {},
): number {
if (!(contentWidth > 0) || !(viewportWidth > 0)) return 1;
if (viewportWidth > viewportMax) return 1;
// 1px of slack: sub-pixel layout rounding routinely reports a scrollWidth a
// hair over clientWidth on content that visually fits.
if (contentWidth <= viewportWidth + 1) return 1;
const scale = viewportWidth / contentWidth;
return scale < minScale ? 1 : scale;
}
/** Undo a fit, leaving the body laid out at the iframe's own width. */
function clearFit(body: HTMLElement): void {
body.style.transform = '';
body.style.width = '';
body.style.boxSizing = '';
}
/** Widest thing in the document. */
function measureContentWidth(doc: Document): number {
return Math.max(doc.body.scrollWidth, doc.documentElement.scrollWidth);
}
/**
* Fit the document body into its iframe, returning the scale that was applied
* (1 = untouched). Idempotent: every call re-measures from the unscaled layout,
* so it can be re-run as images load and the content reflows.
*
* The caller owns the iframe height - multiply the measured (unscaled) content
* height by the returned scale, since a transform does not change layout size.
*/
export function fitEmailBodyWidth(doc: Document, options: FitOptions = {}): number {
const body = doc.body;
if (!body) return 1;
// Measure the intrinsic layout, not the one a previous call left behind.
clearFit(body);
if (options.enabled === false) return 1;
const viewport = doc.documentElement.clientWidth;
let content = measureContentWidth(doc);
let scale = computeFitScale(content, viewport, options);
if (scale === 1) return 1;
// border-box, so the width we set (measured as a scrollWidth, which already
// covers the body's own padding) doesn't add that padding a second time on
// every pass and creep wider - which scales the mail down further than it
// needs to be and leaves a gap down the side.
body.style.boxSizing = 'border-box';
// Widening the body to its content width can reveal more width: percentage-
// sized children re-lay out against the new containing block, and a table
// with `width: 100%` next to a fixed-width sibling grows with it. Settle over
// a couple of passes rather than scaling against a stale measurement.
for (let pass = 0; pass < 3; pass++) {
body.style.width = `${content}px`;
const grown = measureContentWidth(doc);
if (grown <= content + 1) break;
content = grown;
scale = computeFitScale(content, viewport, options);
if (scale === 1) {
// Grew past the shrink floor - back out and leave it scrolling.
clearFit(body);
return 1;
}
}
// Anchor the shrink at the inline start so the content stays flush with the
// edge the reader's eye starts from.
const rtl = doc.defaultView?.getComputedStyle(body).direction === 'rtl';
body.style.transformOrigin = rtl ? 'top right' : 'top left';
body.style.transform = `scale(${scale})`;
return scale;
}