mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
The "Sync with address book" toggle under Content & Senders was off until the user turned it on, so senders trusted on the phone stayed in the local allow-list and never reached the "Trusted Senders" address book the other devices read. A setting the user never chose now counts as on; only an explicit opt-out, or a server without contacts, turns it off.
892 lines
36 KiB
TypeScript
892 lines
36 KiB
TypeScript
import React from 'react';
|
||
import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensions } from 'react-native';
|
||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||
import { useNavigation } from '@react-navigation/native';
|
||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
|
||
import type { Email } from '../api/types';
|
||
import { extractCidRefs } from '../lib/email-html';
|
||
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
|
||
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
|
||
import { bodyDocument } from '../lib/email-body-document';
|
||
import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights';
|
||
import { parseMailtoUrl } from '../lib/unsubscribe';
|
||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||
import { useHasContacts } from '../lib/capabilities';
|
||
import { useSettingsStore } from '../stores/settings-store';
|
||
import { useContactsStore } from '../stores/contacts-store';
|
||
import { useLocaleStore } from '../stores/locale-store';
|
||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||
import type { RootStackParamList } from '../navigation/types';
|
||
|
||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||
|
||
interface EmailBodyViewProps {
|
||
email: Email;
|
||
senderEmail?: string;
|
||
/** Owning account when the message lives in a shared/group mailbox. */
|
||
jmapAccountId?: string;
|
||
// The body is a native WebView, which on Android swallows horizontal touches
|
||
// 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;
|
||
/** Per-message override of the light/dark rendering (More sheet toggle). */
|
||
themeOverride?: 'light' | 'dark' | null;
|
||
/**
|
||
* Replace the message's own body - used when the outer message is an empty
|
||
* envelope around a message/rfc822 attachment or a TNEF winmail.dat and the
|
||
* real content was extracted client-side.
|
||
*/
|
||
bodyOverride?: { html?: string | null; text?: string | null } | null;
|
||
/** Called once the body has loaded and reported its height (it is on screen). */
|
||
onSettled?: () => void;
|
||
/**
|
||
* Until its height is known, take the space the parent leaves (the whole
|
||
* page below the header) rather than a guessed height.
|
||
*/
|
||
fill?: boolean;
|
||
}
|
||
|
||
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
|
||
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
|
||
// heart -> cyan) and stylesheet-defined background images, which the static CSS
|
||
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
|
||
// those so they keep their original colors. Only injected when inversion is on.
|
||
const DARK_REINVERT_SCRIPT = `
|
||
(function () {
|
||
if (document.__rnDarkInvertDone) return;
|
||
var doc = document;
|
||
var win = window;
|
||
if (!doc.body) return;
|
||
document.__rnDarkInvertDone = true;
|
||
|
||
// Re-invert elements with stylesheet-defined background images (CSS attribute
|
||
// selectors only catch inline styles, not rules that survive in <style>).
|
||
doc.body.querySelectorAll('*').forEach(function (el) {
|
||
// Skip elements already handled by CSS attribute selectors.
|
||
if (el.style.backgroundImage || el.style.background ||
|
||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
|
||
// Skip leaf media elements (already re-inverted by CSS).
|
||
var tag = el.tagName;
|
||
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
|
||
var computed = win.getComputedStyle(el);
|
||
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||
// Re-invert so the background image returns to its original
|
||
// appearance. Matches the CSS background-image rules: re-invert even
|
||
// when media is nested, then cancel the per-image re-invert on that
|
||
// media so it isn't left double-inverted.
|
||
el.style.filter = 'invert(1) hue-rotate(180deg)';
|
||
el.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
|
||
.forEach(function (m) { m.style.filter = 'none'; });
|
||
}
|
||
});
|
||
|
||
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
|
||
// run in a span that re-inverts. Only act when the ancestor invert depth is
|
||
// odd - emojis inside a double-inverted bgcolor container already render at
|
||
// their original colors.
|
||
var emojiRe;
|
||
try {
|
||
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
|
||
} catch (e) {
|
||
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
|
||
}
|
||
var emojiTestRe = /\\p{Extended_Pictographic}/u;
|
||
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
|
||
|
||
function isOddInvertDepth(start) {
|
||
var count = 0;
|
||
var n = start;
|
||
while (n) {
|
||
if (n === doc.body) { count++; break; }
|
||
var cs = win.getComputedStyle(n);
|
||
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
|
||
n = n.parentElement;
|
||
}
|
||
return count % 2 === 1;
|
||
}
|
||
|
||
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||
acceptNode: function (node) {
|
||
var p = node.parentElement;
|
||
while (p) {
|
||
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
|
||
p = p.parentElement;
|
||
}
|
||
return emojiTestRe.test(node.nodeValue || '')
|
||
? NodeFilter.FILTER_ACCEPT
|
||
: NodeFilter.FILTER_REJECT;
|
||
}
|
||
});
|
||
|
||
var emojiTextNodes = [];
|
||
var cur;
|
||
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
|
||
|
||
emojiTextNodes.forEach(function (textNode) {
|
||
var parent = textNode.parentElement;
|
||
if (!parent || !isOddInvertDepth(parent)) return;
|
||
var text = textNode.nodeValue || '';
|
||
emojiRe.lastIndex = 0;
|
||
var frag = doc.createDocumentFragment();
|
||
var lastIndex = 0;
|
||
var m;
|
||
while ((m = emojiRe.exec(text)) !== null) {
|
||
if (m.index > lastIndex) {
|
||
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
|
||
}
|
||
var span = doc.createElement('span');
|
||
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
|
||
span.textContent = m[0];
|
||
frag.appendChild(span);
|
||
lastIndex = m.index + m[0].length;
|
||
}
|
||
if (lastIndex === 0) return;
|
||
if (lastIndex < text.length) {
|
||
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
|
||
}
|
||
parent.replaceChild(frag, textNode);
|
||
});
|
||
})();
|
||
`;
|
||
|
||
// Image fix-ups, mirroring the webmail's post-render passes:
|
||
// - collapse empty <td>/<div> wrappers left behind by blocked images so a
|
||
// blocked newsletter doesn't show rows of empty cells;
|
||
// - hide images that fail to load (dead/unreachable URLs) rather than leave
|
||
// the engine's broken-image glyph and alt text as stray labels.
|
||
const IMG_FIXUP_SCRIPT = `
|
||
(function () {
|
||
if (document.__rnImgFixupDone) return;
|
||
var doc = document;
|
||
if (!doc.body) return;
|
||
document.__rnImgFixupDone = true;
|
||
|
||
doc.querySelectorAll('img[data-blocked-src]').forEach(function (img) {
|
||
var el = img.parentElement;
|
||
while (el && el !== doc.body) {
|
||
if (el.tagName === 'TD' || el.tagName === 'TH' ||
|
||
(el.tagName === 'DIV' && el.parentElement && el.parentElement.tagName === 'TD')) {
|
||
var hasText = (el.textContent || '').replace(/[\\s\\u00A0]+/g, '').trim();
|
||
var hasMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
||
var hasLinks = el.querySelector('a[href]');
|
||
if (!hasText && !hasMedia && !hasLinks) {
|
||
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
|
||
el.style.display = 'none';
|
||
el.style.height = '0';
|
||
el.style.padding = '0';
|
||
el.style.overflow = 'hidden';
|
||
}
|
||
break;
|
||
}
|
||
if (el.tagName === 'TABLE' || el.tagName === 'TR') break;
|
||
el = el.parentElement;
|
||
}
|
||
});
|
||
|
||
function hideIfBroken(img) {
|
||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) {
|
||
img.style.display = 'none';
|
||
}
|
||
}
|
||
doc.querySelectorAll('img').forEach(function (img) {
|
||
if (img.hasAttribute('data-blocked-src')) return;
|
||
if (img.complete) {
|
||
hideIfBroken(img);
|
||
} else {
|
||
img.addEventListener('error', function () { img.style.display = 'none'; }, { once: true });
|
||
img.addEventListener('load', function () { hideIfBroken(img); }, { once: true });
|
||
}
|
||
});
|
||
})();
|
||
`;
|
||
|
||
const HEIGHT_REPORTER = `
|
||
(function () {
|
||
// One-time: wrap body contents in a block-level div we can measure reliably.
|
||
// body.scrollHeight on Android WebView is pinned to the container viewport
|
||
// height, so we can't trust it. A dedicated wrapper gives a deterministic
|
||
// bounding box.
|
||
var wrapperEl = null;
|
||
function ensureWrapper() {
|
||
var b = document.body;
|
||
if (!b) return null;
|
||
if (wrapperEl && wrapperEl.isConnected) return wrapperEl;
|
||
var wrap = document.getElementById('__rn_email_body_wrap__');
|
||
if (!wrap) {
|
||
wrap = document.createElement('div');
|
||
wrap.id = '__rn_email_body_wrap__';
|
||
wrap.style.display = 'block';
|
||
wrap.style.width = '100%';
|
||
wrap.style.transformOrigin = 'top left';
|
||
while (b.firstChild) wrap.appendChild(b.firstChild);
|
||
b.appendChild(wrap);
|
||
}
|
||
wrapperEl = wrap;
|
||
return wrap;
|
||
}
|
||
// The width available for the email content (body content-box).
|
||
function availWidth() {
|
||
var b = document.body;
|
||
var cs = window.getComputedStyle(b);
|
||
var padL = parseFloat(cs.paddingLeft) || 0;
|
||
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';
|
||
// An over-wide block in a right-to-left body overflows to the left,
|
||
// where the top-left origin and the pan clamp don't expect it. The
|
||
// negative right margin keeps it flush left in either direction.
|
||
w.style.marginRight = (Z.avail - Z.layoutW) + 'px';
|
||
w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
|
||
} else {
|
||
Z.tx = 0;
|
||
w.style.width = '100%';
|
||
w.style.marginRight = '';
|
||
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
|
||
// the screen — the way native mail clients do. No sideways scrolling.
|
||
function fitToWidth(w) {
|
||
if (!w) return;
|
||
var avail = availWidth();
|
||
if (avail <= 0) return;
|
||
// Reset to full width so wrapping content reports its true overflow extent
|
||
// (the widest unbreakable element) rather than a previously-scaled box.
|
||
w.style.width = '100%';
|
||
w.style.marginRight = '';
|
||
var content = w.scrollWidth;
|
||
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;
|
||
// 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();
|
||
var b = document.body;
|
||
var cs = window.getComputedStyle(b);
|
||
var padTop = parseFloat(cs.paddingTop) || 0;
|
||
var padBottom = parseFloat(cs.paddingBottom) || 0;
|
||
// r.height is the wrapper's own content-box height.
|
||
// r.top already includes body's padding-top (rects are viewport-relative),
|
||
// so we only need to add padding-bottom + padding-top once.
|
||
return Math.ceil(r.height + padTop + padBottom);
|
||
}
|
||
function report() {
|
||
try {
|
||
var h = measure();
|
||
if (h > 0 && window.ReactNativeWebView) {
|
||
window.ReactNativeWebView.postMessage(String(h + 8));
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
// Measure only after layout has settled: two animation frames ensures style
|
||
// recalc + layout + paint are complete, and fonts.ready waits for webfont
|
||
// metrics to apply (text wraps can differ significantly before fonts load).
|
||
function reportWhenReady() {
|
||
var go = function () {
|
||
requestAnimationFrame(function () {
|
||
requestAnimationFrame(report);
|
||
});
|
||
};
|
||
if (document.fonts && document.fonts.ready && typeof document.fonts.ready.then === 'function') {
|
||
document.fonts.ready.then(go).catch(go);
|
||
} else {
|
||
go();
|
||
}
|
||
}
|
||
function scheduleReports() {
|
||
reportWhenReady();
|
||
// Follow-up re-measures: late-arriving images / async layout shifts.
|
||
[120, 300, 700, 1500, 3000].forEach(function (t) { setTimeout(report, t); });
|
||
}
|
||
if (document.readyState === 'complete') scheduleReports();
|
||
else {
|
||
window.addEventListener('load', scheduleReports);
|
||
document.addEventListener('DOMContentLoaded', reportWhenReady);
|
||
}
|
||
try {
|
||
var ro = new ResizeObserver(report);
|
||
if (document.documentElement) ro.observe(document.documentElement);
|
||
if (document.body) ro.observe(document.body);
|
||
} catch (e) {}
|
||
try {
|
||
var mo = new MutationObserver(function (records) {
|
||
for (var i = 0; i < records.length; i++) {
|
||
// Ignore the style mutations fitToWidth() applies to the wrapper itself,
|
||
// otherwise scaling would re-trigger a measure and loop indefinitely.
|
||
var rec = records[i];
|
||
if (rec.target === wrapperEl && rec.type === 'attributes') continue;
|
||
report();
|
||
return;
|
||
}
|
||
});
|
||
if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
|
||
} catch (e) {}
|
||
try {
|
||
Array.prototype.forEach.call(document.images || [], function (img) {
|
||
if (!img.complete) img.addEventListener('load', report);
|
||
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;
|
||
})();
|
||
`;
|
||
|
||
// Detect a clear horizontal flick inside the WebView and report it. Uses
|
||
// capture-phase, passive listeners so it never blocks vertical scrolling, text
|
||
// selection, or link taps — it only reacts to a quick, horizontal-dominant
|
||
// gesture on release. Guarded so re-injection (onLoadEnd) is a no-op.
|
||
const SWIPE_DETECTOR = `
|
||
(function () {
|
||
if (window.__rnSwipeInit) return;
|
||
window.__rnSwipeInit = true;
|
||
var x0 = 0, y0 = 0, t0 = 0, tracking = false;
|
||
document.addEventListener('touchstart', function (e) {
|
||
if (e.touches.length !== 1) { tracking = false; return; }
|
||
var t = e.touches[0];
|
||
x0 = t.clientX; y0 = t.clientY; t0 = Date.now(); tracking = true;
|
||
}, { passive: true, capture: true });
|
||
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;
|
||
if (dt < 700 && Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.8) {
|
||
if (window.ReactNativeWebView) {
|
||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'swipe', dir: dx < 0 ? 'next' : 'prev' }));
|
||
}
|
||
}
|
||
}, { passive: true, capture: true });
|
||
true;
|
||
})();
|
||
`;
|
||
|
||
// 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, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill,
|
||
}: EmailBodyViewProps) {
|
||
const c = useColors();
|
||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||
const t = useLocaleStore((s) => s.t);
|
||
const navigation = useNavigation<Nav>();
|
||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||
const hasContacts = useHasContacts();
|
||
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
|
||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
|
||
const addToTrustedSendersBook = useContactsStore((s) => s.addToTrustedSendersBook);
|
||
const resolvedTheme = useResolvedTheme();
|
||
|
||
// Passively load the dedicated "Trusted Senders" address book once so its
|
||
// entries (synced across devices) feed the external-content trust check. We
|
||
// do not create the book here — it is created lazily when a sender is trusted.
|
||
React.useEffect(() => {
|
||
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
|
||
// Most marketing email is authored against a white background, so dark-mode
|
||
// inversion can wreck logos/banners. With this flag the user opts to render
|
||
// emails on a light surface even while the rest of the app is dark; the
|
||
// per-message toggle in the More sheet overrides both.
|
||
const renderAsDark = themeOverride
|
||
? themeOverride === 'dark'
|
||
: !emailAlwaysLightMode && resolvedTheme === 'dark';
|
||
|
||
// RFC 8621 §4.1.4: an HTML-only message exposes the same part in textBody
|
||
// and htmlBody, so the text part is only an alternative when its partId
|
||
// differs (native #46). `pickEmailBody` routes by part type and id.
|
||
const picked = React.useMemo(() => {
|
||
if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null };
|
||
return pickEmailBody(email);
|
||
}, [email, bodyOverride]);
|
||
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||
const text = picked.text;
|
||
const trusted = isSenderContentTrusted(senderEmail, {
|
||
isLocallyTrusted: isSenderTrusted,
|
||
syncEnabled: syncTrustedSenders,
|
||
trustedBookEmails: trustedSenderEmails,
|
||
});
|
||
|
||
// One-time override: user tapped "Load images" for this email only.
|
||
const [allowOnce, setAllowOnce] = React.useState(false);
|
||
React.useEffect(() => {
|
||
setAllowOnce(false);
|
||
}, [email.id]);
|
||
|
||
// Policy-driven gate (like the webmail's `externalBlocked`): the strict CSP
|
||
// is in effect whenever the policy is not "allow" and the sender is not
|
||
// trusted, regardless of what the detection pass finds - the pass only
|
||
// drives the banner and the placeholder swap.
|
||
const shouldBlock =
|
||
!!rawHtml &&
|
||
!trusted &&
|
||
!allowOnce &&
|
||
externalContentPolicy !== 'allow';
|
||
|
||
// The WebView's height: what the page reported, else the height this body
|
||
// had the last time it was shown, else (see `fill`) an estimate. Never a
|
||
// fixed strip that then grows in steps.
|
||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||
const heightKey = `${jmapAccountId ?? ''}|${email.id}`;
|
||
const [measured, setMeasured] = React.useState<{ key: string; height: number } | null>(null);
|
||
const knownHeight = measured?.key === heightKey
|
||
? measured.height
|
||
: lastBodyHeight(heightKey, windowWidth);
|
||
// A pinch-zoomed page reports its scaled height; that is not one to keep.
|
||
const zoomedRef = React.useRef(false);
|
||
const [cidMap, setCidMap] = React.useState<Record<string, string>>({});
|
||
const webviewRef = React.useRef<WebView>(null);
|
||
const settledRef = React.useRef(false);
|
||
|
||
React.useEffect(() => {
|
||
// Keep the empty map it started with: a new object would rebuild the
|
||
// document (sanitise the body) a second time on every mount.
|
||
setCidMap((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
||
settledRef.current = false;
|
||
}, [email.id]);
|
||
|
||
React.useEffect(() => {
|
||
if (!rawHtml || !email.attachments?.length) return;
|
||
const refs = extractCidRefs(rawHtml);
|
||
if (refs.length === 0) return;
|
||
|
||
const byCid = new Map<string, { blobId: string; type: string; name?: string; size?: number }>();
|
||
for (const att of email.attachments) {
|
||
if (!att.cid || !att.blobId) continue;
|
||
byCid.set(att.cid.replace(/^<|>$/g, ''), att);
|
||
}
|
||
const toFetch = refs
|
||
.map((ref) => ({ ref, att: byCid.get(ref) }))
|
||
.filter((x): x is { ref: string; att: NonNullable<ReturnType<typeof byCid.get>> } => !!x.att);
|
||
if (toFetch.length === 0) return;
|
||
|
||
let cancelled = false;
|
||
void (async () => {
|
||
const next: Record<string, string> = {};
|
||
await Promise.all(
|
||
toFetch.map(async ({ ref, att }) => {
|
||
try {
|
||
const uri = await fetchInlineImageDataUri(att, jmapAccountId);
|
||
if (cancelled || !uri) return;
|
||
next[ref] = uri;
|
||
} catch (err) {
|
||
console.warn('[EmailBodyView] cid fetch failed', ref, err);
|
||
}
|
||
}),
|
||
);
|
||
if (!cancelled && Object.keys(next).length > 0) {
|
||
setCidMap((prev) => ({ ...prev, ...next }));
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [rawHtml, email.attachments, jmapAccountId]);
|
||
|
||
const quoteLabels = React.useMemo(() => ({
|
||
show: t('email_viewer.show_quoted_text', 'Show quoted text'),
|
||
hide: t('email_viewer.hide_quoted_text', 'Hide quoted text'),
|
||
}), [t]);
|
||
|
||
// Sanitised and wrapped once per message, body and settings, and reused
|
||
// when the same body mounts again (lib/email-body-document).
|
||
const emptyLabel = t('email_viewer.no_body_content', '(No body content available)');
|
||
const prepared = React.useMemo(() => bodyDocument({
|
||
key: `${jmapAccountId ?? ''}|${email.id}`,
|
||
rawHtml,
|
||
text: text ?? email.preview ?? '',
|
||
emptyLabel,
|
||
blockRemoteImages: shouldBlock,
|
||
cidMap,
|
||
isDark: renderAsDark,
|
||
messageSpacing,
|
||
plainTextFont,
|
||
quoteLabels,
|
||
}), [
|
||
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
|
||
messageSpacing, plainTextFont, quoteLabels,
|
||
]);
|
||
|
||
const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
|
||
const showBanner = shouldBlock && prepared.blockedExternal;
|
||
// Still cut off after the API's larger refetch (or the refetch failed).
|
||
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
|
||
|
||
// Inversion is applied for HTML bodies in dark mode unless the email ships
|
||
// its own dark-mode CSS - decided once by prepareEmailHtml on the same
|
||
// input the CSS was built from, so the DOM pass and the stylesheet agree.
|
||
const injectedJs = React.useMemo(
|
||
() =>
|
||
(prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
||
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
||
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
|
||
[prepared.isHtml, prepared.applyInversion, quoteLabels],
|
||
);
|
||
|
||
const onMessage = (e: WebViewMessageEvent) => {
|
||
const data = e.nativeEvent.data;
|
||
// 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') {
|
||
zoomedRef.current = !!msg.pinching || (typeof msg.scale === 'number' && msg.scale > 1.01);
|
||
onZoomChange?.({
|
||
pinching: !!msg.pinching,
|
||
zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
|
||
});
|
||
}
|
||
} catch { /* ignore malformed */ }
|
||
return;
|
||
}
|
||
const parsed = parseInt(data, 10);
|
||
if (Number.isNaN(parsed) || parsed <= 0) return;
|
||
setMeasured((prev) => (
|
||
prev?.key === heightKey && Math.abs(parsed - prev.height) < 2 ? prev : { key: heightKey, height: parsed }
|
||
));
|
||
if (!zoomedRef.current) rememberBodyHeight(heightKey, windowWidth, parsed);
|
||
if (!settledRef.current) {
|
||
settledRef.current = true;
|
||
onSettled?.();
|
||
}
|
||
};
|
||
|
||
const estimate = React.useMemo(
|
||
() => estimateBodyHeight({ isHtml: prepared.isHtml, text: text ?? email.preview, width: windowWidth, windowHeight }),
|
||
[prepared.isHtml, text, email.preview, windowWidth, windowHeight],
|
||
);
|
||
const containerSize = knownHeight !== undefined
|
||
? { height: knownHeight }
|
||
: fill ? styles.webContainerFill : { height: estimate };
|
||
|
||
const onLoadImages = () => setAllowOnce(true);
|
||
const onTrustSender = () => {
|
||
if (senderEmail) {
|
||
// Keep the local allow-list for instant effect, and file the sender in
|
||
// the server-side "Trusted Senders" address book (synced across
|
||
// devices) unless the user turned that off.
|
||
addTrustedSender(senderEmail);
|
||
if (syncTrustedSenders) {
|
||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||
}
|
||
setAllowOnce(true);
|
||
}
|
||
};
|
||
|
||
// mailto: links inside the body open the app's own composer with the parsed
|
||
// recipients/subject/body rather than the OS mail handler (webmail 1.8.1).
|
||
const openMailto = (url: string) => {
|
||
const fields = parseMailtoUrl(url);
|
||
if (!fields) return;
|
||
navigation.navigate('Compose', {
|
||
prefillTo: fields.to.map((address) => ({ email: address })),
|
||
prefillCc: fields.cc?.map((address) => ({ email: address })),
|
||
prefillSubject: fields.subject,
|
||
prefillBody: fields.body,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<View style={[styles.wrapper, fill && styles.wrapperFill]}>
|
||
{showBanner && (
|
||
<View style={styles.banner}>
|
||
<Text style={styles.bannerText}>
|
||
{t('email_viewer.external_content_warning', 'Images and external content have been blocked')}
|
||
</Text>
|
||
<View style={styles.bannerActions}>
|
||
{externalContentPolicy === 'ask' && (
|
||
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
|
||
<ImageIcon size={14} color={c.textSecondary} />
|
||
<Text style={styles.bannerButtonText}>{t('email_viewer.load_external_content', 'Load images')}</Text>
|
||
</Pressable>
|
||
)}
|
||
{senderEmail ? (
|
||
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
||
<ShieldCheck size={14} color={c.textSecondary} />
|
||
<Text style={styles.bannerButtonText}>{t('email_viewer.trust_sender', 'Always trust this sender')}</Text>
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
)}
|
||
{showTruncated && (
|
||
<View style={styles.banner}>
|
||
<Text style={styles.bannerText}>
|
||
{t('email_viewer.body_truncated', 'This message is too large to show in full.')}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
<View style={[styles.webContainer, containerSize]}>
|
||
<WebView
|
||
ref={webviewRef}
|
||
originWhitelist={['about:blank']}
|
||
source={source}
|
||
javaScriptEnabled
|
||
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).
|
||
zoomedRef.current = false;
|
||
onZoomChange?.({ pinching: false, zoomed: false });
|
||
// Re-inject after load to cover Android cases where
|
||
// `injectedJavaScript` runs too early to see the final layout. Every
|
||
// pass guards itself so running it again here is a no-op once the
|
||
// body has been processed.
|
||
setTimeout(() => {
|
||
webviewRef.current?.injectJavaScript(injectedJs);
|
||
}, 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}
|
||
thirdPartyCookiesEnabled={false}
|
||
sharedCookiesEnabled={false}
|
||
domStorageEnabled={false}
|
||
allowFileAccess={false}
|
||
allowFileAccessFromFileURLs={false}
|
||
allowUniversalAccessFromFileURLs={false}
|
||
allowsBackForwardNavigationGestures={false}
|
||
incognito
|
||
cacheEnabled={false}
|
||
mixedContentMode="never"
|
||
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
|
||
style={styles.webview}
|
||
containerStyle={styles.webviewContainer}
|
||
onShouldStartLoadWithRequest={(request) => {
|
||
const url = request.url;
|
||
// Only the initial about:blank document may load. A `data:` (or
|
||
// any other) navigation would replace the email body with no way
|
||
// back, so it is refused; images are reachable via the chips.
|
||
if (!url || url === 'about:blank') return true;
|
||
if (/^mailto:/i.test(url)) {
|
||
openMailto(url);
|
||
return false;
|
||
}
|
||
if (/^(https?|tel|sms):/i.test(url)) {
|
||
void Linking.openURL(url).catch(() => undefined);
|
||
}
|
||
return false;
|
||
}}
|
||
/>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function makeStyles(c: ThemePalette) {
|
||
return StyleSheet.create({
|
||
wrapper: { width: '100%' },
|
||
wrapperFill: { flexGrow: 1 },
|
||
banner: {
|
||
gap: spacing.xs,
|
||
paddingHorizontal: spacing.lg,
|
||
paddingVertical: spacing.sm,
|
||
backgroundColor: c.surfaceHover,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: c.border,
|
||
},
|
||
bannerText: {
|
||
...typography.caption,
|
||
color: c.textMuted,
|
||
},
|
||
bannerActions: {
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'center',
|
||
gap: spacing.md,
|
||
},
|
||
bannerButton: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: spacing.xs,
|
||
minHeight: 32,
|
||
},
|
||
bannerButtonText: {
|
||
...typography.caption,
|
||
color: c.textSecondary,
|
||
},
|
||
webContainer: {
|
||
width: '100%',
|
||
backgroundColor: 'transparent',
|
||
overflow: 'hidden',
|
||
},
|
||
webContainerFill: { flexGrow: 1, minHeight: 120 },
|
||
webview: {
|
||
backgroundColor: 'transparent',
|
||
flex: 1,
|
||
},
|
||
webviewContainer: {
|
||
backgroundColor: 'transparent',
|
||
},
|
||
});
|
||
}
|