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 hasFiles = useHasFiles();
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 (
<View style={{ flex: 1, backgroundColor: c.background }}>
@@ -231,6 +241,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<Tab.Screen
name="Mail"
options={{
title: tabLabels.mail,
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
tabBarBadgeStyle: {
@@ -276,9 +287,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Calendar"
component={CalendarScreen}
options={{
title: tabLabels.calendar,
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
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<RootStackParam
name="Contacts"
component={ContactsScreen}
options={{
title: tabLabels.contacts,
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
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<RootStackParam
name="Files"
component={FilesScreen}
options={{
title: tabLabels.files,
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
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<RootStackParam
<Tab.Screen
name="Settings"
options={{
title: tabLabels.settings,
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}}
>
@@ -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 (
<>
<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_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",
+2 -1
View File
@@ -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)) {
+18 -2
View File
@@ -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 (
<Pressable
@@ -184,6 +185,9 @@ function SidebarRow({
onPress={onToggleExpand}
hitSlop={6}
style={styles.chevron}
accessibilityRole="button"
accessibilityLabel={isExpanded ? t('sidebar.collapse', 'Collapse') : t('sidebar.expand', 'Expand')}
accessibilityState={{ expanded: isExpanded }}
>
{isExpanded ? (
<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']}>
{/* Header: close + account switcher */}
<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} />
</Pressable>
<Pressable
@@ -860,7 +870,13 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
</View>
</View>
{!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} />
</Pressable>
)}
@@ -55,6 +55,10 @@ const TRANSLATED_FILES: Record<string, string[]> = {
'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', () => {
+7 -2
View File
@@ -21,6 +21,11 @@ function flatten(obj: Record<string, unknown>, prefix = '', out = new Set<string
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[] {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
@@ -48,7 +53,7 @@ describe('translation coverage', () => {
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];
+1 -1
View File
@@ -1366,7 +1366,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
{unreachedAccounts.length > 0 && (
<View style={[styles.emptyFolderBanner, { borderColor: c.error }]}>
<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]}`}
</Text>
</View>
+63 -14
View File
@@ -33,12 +33,12 @@ type Props = NativeStackScreenProps<RootStackParamList, 'UnifiedInbox'>;
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) {
)}
<View style={styles.avatarWrap}>
<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}
size={componentSizes.avatarMd}
disableImages={role === 'junk' && !showAvatarsInJunk}
@@ -342,7 +342,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
<View style={styles.content}>
<View style={styles.line}>
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
{senderName(item, showRecipient)}
{senderName(item, showRecipient, t('email_viewer.unknown_sender', 'Unknown'))}
</Text>
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
{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']}>
{selectionMode ? (
<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} />
</Pressable>
<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>
<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'} />
</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} />}
</Pressable>
{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} />}
</Pressable>
)}
{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} />
</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} />
</Pressable>
</View>
) : (
<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} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
@@ -436,7 +480,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
returnKeyType="search"
/>
{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} />
</Pressable>
)}
@@ -446,7 +495,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
<View style={styles.errorBanner}>
<AlertTriangle size={14} color={c.error} />
<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]}
</Text>