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')} - +