feat: open a message in a reader view with the app's font, spacing and colours

This commit is contained in:
Linus Rath
2026-10-29 17:53:59 +01:00
parent af3019ea2b
commit caed2d313a
32 changed files with 213 additions and 34 deletions
@@ -274,3 +274,35 @@ describe('pausing animated images', () => {
expect(await frameKey()).not.toContain('/p');
});
});
describe('reader toolbar actions', () => {
async function openMenu() {
await act(async () => { screen.getByRole('button', { name: 'more_actions' }).click(); });
}
const item = (name: string) => screen.getAllByRole('menuitem').find((el) => el.textContent?.includes(name));
it('switches one HTML message to plain text and back', async () => {
const { container } = await show(message({ html: '<p>Fancy <b>HTML</b> version</p>', text: 'The plain version' }));
await openMenu();
await act(async () => { item('show_as_plain_text')!.click(); });
expect(container.querySelector('iframe')).toBeNull();
expect(screen.getByText('The plain version')).toBeInTheDocument();
await openMenu();
expect(item('show_formatted')).toBeTruthy();
});
it('opens the reader view as a dialog that Escape closes, with focus back', async () => {
await show(message({ html: '<p>Read <a href="https://ex.com/r">this</a> in peace.</p>' }));
const more = screen.getByRole('button', { name: 'more_actions' });
await openMenu();
await act(async () => { item('reader_view')!.click(); });
const dialog = screen.getByRole('dialog', { name: 'Bonjour' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(screen.getByTestId('reader-view-body').textContent).toContain('Read this (https://ex.com/r) in peace.');
expect(dialog.contains(document.activeElement)).toBe(true);
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
expect(screen.queryByRole('dialog', { name: 'Bonjour' })).toBeNull();
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
expect(document.activeElement === more || more.contains(document.activeElement)).toBe(true);
});
});
+30 -5
View File
@@ -13,6 +13,7 @@ import { applyMailTextSize, mailFitOptions } from "@/lib/mail-reading";
import { useMailReading } from "@/hooks/use-mail-reading";
import { useReadAloud } from "@/hooks/use-read-aloud";
import { ReadAloudBar } from "./read-aloud-bar";
import { ReaderViewDialog } from "./reader-view-dialog";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
@@ -108,6 +109,7 @@ import {
Minimize2,
Type,
Volume2,
BookOpen,
ArrowLeft,
Pencil,
} from "@/components/icons";
@@ -2224,6 +2226,7 @@ export function EmailViewer({
setShowSourceModal(false);
setEmailViewDarkOverride(null);
setPlainViewOverride(null);
setReaderViewOpen(false);
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
@@ -2860,8 +2863,8 @@ export function EmailViewer({
const [plainViewOverride, setPlainViewOverride] = useState<boolean | null>(null);
const plainView = plainViewOverride ?? mailViewMode === 'plain';
const hasHtmlBody = decodedEmailContent.isHtml;
const effectiveEmailContent = useMemo(() => {
if (!plainView || !decodedEmailContent.isHtml) return decodedEmailContent;
// The decoded body as plain text: the text part, else the HTML converted.
const readableText = useCallback(() => {
let text: string | null = null;
if (decodedEmailContent === emailContent && email) {
const textPart = email.textBody?.[0];
@@ -2869,14 +2872,23 @@ export function EmailViewer({
text = email.bodyValues?.[textPart.partId]?.value ?? null;
}
}
if (!text?.trim()) text = htmlToReadableText(decodedEmailContent.html);
return text?.trim() ? text : htmlToReadableText(decodedEmailContent.html);
}, [decodedEmailContent, emailContent, email]);
const effectiveEmailContent = useMemo(() => {
if (!plainView || !decodedEmailContent.isHtml) return decodedEmailContent;
return {
html: collapsePlainTextQuotes(plainTextToSafeHtml(text), { show: t('show_quoted_text'), hide: t('hide_quoted_text') }),
html: collapsePlainTextQuotes(plainTextToSafeHtml(readableText()), { show: t('show_quoted_text'), hide: t('hide_quoted_text') }),
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
}, [plainView, decodedEmailContent, emailContent, email, t]);
}, [plainView, decodedEmailContent, readableText, t]);
// Reader view: the message full screen in the app's own look.
const [readerViewOpen, setReaderViewOpen] = useState(false);
const readerViewHtml = useMemo(() => {
if (!readerViewOpen) return '';
return decodedEmailContent.isHtml ? plainTextToSafeHtml(readableText()) : decodedEmailContent.html;
}, [readerViewOpen, decodedEmailContent, readableText]);
const resolveAttachmentName = useCallback(
(attachment: EffectiveAttachment) => {
@@ -4168,6 +4180,9 @@ export function EmailViewer({
...(readAloud.supported && !isBodyLoading
? [{ kind: 'item' as const, key: 'extra:read_aloud' as const, icon: Volume2, label: t('read_aloud'), run: readAloud.start }]
: []),
...(!isBodyLoading
? [{ kind: 'item' as const, key: 'extra:reader_view' as const, icon: BookOpen, label: t('reader_view'), run: () => { readAloud.stop(); setReaderViewOpen(true); } }]
: []),
...(hasHtmlBody
? [{ kind: 'item' as const, key: 'extra:plain_text' as const, icon: plainView ? Type : FileText, label: plainView ? t('show_formatted') : t('show_as_plain_text'), run: () => setPlainViewOverride(!plainView) }]
: []),
@@ -6232,6 +6247,16 @@ export function EmailViewer({
)}
style={isDark ? { backgroundColor: '#121212' } : undefined}>
<ReadAloudBar state={readAloud.state} onPause={readAloud.pause} onResume={readAloud.resume} onStop={readAloud.stop} />
{readerViewOpen && (
<ReaderViewDialog
isOpen
onClose={() => setReaderViewOpen(false)}
title={email?.subject || t('no_subject')}
description={frameSender ? (frameSender.name ? `${frameSender.name} <${frameSender.email}>` : frameSender.email) : undefined}
html={readerViewHtml}
lang={bodyLanguage}
/>
)}
{!isBodyLoading && plainView && hasHtmlBody && (
<div className="flex flex-wrap items-center gap-x-2 px-6 pt-3 text-sm text-muted-foreground" data-testid="plain-view-note">
<span>{t('shown_as_plain_text')}</span>
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { Dialog } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Volume2 } from "@/components/icons";
import { useMailReading } from "@/hooks/use-mail-reading";
import { useReadAloud } from "@/hooks/use-read-aloud";
import { sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { ReadAloudBar } from "./read-aloud-bar";
interface ReaderViewDialogProps {
isOpen: boolean;
onClose: () => void;
/** The subject, as the dialog's title. */
title: string;
/** Who sent it, under the title. */
description?: string;
/** The body as safe HTML from plain text (plainTextToSafeHtml). */
html: string;
/** The body's language, for screen readers and the speech voice. */
lang?: string | null;
}
/**
* Reader view: one message, large, in the app's font, text spacing, reading
* width and colours instead of the sender's layout, with read aloud built in.
* A proper dialog: the focus moves in and stays there, Escape closes it and
* the focus goes back to where it was.
*/
export function ReaderViewDialog({ isOpen, onClose, title, description, html, lang }: ReaderViewDialogProps) {
const t = useTranslations('email_viewer');
const reading = useMailReading();
const articleRef = useRef<HTMLDivElement>(null);
const getRoot = useCallback(() => articleRef.current, []);
const readAloud = useReadAloud({ getRoot, lang, resetKey: isOpen ? html : null });
const body = useMemo(() => ({ __html: sanitizePlainTextRenderedHtml(html) }), [html]);
const close = () => {
readAloud.stop();
onClose();
};
return (
<Dialog
isOpen={isOpen}
onClose={close}
title={title}
description={description}
className="max-w-none h-full"
bodyClassName="flex-1"
testId="reader-view"
>
<div className="flex flex-col gap-4">
{readAloud.supported && (
readAloud.state === 'idle' ? (
<div>
<Button variant="outline" size="sm" onClick={readAloud.start}>
<Volume2 className="w-4 h-4 me-1.5" aria-hidden="true" />
{t('read_aloud')}
</Button>
</div>
) : (
<ReadAloudBar
state={readAloud.state}
onPause={readAloud.pause}
onResume={readAloud.resume}
onStop={readAloud.stop}
className="rounded-md border"
/>
)
)}
<div
ref={articleRef}
lang={lang ?? undefined}
dir="auto"
className="text-foreground [&_a]:text-primary [&_a]:underline"
style={{
fontSize: reading.plainFontSize(18),
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
textAlign: 'start',
...(reading.comfortableWidth && { maxWidth: '80ch' }),
}}
data-testid="reader-view-body"
dangerouslySetInnerHTML={body}
/>
</div>
</Dialog>
);
}
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "القراءة متوقفة مؤقتًا",
"read_aloud_pause": "إيقاف مؤقت",
"read_aloud_resume": "استئناف",
"read_aloud_stop": "إيقاف القراءة"
"read_aloud_stop": "إيقاف القراءة",
"reader_view": "عرض القراءة"
},
"external_mail": {
"sender_label": "خارجي",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Lectura en pausa",
"read_aloud_pause": "Pausa",
"read_aloud_resume": "Reprèn",
"read_aloud_stop": "Atura la lectura"
"read_aloud_stop": "Atura la lectura",
"reader_view": "Vista de lectura"
},
"external_mail": {
"sender_label": "Extern",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Předčítání pozastaveno",
"read_aloud_pause": "Pozastavit",
"read_aloud_resume": "Pokračovat",
"read_aloud_stop": "Ukončit předčítání"
"read_aloud_stop": "Ukončit předčítání",
"reader_view": "Zobrazení pro čtení"
},
"external_mail": {
"sender_label": "Externí",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Oplæsning sat på pause",
"read_aloud_pause": "Pause",
"read_aloud_resume": "Fortsæt",
"read_aloud_stop": "Stop oplæsning"
"read_aloud_stop": "Stop oplæsning",
"reader_view": "Læsevisning"
},
"external_mail": {
"sender_label": "Ekstern",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Vorlesen angehalten",
"read_aloud_pause": "Anhalten",
"read_aloud_resume": "Fortsetzen",
"read_aloud_stop": "Vorlesen beenden"
"read_aloud_stop": "Vorlesen beenden",
"reader_view": "Leseansicht"
},
"external_mail": {
"sender_label": "Extern",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Reading paused",
"read_aloud_pause": "Pause",
"read_aloud_resume": "Resume",
"read_aloud_stop": "Stop reading"
"read_aloud_stop": "Stop reading",
"reader_view": "Reader view"
},
"external_mail": {
"sender_label": "External",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Lectura en pausa",
"read_aloud_pause": "Pausar",
"read_aloud_resume": "Reanudar",
"read_aloud_stop": "Detener la lectura"
"read_aloud_stop": "Detener la lectura",
"reader_view": "Vista de lectura"
},
"external_mail": {
"sender_label": "Externo",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Ettelugemine on peatatud",
"read_aloud_pause": "Peata",
"read_aloud_resume": "Jätka",
"read_aloud_stop": "Lõpeta ettelugemine"
"read_aloud_stop": "Lõpeta ettelugemine",
"reader_view": "Lugemisvaade"
},
"external_mail": {
"sender_label": "Väline",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "خواندن متوقف شد",
"read_aloud_pause": "توقف",
"read_aloud_resume": "ادامه",
"read_aloud_stop": "پایان خواندن"
"read_aloud_stop": "پایان خواندن",
"reader_view": "نمای مطالعه"
},
"external_mail": {
"sender_label": "خارجی",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Lecture en pause",
"read_aloud_pause": "Pause",
"read_aloud_resume": "Reprendre",
"read_aloud_stop": "Arrêter la lecture"
"read_aloud_stop": "Arrêter la lecture",
"reader_view": "Mode lecture"
},
"external_mail": {
"sender_label": "Externe",
+2 -1
View File
@@ -747,7 +747,8 @@
"read_aloud_paused": "ההקראה מושהית",
"read_aloud_pause": "השהיה",
"read_aloud_resume": "המשך",
"read_aloud_stop": "עצירת ההקראה"
"read_aloud_stop": "עצירת ההקראה",
"reader_view": "תצוגת קריאה"
},
"external_mail": {
"sender_label": "חיצוני",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Felolvasás szüneteltetve",
"read_aloud_pause": "Szünet",
"read_aloud_resume": "Folytatás",
"read_aloud_stop": "Felolvasás leállítása"
"read_aloud_stop": "Felolvasás leállítása",
"reader_view": "Olvasó nézet"
},
"external_mail": {
"sender_label": "Külső",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Lettura in pausa",
"read_aloud_pause": "Pausa",
"read_aloud_resume": "Riprendi",
"read_aloud_stop": "Interrompi la lettura"
"read_aloud_stop": "Interrompi la lettura",
"reader_view": "Modalità lettura"
},
"external_mail": {
"sender_label": "Esterno",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "読み上げを一時停止中",
"read_aloud_pause": "一時停止",
"read_aloud_resume": "再開",
"read_aloud_stop": "読み上げを停止"
"read_aloud_stop": "読み上げを停止",
"reader_view": "リーダー表示"
},
"external_mail": {
"sender_label": "外部",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "읽기 일시 중지됨",
"read_aloud_pause": "일시 중지",
"read_aloud_resume": "다시 시작",
"read_aloud_stop": "읽기 중지"
"read_aloud_stop": "읽기 중지",
"reader_view": "읽기 보기"
},
"external_mail": {
"sender_label": "외부",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Nolasīšana pauzēta",
"read_aloud_pause": "Pauzēt",
"read_aloud_resume": "Turpināt",
"read_aloud_stop": "Beigt nolasīšanu"
"read_aloud_stop": "Beigt nolasīšanu",
"reader_view": "Lasīšanas skats"
},
"external_mail": {
"sender_label": "Ārējs",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Уншихыг түр зогсоосон",
"read_aloud_pause": "Түр зогсоох",
"read_aloud_resume": "Үргэлжлүүлэх",
"read_aloud_stop": "Уншихыг зогсоох"
"read_aloud_stop": "Уншихыг зогсоох",
"reader_view": "Унших харагдац"
},
"external_mail": {
"sender_label": "Гадаад",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Opplesning satt på pause",
"read_aloud_pause": "Pause",
"read_aloud_resume": "Fortsett",
"read_aloud_stop": "Stopp opplesning"
"read_aloud_stop": "Stopp opplesning",
"reader_view": "Lesevisning"
},
"external_mail": {
"sender_label": "Ekstern",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Voorlezen gepauzeerd",
"read_aloud_pause": "Pauzeren",
"read_aloud_resume": "Hervatten",
"read_aloud_stop": "Voorlezen stoppen"
"read_aloud_stop": "Voorlezen stoppen",
"reader_view": "Leesweergave"
},
"external_mail": {
"sender_label": "Extern",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Czytanie wstrzymane",
"read_aloud_pause": "Wstrzymaj",
"read_aloud_resume": "Wznów",
"read_aloud_stop": "Zakończ czytanie"
"read_aloud_stop": "Zakończ czytanie",
"reader_view": "Widok do czytania"
},
"external_mail": {
"sender_label": "Zewnętrzny",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Leitura em pausa",
"read_aloud_pause": "Pausar",
"read_aloud_resume": "Retomar",
"read_aloud_stop": "Parar a leitura"
"read_aloud_stop": "Parar a leitura",
"reader_view": "Vista de leitura"
},
"external_mail": {
"sender_label": "Externo",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Citire întreruptă",
"read_aloud_pause": "Pauză",
"read_aloud_resume": "Reluați",
"read_aloud_stop": "Opriți citirea"
"read_aloud_stop": "Opriți citirea",
"reader_view": "Vizualizare pentru citire"
},
"external_mail": {
"sender_label": "Extern",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Чтение приостановлено",
"read_aloud_pause": "Пауза",
"read_aloud_resume": "Продолжить",
"read_aloud_stop": "Остановить чтение"
"read_aloud_stop": "Остановить чтение",
"reader_view": "Режим чтения"
},
"external_mail": {
"sender_label": "Внешнее",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Čítanie pozastavené",
"read_aloud_pause": "Pozastaviť",
"read_aloud_resume": "Pokračovať",
"read_aloud_stop": "Zastaviť čítanie"
"read_aloud_stop": "Zastaviť čítanie",
"reader_view": "Zobrazenie na čítanie"
},
"external_mail": {
"sender_label": "Externé",
+2 -1
View File
@@ -1712,7 +1712,8 @@
"read_aloud_paused": "หยุดการอ่านชั่วคราว",
"read_aloud_pause": "หยุดชั่วคราว",
"read_aloud_resume": "อ่านต่อ",
"read_aloud_stop": "หยุดอ่าน"
"read_aloud_stop": "หยุดอ่าน",
"reader_view": "มุมมองการอ่าน"
},
"external_mail": {
"sender_label": "ภายนอก",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Okuma duraklatıldı",
"read_aloud_pause": "Duraklat",
"read_aloud_resume": "Devam et",
"read_aloud_stop": "Okumayı durdur"
"read_aloud_stop": "Okumayı durdur",
"reader_view": "Okuma görünümü"
},
"external_mail": {
"sender_label": "Harici",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "Читання призупинено",
"read_aloud_pause": "Пауза",
"read_aloud_resume": "Продовжити",
"read_aloud_stop": "Зупинити читання"
"read_aloud_stop": "Зупинити читання",
"reader_view": "Режим читання"
},
"external_mail": {
"sender_label": "Зовнішнє",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "朗讀已暫停",
"read_aloud_pause": "暫停",
"read_aloud_resume": "繼續",
"read_aloud_stop": "停止朗讀"
"read_aloud_stop": "停止朗讀",
"reader_view": "閱讀檢視"
},
"external_mail": {
"sender_label": "外部",
+2 -1
View File
@@ -782,7 +782,8 @@
"read_aloud_paused": "朗读已暂停",
"read_aloud_pause": "暂停",
"read_aloud_resume": "继续",
"read_aloud_stop": "停止朗读"
"read_aloud_stop": "停止朗读",
"reader_view": "阅读视图"
},
"external_mail": {
"sender_label": "外部",