From b178ee8d07fcd3680660f675a71d372159a0ee39 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:35:28 +0200 Subject: [PATCH] fix: translate the tab bar and label the unified inbox and drawer buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom tabs showed their route names ("Mail", "Calendar", …) in every language; they now use the webmail's navigation names, and an unavailable tab says so with one translated message instead of an English "(unavailable)" suffix. The first loading screen is translated as well. The Unified Inbox selection toolbar, its back and clear-search buttons, and the drawer's close, expand/collapse and remove-account buttons get screen reader labels. Its unknown-sender fallback, selection count and "account(s) could not be loaded" banner are proper translations and plurals now. The key harvest and the translation coverage test did not look at App.tsx, which sits next to src/; they do now. --- App.tsx | 24 +++++- locales/rn/en.json | 5 +- scripts/harvest-rn-keys.mjs | 3 +- src/components/SidebarDrawer.tsx | 20 ++++- src/components/__tests__/screens-i18n.test.ts | 4 + src/i18n/__tests__/keys.test.ts | 9 ++- src/screens/EmailListScreen.tsx | 2 +- src/screens/UnifiedInboxScreen.tsx | 77 +++++++++++++++---- 8 files changed, 118 insertions(+), 26 deletions(-) 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]}