From 9c0c35c7badd3a50ca13508e860acc3241aaf87b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:33:06 +0200 Subject: [PATCH] fix: translate the mail list and label its icon buttons The filter sheet, the .eml import result, the selection count, the folder totals and the offline sync banner were English literals or built by gluing translated words to numbers. They now use t() with ICU arguments and plurals; filter chips go through a "{field}: {value}" message so translators control the order. The invitation trust warnings used keys that no catalog had, so they are now literal t() calls and harvested. For screen readers (audit U8) the hamburger, select-all checkbox, filter, sort and compose buttons, the selection toolbar, search and chip clear buttons, and the close buttons of the move, tag, template, identity, QR and action sheets get translated labels and roles. --- locales/rn/en.json | 28 ++- src/components/IdentitySheet.tsx | 5 +- src/components/MoveSheet.tsx | 5 +- src/components/OfflineCacheBanner.tsx | 46 +++-- src/components/QrScanModal.tsx | 5 +- src/components/TagSheet.tsx | 5 +- src/components/TemplateSheet.tsx | 5 +- src/components/__tests__/screens-i18n.test.ts | 21 +++ src/components/email/ActionSheet.tsx | 7 +- .../email/CalendarInvitationBanner.tsx | 41 +++-- src/screens/EmailListScreen.tsx | 174 ++++++++++++++---- src/screens/EmailSourceScreen.tsx | 17 +- src/screens/ScheduledScreen.tsx | 8 +- 13 files changed, 281 insertions(+), 86 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index f32f685c..a0e73917 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -56,6 +56,10 @@ "response_sent": "Response sent", "tentative": "Maybe", "title": "Calendar invitation", + "trust_authentication_failed": "This message failed sender authentication (SPF/DKIM/DMARC).", + "trust_authentication_missing": "The sender could not be verified.", + "trust_sender_mismatch": "The sender differs from the event organizer.", + "trust_sender_mismatch_unverified": "The sender does not match the organizer and the message is not authenticated.", "trust_verified": "Sender verified" }, "management": { @@ -266,6 +270,15 @@ "emails_archived_count": "{count, plural, one {# email} other {# emails}} archived", "emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}", "error": "Error", + "filter_chip": "{field}: {value}", + "filter_title": "Filter emails", + "folder_counts": "{total} total · {unread} unread", + "import": { + "none": "No messages were imported.", + "partial": "{imported} imported, {failed} failed.", + "success": "{count, plural, one {# message imported.} other {# messages imported.}}", + "title": "Import" + }, "marked_as_spam": "Marked as spam", "marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam", "marked_not_spam": "Marked as not spam", @@ -285,10 +298,12 @@ "outbox_discard": "Discard", "outbox_failed": "{count} changes could not be saved to the server.", "recent_searches": "Recent searches", + "remove_filter": "Remove filter {label}", "scope_all_folders": "All folders", "scope_this_folder": "This folder", "search_placeholder": "Search mail...", "showing_cached": "Showing cached mail", + "sort_toggle_hint": "Reverses the mail sort order", "swipe_move": "Move", "swipe_spam": "Spam", "total": "total", @@ -475,7 +490,18 @@ }, "offline": { "banner": "You are offline", - "queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect" + "queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect", + "sync": { + "cached_count": "{count, plural, one {# new message cached} other {# new messages cached}} • {size}", + "cancelled": "Sync cancelled", + "failed": "Offline sync failed", + "processed": "{completed}/{total} processed", + "ready": "Offline mail ready", + "syncing": "Syncing offline mail", + "title": "Offline sync", + "unable_to_download": "Unable to download", + "up_to_date": "Already up to date" + } }, "push_prompt": { "body": "Turn on background notifications so new messages reach you while the app is closed.", diff --git a/src/components/IdentitySheet.tsx b/src/components/IdentitySheet.tsx index 081e53de..65390aae 100644 --- a/src/components/IdentitySheet.tsx +++ b/src/components/IdentitySheet.tsx @@ -60,7 +60,10 @@ export function IdentitySheet({ {t('email_composer.from', 'From')} - + diff --git a/src/components/MoveSheet.tsx b/src/components/MoveSheet.tsx index cb9869e4..ac458760 100644 --- a/src/components/MoveSheet.tsx +++ b/src/components/MoveSheet.tsx @@ -92,7 +92,10 @@ export function MoveSheet({ {t('context_menu.move_to', 'Move to folder')} - + diff --git a/src/components/OfflineCacheBanner.tsx b/src/components/OfflineCacheBanner.tsx index c7c6d682..2aac398f 100644 --- a/src/components/OfflineCacheBanner.tsx +++ b/src/components/OfflineCacheBanner.tsx @@ -10,11 +10,13 @@ import { useUpdatesStore } from '../stores/updates-store'; import { formatBytes } from '../lib/offline-sync'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; +import { useLocaleStore } from '../stores/locale-store'; export function OfflineCacheBanner(): React.ReactElement | null { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); + const t = useLocaleStore((s) => s.t); const sync = useOfflineCacheStore((s) => s.sync); const requestAbort = useOfflineCacheStore((s) => s.requestAbort); const resetSync = useOfflineCacheStore((s) => s.resetSync); @@ -32,9 +34,9 @@ export function OfflineCacheBanner(): React.ReactElement | null { // permanent fixture; the Settings screen still shows the cache stats. React.useEffect(() => { if (sync.phase === 'done' || sync.phase === 'cancelled') { - const t = setTimeout(() => resetSync(), 4000); - setHideTimer(t); - return () => clearTimeout(t); + const timer = setTimeout(() => resetSync(), 4000); + setHideTimer(timer); + return () => clearTimeout(timer); } if (hideTimer) clearTimeout(hideTimer); return undefined; @@ -48,30 +50,34 @@ export function OfflineCacheBanner(): React.ReactElement | null { ? Math.min(100, Math.round((sync.completed / sync.total) * 100)) : sync.phase === 'done' ? 100 : 0; - let title = 'Offline sync'; + let title = t('offline.sync.title', 'Offline sync'); let subtitle = ''; switch (sync.phase) { case 'scanning': - title = 'Syncing offline mail'; - subtitle = 'Scanning recent messages…'; + title = t('offline.sync.syncing', 'Syncing offline mail'); + subtitle = t('settings.offline.scanning', 'Scanning recent mail…'); break; case 'fetching': - title = 'Syncing offline mail'; + title = t('offline.sync.syncing', 'Syncing offline mail'); subtitle = `${sync.completed}/${sync.total} • ${formatBytes(sync.bytes)}`; break; case 'done': - title = 'Offline mail ready'; + title = t('offline.sync.ready', 'Offline mail ready'); subtitle = sync.fetched > 0 - ? `${sync.fetched} new message${sync.fetched === 1 ? '' : 's'} cached • ${formatBytes(sync.bytes)}` - : 'Already up to date'; + ? t( + 'offline.sync.cached_count', + '{count, plural, one {# new message cached} other {# new messages cached}} • {size}', + { count: sync.fetched, size: formatBytes(sync.bytes) }, + ) + : t('offline.sync.up_to_date', 'Already up to date'); break; case 'cancelled': - title = 'Sync cancelled'; - subtitle = `${sync.completed}/${sync.total} processed`; + title = t('offline.sync.cancelled', 'Sync cancelled'); + subtitle = t('offline.sync.processed', '{completed}/{total} processed', { completed: sync.completed, total: sync.total }); break; case 'error': - title = 'Offline sync failed'; - subtitle = sync.message ?? 'Unable to download'; + title = t('offline.sync.failed', 'Offline sync failed'); + subtitle = sync.message ?? t('offline.sync.unable_to_download', 'Unable to download'); break; default: return null; @@ -94,12 +100,18 @@ export function OfflineCacheBanner(): React.ReactElement | null { )} {showCancel && ( - - Cancel + + {t('common.cancel', 'Cancel')} )} {showDismiss && ( - + )} diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx index dfcb1ecc..cc458cd7 100644 --- a/src/components/QrScanModal.tsx +++ b/src/components/QrScanModal.tsx @@ -76,7 +76,10 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { - + {t('login.mobile.qr_title', 'Sign-in code')} diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index 0a9734ed..19b94fbe 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -83,7 +83,10 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle {t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })} - + diff --git a/src/components/TemplateSheet.tsx b/src/components/TemplateSheet.tsx index 3737552b..4d0cb034 100644 --- a/src/components/TemplateSheet.tsx +++ b/src/components/TemplateSheet.tsx @@ -79,7 +79,10 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) {t('templates.picker_title', 'Choose a Template')} - + diff --git a/src/components/__tests__/screens-i18n.test.ts b/src/components/__tests__/screens-i18n.test.ts index aa81c6b4..0ad50169 100644 --- a/src/components/__tests__/screens-i18n.test.ts +++ b/src/components/__tests__/screens-i18n.test.ts @@ -12,6 +12,27 @@ const TRANSLATED_FILES: Record = { 'screens/FilesScreen.tsx': [], 'components/files/ShareSheet.tsx': [], 'components/files/FilePreviewModal.tsx': [], + // The empty date filter shows the input format. + 'screens/EmailListScreen.tsx': ['YYYY-MM-DD'], + 'screens/ScheduledScreen.tsx': [], + 'screens/EmailSourceScreen.tsx': [], + 'components/OfflineBanner.tsx': [], + 'components/OfflineCacheBanner.tsx': [], + 'components/UndoSnackbar.tsx': [], + 'components/UpdateBanner.tsx': [], + 'components/ToastHost.tsx': [], + 'components/PushOnboardingPrompt.tsx': [], + 'components/MoveSheet.tsx': [], + 'components/TagSheet.tsx': [], + 'components/TemplateSheet.tsx': [], + 'components/IdentitySheet.tsx': [], + 'components/QrScanModal.tsx': [], + 'components/email/ActionSheet.tsx': [], + 'components/email/AddressActionSheet.tsx': [], + 'components/email/CalendarInvitationBanner.tsx': [], + 'components/email/ListAttachmentChips.tsx': [], + 'components/email/ReadReceiptBanner.tsx': [], + 'components/email/UnsubscribeBanner.tsx': [], }; describe('translated screens', () => { diff --git a/src/components/email/ActionSheet.tsx b/src/components/email/ActionSheet.tsx index 0001ae2c..6c83cf95 100644 --- a/src/components/email/ActionSheet.tsx +++ b/src/components/email/ActionSheet.tsx @@ -5,6 +5,7 @@ import { X } from 'lucide-react-native'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useSheetDrag } from '../../lib/use-sheet-drag'; +import { useLocaleStore } from '../../stores/locale-store'; export interface ActionSheetItem { key: string; @@ -34,6 +35,7 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); + const t = useLocaleStore((s) => s.t); const slideY = React.useRef(new Animated.Value(500)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); @@ -72,7 +74,10 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children {title} {subtitle ? {subtitle} : null} - + diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index e4fcaca9..e400d4e2 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -39,24 +39,25 @@ interface Props { jmapAccountId?: string; } -const TRUST_REASON_KEYS: Record, [string, string]> = { - sender_mismatch_unverified: [ - 'calendar.invitation.trust_sender_mismatch_unverified', - 'The sender does not match the organizer and the message is not authenticated.', - ], - authentication_failed: [ - 'calendar.invitation.trust_authentication_failed', - 'This message failed sender authentication (SPF/DKIM/DMARC).', - ], - sender_mismatch: [ - 'calendar.invitation.trust_sender_mismatch', - 'The sender differs from the event organizer.', - ], - authentication_missing: [ - 'calendar.invitation.trust_authentication_missing', - 'The sender could not be verified.', - ], -}; +// Literal t() calls so the keys are harvested into the catalog. +function trustReasonText( + reason: NonNullable, + t: (key: string, fallback?: string) => string, +): string { + switch (reason) { + case 'sender_mismatch_unverified': + return t( + 'calendar.invitation.trust_sender_mismatch_unverified', + 'The sender does not match the organizer and the message is not authenticated.', + ); + case 'authentication_failed': + return t('calendar.invitation.trust_authentication_failed', 'This message failed sender authentication (SPF/DKIM/DMARC).'); + case 'sender_mismatch': + return t('calendar.invitation.trust_sender_mismatch', 'The sender differs from the event organizer.'); + case 'authentication_missing': + return t('calendar.invitation.trust_authentication_missing', 'The sender could not be verified.'); + } +} export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const c = useColors(); @@ -264,7 +265,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { )} {trust.reason - ? t(TRUST_REASON_KEYS[trust.reason][0], TRUST_REASON_KEYS[trust.reason][1]) + ? trustReasonText(trust.reason, t) : t('calendar.invitation.trust_verified', 'Sender verified')} @@ -287,7 +288,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { {organizer ? ( } - text={`${t('calendar.invitation.organizer', 'Organizer')}: ${organizer}`} + text={t('email_viewer.calendar_invitation.organizer', 'Organized by {name}', { name: organizer })} styles={styles} /> ) : null} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 377c57b0..7d71bef9 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -52,8 +52,8 @@ import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email'; import type { RootStackParamList } from '../navigation/types'; import type { Attachment, Email } from '../api/types'; -function getSenderName(email: Email): string { - return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown'; +function getSenderName(email: Email, unknownLabel: string): string { + return email.from?.[0]?.name || email.from?.[0]?.email || unknownLabel; } function getSenderEmail(email: Email): string | undefined { @@ -61,12 +61,12 @@ function getSenderEmail(email: Email): string | undefined { } // Sent/Drafts rows name the recipient, not "me" (webmail 1.4.12). -function getCounterpart(email: Email, showRecipient: boolean): { name: string; email?: string } { +function getCounterpart(email: Email, showRecipient: boolean, unknownLabel: string): { name: string; email?: string } { if (showRecipient) { const to = email.to?.[0] ?? email.cc?.[0] ?? email.bcc?.[0]; if (to) return { name: to.name || to.email, email: to.email }; } - return { name: getSenderName(email), email: getSenderEmail(email) }; + return { name: getSenderName(email, unknownLabel), email: getSenderEmail(email) }; } function isUnread(email: Email): boolean { @@ -128,7 +128,11 @@ const EmailRow = React.memo(function EmailRow({ const timeFormat = useSettingsStore((s) => s.timeFormat); const locale = useLocaleStore((s) => s.locale); const tr = useLocaleStore((s) => s.t); - const { name: senderName, email: senderEmail } = getCounterpart(item, showRecipient); + const { name: senderName, email: senderEmail } = getCounterpart( + item, + showRecipient, + tr('email_viewer.unknown_sender', 'Unknown'), + ); const unread = isUnread(item); const starred = isStarred(item); const pinned = isPinned(item); @@ -493,12 +497,12 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL })); const { imported, failed } = await importEmails(files, targetMailboxId); Alert.alert( - 'Import', + t('email_list.import.title', 'Import'), failed > 0 - ? `${imported} imported, ${failed} failed.` + ? t('email_list.import.partial', '{imported} imported, {failed} failed.', { imported, failed }) : imported === 0 - ? 'No messages were imported.' - : `${imported} message${imported === 1 ? '' : 's'} imported.`, + ? t('email_list.import.none', 'No messages were imported.') + : t('email_list.import.success', '{count, plural, one {# message imported.} other {# messages imported.}}', { count: imported }), ); } finally { setImporting(false); @@ -965,14 +969,23 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL {/* Header */} {selectionMode ? ( - + - {selectedIds.size} selected + + {t('email_list.batch_actions.selected_messages', '{count, plural, one {1 email} other {# emails}} selected', { count: selectedIds.size })} + { void handleBulkStar(); }} style={styles.headerButton} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={allSelectedAreStarred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star')} > { void handleBulkMarkReadToggle(); }} style={styles.headerButton} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={allSelectedAreRead + ? t('email_list.batch_actions.mark_unread', 'Mark as unread') + : t('email_list.batch_actions.mark_read', 'Mark as read')} > {allSelectedAreRead ? ( @@ -995,6 +1012,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL onPress={() => setTagSheetOpen(true)} style={styles.headerButton} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('context_menu.tag', 'Tag')} > @@ -1002,6 +1021,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL onPress={() => setBatchMoveOpen(true)} style={styles.headerButton} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('email_viewer.move', 'Move')} > @@ -1010,6 +1031,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL onPress={() => { void handleBulkSpam(); }} style={styles.headerButton} hitSlop={6} + accessibilityRole="button" accessibilityLabel={inJunk ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam')} > {inJunk ? ( @@ -1024,6 +1046,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL onPress={() => { void handleBulkArchive(); }} style={styles.headerButton} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('context_menu.archive', 'Archive')} > @@ -1032,13 +1056,20 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL onPress={() => { void handleBulkDelete(); }} style={styles.headerButton} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('email_list.batch_actions.delete', 'Delete')} > ) : ( - setDrawerOpen(true)} style={styles.headerButton}> + setDrawerOpen(true)} + style={styles.headerButton} + accessibilityRole="button" + accessibilityLabel={t('sidebar.mobile.toggle_menu', 'Toggle menu')} + > @@ -1050,6 +1081,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL style={styles.headerButton} disabled={importing} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('email_viewer.import_email', 'Import .eml or .zip')} > {importing ? ( @@ -1067,7 +1100,14 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL {/* Search bar (always visible) */} - + {allSelected ? ( ) : selectionMode ? ( @@ -1099,6 +1139,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL onPress={() => { setSearchInput(''); setSearchQuery(''); }} hitSlop={8} style={styles.searchClearButton} + accessibilityRole="button" + accessibilityLabel={t('contacts.clear_search', 'Clear search')} > @@ -1108,8 +1150,10 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL style={styles.filterButton} onPress={() => setSortAscending(!sortAscending)} accessibilityRole="button" - accessibilityLabel={sortAscending ? 'Sorted oldest first' : 'Sorted newest first'} - accessibilityHint="Reverses the mail sort order" + accessibilityLabel={sortAscending + ? t('settings.appearance.message_list_order.direction.oldest_first', 'Oldest first') + : t('settings.appearance.message_list_order.direction.newest_first', 'Newest first')} + accessibilityHint={t('email_list.sort_toggle_hint', 'Reverses the mail sort order')} > {sortAscending ? ( @@ -1120,6 +1164,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL 0 && styles.filterButtonActive]} onPress={() => setFilterMenuOpen(true)} + accessibilityRole="button" + accessibilityLabel={t('advanced_search.advanced_filters_tooltip', 'Advanced search filters')} + accessibilityValue={activeFilterCount > 0 ? { text: String(activeFilterCount) } : undefined} > 0 ? c.primary : c.textMuted} /> {activeFilterCount > 0 && ( @@ -1141,7 +1188,12 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL {q} - removeRecentSearch(q)} hitSlop={8}> + removeRecentSearch(q)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('advanced_search.suggestions_remove_recent', 'Remove from recent searches')} + > @@ -1202,28 +1254,40 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL /> ) : null} {filters.from ? ( - setFilterField('from', undefined)} /> + setFilterField('from', undefined)} + /> ) : null} {filters.to ? ( - setFilterField('to', undefined)} /> + setFilterField('to', undefined)} + /> ) : null} {filters.subject ? ( - setFilterField('subject', undefined)} /> + setFilterField('subject', undefined)} + /> ) : null} {filters.body ? ( - setFilterField('body', undefined)} /> + setFilterField('body', undefined)} + /> ) : null} {filters.dateAfter ? ( } - label={`${t('advanced_search.after', 'After')} ${filters.dateAfter}`} + label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.date_after', 'After'), value: filters.dateAfter })} onRemove={() => setFilterField('dateAfter', undefined)} /> ) : null} {filters.dateBefore ? ( } - label={`${t('advanced_search.before', 'Before')} ${filters.dateBefore}`} + label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.date_before', 'Before'), value: filters.dateBefore })} onRemove={() => setFilterField('dateBefore', undefined)} /> ) : null} @@ -1232,7 +1296,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL icon={filters.isUnread ? : } - label={filters.isUnread ? t('email_list.unread', 'Unread') : t('advanced_search.read', 'Read')} + label={filters.isUnread ? t('advanced_search.unread', 'Unread') : t('advanced_search.read', 'Read')} onRemove={() => cycleTriStateTo('isUnread', undefined)} /> )} @@ -1259,6 +1323,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL }} style={styles.clearAllButton} hitSlop={6} + accessibilityRole="button" > {t('advanced_search.clear', 'Clear')} @@ -1352,7 +1417,10 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL {currentMailbox && !hasActiveSearchOrFilter ? ( - {currentMailbox.totalEmails} {t('email_list.total', 'total')} · {currentMailbox.unreadEmails} {t('email_list.unread', 'unread')} + {t('email_list.folder_counts', '{total} total · {unread} unread', { + total: currentMailbox.totalEmails, + unread: currentMailbox.unreadEmails, + })} ) : null} @@ -1377,6 +1445,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL [styles.fab, pressed && styles.fabPressed]} + accessibilityRole="button" + accessibilityLabel={t('sidebar.mobile.compose', 'Compose')} > @@ -1395,20 +1465,23 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL - Filter emails + {t('email_list.filter_title', 'Filter emails')} setFilters({})} style={styles.filterMenuHeaderBtn} hitSlop={6} + accessibilityRole="button" > - Clear + {t('advanced_search.clear', 'Clear')} setFilterMenuOpen(false)} style={styles.filterMenuClose} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('common.close', 'Close')} > @@ -1418,11 +1491,11 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL - From + {t('advanced_search.from', 'From')} setFilterField('from', v)} - placeholder="sender@example.com" + placeholder={t('advanced_search.from_placeholder', 'Sender email or name')} placeholderTextColor={c.textMuted} autoCapitalize="none" autoCorrect={false} @@ -1430,11 +1503,11 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL /> - To + {t('advanced_search.to', 'To')} setFilterField('to', v)} - placeholder="recipient@example.com" + placeholder={t('advanced_search.to_placeholder', 'Recipient email or name')} placeholderTextColor={c.textMuted} autoCapitalize="none" autoCorrect={false} @@ -1496,7 +1569,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL - After + {t('advanced_search.date_after', 'After')} setDatePickerField('dateAfter')} @@ -1509,6 +1582,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL setFilterField('dateAfter', undefined)} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('advanced_search.clear', 'Clear')} > @@ -1516,7 +1591,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL - Before + {t('advanced_search.date_before', 'Before')} setDatePickerField('dateBefore')} @@ -1529,6 +1604,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL setFilterField('dateBefore', undefined)} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('advanced_search.clear', 'Clear')} > @@ -1540,13 +1617,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL } - label="Has attachment" + label={t('email_list.has_attachment', 'Has attachment')} value={filters.hasAttachment} onPress={() => cycleTriState('hasAttachment')} /> } - label="Starred" + label={t('advanced_search.starred', 'Starred')} value={filters.isStarred} onPress={() => cycleTriState('isStarred')} /> @@ -1558,7 +1635,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL ) } - label={filters.isUnread === false ? 'Read' : 'Unread'} + label={filters.isUnread === false ? t('advanced_search.read', 'Read') : t('advanced_search.unread', 'Unread')} value={filters.isUnread} onPress={() => cycleTriState('isUnread')} /> @@ -1588,7 +1665,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL setDatePickerField(null)} hitSlop={8}> - Done + {t('common.done', 'Done')} makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); return ( - + {icon} {label} {onRemove ? ( - + ) : null} @@ -1681,7 +1770,12 @@ function ScopeChip({ label, active, onPress }: { label: string; active: boolean; const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); return ( - + {label} ); @@ -1710,6 +1804,8 @@ function TriToggle({ state === 'on' && styles.triToggleOn, state === 'off' && styles.triToggleOff, ]} + accessibilityRole="button" + accessibilityState={{ selected: state !== 'unset' }} > {icon} - navigation.goBack()} style={styles.headerBtn} hitSlop={8}> + navigation.goBack()} + style={styles.headerBtn} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.back', 'Back')} + > @@ -82,7 +88,14 @@ export default function EmailSourceScreen({ route, navigation }: Props) { > {copied ? : } - + diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 6146b12d..c09b2169 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -277,7 +277,13 @@ export default function ScheduledScreen({ navigation }: Props) { return ( - navigation.goBack()} style={styles.headerBtn} hitSlop={8}> + navigation.goBack()} + style={styles.headerBtn} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.back', 'Back')} + > {t('sidebar.scheduled', 'Scheduled')}