// Builds the self-contained HTML document for the RichTextEditor WebView. // Kept as a pure module (no react-native imports) so tests can assert the // embedded page script stays syntactically valid JavaScript. // // IMPORTANT: the page script lives inside a JS template literal. Backslash // escapes are "cooked" by the template (`\/` becomes `/`, `\s` becomes `s`), // which once shipped an invalid regex that killed the whole inline script // with a SyntaxError (issue #11 — Send stayed disabled because the editor // never posted `change` messages). Never rely on a backslash reaching the // page verbatim; write escape-free code or double the backslash. import type { ThemePalette } from '../theme/tokens'; import { randomHex } from './random'; // Minimum visible editor height (px). The editor auto-grows beyond this as the // user types, and the parent ScrollView handles overflow. export const MIN_EDITOR_HEIGHT = 220; // Shortest gap between two `change` posts while typing (see the page script). // Readers that need the current body - send, save, format switch - ask the // page with `getHtml` instead of waiting for the next post. export const CHANGE_THROTTLE_MS = 400; /** * The editor page's Content-Security-Policy, mirroring the viewer's * (`src/lib/email-html.ts`). Nothing but images, styles and our own inline * script may load. With `blockRemoteImages` images and fonts are limited to * what the composer produces itself - `data:` (inline and hydrated `cid:` * images) and `blob:` (pasted images) - so a quoted original's tracking * pixels and remote images don't fire just because it is replied to. * * Scripts need the page's `nonce`: the editor's own script carries it, and * inline handlers in seeded or pasted HTML (a quoted ``) * don't run. Commands from the app arrive through injectJavaScript, which * page CSP doesn't govern. */ export function buildEditorCsp(blockRemoteImages: boolean, nonce?: string): string { const resources = blockRemoteImages ? ['img-src data: blob:', 'font-src data:', "media-src 'none'"] : ['img-src data: blob: https: http:', 'font-src data: https: http:', 'media-src data: https: http:']; return [ "default-src 'none'", nonce ? `script-src 'nonce-${nonce}'` : "script-src 'none'", "style-src 'unsafe-inline'", ...resources, "base-uri 'none'", "form-action 'none'", ].join('; '); } /** * Whether the composer must open the editor with remote images blocked: it * is seeded with someone else's HTML (the quoted original of a reply or * forward, or a reopened draft that carries a quote) and the viewer would * have shown that message with its remote content blocked too. A reopened * draft doesn't say whose message it quotes, so its quote counts as * untrusted. */ export function shouldBlockEditorRemoteImages(opts: { /** The HTML the editor is seeded with from a message, if any. */ seedHtml?: string | null; isDraft: boolean; externalContentPolicy: 'allow' | 'block' | 'ask'; /** The quoted message's sender is trusted (ignored for drafts). */ senderTrusted: boolean; }): boolean { const { seedHtml, isDraft, externalContentPolicy, senderTrusted } = opts; if (!seedHtml || externalContentPolicy === 'allow') return false; if (isDraft) return /\sdata-quoted-html=|`. The HTML tokenizer * ends the script at the first ``, say) used to close * the editor's script early and run as markup. `<` is written as `<`; * U+2028/2029 are escaped too, for engines that read them as line ends. */ export function jsonForScript(value: unknown): string { return JSON.stringify(value) .replace(//g, '>'); } export function buildEditorHtml(opts: { initialHtml: string; placeholder: string; c: ThemePalette; /** Block remote images and fonts (see `buildEditorCsp`). */ blockRemoteImages?: boolean; /** * Keep the page's visual viewport at the top and report every pan to RN as * a `viewportShift` (CSS px = dp) instead (see the page script). Android * only: its WebView pans the visual viewport to reveal the caret. */ pinVisualViewport?: boolean; }): string { const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false } = opts; // Inject initial content as a JSON-encoded string so any HTML/quotes inside // are safely embedded (no template literal collision with the script body, // and no `
`; }