diff --git a/App.tsx b/App.tsx index 81c98102..e00140b3 100644 --- a/App.tsx +++ b/App.tsx @@ -200,6 +200,16 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps state.t); + // The webmail's navigation names; a tab the server lacks says so. + const tabLabels = { + mail: t('sidebar.mail', 'Mail'), + calendar: t('sidebar.calendar', 'Calendar'), + contacts: t('sidebar.contacts', 'Contacts'), + files: t('sidebar.files', 'Files'), + settings: t('sidebar.settings', 'Settings'), + }; + const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name }); return ( @@ -231,6 +241,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined, tabBarBadgeStyle: { @@ -276,9 +287,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle, - tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)', + tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar), }} listeners={{ tabPress: (e) => { @@ -290,9 +302,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarItemStyle: hasContacts ? undefined : disabledTabStyle, - tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)', + tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts), }} listeners={{ tabPress: (e) => { @@ -304,9 +317,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarItemStyle: hasFiles ? undefined : disabledTabStyle, - tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)', + tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files), }} listeners={{ tabPress: (e) => { @@ -317,6 +331,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , }} > @@ -332,6 +347,7 @@ export default function App() { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const client = useAuthStore((state) => state.client); const restoreSession = useAuthStore((state) => state.restoreSession); + const t = useLocaleStore((state) => state.t); // Resolve the user's theme preference to a concrete light/dark style for the // system status bar. The rest of the app's colors are still hard-coded dark @@ -700,7 +716,7 @@ export default function App() { return ( <> - + ); } diff --git a/locales/rn/en.json b/locales/rn/en.json index a0993041..c711f846 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -988,10 +988,11 @@ "remove_account_hint": "Long-press to remove this account", "remove_account_message": "Sign out of {account} on this device. Your mail stays on the server.", "remove_account_title": "Remove account?", - "show_unread": "Show unread" + "show_unread": "Show unread", + "tab_unavailable": "{name} (unavailable)" }, "unified_mailbox": { - "accounts_failed": "{count} account(s) could not be loaded" + "accounts_failed": "{count, plural, one {# account could not be loaded} other {# accounts could not be loaded}}" }, "updates": { "advisory": "Security advisory", diff --git a/scripts/harvest-rn-keys.mjs b/scripts/harvest-rn-keys.mjs index c7875aaa..5c2853b5 100644 --- a/scripts/harvest-rn-keys.mjs +++ b/scripts/harvest-rn-keys.mjs @@ -68,7 +68,8 @@ const KEY_ONLY_RE = /\btr?\(\s*'([^']+)'/g; const added = []; const missingNoFallback = []; -for (const file of walk(SRC)) { +// App.tsx (the tab bar and loading screen) sits next to src/. +for (const file of [...walk(SRC), join(ROOT, 'App.tsx')]) { const text = readFileSync(file, 'utf8'); const withFallback = new Map(); for (const m of text.matchAll(CALL_RE)) { diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 528550bc..d207879f 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -166,6 +166,7 @@ function SidebarRow({ }: SidebarRowProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const leftPad = ROW_PX_BASE + depth * INDENT_STEP; return ( {isExpanded ? ( @@ -758,7 +762,13 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {/* Header: close + account switcher */} - + {!isActive && ( - confirmRemoveAccount(acc)} hitSlop={8} style={styles.accountMenuRemove}> + confirmRemoveAccount(acc)} + hitSlop={8} + style={styles.accountMenuRemove} + accessibilityRole="button" + accessibilityLabel={t('sidebar.remove_account', 'Remove account')} + > )} diff --git a/src/components/__tests__/screens-i18n.test.ts b/src/components/__tests__/screens-i18n.test.ts index 3687d599..4be30f12 100644 --- a/src/components/__tests__/screens-i18n.test.ts +++ b/src/components/__tests__/screens-i18n.test.ts @@ -55,6 +55,10 @@ const TRANSLATED_FILES: Record = { 'components/email/QuickReplyBox.tsx': [], 'components/email/AttachmentChips.tsx': [], 'components/email/AttachmentPreviewModal.tsx': [], + 'screens/UnifiedInboxScreen.tsx': [], + 'components/SidebarDrawer.tsx': [], + // The app shell (tab bar, loading screen), next to src/. + '../App.tsx': [], }; describe('translated screens', () => { diff --git a/src/i18n/__tests__/keys.test.ts b/src/i18n/__tests__/keys.test.ts index 08b515db..8cdfb389 100644 --- a/src/i18n/__tests__/keys.test.ts +++ b/src/i18n/__tests__/keys.test.ts @@ -21,6 +21,11 @@ function flatten(obj: Record, prefix = '', out = new Set { it('every literal t() key in src exists in the English catalog', () => { const missing: string[] = []; - for (const file of walk(SRC_ROOT)) { + for (const file of sourceFiles()) { const text = readFileSync(file, 'utf8'); for (const m of text.matchAll(LITERAL_KEY_RE)) { if (!en.has(m[1])) missing.push(`${m[1]} (${relative(SRC_ROOT, file)})`); @@ -59,7 +64,7 @@ describe('translation coverage', () => { it('every template t() key prefix has at least one English entry', () => { const missing: string[] = []; - for (const file of walk(SRC_ROOT)) { + for (const file of sourceFiles()) { const text = readFileSync(file, 'utf8'); for (const m of text.matchAll(TEMPLATE_KEY_RE)) { const prefix = m[1]; diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 41e60310..969a31f1 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -1366,7 +1366,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL {unreachedAccounts.length > 0 && ( - {t('unified_mailbox.accounts_failed', `${unreachedAccounts.length} account(s) could not be loaded`, { count: unreachedAccounts.length })} + {t('unified_mailbox.accounts_failed', '{count, plural, one {# account could not be loaded} other {# accounts could not be loaded}}', { count: unreachedAccounts.length })} {`: ${unreachedAccounts[0]}`} diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index a1e5cd41..a0d43f10 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -33,12 +33,12 @@ type Props = NativeStackScreenProps; const PAGE_SIZE = 25; -function senderName(email: UnifiedEmail, showRecipient: boolean): string { +function senderName(email: UnifiedEmail, showRecipient: boolean, unknown: string): string { if (showRecipient) { const to = email.to?.[0]; if (to) return to.name || to.email; } - return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown'; + return email.from?.[0]?.name || email.from?.[0]?.email || unknown; } function rowKey(e: UnifiedEmail): string { @@ -330,7 +330,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { )} - {senderName(item, showRecipient)} + {senderName(item, showRecipient, t('email_viewer.unknown_sender', 'Unknown'))} {starred && } {item.hasAttachment && } @@ -384,35 +384,79 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { {selectionMode ? ( - + - {t('context_menu.items_selected', `${selected.size} selected`, { count: selected.size })} + {t('email_list.batch_actions.selected_messages', '{count, plural, one {1 email} other {# emails}} selected', { count: selected.size })} - { setStarred(selectedEmails, !allStarred); clearSelection(); }} style={styles.headerBtn} hitSlop={6}> + { setStarred(selectedEmails, !allStarred); clearSelection(); }} + style={styles.headerBtn} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={allStarred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star')} + > - { setRead(selectedEmails, !allRead); clearSelection(); }} style={styles.headerBtn} hitSlop={6}> + { setRead(selectedEmails, !allRead); clearSelection(); }} + style={styles.headerBtn} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={allRead + ? t('email_list.batch_actions.mark_unread', 'Mark as unread') + : t('email_list.batch_actions.mark_read', 'Mark as read')} + > {allRead ? : } {role !== 'sent' && role !== 'drafts' && ( - { spam(selectedEmails); clearSelection(); }} style={styles.headerBtn} hitSlop={6}> + { spam(selectedEmails); clearSelection(); }} + style={styles.headerBtn} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={role === 'junk' ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam')} + > {role === 'junk' ? : } )} {role !== 'archive' && ( - { archive(selectedEmails); clearSelection(); }} style={styles.headerBtn} hitSlop={6}> + { archive(selectedEmails); clearSelection(); }} + style={styles.headerBtn} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('context_menu.archive', 'Archive')} + > )} - { void remove(selectedEmails).then(clearSelection); }} style={styles.headerBtn} hitSlop={6}> + { void remove(selectedEmails).then(clearSelection); }} + style={styles.headerBtn} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('email_list.batch_actions.delete', 'Delete')} + > ) : ( - navigation.goBack()} style={styles.headerBtn} hitSlop={8}> + navigation.goBack()} + style={styles.headerBtn} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.back', 'Back')} + > {title} @@ -436,7 +480,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { returnKeyType="search" /> {searchInput.length > 0 && ( - { setSearchInput(''); setQuery(''); }} hitSlop={8}> + { setSearchInput(''); setQuery(''); }} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('contacts.clear_search', 'Clear search')} + > )} @@ -446,7 +495,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { - {t('unified_mailbox.accounts_failed', `${errorCount} account(s) could not be loaded`, { count: errorCount })} + {t('unified_mailbox.accounts_failed', '{count, plural, one {# account could not be loaded} other {# accounts could not be loaded}}', { count: errorCount })} {': '} {Object.values(errors)[0]}