fix: translate the tab bar and label the unified inbox and drawer buttons

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.
This commit is contained in:
Linus Rath
2026-09-24 21:44:47 +02:00
parent 1cbf763a36
commit b178ee8d07
8 changed files with 118 additions and 26 deletions
+20 -4
View File
@@ -200,6 +200,16 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
const hasContacts = useHasContacts(); const hasContacts = useHasContacts();
const hasFiles = useHasFiles(); const hasFiles = useHasFiles();
const disabledTabStyle = { opacity: 0.4 } as const; const disabledTabStyle = { opacity: 0.4 } as const;
const t = useLocaleStore((state) => 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 ( return (
<View style={{ flex: 1, backgroundColor: c.background }}> <View style={{ flex: 1, backgroundColor: c.background }}>
@@ -231,6 +241,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<Tab.Screen <Tab.Screen
name="Mail" name="Mail"
options={{ options={{
title: tabLabels.mail,
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />, tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined, tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
tabBarBadgeStyle: { tabBarBadgeStyle: {
@@ -276,9 +287,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Calendar" name="Calendar"
component={CalendarScreen} component={CalendarScreen}
options={{ options={{
title: tabLabels.calendar,
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />, tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle, tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)', tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
}} }}
listeners={{ listeners={{
tabPress: (e) => { tabPress: (e) => {
@@ -290,9 +302,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Contacts" name="Contacts"
component={ContactsScreen} component={ContactsScreen}
options={{ options={{
title: tabLabels.contacts,
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />, tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle, tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)', tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
}} }}
listeners={{ listeners={{
tabPress: (e) => { tabPress: (e) => {
@@ -304,9 +317,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Files" name="Files"
component={FilesScreen} component={FilesScreen}
options={{ options={{
title: tabLabels.files,
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />, tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle, tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)', tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
}} }}
listeners={{ listeners={{
tabPress: (e) => { tabPress: (e) => {
@@ -317,6 +331,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<Tab.Screen <Tab.Screen
name="Settings" name="Settings"
options={{ options={{
title: tabLabels.settings,
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />, tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}} }}
> >
@@ -332,6 +347,7 @@ export default function App() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const client = useAuthStore((state) => state.client); const client = useAuthStore((state) => state.client);
const restoreSession = useAuthStore((state) => state.restoreSession); 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 // 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 // system status bar. The rest of the app's colors are still hard-coded dark
@@ -700,7 +716,7 @@ export default function App() {
return ( return (
<> <>
<StatusBar style={statusBarStyle} /> <StatusBar style={statusBarStyle} />
<LoadingScreen message="Loading..." /> <LoadingScreen message={t('common.loading', 'Loading...')} />
</> </>
); );
} }
+3 -2
View File
@@ -988,10 +988,11 @@
"remove_account_hint": "Long-press to remove this account", "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_message": "Sign out of {account} on this device. Your mail stays on the server.",
"remove_account_title": "Remove account?", "remove_account_title": "Remove account?",
"show_unread": "Show unread" "show_unread": "Show unread",
"tab_unavailable": "{name} (unavailable)"
}, },
"unified_mailbox": { "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": { "updates": {
"advisory": "Security advisory", "advisory": "Security advisory",
+2 -1
View File
@@ -68,7 +68,8 @@ const KEY_ONLY_RE = /\btr?\(\s*'([^']+)'/g;
const added = []; const added = [];
const missingNoFallback = []; 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 text = readFileSync(file, 'utf8');
const withFallback = new Map(); const withFallback = new Map();
for (const m of text.matchAll(CALL_RE)) { for (const m of text.matchAll(CALL_RE)) {
+18 -2
View File
@@ -166,6 +166,7 @@ function SidebarRow({
}: SidebarRowProps) { }: SidebarRowProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const leftPad = ROW_PX_BASE + depth * INDENT_STEP; const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
return ( return (
<Pressable <Pressable
@@ -184,6 +185,9 @@ function SidebarRow({
onPress={onToggleExpand} onPress={onToggleExpand}
hitSlop={6} hitSlop={6}
style={styles.chevron} style={styles.chevron}
accessibilityRole="button"
accessibilityLabel={isExpanded ? t('sidebar.collapse', 'Collapse') : t('sidebar.expand', 'Expand')}
accessibilityState={{ expanded: isExpanded }}
> >
{isExpanded ? ( {isExpanded ? (
<ChevronDown size={12} color={c.textMuted} /> <ChevronDown size={12} color={c.textMuted} />
@@ -758,7 +762,13 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}> <SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
{/* Header: close + account switcher */} {/* Header: close + account switcher */}
<View style={styles.header}> <View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}> <Pressable
onPress={onClose}
style={styles.headerClose}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={20} color={c.text} /> <X size={20} color={c.text} />
</Pressable> </Pressable>
<Pressable <Pressable
@@ -860,7 +870,13 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
</View> </View>
</View> </View>
{!isActive && ( {!isActive && (
<Pressable onPress={() => confirmRemoveAccount(acc)} hitSlop={8} style={styles.accountMenuRemove}> <Pressable
onPress={() => confirmRemoveAccount(acc)}
hitSlop={8}
style={styles.accountMenuRemove}
accessibilityRole="button"
accessibilityLabel={t('sidebar.remove_account', 'Remove account')}
>
<UserMinus size={14} color={c.textMuted} /> <UserMinus size={14} color={c.textMuted} />
</Pressable> </Pressable>
)} )}
@@ -55,6 +55,10 @@ const TRANSLATED_FILES: Record<string, string[]> = {
'components/email/QuickReplyBox.tsx': [], 'components/email/QuickReplyBox.tsx': [],
'components/email/AttachmentChips.tsx': [], 'components/email/AttachmentChips.tsx': [],
'components/email/AttachmentPreviewModal.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', () => { describe('translated screens', () => {
+7 -2
View File
@@ -21,6 +21,11 @@ function flatten(obj: Record<string, unknown>, prefix = '', out = new Set<string
return out; return out;
} }
// src/ plus App.tsx next to it (the tab bar and loading screen).
function sourceFiles(): string[] {
return [...walk(SRC_ROOT), join(SRC_ROOT, '..', 'App.tsx')];
}
function walk(dir: string, out: string[] = []): string[] { function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) { for (const entry of readdirSync(dir)) {
const p = join(dir, entry); const p = join(dir, entry);
@@ -48,7 +53,7 @@ describe('translation coverage', () => {
it('every literal t() key in src exists in the English catalog', () => { it('every literal t() key in src exists in the English catalog', () => {
const missing: string[] = []; const missing: string[] = [];
for (const file of walk(SRC_ROOT)) { for (const file of sourceFiles()) {
const text = readFileSync(file, 'utf8'); const text = readFileSync(file, 'utf8');
for (const m of text.matchAll(LITERAL_KEY_RE)) { for (const m of text.matchAll(LITERAL_KEY_RE)) {
if (!en.has(m[1])) missing.push(`${m[1]} (${relative(SRC_ROOT, file)})`); 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', () => { it('every template t() key prefix has at least one English entry', () => {
const missing: string[] = []; const missing: string[] = [];
for (const file of walk(SRC_ROOT)) { for (const file of sourceFiles()) {
const text = readFileSync(file, 'utf8'); const text = readFileSync(file, 'utf8');
for (const m of text.matchAll(TEMPLATE_KEY_RE)) { for (const m of text.matchAll(TEMPLATE_KEY_RE)) {
const prefix = m[1]; const prefix = m[1];
+1 -1
View File
@@ -1366,7 +1366,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
{unreachedAccounts.length > 0 && ( {unreachedAccounts.length > 0 && (
<View style={[styles.emptyFolderBanner, { borderColor: c.error }]}> <View style={[styles.emptyFolderBanner, { borderColor: c.error }]}>
<Text style={styles.emptyFolderHint} numberOfLines={2}> <Text style={styles.emptyFolderHint} numberOfLines={2}>
{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]}`} {`: ${unreachedAccounts[0]}`}
</Text> </Text>
</View> </View>
+63 -14
View File
@@ -33,12 +33,12 @@ type Props = NativeStackScreenProps<RootStackParamList, 'UnifiedInbox'>;
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
function senderName(email: UnifiedEmail, showRecipient: boolean): string { function senderName(email: UnifiedEmail, showRecipient: boolean, unknown: string): string {
if (showRecipient) { if (showRecipient) {
const to = email.to?.[0]; const to = email.to?.[0];
if (to) return to.name || to.email; 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 { function rowKey(e: UnifiedEmail): string {
@@ -330,7 +330,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
)} )}
<View style={styles.avatarWrap}> <View style={styles.avatarWrap}>
<SenderAvatar <SenderAvatar
name={senderName(item, showRecipient)} name={senderName(item, showRecipient, t('email_viewer.unknown_sender', 'Unknown'))}
email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email} email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email}
size={componentSizes.avatarMd} size={componentSizes.avatarMd}
disableImages={role === 'junk' && !showAvatarsInJunk} disableImages={role === 'junk' && !showAvatarsInJunk}
@@ -342,7 +342,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
<View style={styles.content}> <View style={styles.content}>
<View style={styles.line}> <View style={styles.line}>
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}> <Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
{senderName(item, showRecipient)} {senderName(item, showRecipient, t('email_viewer.unknown_sender', 'Unknown'))}
</Text> </Text>
{starred && <Star size={12} color={c.starred} fill={c.starred} />} {starred && <Star size={12} color={c.starred} fill={c.starred} />}
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />} {item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
@@ -384,35 +384,79 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
{selectionMode ? ( {selectionMode ? (
<View style={styles.header}> <View style={styles.header}>
<Pressable onPress={clearSelection} style={styles.headerBtn} hitSlop={8}> <Pressable
onPress={clearSelection}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('email_list.batch_actions.clear_selection', 'Clear selection')}
>
<X size={20} color={c.text} /> <X size={20} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.headerTitle} numberOfLines={1}> <Text style={styles.headerTitle} numberOfLines={1}>
{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 })}
</Text> </Text>
<Pressable onPress={() => { setStarred(selectedEmails, !allStarred); clearSelection(); }} style={styles.headerBtn} hitSlop={6}> <Pressable
onPress={() => { setStarred(selectedEmails, !allStarred); clearSelection(); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={allStarred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star')}
>
<Star size={20} color={allStarred ? c.starred : c.text} fill={allStarred ? c.starred : 'transparent'} /> <Star size={20} color={allStarred ? c.starred : c.text} fill={allStarred ? c.starred : 'transparent'} />
</Pressable> </Pressable>
<Pressable onPress={() => { setRead(selectedEmails, !allRead); clearSelection(); }} style={styles.headerBtn} hitSlop={6}> <Pressable
onPress={() => { 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 ? <MailIcon size={20} color={c.text} /> : <MailOpen size={20} color={c.text} />} {allRead ? <MailIcon size={20} color={c.text} /> : <MailOpen size={20} color={c.text} />}
</Pressable> </Pressable>
{role !== 'sent' && role !== 'drafts' && ( {role !== 'sent' && role !== 'drafts' && (
<Pressable onPress={() => { spam(selectedEmails); clearSelection(); }} style={styles.headerBtn} hitSlop={6}> <Pressable
onPress={() => { 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' ? <ShieldCheck size={20} color={c.text} /> : <ShieldAlert size={20} color={c.text} />} {role === 'junk' ? <ShieldCheck size={20} color={c.text} /> : <ShieldAlert size={20} color={c.text} />}
</Pressable> </Pressable>
)} )}
{role !== 'archive' && ( {role !== 'archive' && (
<Pressable onPress={() => { archive(selectedEmails); clearSelection(); }} style={styles.headerBtn} hitSlop={6}> <Pressable
onPress={() => { archive(selectedEmails); clearSelection(); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('context_menu.archive', 'Archive')}
>
<Archive size={20} color={c.text} /> <Archive size={20} color={c.text} />
</Pressable> </Pressable>
)} )}
<Pressable onPress={() => { void remove(selectedEmails).then(clearSelection); }} style={styles.headerBtn} hitSlop={6}> <Pressable
onPress={() => { void remove(selectedEmails).then(clearSelection); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('email_list.batch_actions.delete', 'Delete')}
>
<Trash2 size={20} color={c.text} /> <Trash2 size={20} color={c.text} />
</Pressable> </Pressable>
</View> </View>
) : ( ) : (
<View style={styles.header}> <View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}> <Pressable
onPress={() => navigation.goBack()}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} /> <ArrowLeft size={22} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text> <Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
@@ -436,7 +480,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
returnKeyType="search" returnKeyType="search"
/> />
{searchInput.length > 0 && ( {searchInput.length > 0 && (
<Pressable onPress={() => { setSearchInput(''); setQuery(''); }} hitSlop={8}> <Pressable
onPress={() => { setSearchInput(''); setQuery(''); }}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.clear_search', 'Clear search')}
>
<X size={14} color={c.textMuted} /> <X size={14} color={c.textMuted} />
</Pressable> </Pressable>
)} )}
@@ -446,7 +495,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
<View style={styles.errorBanner}> <View style={styles.errorBanner}>
<AlertTriangle size={14} color={c.error} /> <AlertTriangle size={14} color={c.error} />
<Text style={styles.errorBannerText} numberOfLines={2}> <Text style={styles.errorBannerText} numberOfLines={2}>
{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]} {Object.values(errors)[0]}
</Text> </Text>