From d59c571d93e182868724fdedbc8781d43c9c8651 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:18:19 +0200 Subject: [PATCH] fix: translate the contacts screens The contact list, detail and edit screens, the group screen, the import, picker and tag sheets and the contacts drawer were written in English, including the activity panel ("Unknown", "Upcoming events"), alert titles and every form label and option. They now go through t(), reusing the webmail's contacts.* keys where the meaning matches, with ICU plurals for member, contact and import counts instead of `contact${n === 1 ? '' : 's'}`. JSContact context, phone feature, personal-info and gender values on the detail screen are shown with their translated labels instead of the raw keys. Icon-only buttons on these screens (back, edit, more, bulk actions, remove, close, clear search) get screen reader labels, and chips, checkboxes and collapsible sections report their state. --- locales/rn/en.json | 96 ++++- .../__tests__/contacts-i18n.test.ts | 36 ++ .../contacts/AddressBookPickerSheet.tsx | 25 +- src/components/contacts/ContactActivity.tsx | 22 +- .../contacts/ContactImportSheet.tsx | 72 ++-- src/components/contacts/ContactListRow.tsx | 6 +- .../contacts/ContactPickerSheet.tsx | 30 +- .../contacts/ContactsSidebarDrawer.tsx | 59 ++- src/components/contacts/TagAssignSheet.tsx | 21 +- src/screens/ContactDetailScreen.tsx | 235 ++++++---- src/screens/ContactFormScreen.tsx | 406 +++++++++++------- src/screens/ContactsScreen.tsx | 120 ++++-- src/screens/GroupDetailScreen.tsx | 81 ++-- 13 files changed, 845 insertions(+), 364 deletions(-) create mode 100644 src/components/__tests__/contacts-i18n.test.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index dcaa97d2..2fab8f32 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -110,12 +110,104 @@ "address_books": { "confirm_delete_count": "{count, plural, =0 {Delete \"{name}\"? This cannot be undone.} one {Delete \"{name}\"? Its # contact will be deleted with it. This cannot be undone.} other {Delete \"{name}\"? Its # contacts will be deleted with it. This cannot be undone.}}" }, + "all_contacts": "All Contacts", + "bulk": { + "add_tag": "Add tag", + "delete_confirm_mobile": "{count, plural, one {Delete # contact? This cannot be undone.} other {Delete # contacts? This cannot be undone.}}", + "move_to_address_book": "Move to address book", + "tag_failed": "Tagging failed" + }, + "contact": "Contact", + "create_hint_mobile": "Long-press to create a group instead", + "detail": { + "add_to_group_failed": "Add to group failed", + "copy_name": "{name} (Copy)", + "created_on": "Created {date}", + "duplicate_failed": "Duplicate failed", + "duplicate_no_book": "Cannot duplicate without an address book.", + "no_address_book": "No address book", + "not_found": "Contact not found", + "open_dialer_failed": "Cannot open dialer", + "open_link_failed": "Cannot open link", + "open_maps_failed": "Cannot open maps", + "open_sms_failed": "Cannot open messaging app", + "share": "Share", + "share_title": "Share {name}", + "sms": "SMS", + "timezone_value": "Timezone: {zone}", + "updated_on": "Updated {date}" + }, + "empty_state_hint_mobile": "Tap + to add one", "export": { "failed": "Export failed" }, + "form": { + "add_nickname": "Add nickname", + "add_note": "Add note", + "add_organization": "Add organization", + "address_book_required": "Select an address book to save this contact.", + "categories_placeholder_mobile": "Add tag and press Enter", + "context_none": "None", + "date_placeholder": "YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD", + "display_name_optional": "Display name (optional)", + "display_name_placeholder": "Custom display name", + "edit_named": "Edit {name}", + "gender_unspecified": "Unspecified", + "grammatical_gender": "Grammatical gender", + "invalid_date_message": "\"{date}\" is not a date. Use YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD.", + "invalid_date_title": "Invalid date", + "invalid_email_message": "\"{address}\" is not a valid email address.", + "invalid_email_title": "Invalid email", + "label": "Label", + "label_placeholder": "Work, Personal, …", + "missing_address_book": "Missing address book", + "missing_info": "Missing info", + "name_or_email_required": "Add a name or at least one email.", + "new_organization": "New organization", + "org_name_required": "Add the organization name.", + "photo_hint_mobile": "Tap photo to choose an image", + "photo_permission_message": "Grant photo library permission to pick a contact photo.", + "photo_permission_title": "Photo access needed", + "pick_date": "Pick a date", + "pronouns": "Pronouns", + "pronouns_placeholder": "they/them", + "url": "URL" + }, + "group": "Group", + "groups": { + "add_member": "Add member", + "add_members": "Add members", + "add_members_failed": "Failed to add members", + "create_ellipsis": "New group…", + "delete_confirm_named": "Delete \"{name}\"? Members will not be deleted.", + "delete_failed": "Failed to delete group", + "email_all": "Email all", + "members_with_count": "Members ({count})", + "no_emails": "No emails", + "no_members_hint": "Tap \"Add member\" to get started", + "not_found": "Group not found", + "remove_member": "Remove member", + "remove_member_confirm": "Remove \"{name}\" from this group?", + "remove_member_failed": "Failed to remove member" + }, "import": { - "into_address_book": "Import into address book" - } + "change_target": "Change", + "choose_file_mobile": "Choose a vCard file", + "failed_count": "{count, plural, one {# contact could not be imported.} other {# contacts could not be imported.}}", + "import_count": "Import {count}", + "into_address_book": "Import into address book", + "into_named": "Import into {name}", + "no_address_book": "Create an address book before importing contacts.", + "picker_failed": "Could not open the file picker" + }, + "no_tags_hint": "No tags yet. Type one above.", + "picker": { + "add_count": "{count, plural, =0 {Add} other {Add (#)}}", + "title": "Add Contact" + }, + "rename_tag_hint_mobile": "Long-press a tag to rename it", + "tag_name_placeholder": "New tag name", + "unnamed": "Unnamed" }, "context_menu": { "color_tag": "Tag", diff --git a/src/components/__tests__/contacts-i18n.test.ts b/src/components/__tests__/contacts-i18n.test.ts new file mode 100644 index 00000000..aa2818d7 --- /dev/null +++ b/src/components/__tests__/contacts-i18n.test.ts @@ -0,0 +1,36 @@ +// Contacts screens and sheets whose every user-visible string goes through +// t(). A literal English label, alert or JSX text sneaking back in stays +// English in every locale, so this scans the source for them. +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { findUntranslatedLiterals } from './untranslated-literals'; + +const SRC = join(__dirname, '..', '..'); + +// File (relative to src/) → texts that stay as they are on purpose. +const TRANSLATED_FILES: Record = { + 'screens/ContactsScreen.tsx': [], + 'screens/ContactDetailScreen.tsx': [], + // Service names are product names. + 'screens/ContactFormScreen.tsx': ['LinkedIn, Mastodon, …'], + 'screens/GroupDetailScreen.tsx': [], + 'components/contacts/ContactActivity.tsx': [], + 'components/contacts/ContactImportSheet.tsx': [ + // Only reachable in a development build without the native module. + 'Import unavailable', + 'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.', + ], + 'components/contacts/ContactsSidebarDrawer.tsx': [], + 'components/contacts/AddressBookPickerSheet.tsx': [], + 'components/contacts/ContactPickerSheet.tsx': [], + 'components/contacts/TagAssignSheet.tsx': [], + 'components/contacts/ContactListRow.tsx': [], +}; + +describe('translated contacts screens', () => { + for (const [file, allow] of Object.entries(TRANSLATED_FILES)) { + it(`${file} has no hard-coded English text`, () => { + expect(findUntranslatedLiterals(join(SRC, file), allow)).toEqual([]); + }); + } +}); diff --git a/src/components/contacts/AddressBookPickerSheet.tsx b/src/components/contacts/AddressBookPickerSheet.tsx index f3e0eca5..3cf71758 100644 --- a/src/components/contacts/AddressBookPickerSheet.tsx +++ b/src/components/contacts/AddressBookPickerSheet.tsx @@ -8,6 +8,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from ' import { useColors } from '../../theme/colors'; import { useSheetDrag } from '../../lib/use-sheet-drag'; import { useContactsStore } from '../../stores/contacts-store'; +import { useLocaleStore } from '../../stores/locale-store'; interface Props { visible: boolean; @@ -19,9 +20,10 @@ interface Props { } export default function AddressBookPickerSheet({ - visible, onClose, currentBookId, onPick, title = 'Move to address book', + visible, onClose, currentBookId, onPick, title, }: Props) { const c = useColors(); + const t = useLocaleStore((s) => s.t); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); const addressBooks = useContactsStore((s) => s.addressBooks); @@ -61,7 +63,7 @@ export default function AddressBookPickerSheet({ setNewName(''); onPick(book.id); } catch (err) { - Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error'); + Alert.alert(t('contacts.address_books.create_failed', 'Failed to create address book'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error')); } finally { setBusy(false); } @@ -83,8 +85,14 @@ export default function AddressBookPickerSheet({ - {title} - + {title ?? t('contacts.bulk.move_to_address_book', 'Move to address book')} + @@ -100,6 +108,8 @@ export default function AddressBookPickerSheet({ key={book.id} onPress={canTarget ? () => onPick(book.id) : undefined} disabled={!canTarget} + accessibilityRole="button" + accessibilityState={{ disabled: !canTarget, selected: isCurrent }} style={({ pressed }) => [styles.row, pressed && canTarget && styles.rowPressed]} > @@ -115,7 +125,7 @@ export default function AddressBookPickerSheet({ @@ -136,9 +148,10 @@ export default function AddressBookPickerSheet({ setCreating(true)} style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" > - New address book… + {t('contacts.address_books.create', 'New address book')} )} diff --git a/src/components/contacts/ContactActivity.tsx b/src/components/contacts/ContactActivity.tsx index d78c3e4d..6a53dc77 100644 --- a/src/components/contacts/ContactActivity.tsx +++ b/src/components/contacts/ContactActivity.tsx @@ -9,7 +9,7 @@ import type { RootStackParamList } from '../../navigation/types'; import { getEmails, queryEmailsByFilter } from '../../api/email'; import { getEvents, queryEvents } from '../../api/calendar'; import { useCalendarStore } from '../../stores/calendar-store'; -import { useLocaleStore } from '../../stores/locale-store'; +import { useLocaleStore, type TranslateFn } from '../../stores/locale-store'; import { getEventColor } from '../../lib/calendar-utils'; import { hasCalendarCapability } from '../../lib/capabilities'; import { singleLine } from '../../lib/single-line'; @@ -76,8 +76,8 @@ function formatRelativeDate(iso: string): string { } } -function formatEventTime(event: CalendarEvent): string { - if (event.showWithoutTime) return 'All day'; +function formatEventTime(event: CalendarEvent, t: TranslateFn): string { + if (event.showWithoutTime) return t('contacts.activity.all_day', 'All day'); try { return format(parseISO(event.start), 'HH:mm'); } catch { @@ -189,7 +189,7 @@ export function ContactActivity({ contact }: Props) { - Recent emails + {t('contacts.activity.recent_emails', 'Recent Emails')} {emailsLoading ? ( @@ -197,13 +197,13 @@ export function ContactActivity({ contact }: Props) { ) : emailsError ? ( - Couldn't load emails. + {t('contacts.activity.load_failed', 'Failed to load')} ) : !emails || emails.length === 0 ? ( - No emails with this contact. + {t('contacts.activity.no_emails', 'No recent emails')} ) : ( emails.map((email) => { const sender = email.from?.[0]; - const senderName = sender?.name || sender?.email || 'Unknown'; + const senderName = sender?.name || sender?.email || t('contacts.activity.unknown_sender', 'Unknown sender'); const preview = singleLine(email.preview); return ( - Upcoming events + {t('contacts.activity.upcoming_events', 'Upcoming Events')} {upcomingEvents.length === 0 ? ( - No upcoming events. + {t('contacts.activity.no_events', 'No upcoming events')} ) : ( upcomingEvents.map((event) => ( - {formatEventTime(event)} + {formatEventTime(event, t)} - {event.title || '(no title)'} + {event.title || t('contacts.activity.no_title', '(No title)')} {formatRelativeDate(event.utcStart || event.start)} diff --git a/src/components/contacts/ContactImportSheet.tsx b/src/components/contacts/ContactImportSheet.tsx index 8cdde32a..e7d04db1 100644 --- a/src/components/contacts/ContactImportSheet.tsx +++ b/src/components/contacts/ContactImportSheet.tsx @@ -12,6 +12,7 @@ import { useContactsStore } from '../../stores/contacts-store'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; // expo-document-picker is loaded lazily on first use; its native module is not // linked into every build (matches the FilesScreen upload flow). @@ -43,6 +44,7 @@ export default function ContactImportSheet({ const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); + const t = useLocaleStore((s) => s.t); const existing = useContactsStore((s) => s.contacts); const importContacts = useContactsStore((s) => s.importContacts); @@ -94,14 +96,14 @@ export default function ContactImportSheet({ try { res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false }); } catch (e) { - setError(e instanceof Error ? e.message : 'Could not open the file picker'); + setError(e instanceof Error ? e.message : t('contacts.import.picker_failed', 'Could not open the file picker')); return; } if (res.canceled || res.assets.length === 0) return; const asset = res.assets[0]; if (asset.size && asset.size > 5 * 1024 * 1024) { - setError('That file is too large (max 5 MB).'); + setError(t('contacts.import.file_too_large', 'File is too large (max 5 MB)')); return; } @@ -109,7 +111,7 @@ export default function ContactImportSheet({ const text = await FileSystem.readAsStringAsync(asset.uri); const contacts = parseVCard(text); if (contacts.length === 0) { - setError('No contacts were found in that file.'); + setError(t('contacts.import.no_contacts', 'No contacts found in file')); return; } const dupes = detectDuplicates(existing, contacts); @@ -119,7 +121,7 @@ export default function ContactImportSheet({ setDuplicates(dupes); setSelected(initial); } catch (e) { - setError(e instanceof Error ? e.message : 'Could not read or parse that file.'); + setError(e instanceof Error ? e.message : t('contacts.import.parse_error', 'Failed to parse vCard file')); } }; @@ -137,7 +139,10 @@ export default function ContactImportSheet({ const doImport = async () => { if (!targetBookId) { - Alert.alert('No address book', 'Create an address book before importing contacts.'); + Alert.alert( + t('contacts.detail.no_address_book', 'No address book'), + t('contacts.import.no_address_book', 'Create an address book before importing contacts.'), + ); return; } const toImport = parsed.filter((_, i) => selected.has(i)); @@ -148,7 +153,7 @@ export default function ContactImportSheet({ setResult(outcome); onImported?.(outcome.imported); } catch (e) { - setError(e instanceof Error ? e.message : 'Import failed.'); + setError(e instanceof Error ? e.message : t('contacts.import.failed', 'Import failed')); } finally { setImporting(false); } @@ -162,8 +167,14 @@ export default function ContactImportSheet({ - Import contacts - + {t('contacts.import.title', 'Import Contacts')} + @@ -174,21 +185,25 @@ export default function ContactImportSheet({ - Imported {result.imported} contact{result.imported === 1 ? '' : 's'}. + {t('contacts.import.success', '{count, plural, one {1 contact imported} other {# contacts imported}}', { count: result.imported })} {result.failed > 0 && ( - {result.failed} contact{result.failed === 1 ? '' : 's'} could not be imported. + {t( + 'contacts.import.failed_count', + '{count, plural, one {# contact could not be imported.} other {# contacts could not be imported.}}', + { count: result.failed }, + )} )} - + ) : parsed.length === 0 ? ( - { void pickFile(); }}> + { void pickFile(); }} accessibilityRole="button"> - Choose a vCard file - .vcf / .vcard — single or multiple contacts + {t('contacts.import.choose_file_mobile', 'Choose a vCard file')} + {t('contacts.import.file_types', '.vcf or .vcard files')} {!!error && ( @@ -203,24 +218,29 @@ export default function ContactImportSheet({ style={styles.targetRow} onPress={onChangeTarget} disabled={!onChangeTarget} + accessibilityRole={onChangeTarget ? 'button' : undefined} > - Import into {targetBookName || 'address book'} + {targetBookName + ? t('contacts.import.into_named', 'Import into {name}', { name: targetBookName }) + : t('contacts.import.into_address_book', 'Import into address book')} - {onChangeTarget && Change} + {onChangeTarget && {t('contacts.import.change_target', 'Change')}} - {parsed.length} found · {selected.size} selected + {t('contacts.import.found', '{count, plural, one {1 contact found} other {# contacts found}}', { count: parsed.length })} + {' · '} + {t('contacts.import.selected', '{count, plural, one {1 selected} other {# selected}}', { count: selected.size })} - - All + + {t('contacts.import.select_all', 'Select all')} - - None + + {t('contacts.import.deselect_all', 'Deselect all')} @@ -236,6 +256,8 @@ export default function ContactImportSheet({ key={idx} onPress={() => toggle(idx)} style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + accessibilityRole="checkbox" + accessibilityState={{ checked: isSelected }} > {isSelected && } @@ -248,7 +270,7 @@ export default function ContactImportSheet({ )} {isDupe && ( - Duplicate + {t('contacts.import.duplicate', 'Duplicate')} )} ); @@ -263,13 +285,15 @@ export default function ContactImportSheet({ )} - + diff --git a/src/components/contacts/ContactListRow.tsx b/src/components/contacts/ContactListRow.tsx index aed1245b..b1e7853e 100644 --- a/src/components/contacts/ContactListRow.tsx +++ b/src/components/contacts/ContactListRow.tsx @@ -12,6 +12,7 @@ import { import SenderAvatar from '../SenderAvatar'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; interface ContactListRowProps { contact: ContactCard; @@ -32,7 +33,8 @@ export default function ContactListRow({ }: ContactListRowProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const name = getContactDisplayName(contact) || 'Unnamed'; + const t = useLocaleStore((s) => s.t); + const name = getContactDisplayName(contact) || t('contacts.unnamed', 'Unnamed'); const email = getContactPrimaryEmail(contact); const org = getPrimaryOrg(contact); const photoUri = getContactPhotoUri(contact); @@ -45,6 +47,8 @@ export default function ContactListRow({ [ styles.row, pressed && styles.rowPressed, diff --git a/src/components/contacts/ContactPickerSheet.tsx b/src/components/contacts/ContactPickerSheet.tsx index 49e5a8de..62828edd 100644 --- a/src/components/contacts/ContactPickerSheet.tsx +++ b/src/components/contacts/ContactPickerSheet.tsx @@ -15,6 +15,7 @@ import ContactListRow from './ContactListRow'; import { useSheetDrag } from '../../lib/use-sheet-drag'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; interface Props { visible: boolean; @@ -26,9 +27,10 @@ interface Props { } export default function ContactPickerSheet({ - visible, onClose, onSelect, title = 'Add Contact', excludedIds, multi = true, + visible, onClose, onSelect, title, excludedIds, multi = true, }: Props) { const c = useColors(); + const t = useLocaleStore((s) => s.t); const styles = React.useMemo(() => makeStyles(c), [c]); const allContacts = useContactsStore((s) => s.contacts); const sorted = React.useMemo( @@ -104,18 +106,27 @@ export default function ContactPickerSheet({ - + - {title} + {title ?? t('contacts.picker.title', 'Add Contact')} {multi && ( - Add{selected.size > 0 ? ` (${selected.size})` : ''} + + {t('contacts.picker.add_count', '{count, plural, =0 {Add} other {Add (#)}}', { count: selected.size })} + )} @@ -125,14 +136,19 @@ export default function ContactPickerSheet({ {query.length > 0 && ( - setQuery('')} hitSlop={8}> + setQuery('')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('contacts.clear_search', 'Clear search')} + > )} @@ -152,7 +168,7 @@ export default function ContactPickerSheet({ ItemSeparatorComponent={() => } ListEmptyComponent={ - No contacts match + {t('contacts.empty_search', 'No contacts match your search')} } keyboardShouldPersistTaps="handled" diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx index c6254ba1..68be926b 100644 --- a/src/components/contacts/ContactsSidebarDrawer.tsx +++ b/src/components/contacts/ContactsSidebarDrawer.tsx @@ -17,6 +17,7 @@ import { import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/contact-utils'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; type Nav = NativeStackNavigationProp; @@ -37,6 +38,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const navigation = useNavigation