mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 16:07:32 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user