diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx
index 59d5fd7b..a9e5dc91 100644
--- a/src/components/EmailBodyView.tsx
+++ b/src/components/EmailBodyView.tsx
@@ -31,6 +31,11 @@ interface EmailBodyViewProps {
// before the surrounding pager can see them. We detect a clear horizontal
// swipe inside the page and report it here so the pager can change messages.
onSwipe?: (direction: 'prev' | 'next') => void;
+ // Pinch-zoom state from inside the page: `pinching` while two fingers are
+ // down, `zoomed` while the content is scaled in. The hosts use it to freeze
+ // the surrounding vertical scroll / horizontal pager so a zoom gesture can't
+ // scroll the pane away or switch mails.
+ onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
}
function extractHtmlBody(email: Email): string | null {
@@ -181,6 +186,30 @@ const HEIGHT_REPORTER = `
var padR = parseFloat(cs.paddingRight) || 0;
return b.clientWidth - padL - padR;
}
+ // Shared zoom state, mutated by the pinch-zoom handler injected after this
+ // script. "fit" is the shrink-to-fit scale, "scale" the user's pinch factor
+ // on top of it, "tx" pans the zoomed overflow into view horizontally.
+ // Vertical panning needs no state here: the height reports below carry the
+ // scaled height, so the surrounding native ScrollView grows and scrolls.
+ var Z = window.__rnZoom = window.__rnZoom || {
+ scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
+ };
+ // Apply the combined fit × pinch transform using the cached layout numbers
+ // (cheap — no reflow), clamping the pan so content edges pin to the viewport.
+ function applyTransform(w) {
+ var s = Z.fit * Z.scale;
+ if (Z.scale > 1.001 || Z.fit < 0.999) {
+ var minTx = Math.min(0, Z.avail - Z.layoutW * s);
+ if (Z.tx < minTx) Z.tx = minTx;
+ if (Z.tx > 0) Z.tx = 0;
+ w.style.width = Z.layoutW + 'px';
+ w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
+ } else {
+ Z.tx = 0;
+ w.style.width = '100%';
+ w.style.transform = 'none';
+ }
+ }
// Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow
// a phone viewport and force horizontal scrolling. Lay the content out at its
// natural width, then scale the whole block down so its widest element fits
@@ -193,18 +222,18 @@ const HEIGHT_REPORTER = `
// (the widest unbreakable element) rather than a previously-scaled box.
w.style.width = '100%';
var content = w.scrollWidth;
- if (content > avail + 1) {
- var scale = avail / content;
- w.style.width = content + 'px';
- w.style.transform = 'scale(' + scale + ')';
- } else {
- w.style.transform = 'none';
- }
+ Z.avail = avail;
+ Z.layoutW = content > avail + 1 ? content : avail;
+ Z.fit = avail / Z.layoutW;
+ applyTransform(w);
}
function measure() {
var w = ensureWrapper();
if (!w) return 0;
- fitToWidth(w);
+ // While a pinch is in flight only re-apply the transform — a full re-fit
+ // resets the wrapper width and would thrash layout on every frame.
+ if (Z.pinching) applyTransform(w);
+ else fitToWidth(w);
// getBoundingClientRect reflects the applied transform, so r.height is the
// already-scaled visual height of the content.
var r = w.getBoundingClientRect();
@@ -274,6 +303,9 @@ const HEIGHT_REPORTER = `
img.addEventListener('error', report);
});
} catch (e) {}
+ // Hooks for the pinch-zoom handler script.
+ window.__rnApplyZoom = function () { var w = ensureWrapper(); if (w) applyTransform(w); };
+ window.__rnReport = report;
true;
})();
`;
@@ -295,6 +327,9 @@ const SWIPE_DETECTOR = `
document.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
+ // While the content is pinch-zoomed, horizontal flicks pan the zoomed
+ // content — never switch mails.
+ if (window.__rnZoom && (window.__rnZoom.pinching || window.__rnZoom.scale > 1.001)) return;
var t = e.changedTouches[0];
if (!t) return;
var dx = t.clientX - x0, dy = t.clientY - y0, dt = Date.now() - t0;
@@ -308,7 +343,121 @@ const SWIPE_DETECTOR = `
})();
`;
-export default function EmailBodyView({ email, senderEmail, onSwipe }: EmailBodyViewProps) {
+// Pinch zoom + pan for the email content, driven entirely in the page: native
+// WebView zoom can't work here because the WebView is auto-height inside a
+// native ScrollView (viewport meta pins it to scale 1). A two-finger pinch
+// scales the measured wrapper (on top of the shrink-to-fit scale the height
+// reporter maintains) anchored at the fingers' horizontal midpoint; while
+// zoomed, one-finger horizontal drags pan via translateX and vertical drags
+// still scroll the surrounding ScrollView, which sees the scaled height
+// through the regular height reports. Pinch moves call preventDefault so the
+// engine consumes the gesture and Android parents don't intercept it; `zoom`
+// messages let the RN side freeze the pane scroll / pager as a second guard.
+const PINCH_ZOOM = `
+(function () {
+ if (window.__rnPinchInit) return;
+ window.__rnPinchInit = true;
+ var Z = window.__rnZoom = window.__rnZoom || {
+ scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
+ };
+ var MAX_ZOOM = 4;
+ var pinch = null; // { d0, s0, cx0, tx0 }
+ var pan = null; // { x0, tx0 }
+ var rafPending = false;
+
+ function post(msg) {
+ if (window.ReactNativeWebView) {
+ window.ReactNativeWebView.postMessage(JSON.stringify(msg));
+ }
+ }
+ function apply() {
+ if (window.__rnApplyZoom) window.__rnApplyZoom();
+ }
+ // Height reports are rAF-throttled during the gesture so the RN container
+ // keeps up with the growing content without re-measuring on every move.
+ function scheduleReport() {
+ if (rafPending) return;
+ rafPending = true;
+ requestAnimationFrame(function () {
+ rafPending = false;
+ if (window.__rnReport) window.__rnReport();
+ });
+ }
+ function dist(t0, t1) {
+ var dx = t1.clientX - t0.clientX, dy = t1.clientY - t0.clientY;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ document.addEventListener('touchstart', function (e) {
+ if (e.touches.length === 2) {
+ var t0 = e.touches[0], t1 = e.touches[1];
+ pinch = {
+ d0: dist(t0, t1) || 1,
+ s0: Z.scale,
+ cx0: (t0.clientX + t1.clientX) / 2,
+ tx0: Z.tx,
+ };
+ pan = null;
+ if (!Z.pinching) {
+ Z.pinching = true;
+ post({ type: 'zoom', pinching: true, scale: Z.scale });
+ }
+ e.preventDefault();
+ } else if (e.touches.length === 1 && Z.scale > 1.001) {
+ pan = { x0: e.touches[0].clientX, tx0: Z.tx };
+ } else {
+ pan = null;
+ }
+ }, { passive: false, capture: true });
+
+ document.addEventListener('touchmove', function (e) {
+ if (pinch && e.touches.length === 2) {
+ e.preventDefault();
+ var t0 = e.touches[0], t1 = e.touches[1];
+ var z = pinch.s0 * (dist(t0, t1) / pinch.d0);
+ if (z < 1) z = 1;
+ if (z > MAX_ZOOM) z = MAX_ZOOM;
+ // Keep the content under the fingers' midpoint stationary horizontally:
+ // the wrapper-local point that started under cx0 must map back to the
+ // current midpoint at the new scale. (applyTransform clamps the result.)
+ var cx = (t0.clientX + t1.clientX) / 2;
+ var local = (pinch.cx0 - pinch.tx0) / (Z.fit * pinch.s0);
+ Z.scale = z;
+ Z.tx = cx - local * (Z.fit * z);
+ apply();
+ scheduleReport();
+ } else if (pan && e.touches.length === 1 && Z.scale > 1.001) {
+ // No preventDefault: vertical-dominant drags must keep scrolling the
+ // native ScrollView around the WebView. Horizontal-dominant drags stay
+ // inside the WebView and pan here.
+ Z.tx = pan.tx0 + (e.touches[0].clientX - pan.x0);
+ apply();
+ }
+ }, { passive: false, capture: true });
+
+ function onTouchEnd(e) {
+ if (pinch && e.touches.length < 2) {
+ pinch = null;
+ // Snap back to the fitted layout when the pinch ends near scale 1.
+ if (Z.scale < 1.02) { Z.scale = 1; Z.tx = 0; }
+ Z.pinching = false;
+ apply();
+ if (window.__rnReport) window.__rnReport();
+ post({ type: 'zoom', pinching: false, scale: Z.scale });
+ // A finger left on screen continues as a pan.
+ if (e.touches.length === 1 && Z.scale > 1.001) {
+ pan = { x0: e.touches[0].clientX, tx0: Z.tx };
+ }
+ }
+ if (e.touches.length === 0) pan = null;
+ }
+ document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
+ document.addEventListener('touchcancel', onTouchEnd, { passive: true, capture: true });
+ true;
+})();
+`;
+
+export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
@@ -459,18 +608,23 @@ export default function EmailBodyView({ email, senderEmail, onSwipe }: EmailBody
const injectedJs = React.useMemo(
() =>
(applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER)
- + SWIPE_DETECTOR,
+ + SWIPE_DETECTOR + PINCH_ZOOM,
[applyInversion],
);
const onMessage = (e: WebViewMessageEvent) => {
const data = e.nativeEvent.data;
- // Swipe messages arrive as JSON; the height reporter posts a bare number.
+ // Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
if (data.charCodeAt(0) === 123 /* '{' */) {
try {
const msg = JSON.parse(data);
if (msg.type === 'swipe' && (msg.dir === 'prev' || msg.dir === 'next')) {
onSwipe?.(msg.dir);
+ } else if (msg.type === 'zoom') {
+ onZoomChange?.({
+ pinching: !!msg.pinching,
+ zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
+ });
}
} catch { /* ignore malformed */ }
return;
@@ -520,6 +674,10 @@ export default function EmailBodyView({ email, senderEmail, onSwipe }: EmailBody
injectedJavaScript={injectedJs}
onMessage={onMessage}
onLoadEnd={() => {
+ // A (re)load resets the page's zoom state to 1 — drop any host-side
+ // scroll locks so they can't go stale (e.g. cid images arriving
+ // reload the source mid-zoom).
+ onZoomChange?.({ pinching: false, zoomed: false });
// Re-inject after load to cover Android cases where
// `injectedJavaScript` runs too early to see the final layout. The
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
@@ -529,6 +687,9 @@ export default function EmailBodyView({ email, senderEmail, onSwipe }: EmailBody
}, 50);
}}
scrollEnabled={false}
+ // Native Android zoom would scale the viewport inside the fixed-size
+ // container; pinch zoom is implemented in the page instead.
+ setBuiltInZoomControls={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
setSupportMultipleWindows={false}
diff --git a/src/lib/email-html.ts b/src/lib/email-html.ts
index 426fa8dd..f425f8ad 100644
Binary files a/src/lib/email-html.ts and b/src/lib/email-html.ts differ
diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx
index b3fe5927..52ea31ee 100644
--- a/src/screens/EmailThreadScreen.tsx
+++ b/src/screens/EmailThreadScreen.tsx
@@ -157,6 +157,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowWidth]);
+ // While the body is pinch-zoomed (or a pinch is in flight) horizontal
+ // gestures pan the zoomed content, so the pager must not treat them as page
+ // swipes. Prev/Next stay usable: programmatic scrollToOffset ignores
+ // scrollEnabled. Reset when the active message changes — the zoomed pane's
+ // WebView can unmount without reporting back.
+ const [pagerLocked, setPagerLocked] = React.useState(false);
+ React.useEffect(() => { setPagerLocked(false); }, [activeEmailId]);
+
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
@@ -510,6 +518,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
initialNumToRender={1}
maxToRenderPerBatch={2}
removeClippedSubviews
+ scrollEnabled={!pagerLocked}
onMomentumScrollEnd={onMomentumEnd}
renderItem={({ item, index }) => (
@@ -526,6 +535,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
onToggleStar={toggleStarFor}
onPressAttachment={onPressAttachment}
onSwipe={(dir) => goToIndex(dir === 'next' ? index + 1 : index - 1)}
+ onZoomChange={(z) => setPagerLocked(z.pinching || z.zoomed)}
/>
)}
@@ -631,6 +641,7 @@ interface EmailPaneProps {
onToggleStar: (email: Email) => void;
onPressAttachment: (email: Email, blobId: string, name: string | undefined, type: string | undefined) => void;
onSwipe: (direction: 'prev' | 'next') => void;
+ onZoomChange: (zoom: { pinching: boolean; zoomed: boolean }) => void;
}
// One swipeable page: the scrollable subject / sender / attachments / body for a
@@ -641,8 +652,13 @@ interface EmailPaneProps {
function EmailPane({
id, email, ensureDetail, c, styles, bottomBarHeight, attachmentPosition,
hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment, onSwipe,
+ onZoomChange,
}: EmailPaneProps) {
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
+ // Freeze the pane's vertical scroll while a pinch is in flight so a two-
+ // finger zoom can't fling the page; while merely zoomed, vertical scrolling
+ // stays on — it is how the user pans the (taller) zoomed content vertically.
+ const [pinching, setPinching] = React.useState(false);
// Each pane owns loading its own message: when the list mounts this page and
// its detail isn't cached yet, fetch it (coalesced upstream). The shared
@@ -723,6 +739,7 @@ function EmailPane({
{/* Subject block */}
@@ -777,7 +794,12 @@ function EmailPane({
{/* Body */}
-
+ { setPinching(z.pinching); onZoomChange(z); }}
+ />
);