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