From 13cba6485415899f09520741e981ec827061df71 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:03:08 +0200 Subject: [PATCH] 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 :" 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. --- locales/rn/en.json | 5 +++ src/components/__tests__/screens-i18n.test.ts | 8 +++++ .../email/CalendarInvitationBanner.tsx | 6 ++-- src/components/email/MessageHeader.tsx | 34 +++++++++++++++---- src/components/email/QuickReplyBox.tsx | 15 ++++++-- src/lib/quote-header.ts | 14 ++++++++ src/screens/ComposeScreen.tsx | 10 ++---- src/screens/EmailThreadScreen.tsx | 26 ++++++++++++-- 8 files changed, 96 insertions(+), 22 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index dcc8a4e3..1e6415fd 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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", diff --git a/src/components/__tests__/screens-i18n.test.ts b/src/components/__tests__/screens-i18n.test.ts index effbec40..3687d599 100644 --- a/src/components/__tests__/screens-i18n.test.ts +++ b/src/components/__tests__/screens-i18n.test.ts @@ -47,6 +47,14 @@ const TRANSLATED_FILES: Record = { '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', () => { diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index e400d4e2..3534354c 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -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; diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index c46db46f..b8e4ce3a 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -61,13 +61,16 @@ function authTone(result?: string): 'ok' | 'warn' | 'bad' | 'none' { } function AuthChip({ label, result, styles, c }: { label: string; result?: string; styles: ReturnType; 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 ( - {label} {result ?? '-'} + + {label} {result ? t(`email_viewer.authentication.result.${result}`, result) : '-'} + ); } @@ -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 ( - onAddressPress(from) : undefined} hitSlop={4}> + onAddressPress(from) : undefined} + hitSlop={4} + accessibilityRole="button" + accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} + > @@ -165,7 +177,13 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA {!compact && email.size > 0 ? ` · ${formatSize(email.size)}` : ''} {onToggleStar && ( - onToggleStar(email)} hitSlop={8} style={styles.star}> + onToggleStar(email)} + hitSlop={8} + style={styles.star} + accessibilityRole="button" + accessibilityLabel={starred ? t('email_viewer.unstar', 'Unstar') : t('email_viewer.star', 'Star')} + > )} @@ -237,7 +255,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA 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 `> ${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, }), diff --git a/src/lib/quote-header.ts b/src/lib/quote-header.ts index 7c2a2703..7b62edcc 100644 --- a/src/lib/quote-header.ts +++ b/src/lib/quote-header.ts @@ -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; diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index e0eebb3f..270d0c04 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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(() => ({ - 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(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 diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index f510836f..104f3852 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -676,7 +676,13 @@ function EmailViewer({ route, navigation }: Props) { {/* Toolbar */} - navigation.goBack()} style={styles.toolbarBack} hitSlop={8}> + navigation.goBack()} + style={styles.toolbarBack} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.back', 'Back')} + > @@ -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')} > {t('email_viewer.more_actions', 'More actions')} - + @@ -1389,7 +1403,13 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: {t('email_viewer.tag', 'Tag')} - +