mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: read mail as plain text when plain text only is on, or per message from the menu
This commit is contained in:
@@ -14,6 +14,7 @@ import { parseMailtoUrl } from '../lib/unsubscribe';
|
||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
import { senderVerificationOf } from '../lib/email-headers';
|
||||
import { plainTextReading } from '../lib/html-to-readable-text';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
@@ -54,6 +55,13 @@ interface EmailBodyViewProps {
|
||||
* page below the header) rather than a guessed height.
|
||||
*/
|
||||
fill?: boolean;
|
||||
/**
|
||||
* Per-message "Show as plain text" from the More sheet; null or undefined
|
||||
* follows the "Plain Text Only" setting.
|
||||
*/
|
||||
plainView?: boolean | null;
|
||||
/** "Show formatted" in the plain-text note: turn the per-message view off. */
|
||||
onShowFormatted?: () => void;
|
||||
}
|
||||
|
||||
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
|
||||
@@ -521,6 +529,7 @@ const PINCH_ZOOM = `
|
||||
|
||||
export default function EmailBodyView({
|
||||
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill,
|
||||
plainView: plainViewOverride, onShowFormatted,
|
||||
}: EmailBodyViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -535,6 +544,7 @@ export default function EmailBodyView({
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||||
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||||
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
|
||||
@@ -562,8 +572,19 @@ export default function EmailBodyView({
|
||||
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 formattedHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||
// HTML mail as plain text: "Plain Text Only" for every message, or the More
|
||||
// sheet's per-message switch. The message's own text part when it has one,
|
||||
// else a readable conversion that keeps link targets and image alt text
|
||||
// (webmail 1e929b0a9). A plain-text view loads nothing remote, so the
|
||||
// external-content banner goes too.
|
||||
const plainView = plainViewOverride ?? plainTextMode;
|
||||
const plainText = React.useMemo(
|
||||
() => (plainView ? plainTextReading({ html: formattedHtml, text: picked.text }) : null),
|
||||
[plainView, formattedHtml, picked.text],
|
||||
);
|
||||
const rawHtml = plainText !== null ? null : formattedHtml;
|
||||
const text = plainText ?? picked.text;
|
||||
// A From address the server's checks don't back may be forged: neither
|
||||
// honour an earlier "Always trust" for it nor offer one, or a forgery of a
|
||||
// trusted sender would load remote content (and trusting it would let the
|
||||
@@ -765,6 +786,18 @@ export default function EmailBodyView({
|
||||
|
||||
return (
|
||||
<View style={[styles.wrapper, fill && styles.wrapperFill]}>
|
||||
{plainText !== null && (
|
||||
<View style={styles.plainNote}>
|
||||
<Text style={styles.bannerText}>
|
||||
{t('email_viewer.shown_as_plain_text', 'This message is shown as plain text.')}
|
||||
</Text>
|
||||
{onShowFormatted && (
|
||||
<Pressable onPress={onShowFormatted} hitSlop={8} accessibilityRole="button">
|
||||
<Text style={styles.plainNoteLink}>{t('email_viewer.show_formatted', 'Show formatted')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{showBanner && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>
|
||||
@@ -895,6 +928,19 @@ function makeStyles(c: ThemePalette) {
|
||||
...typography.caption,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
plainNote: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
columnGap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
plainNoteLink: {
|
||||
...typography.caption,
|
||||
color: c.primary,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
webContainer: {
|
||||
width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// "Plain Text Only" reads "Compose and read in plain text only", but only the
|
||||
// composer honoured it: the reader always showed the HTML. The webmail reads
|
||||
// HTML mail as plain text by default or per message (1e929b0a9). The
|
||||
// conversion itself is tested in lib/__tests__/html-to-readable-text.test.ts;
|
||||
// this checks the reader is wired to it.
|
||||
|
||||
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8');
|
||||
|
||||
describe('plain-text reading', () => {
|
||||
it('renders the plain-text reading when the setting or the per-message switch is on', () => {
|
||||
const body = src('components/EmailBodyView.tsx');
|
||||
expect(body).toMatch(/useSettingsStore\(\(s\) => s\.plainTextMode\)/);
|
||||
expect(body).toMatch(/const plainView = plainViewOverride \?\? plainTextMode;/);
|
||||
expect(body).toMatch(/plainTextReading\(\{ html: formattedHtml, text: picked\.text \}\)/);
|
||||
// The document is built from the text, not the HTML, while it is on.
|
||||
expect(body).toMatch(/const rawHtml = plainText !== null \? null : formattedHtml;/);
|
||||
expect(body).toMatch(/email_viewer\.shown_as_plain_text/);
|
||||
});
|
||||
|
||||
it('offers "Show as plain text" per message in the More sheet and passes it to every card', () => {
|
||||
const screen = src('screens/EmailThreadScreen.tsx');
|
||||
expect(screen).toMatch(/email_viewer\.show_as_plain_text/);
|
||||
expect(screen).toMatch(/plainView=\{plainOverrides\[m\.id\] \?\? null\}/);
|
||||
expect(src('components/email/MessageContent.tsx')).toMatch(/plainView=\{plainView\}/);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,10 @@ export interface MessageContentProps {
|
||||
/** On screen rather than pre-rendered by the pager (gates auto-sent read receipts). */
|
||||
active: boolean;
|
||||
themeOverride?: 'light' | 'dark' | null;
|
||||
/** Per-message "Show as plain text"; null follows the "Plain Text Only" setting. */
|
||||
plainView?: boolean | null;
|
||||
/** Turn the per-message plain-text view off ("Show formatted"). */
|
||||
onShowFormatted?: (email: Email) => void;
|
||||
onSwipe?: (direction: 'prev' | 'next') => void;
|
||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
@@ -52,8 +56,8 @@ export interface MessageContentProps {
|
||||
* cards.
|
||||
*/
|
||||
export function MessageContent({
|
||||
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, onSwipe, onZoomChange,
|
||||
onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
|
||||
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, plainView, onShowFormatted,
|
||||
onSwipe, onZoomChange, onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
|
||||
}: MessageContentProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -124,6 +128,8 @@ export function MessageContent({
|
||||
onSwipe={onSwipe}
|
||||
onZoomChange={onZoomChange}
|
||||
themeOverride={themeOverride}
|
||||
plainView={plainView}
|
||||
onShowFormatted={onShowFormatted ? () => onShowFormatted(email) : undefined}
|
||||
bodyOverride={unwrap.override}
|
||||
onSettled={onBodySettled}
|
||||
fill={fill}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { htmlToReadableText, plainTextReading } from '../html-to-readable-text';
|
||||
|
||||
// "Plain Text Only" promised plain-text reading, but the reader showed the
|
||||
// HTML anyway. These are the webmail's cases for its converter
|
||||
// (lib/__tests__/html-to-readable-text.test.ts) plus the HTML a DOM parser
|
||||
// fixes up on its own.
|
||||
|
||||
describe('htmlToReadableText', () => {
|
||||
it('keeps paragraphs and line breaks', () => {
|
||||
expect(htmlToReadableText('<p>Hello <b>Ann</b>,</p><p>first line<br>second line</p>'))
|
||||
.toBe('Hello Ann,\n\nfirst line\nsecond line');
|
||||
});
|
||||
|
||||
it('keeps link targets and image alt text', () => {
|
||||
expect(htmlToReadableText('<p>Read <a href="https://ex.com/a">the report</a> <img src="x.png" alt="Logo"></p>'))
|
||||
.toBe('Read the report (https://ex.com/a) [Logo]');
|
||||
});
|
||||
|
||||
it('does not repeat a target that is the link text, and drops script and style', () => {
|
||||
expect(htmlToReadableText('<style>p{color:red}</style><a href="https://ex.com/">ex.com</a><script>x()</script>'))
|
||||
.toBe('ex.com');
|
||||
expect(htmlToReadableText('<a href="mailto:ann@ex.com?subject=Hi">Write to Ann</a>'))
|
||||
.toBe('Write to Ann (ann@ex.com)');
|
||||
});
|
||||
|
||||
it('turns layout tables into lines and lists into bullets', () => {
|
||||
const html = '<table><tr><td>Name</td><td>Ann</td></tr><tr><td>City</td><td>Berlin</td></tr></table><ul><li>one</li><li>two</li></ul>';
|
||||
expect(htmlToReadableText(html)).toBe('Name Ann\nCity Berlin\n\n• one\n• two');
|
||||
});
|
||||
|
||||
it('skips hidden preheaders and images without alt text', () => {
|
||||
expect(htmlToReadableText('<div style="display:none">preheader</div><img src="t.gif"><p>Body</p>')).toBe('Body');
|
||||
});
|
||||
|
||||
it('reads a whole document: head, entities, comments and unclosed tags', () => {
|
||||
const html = '<!DOCTYPE html><html><head><title>T</title><style>a{}</style></head><body>'
|
||||
+ '<!-- tracking --><p>Fish & chips €5<p>Next <a href="https://ex.com/?a=1&b=2">deal</a>'
|
||||
+ '<ul><li>one<li>two</ul><script>if (a < b) document.write("<p>x</p>")</script></body></html>';
|
||||
expect(htmlToReadableText(html)).toBe('Fish & chips €5\n\nNext deal (https://ex.com/?a=1&b=2)\n\n• one\n• two');
|
||||
});
|
||||
|
||||
it('keeps preformatted text and marks quotes', () => {
|
||||
expect(htmlToReadableText('<pre>a b\n c</pre>')).toBe('a b\n c');
|
||||
expect(htmlToReadableText('<p>On Monday Bob wrote:</p><blockquote>hi</blockquote>')).toBe('On Monday Bob wrote:\n\n> hi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plainTextReading', () => {
|
||||
it("prefers the message's own text part, else converts the HTML, and leaves plain-text mail alone", () => {
|
||||
expect(plainTextReading({ html: '<p>Hi <b>there</b></p>', text: 'Hi there (text part)' })).toBe('Hi there (text part)');
|
||||
expect(plainTextReading({ html: '<p>Hi <b>there</b></p>', text: null })).toBe('Hi there');
|
||||
expect(plainTextReading({ html: '<p>Hi</p>', text: ' ' })).toBe('Hi');
|
||||
expect(plainTextReading({ html: null, text: 'just text' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* HTML mail as plain text, for "Plain Text Only" reading and the reader's
|
||||
* per-message "Show as plain text" when a message has no text/plain part of
|
||||
* its own. Port of the webmail's lib/html-to-readable-text.ts.
|
||||
*
|
||||
* Layout tables become lines, headings and paragraphs keep a blank line
|
||||
* around them, list items get a bullet, images are replaced by their alt text
|
||||
* and a link keeps its target in brackets after its text, so nothing the
|
||||
* formatted mail offered is lost.
|
||||
*
|
||||
* The webmail walks a DOMParser tree; React Native has no DOM, so this walks
|
||||
* the tags in order with a stack of open elements and closes the elements an
|
||||
* HTML parser would close on its own (a paragraph before a block, a list item
|
||||
* before the next one, table cells and rows).
|
||||
*/
|
||||
|
||||
import { decodeEntities } from './verification-code';
|
||||
|
||||
const BLOCK = new Set([
|
||||
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'CENTER', 'DD', 'DETAILS', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION',
|
||||
'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE',
|
||||
'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TFOOT', 'THEAD', 'TR', 'UL',
|
||||
]);
|
||||
/** Blocks that get a blank line before and after, not just a line break. */
|
||||
const PARAGRAPH = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'UL', 'OL', 'TABLE', 'HR']);
|
||||
const SKIP = new Set(['HEAD', 'STYLE', 'SCRIPT', 'TEMPLATE', 'NOSCRIPT', 'TITLE', 'META', 'LINK', 'SVG', 'OBJECT', 'IFRAME']);
|
||||
/** Elements whose content is raw text up to their end tag. */
|
||||
const RAW_TEXT = new Set(['SCRIPT', 'STYLE', 'TITLE', 'TEXTAREA', 'XMP', 'NOSCRIPT', 'IFRAME', 'NOEMBED', 'NOFRAMES']);
|
||||
const VOID = new Set(['AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
||||
|
||||
/** Link targets worth writing out: web, mail and phone links. */
|
||||
function shownHref(href: string | null): string | null {
|
||||
if (!href) return null;
|
||||
const trimmed = href.trim();
|
||||
if (/^mailto:/i.test(trimmed)) return trimmed.slice(7).split('?')[0] || null;
|
||||
if (/^(https?:|tel:)/i.test(trimmed)) return trimmed;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalize(text: string): string {
|
||||
return text.replace(/[ \t\f\v\r\n\xa0]+/g, ' ');
|
||||
}
|
||||
|
||||
/** Same address once with and once without the scheme or a trailing slash. */
|
||||
function sameTarget(text: string, href: string): boolean {
|
||||
const strip = (s: string) => s.trim().toLowerCase().replace(/^(https?:\/\/|mailto:|tel:)/, '').replace(/\/$/, '');
|
||||
return strip(text) === strip(href);
|
||||
}
|
||||
|
||||
const TOKEN = /<!--[\s\S]*?(?:-->|$)|<![^>]*>?|<\?[^>]*>?|<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
|
||||
const ATTR = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
||||
|
||||
function parseAttrs(raw: string): Map<string, string> {
|
||||
const attrs = new Map<string, string>();
|
||||
ATTR.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ATTR.exec(raw)) !== null) {
|
||||
const name = m[1].toLowerCase();
|
||||
if (!attrs.has(name)) attrs.set(name, decodeEntities(m[2] ?? m[3] ?? m[4] ?? ''));
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
interface Frame {
|
||||
tag: string;
|
||||
/** Inside a skipped or hidden element: nothing is written. */
|
||||
skip: boolean;
|
||||
pre: boolean;
|
||||
listDepth: number;
|
||||
/** out.length when the element opened, for a link's text. */
|
||||
start: number;
|
||||
href: string | null;
|
||||
}
|
||||
|
||||
/** Open elements an element closes when it starts, as an HTML parser does. */
|
||||
function closesOnOpen(open: string, tag: string): boolean {
|
||||
if (open === 'P') return BLOCK.has(tag);
|
||||
if (open === 'LI') return tag === 'LI';
|
||||
if (open === 'DT' || open === 'DD') return tag === 'DT' || tag === 'DD';
|
||||
if (open === 'TD' || open === 'TH') return tag === 'TD' || tag === 'TH' || tag === 'TR';
|
||||
if (open === 'TR') return tag === 'TR';
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Elements that stop the search for an element to close on open. */
|
||||
const SCOPE = new Set(['UL', 'OL', 'TABLE', 'DL', 'BLOCKQUOTE', 'DIV', 'TD', 'TH']);
|
||||
|
||||
export function htmlToReadableText(html: string): string {
|
||||
if (!html) return '';
|
||||
const out: string[] = [];
|
||||
// Pending line breaks, so runs of empty blocks collapse to one blank line.
|
||||
let pendingBreaks = 0;
|
||||
let atLineStart = true;
|
||||
|
||||
const breakLine = (count: number) => {
|
||||
if (out.length === 0) return;
|
||||
pendingBreaks = Math.max(pendingBreaks, count);
|
||||
};
|
||||
const write = (text: string) => {
|
||||
if (!text) return;
|
||||
if (pendingBreaks > 0) {
|
||||
out.push('\n'.repeat(pendingBreaks));
|
||||
pendingBreaks = 0;
|
||||
atLineStart = true;
|
||||
}
|
||||
if (atLineStart) text = text.replace(/^ +/, '');
|
||||
if (!text) return;
|
||||
out.push(text);
|
||||
atLineStart = text.endsWith('\n');
|
||||
};
|
||||
|
||||
const stack: Frame[] = [];
|
||||
const top = (): Frame | undefined => stack[stack.length - 1];
|
||||
|
||||
const close = (frame: Frame) => {
|
||||
if (frame.skip) return;
|
||||
const { tag } = frame;
|
||||
if (tag === 'A') {
|
||||
const text = out.slice(frame.start).join('').trim();
|
||||
if (frame.href && !sameTarget(text, frame.href)) write(text ? ` (${frame.href})` : frame.href);
|
||||
}
|
||||
if (BLOCK.has(tag)) {
|
||||
const gap = PARAGRAPH.has(tag) && !(tag === 'UL' || tag === 'OL') ? 2 : 1;
|
||||
breakLine(tag === 'LI' ? 1 : gap);
|
||||
}
|
||||
};
|
||||
|
||||
const open = (tag: string, attrs: Map<string, string>) => {
|
||||
// Close what this element implicitly ends (a <p> before a block, the
|
||||
// previous <li>, table cells and rows).
|
||||
for (let i = stack.length - 1; i >= 0; i--) {
|
||||
const frameTag = stack[i].tag;
|
||||
if (closesOnOpen(frameTag, tag)) {
|
||||
while (stack.length > i) close(stack.pop()!);
|
||||
break;
|
||||
}
|
||||
if (SCOPE.has(frameTag)) break;
|
||||
}
|
||||
|
||||
const parent = top();
|
||||
const inherited = parent?.skip ?? false;
|
||||
const hidden = attrs.has('hidden') || /display\s*:\s*none/i.test(attrs.get('style') ?? '');
|
||||
const skip = inherited || SKIP.has(tag) || hidden;
|
||||
const pre = parent?.pre ?? false;
|
||||
const listDepth = parent?.listDepth ?? 0;
|
||||
|
||||
if (VOID.has(tag)) {
|
||||
if (skip) return;
|
||||
if (tag === 'BR') {
|
||||
out.push('\n');
|
||||
pendingBreaks = 0;
|
||||
atLineStart = true;
|
||||
} else if (tag === 'IMG' || (tag === 'INPUT' && attrs.get('type') === 'image')) {
|
||||
const alt = normalize(attrs.get('alt') ?? '').trim();
|
||||
if (alt) write(`[${alt}]`);
|
||||
} else if (tag === 'HR') {
|
||||
breakLine(2);
|
||||
write('---');
|
||||
breakLine(2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skip) {
|
||||
const isBlock = BLOCK.has(tag);
|
||||
const gap = PARAGRAPH.has(tag) && !(tag === 'UL' || tag === 'OL') ? 2 : 1;
|
||||
if (isBlock) breakLine(listDepth > 0 && (tag === 'UL' || tag === 'OL') ? 1 : gap);
|
||||
if (tag === 'LI') write(`${' '.repeat(Math.max(0, listDepth - 1))}• `);
|
||||
if (tag === 'TD' || tag === 'TH') {
|
||||
if (!atLineStart && pendingBreaks === 0) write(' ');
|
||||
}
|
||||
if (tag === 'BLOCKQUOTE') write('> ');
|
||||
}
|
||||
stack.push({
|
||||
tag,
|
||||
skip,
|
||||
pre: pre || tag === 'PRE',
|
||||
listDepth: tag === 'UL' || tag === 'OL' ? listDepth + 1 : listDepth,
|
||||
start: out.length,
|
||||
href: tag === 'A' ? shownHref(attrs.get('href') ?? null) : null,
|
||||
});
|
||||
};
|
||||
|
||||
const end = (tag: string) => {
|
||||
let i = stack.length - 1;
|
||||
while (i >= 0 && stack[i].tag !== tag) i--;
|
||||
if (i < 0) return; // a stray end tag
|
||||
while (stack.length > i) close(stack.pop()!);
|
||||
};
|
||||
|
||||
const text = (raw: string) => {
|
||||
const frame = top();
|
||||
if (frame?.skip || !raw) return;
|
||||
const value = decodeEntities(raw);
|
||||
write(frame?.pre ? value : normalize(value));
|
||||
};
|
||||
|
||||
TOKEN.lastIndex = 0;
|
||||
let lower: string | undefined;
|
||||
let pos = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TOKEN.exec(html)) !== null) {
|
||||
if (m.index > pos) text(html.slice(pos, m.index));
|
||||
pos = TOKEN.lastIndex;
|
||||
if (m[2] === undefined) continue; // comment, doctype, processing instruction
|
||||
const tag = m[2].toUpperCase();
|
||||
if (m[1]) {
|
||||
end(tag);
|
||||
continue;
|
||||
}
|
||||
open(tag, parseAttrs(m[3]));
|
||||
const selfClosing = /\/\s*$/.test(m[3]);
|
||||
if (selfClosing && !VOID.has(tag)) end(tag);
|
||||
if (RAW_TEXT.has(tag) && !selfClosing) {
|
||||
// Raw text runs to the end tag: a "<" inside a script is not a tag.
|
||||
lower ??= html.toLowerCase();
|
||||
const endAt = lower.indexOf(`</${tag.toLowerCase()}`, pos);
|
||||
const stop = endAt < 0 ? html.length : endAt;
|
||||
if (!SKIP.has(tag)) text(html.slice(pos, stop));
|
||||
pos = stop;
|
||||
TOKEN.lastIndex = stop;
|
||||
}
|
||||
}
|
||||
if (pos < html.length) text(html.slice(pos));
|
||||
while (stack.length > 0) close(stack.pop()!);
|
||||
|
||||
return out
|
||||
.join('')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/[ \t]+$/g, ''))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The text the reader shows for a message read as plain text: its own
|
||||
* text/plain part when it has one, else its HTML turned into text (webmail
|
||||
* email-viewer.tsx, 1e929b0a9). Null when there is no HTML to replace: a
|
||||
* plain-text message reads as it is.
|
||||
*/
|
||||
export function plainTextReading(body: { html: string | null; text: string | null }): string | null {
|
||||
if (!body.html) return null;
|
||||
if (body.text?.trim()) return body.text;
|
||||
return htmlToReadableText(body.html);
|
||||
}
|
||||
@@ -363,7 +363,7 @@ const LETTER_ENTITIES: Record<string, string> = {
|
||||
upsih: 'ϒ', upsilon: 'υ', uuml: 'ü', xi: 'ξ', yacute: 'ý', yuml: 'ÿ', zeta: 'ζ',
|
||||
};
|
||||
|
||||
function decodeEntities(text: string): string {
|
||||
export function decodeEntities(text: string): string {
|
||||
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (m, ref: string) => {
|
||||
if (ref[0] === '#') {
|
||||
const cp = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||
ChevronLeft, ChevronRight, Archive, Mail, MailOpen,
|
||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter,
|
||||
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter, FileText, Type,
|
||||
} from '../components/icons';
|
||||
import { pickEmailBody } from '../lib/email-body';
|
||||
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
|
||||
import { isSieveSupported } from '../api/sieve';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
@@ -177,6 +178,13 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
||||
// Per-message override of the light/dark rendering (More sheet toggle).
|
||||
const [themeOverrides, setThemeOverrides] = React.useState<Record<string, 'light' | 'dark'>>({});
|
||||
// Per-message "Show as plain text" (More sheet); unset follows "Plain Text Only".
|
||||
const [plainOverrides, setPlainOverrides] = React.useState<Record<string, boolean>>({});
|
||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||
const showFormatted = React.useCallback(
|
||||
(m: Email) => setPlainOverrides((prev) => ({ ...prev, [m.id]: false })),
|
||||
[],
|
||||
);
|
||||
|
||||
// Message details and conversations come from the shared cache
|
||||
// (lib/email-detail-cache): every rendered pane, the active one and its
|
||||
@@ -693,6 +701,10 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
const showMarkUnread = windowWidth >= 340;
|
||||
const showArchive = windowWidth >= 400 && canArchive;
|
||||
|
||||
// The active message read as plain text, and whether it has HTML to switch from.
|
||||
const activePlainView = email ? plainOverrides[email.id] ?? plainTextMode : false;
|
||||
const activeHasHtml = !!email && !!pickEmailBody(email).html;
|
||||
|
||||
// Current rendering mode of the active message, for the More sheet label.
|
||||
const activeRenderDark = email
|
||||
? (themeOverrides[email.id] ? themeOverrides[email.id] === 'dark' : !emailAlwaysLightMode && resolvedTheme === 'dark')
|
||||
@@ -817,6 +829,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
currentMailboxRole={currentMailboxRole}
|
||||
identities={identities}
|
||||
themeOverrides={themeOverrides}
|
||||
plainOverrides={plainOverrides}
|
||||
onShowFormatted={showFormatted}
|
||||
ensureDetail={ensureDetail}
|
||||
ensureDetails={ensureDetails}
|
||||
ensureThread={ensureThread}
|
||||
@@ -880,6 +894,12 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
canExport={!!email?.blobId}
|
||||
canTag={keywordDefs.length > 0}
|
||||
renderDark={activeRenderDark}
|
||||
plainView={activeHasHtml ? activePlainView : null}
|
||||
onTogglePlainView={() => {
|
||||
setMoreMenuOpen(false);
|
||||
if (!email) return;
|
||||
setPlainOverrides((prev) => ({ ...prev, [email.id]: !activePlainView }));
|
||||
}}
|
||||
hasSender={!!email?.from?.[0]?.email}
|
||||
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
||||
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
||||
@@ -971,6 +991,8 @@ interface EmailPaneProps {
|
||||
currentMailboxRole: string | null;
|
||||
identities: Identity[];
|
||||
themeOverrides: Record<string, 'light' | 'dark'>;
|
||||
plainOverrides: Record<string, boolean>;
|
||||
onShowFormatted: (email: Email) => void;
|
||||
ensureDetail: (id: string) => Promise<Email | null>;
|
||||
ensureDetails: (ids: string[]) => void;
|
||||
ensureThread: (threadId: string) => void;
|
||||
@@ -993,7 +1015,8 @@ interface EmailPaneProps {
|
||||
// members' headers; bodies are only downloaded for the cards that are open.
|
||||
function EmailPane({
|
||||
id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint,
|
||||
threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails,
|
||||
threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, plainOverrides, onShowFormatted,
|
||||
ensureDetail, ensureDetails,
|
||||
ensureThread, scheduleMarkRead, styles, bottomBarHeight, kbObstruction, onToggleStar, onAddressPress,
|
||||
onEmailPatched, onReply, onSwipe, onZoomChange,
|
||||
}: EmailPaneProps) {
|
||||
@@ -1219,6 +1242,8 @@ function EmailPane({
|
||||
currentMailboxRole={currentMailboxRole}
|
||||
active={active}
|
||||
themeOverride={themeOverrides[m.id] ?? null}
|
||||
plainView={plainOverrides[m.id] ?? null}
|
||||
onShowFormatted={onShowFormatted}
|
||||
onSwipe={onSwipe}
|
||||
onZoomChange={onPaneZoom}
|
||||
onToggleStar={onToggleStar}
|
||||
@@ -1307,6 +1332,9 @@ interface MoreMenuSheetProps {
|
||||
canViewSource: boolean;
|
||||
canExport: boolean;
|
||||
renderDark: boolean;
|
||||
/** The active message read as plain text; null when it has no HTML to switch from. */
|
||||
plainView: boolean | null;
|
||||
onTogglePlainView: () => void;
|
||||
hasSender: boolean;
|
||||
onArchive: () => void;
|
||||
onToggleUnread: () => void;
|
||||
@@ -1324,7 +1352,7 @@ interface MoreMenuSheetProps {
|
||||
|
||||
function MoreMenuSheet({
|
||||
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
|
||||
showSpam, isInJunk, canViewSource, canExport, renderDark, hasSender,
|
||||
showSpam, isInJunk, canViewSource, canExport, renderDark, plainView, onTogglePlainView, hasSender,
|
||||
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions,
|
||||
onForwardAsAttachment, onRules, onViewSource, onExport,
|
||||
}: MoreMenuSheetProps) {
|
||||
@@ -1430,6 +1458,13 @@ function MoreMenuSheet({
|
||||
onPress={onToggleSpam}
|
||||
/>
|
||||
)}
|
||||
{plainView !== null && (
|
||||
<MoreMenuItem
|
||||
icon={plainView ? <Type size={18} color={c.textSecondary} /> : <FileText size={18} color={c.textSecondary} />}
|
||||
label={plainView ? t('email_viewer.show_formatted', 'Show formatted') : t('email_viewer.show_as_plain_text', 'Show as plain text')}
|
||||
onPress={onTogglePlainView}
|
||||
/>
|
||||
)}
|
||||
<MoreMenuItem
|
||||
icon={renderDark ? <Sun size={18} color={c.textSecondary} /> : <Moon size={18} color={c.textSecondary} />}
|
||||
label={renderDark ? t('email_viewer.view_light_mode', 'View in light mode') : t('email_viewer.view_dark_mode', 'View in dark mode')}
|
||||
|
||||
Reference in New Issue
Block a user