mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user