fix: translate the viewer's leftovers and label its icon buttons

The message details panel built its received line, attachment summary
and spam score by gluing strings together; each is now one ICU message
(the attachment count is a proper plural). The SPF/DKIM/DMARC results
use the webmail's translated result names, and an invitation's date is
formatted in the app language instead of always in English.

The quick reply box wrote its own "date, name <email>:" quote header;
it now uses the composer's translated "On {date}, {from} wrote:" line
so both kinds of reply look the same.

The back button, the subject and header star buttons, the sender
avatar and the More/Tag sheet close buttons get screen reader labels,
and the screens i18n test covers the viewer files.
This commit is contained in:
Linus Rath
2026-09-24 21:44:46 +02:00
parent e3501f939d
commit 13cba64854
8 changed files with 96 additions and 22 deletions
+5
View File
@@ -351,6 +351,11 @@
},
"delete_permanently_message": "This message will be deleted permanently and cannot be recovered.",
"delete_permanently_title": "Delete permanently?",
"details": {
"attachment_count_size": "{count, plural, one {# file} other {# files}} · {size}",
"received_after_delay": "{date} · {delay} delivery time",
"spam_score_value": "{score} ({status})"
},
"export_failed": "Export failed",
"forwarded": "Forwarded",
"load_failed": "Failed to load email",
@@ -47,6 +47,14 @@ const TRANSLATED_FILES: Record<string, string[]> = {
'components/email/ListAttachmentChips.tsx': [],
'components/email/ReadReceiptBanner.tsx': [],
'components/email/UnsubscribeBanner.tsx': [],
'screens/EmailThreadScreen.tsx': [],
// Protocol names on the authentication chips.
'components/email/MessageHeader.tsx': ['SPF', 'DKIM', 'DMARC'],
'components/email/MessageContent.tsx': [],
'components/email/ThreadMessageCard.tsx': [],
'components/email/QuickReplyBox.tsx': [],
'components/email/AttachmentChips.tsx': [],
'components/email/AttachmentPreviewModal.tsx': [],
};
describe('translated screens', () => {
@@ -27,6 +27,7 @@ import {
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
import { canCreateEventsIn } from '../../lib/calendar-editability';
import { getCalendarColor, timePattern } from '../../lib/calendar-utils';
import { getDateFnsLocale } from '../../lib/calendar-locale';
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
@@ -63,6 +64,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const dateLocale = getDateFnsLocale(useLocaleStore((s) => s.locale));
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const calendars = useCalendarStore((s) => s.calendars);
@@ -173,8 +175,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
const startDate = startStr ? parseISO(startStr) : null;
const dateLabel = startDate && !isNaN(startDate.getTime())
? (event.showWithoutTime
? format(startDate, 'EEEE, MMM d, yyyy')
: format(startDate, `EEE, MMM d · ${timePattern(timeFormat)}`))
? format(startDate, 'EEEE, MMM d, yyyy', { locale: dateLocale })
: format(startDate, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale: dateLocale }))
: null;
const organizer = getOrganizerName(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
+28 -6
View File
@@ -61,13 +61,16 @@ function authTone(result?: string): 'ok' | 'warn' | 'bad' | 'none' {
}
function AuthChip({ label, result, styles, c }: { label: string; result?: string; styles: ReturnType<typeof makeStyles>; c: ThemePalette }) {
const t = useLocaleStore((s) => s.t);
const tone = authTone(result);
const color = tone === 'ok' ? c.success : tone === 'bad' ? c.error : tone === 'warn' ? c.warning : c.textMuted;
const Icon = tone === 'ok' ? ShieldCheck : tone === 'bad' ? ShieldAlert : ShieldQuestion;
return (
<View style={[styles.chip, { borderColor: color }]}>
<Icon size={12} color={color} />
<Text style={[styles.chipText, { color }]}>{label} {result ?? '-'}</Text>
<Text style={[styles.chipText, { color }]}>
{label} {result ? t(`email_viewer.authentication.result.${result}`, result) : '-'}
</Text>
</View>
);
}
@@ -131,13 +134,22 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
const auth: AuthenticationResults | undefined = info.auth;
const hasAuth = !!(auth && (auth.spf || auth.dkim || auth.dmarc || auth.iprev));
const attachmentsSummary = email.attachments?.length
? `${email.attachments.length} · ${formatSize(email.attachments.reduce((n, a) => n + (a.size ?? 0), 0))}`
? t('email_viewer.details.attachment_count_size', '{count, plural, one {# file} other {# files}} · {size}', {
count: email.attachments.length,
size: formatSize(email.attachments.reduce((n, a) => n + (a.size ?? 0), 0)),
})
: null;
const receivedAt = formatFullDateTime(email.receivedAt, timeFormat, locale);
return (
<View style={styles.block}>
<View style={styles.row}>
<Pressable onPress={from ? () => onAddressPress(from) : undefined} hitSlop={4}>
<Pressable
onPress={from ? () => onAddressPress(from) : undefined}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
>
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
</Pressable>
<View style={styles.info}>
@@ -165,7 +177,13 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
{!compact && email.size > 0 ? ` · ${formatSize(email.size)}` : ''}
</Text>
{onToggleStar && (
<Pressable onPress={() => onToggleStar(email)} hitSlop={8} style={styles.star}>
<Pressable
onPress={() => onToggleStar(email)}
hitSlop={8}
style={styles.star}
accessibilityRole="button"
accessibilityLabel={starred ? t('email_viewer.unstar', 'Unstar') : t('email_viewer.star', 'Star')}
>
<Star size={16} color={starred ? c.starred : c.textMuted} fill={starred ? c.starred : 'transparent'} />
</Pressable>
)}
@@ -237,7 +255,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
<DetailRow label={t('email_viewer.details.sent', 'Sent')} value={formatFullDateTime(email.sentAt, timeFormat, locale)} styles={styles} />
<DetailRow
label={t('email_viewer.details.received', 'Received')}
value={`${formatFullDateTime(email.receivedAt, timeFormat, locale)}${delta !== null && delta > 60000 ? ` · ${formatDelta(delta)} ${t('email_viewer.details.delivery_time', 'Delivery time').toLowerCase()}` : ''}`}
value={delta !== null && delta > 60000
? t('email_viewer.details.received_after_delay', '{date} · {delay} delivery time', { date: receivedAt, delay: formatDelta(delta) })
: receivedAt}
styles={styles}
/>
@@ -258,7 +278,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
<DetailRow label={t('email_viewer.authentication.policy', 'Policy')} value={auth?.dmarc?.policy} styles={styles} />
<DetailRow
label={t('email_viewer.authentication.spam_score', 'Spam Score')}
value={info.spamScore ? `${info.spamScore.score} (${info.spamScore.status})` : undefined}
value={info.spamScore
? t('email_viewer.details.spam_score_value', '{score} ({status})', { score: info.spamScore.score, status: info.spamScore.status })
: undefined}
styles={styles}
/>
<DetailRow
+12 -3
View File
@@ -19,7 +19,8 @@ import { signatureIdentityFor, signPlainTextReply } from '../../lib/signature-ut
import { pickEmailBody, plainTextBody } from '../../lib/email-body';
import { htmlToPlainText } from '../../lib/compose-html';
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
import { emailDisplayDate, formatFullDateTime } from '../../lib/email-date';
import { emailDisplayDate } from '../../lib/email-date';
import { buildQuoteHeader, quoteHeaderLabels } from '../../lib/quote-header';
interface Props {
email: Email;
@@ -87,7 +88,15 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
const picked = pickEmailBody(email);
const original = picked.text ?? (picked.html ? htmlToPlainText(picked.html) : plainTextBody(email));
const quoted = original.split('\n').map((l) => `> ${l}`).join('\n');
const header = `${formatFullDateTime(emailDisplayDate(email), timeFormat, locale)}, ${from.name ? `${from.name} <${from.email}>` : from.email}:`;
// The header the composer puts above a quoted reply ("On …, X wrote:").
const header = buildQuoteHeader({
mode: 'reply',
email: { from, subject: email.subject, receivedAt: emailDisplayDate(email) },
timeFormat,
locale,
unknownLabel: t('common.unknown', 'Unknown'),
labels: quoteHeaderLabels(t),
});
const threading = computeReplyThreadingHeaders(email);
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId);
const result = await sendEmail(
@@ -98,7 +107,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
subject: buildReplySubject(email.subject, t('email_composer.prefix.reply', 'Re:')),
// Signed as the composer signs a plain-text reply; an alias without a
// signature of its own carries the primary identity's.
textBody: signPlainTextReply(body, `${header}\n${quoted}`, signatureIdentityFor(identity, identities), {
textBody: signPlainTextReply(body, `${header.text}${quoted}`, signatureIdentityFor(identity, identities), {
position: signaturePosition,
separator: signatureSeparatorEnabled,
}),
+14
View File
@@ -27,6 +27,20 @@ export const DEFAULT_QUOTE_HEADER_LABELS: QuoteHeaderLabels = {
subjectLabel: 'Subject',
};
/**
* The labels in the app language. Shared by the composer and the quick reply
* box so a reply carries the same header whichever one sent it.
*/
export function quoteHeaderLabels(t: (key: string, fallback: string) => string): QuoteHeaderLabels {
return {
replyLine: t('quote_header.reply_line', 'On {date}, {from} wrote:'),
forwardedSeparator: t('quote_header.forwarded_separator', '---------- Forwarded message ----------'),
fromLabel: t('quote_header.from_label', 'From'),
dateLabel: t('quote_header.date_label', 'Date'),
subjectLabel: t('quote_header.subject_label', 'Subject'),
};
}
export interface QuoteHeader {
html: string;
text: string;
+2 -8
View File
@@ -51,7 +51,7 @@ import {
rewriteCidImagesForEditor, replaceInlineImagePlaceholders, sniffImageMime, QUOTED_BLOCK_START,
} from '../lib/compose-html';
import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format';
import { buildQuoteHeader, formatQuoteDate, type QuoteHeaderLabels } from '../lib/quote-header';
import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header';
import {
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient,
} from '../lib/recipients';
@@ -482,13 +482,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const quoteLabels = React.useMemo<QuoteHeaderLabels>(() => ({
replyLine: t('quote_header.reply_line', 'On {date}, {from} wrote:'),
forwardedSeparator: t('quote_header.forwarded_separator', '---------- Forwarded message ----------'),
fromLabel: t('quote_header.from_label', 'From'),
dateLabel: t('quote_header.date_label', 'Date'),
subjectLabel: t('quote_header.subject_label', 'Subject'),
}), [t]);
const quoteLabels = React.useMemo<QuoteHeaderLabels>(() => quoteHeaderLabels(t), [t]);
// Every address that is "us": the login, the account's primary address and
// every identity. Reply-all must not send the user a copy, and replying to
+23 -3
View File
@@ -676,7 +676,13 @@ function EmailViewer({ route, navigation }: Props) {
<SafeAreaView style={styles.container} edges={['top']}>
{/* Toolbar */}
<View style={styles.toolbar}>
<Pressable onPress={() => navigation.goBack()} style={styles.toolbarBack} hitSlop={8}>
<Pressable
onPress={() => navigation.goBack()}
style={styles.toolbarBack}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<View style={styles.toolbarActions}>
@@ -1077,6 +1083,8 @@ function EmailPane({
onPress={email ? () => onToggleStar(email) : undefined}
hitSlop={8}
style={styles.subjectStar}
accessibilityRole="button"
accessibilityLabel={shown.keywords?.$flagged ? t('email_viewer.unstar', 'Unstar') : t('email_viewer.star', 'Star')}
>
<Star
size={18}
@@ -1246,7 +1254,13 @@ function MoreMenuSheet({
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_viewer.more_actions', 'More actions')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<Pressable
onPress={onClose}
hitSlop={8}
style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
@@ -1389,7 +1403,13 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_viewer.tag', 'Tag')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<Pressable
onPress={onClose}
hitSlop={8}
style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>