diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 65a1e17b..add6ede5 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -5,11 +5,10 @@ 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 { - prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, -} from '../lib/email-html'; +import { extractCidRefs } from '../lib/email-html'; import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body'; -import { buildQuoteCollapseScript, collapsePlainTextQuotes } from '../lib/quote-collapse'; +import { buildQuoteCollapseScript } from '../lib/quote-collapse'; +import { bodyDocument } from '../lib/email-body-document'; import { parseMailtoUrl } from '../lib/unsubscribe'; import { fetchInlineImageDataUri } from '../lib/email-export'; import { isSenderContentTrusted } from '../lib/trusted-senders'; @@ -583,7 +582,9 @@ export default function EmailBodyView({ React.useEffect(() => { setHeight(120); - setCidMap({}); + // 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]); @@ -628,33 +629,24 @@ export default function EmailBodyView({ hide: t('email_viewer.hide_quoted_text', 'Hide quoted text'), }), [t]); - const prepared = React.useMemo(() => { - if (rawHtml) { - const res = prepareEmailHtml(rawHtml, { - blockRemoteImages: shouldBlock, - cidMap, - isDark: renderAsDark, - messageSpacing, - }); - return { ...res, isHtml: true }; - } - const fallbackText = text ?? email.preview ?? ''; - if (!fallbackText) { - const res = prepareEmailHtml( - `${t('email_viewer.no_body_content', '(No body content available)')}`, - { isDark: renderAsDark }, - ); - return { ...res, isHtml: true }; - } - const safe = collapsePlainTextQuotes(plainTextToSafeHtml(fallbackText), quoteLabels); - return { - html: wrapPlainTextEmail(safe, { isDark: renderAsDark, font: plainTextFont }), - applyInversion: false, - hasNativeDark: false, - blockedExternal: false, - isHtml: false, - }; - }, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark, messageSpacing, plainTextFont, quoteLabels, 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; diff --git a/src/lib/__tests__/email-body-document.test.ts b/src/lib/__tests__/email-body-document.test.ts new file mode 100644 index 00000000..b685819e --- /dev/null +++ b/src/lib/__tests__/email-body-document.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { bodyDocument, buildBodyDocument, clearBodyDocuments, type BodyDocumentInput } from '../email-body-document'; + +function input(over: Partial = {}): BodyDocumentInput { + return { + key: '|e1', + rawHtml: '

Hello

', + text: null, + emptyLabel: '(No body content available)', + blockRemoteImages: true, + cidMap: {}, + isDark: false, + messageSpacing: 'auto', + plainTextFont: 'sans', + quoteLabels: { show: 'Show quoted text', hide: 'Hide quoted text' }, + ...over, + }; +} + +beforeEach(() => { + clearBodyDocuments(); +}); + +describe('bodyDocument', () => { + it('builds a mounted-again body only once', () => { + const first = bodyDocument(input()); + // A remount passes equal values in new objects (a fresh empty cid map). + const again = bodyDocument(input({ cidMap: {}, quoteLabels: { show: 'Show quoted text', hide: 'Hide quoted text' } })); + + expect(again).toBe(first); + expect(first.isHtml).toBe(true); + expect(first.blockedExternal).toBe(true); + }); + + it('matches what building it directly gives', () => { + expect(bodyDocument(input())).toEqual(buildBodyDocument(input())); + const text = input({ rawHtml: null, text: 'Hi\n\n> quoted' }); + expect(bodyDocument(text)).toEqual(buildBodyDocument(text)); + expect(bodyDocument(text).isHtml).toBe(false); + const empty = input({ rawHtml: null, text: '' }); + expect(bodyDocument(empty).html).toContain('(No body content available)'); + }); + + it('rebuilds when a setting or the source changes', () => { + const first = bodyDocument(input()); + + expect(bodyDocument(input({ isDark: true }))).not.toBe(first); + expect(bodyDocument(input({ blockRemoteImages: false }))).not.toBe(first); + expect(bodyDocument(input({ rawHtml: '

Other

' })).html).toContain('Other'); + expect(bodyDocument(input({ cidMap: { a: 'data:image/png;base64,AA' } }))).not.toBe(first); + }); + + it('keeps messages apart by key', () => { + const a = bodyDocument(input({ key: '|e1' })); + const b = bodyDocument(input({ key: 'group|e1' })); + expect(b).not.toBe(a); + expect(bodyDocument(input({ key: '|e1' }))).toBe(a); + }); + + it('forgets the least recently used documents', () => { + const first = bodyDocument(input({ key: '|m0' })); + for (let i = 1; i <= 10; i++) bodyDocument(input({ key: `|m${i}` })); + expect(bodyDocument(input({ key: '|m0' }))).not.toBe(first); + }); +}); diff --git a/src/lib/email-body-document.ts b/src/lib/email-body-document.ts new file mode 100644 index 00000000..ddab3ba8 --- /dev/null +++ b/src/lib/email-body-document.ts @@ -0,0 +1,104 @@ +// The sandboxed document a message body is shown in, built once per message +// and rendering settings. Sanitising a large newsletter is a noticeable chunk +// of JS time, and the viewer mounts the same body again and again: the page +// beside the open one becomes the open one, a card is collapsed and opened, +// the message is opened a second time. Those reuse the document built before. + +import { + prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, + type MessageSpacing, type PlainTextFont, +} from './email-html'; +import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse'; + +export interface BodyDocumentInput { + /** The message (account and id); the sources below are compared as well. */ + key: string; + /** The HTML part to render, if any. */ + rawHtml: string | null; + /** The plain text to render when there is no HTML part. */ + text: string | null; + /** Shown when there is neither. */ + emptyLabel: string; + blockRemoteImages: boolean; + cidMap: Record; + isDark: boolean; + messageSpacing: MessageSpacing; + plainTextFont: PlainTextFont; + quoteLabels: QuoteCollapseLabels; +} + +export interface BodyDocument { + html: string; + /** Inversion CSS is in effect; the DOM re-invert pass should run. */ + applyInversion: boolean; + hasNativeDark: boolean; + /** At least one external resource was neutralised. */ + blockedExternal: boolean; + isHtml: boolean; +} + +/** Build the document (no caching). */ +export function buildBodyDocument(input: BodyDocumentInput): BodyDocument { + const { rawHtml, text, isDark } = input; + if (rawHtml) { + const res = prepareEmailHtml(rawHtml, { + blockRemoteImages: input.blockRemoteImages, + cidMap: input.cidMap, + isDark, + messageSpacing: input.messageSpacing, + }); + return { ...res, isHtml: true }; + } + if (!text) { + const res = prepareEmailHtml(`${input.emptyLabel}`, { isDark }); + return { ...res, isHtml: true }; + } + const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels); + return { + html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont }), + applyInversion: false, + hasNativeDark: false, + blockedExternal: false, + isHtml: false, + }; +} + +const MAX_DOCUMENTS = 10; +const documents = new Map(); + +function sameCidMap(a: Record, b: Record): boolean { + if (a === b) return true; + const ak = Object.keys(a); + return ak.length === Object.keys(b).length && ak.every((k) => a[k] === b[k]); +} + +function sameSources(a: BodyDocumentInput, b: BodyDocumentInput): boolean { + return a.rawHtml === b.rawHtml && a.text === b.text && sameCidMap(a.cidMap, b.cidMap); +} + +/** + * The document for a message body, built at most once per message, body and + * settings (see the module comment). The same input returns the same object. + */ +export function bodyDocument(input: BodyDocumentInput): BodyDocument { + const key = JSON.stringify([ + input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont, + input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, + ]); + const hit = documents.get(key); + if (hit && sameSources(hit.input, input)) { + documents.delete(key); + documents.set(key, hit); + return hit.doc; + } + const doc = buildBodyDocument(input); + documents.delete(key); + documents.set(key, { input, doc }); + while (documents.size > MAX_DOCUMENTS) documents.delete(documents.keys().next().value as string); + return doc; +} + +/** Forget every document built (tests). */ +export function clearBodyDocuments(): void { + documents.clear(); +}