From 6e6fd95e5e8489da80a83753811f6f0f4f2b7e40 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:05:50 +0200 Subject: [PATCH] fix: translate the contacts and content settings The Contacts and Content & Senders panes, the swipe behaviour row in Layout, the device time zone option in Calendar and the identity editor placeholders were written as English literals, so they stayed English in every language. They now go through t(), reusing the webmail's settings.contacts, contacts.address_books and settings.email_behavior.trusted_senders keys where the wording matches. Counts use ICU plurals instead of `contact${n === 1 ? '' : 's'}`, and the address book icon buttons get screen reader labels. The settings i18n test now parses each pane with the TypeScript compiler and also flags JSX text, alerts and accessibility labels, and covers the newly cleaned panes. --- locales/rn/en.json | 37 ++++++ .../__tests__/settings-i18n.test.ts | 34 ++++-- .../__tests__/untranslated-literals.ts | 83 ++++++++++++++ src/components/settings/CalendarSettings.tsx | 2 +- src/components/settings/ContactsSettings.tsx | 106 ++++++++++++------ .../settings/ContentSendersSettings.tsx | 72 ++++++++---- src/components/settings/IdentitySettings.tsx | 4 +- src/components/settings/LayoutSettings.tsx | 8 +- 8 files changed, 271 insertions(+), 75 deletions(-) create mode 100644 src/components/__tests__/untranslated-literals.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index a698f13d..dcaa97d2 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -72,6 +72,7 @@ "show_week_numbers_mobile_desc": "Display week numbers in the month view.", "time_zone": "Time zone", "time_zone_auto": "Device time zone", + "time_zone_auto_zone": "Device time zone ({zone})", "time_zone_desc": "Zone used for new events and for interpreting the calendar. \"Device\" follows the phone." }, "share": { @@ -105,6 +106,17 @@ "undo": "Undo", "update": "Update" }, + "contacts": { + "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.}}" + }, + "export": { + "failed": "Export failed" + }, + "import": { + "into_address_book": "Import into address book" + } + }, "context_menu": { "color_tag": "Tag", "remove_color": "Remove tag" @@ -426,6 +438,15 @@ "not_implemented": "Not implemented", "unavailable": "Unavailable" }, + "contacts": { + "book_count": "{count, plural, one {# contact} other {# contacts}}", + "default_book": "Default address book", + "description_mobile": "Display preferences and address-book tools.", + "export_description_count": "{count, plural, one {Export # contact as a single vCard file.} other {Export # contacts as a single vCard file.}}", + "export_empty": "No contacts to export", + "import_description_mobile": "Import contacts from a vCard (.vcf) file with duplicate detection.", + "manage_description_mobile": "Create, rename, and remove the address books that organize your contacts." + }, "downloads": { "attachment_template": { "description_mobile": "Template used when saving or sharing an attachment." @@ -451,6 +472,12 @@ "auto_select_reply_identity": { "description_mobile": "Replies always come from the identity a message was sent to. With this on, a reply to another address on your domains (a catch-all alias) is sent from that address too." }, + "content_senders_description_mobile": "Control how external resources and tracking pixels are handled.", + "external_content": { + "allow_mobile": "Allow", + "ask_mobile": "Ask", + "block_mobile": "Block" + }, "plain_text_mode": { "description_mobile": "Compose and read in plain text only." }, @@ -474,6 +501,13 @@ "swipe_right_action": { "description_mobile": "Action when you drag a row from its left edge towards the right.", "label_mobile": "Swipe right (left → right)" + }, + "trusted_senders": { + "description_mobile": "External content always loads for senders on this list.", + "description_synced_mobile": "External content always loads for senders on this list. {count} synced via your address book.", + "sync_add_failed": "Could not sync to address book", + "sync_remove_failed": "Could not remove from address book", + "synced_badge": "Synced via address book" } }, "experimental": { @@ -509,6 +543,7 @@ "delete_title": "Delete identity", "description_mobile": "Manage sender names, email addresses, and signatures. Tap a row to edit.", "display_name": "Display name", + "display_name_placeholder": "Jane Doe", "edit": "Edit identity", "email_address": "Email address", "email_locked": "JMAP does not allow changing an identity's email; create a new one instead.", @@ -550,7 +585,9 @@ "show_folder_total_count": "Show total message count on folders", "show_folder_total_count_description": "Show the total next to the unread count in the folder list.", "swipe_mode": { + "description": "Pick instant (swipe past the threshold to fire the action) or reveal (swipe to expose an action band, then tap to confirm).", "instant": "Instant (swipe = action)", + "label": "Swipe behavior", "reveal": "Reveal (swipe, then tap)" }, "unified_cross_account": "Unified views across accounts", diff --git a/src/components/__tests__/settings-i18n.test.ts b/src/components/__tests__/settings-i18n.test.ts index 0141881a..86208132 100644 --- a/src/components/__tests__/settings-i18n.test.ts +++ b/src/components/__tests__/settings-i18n.test.ts @@ -1,21 +1,31 @@ // Settings panes whose every user-visible string goes through t(). A literal -// English label, description or option label sneaking back in stays English -// in every locale, so this scans the source for the prop shapes the panes use. +// English label, description, option 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 { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { findUntranslatedLiterals } from './untranslated-literals'; -const TRANSLATED_PANES = ['ReadingSettings.tsx']; - -// `label="Foo"`, `description='Foo'`, `placeholder="Foo"` and `{ label: 'Foo' }` -// with a letter in the text (numeric option labels such as '25' are fine). -const LITERAL_RE = /\b(label|description|title|placeholder)(=|:\s*)(["'`])[^"'`]*[A-Za-z][^"'`]*\3/g; +// Pane file → texts that stay as they are on purpose. +const TRANSLATED_PANES: Record = { + 'ReadingSettings.tsx': [], + 'ContactsSettings.tsx': [], + 'ContentSendersSettings.tsx': [], + 'LayoutSettings.tsx': [], + 'CalendarSettings.tsx': [], + 'IdentitySettings.tsx': [ + // Example signatures in the placeholders: sample data, not UI text. + '-- Jane Doe Bulwark Mail', + '

Jane Doe
Bulwark Mail

', + ], + 'FilterSettings.tsx': [], + 'SidebarAppsSettings.tsx': ['https://example.com'], + 'TemplateSettings.tsx': ['JSON', '{"templates": [...]}'], +}; describe('translated settings panes', () => { - for (const file of TRANSLATED_PANES) { - it(`${file} has no hard-coded English labels`, () => { - const source = readFileSync(join(__dirname, '..', 'settings', file), 'utf8'); - const literals = [...source.matchAll(LITERAL_RE)].map((m) => m[0]); + for (const [file, allow] of Object.entries(TRANSLATED_PANES)) { + it(`${file} has no hard-coded English text`, () => { + const literals = findUntranslatedLiterals(join(__dirname, '..', 'settings', file), allow); expect(literals).toEqual([]); }); } diff --git a/src/components/__tests__/untranslated-literals.ts b/src/components/__tests__/untranslated-literals.ts new file mode 100644 index 00000000..dc2b0b75 --- /dev/null +++ b/src/components/__tests__/untranslated-literals.ts @@ -0,0 +1,83 @@ +// Shared by the *-i18n tests: finds English text in a component's source that +// does not go through t(). Such a literal stays English in every locale. +// Parses the file with the TypeScript compiler instead of matching regexes so +// multi-line JSX text and ternaries are caught too. +import { readFileSync } from 'node:fs'; +import ts from 'typescript'; + +// Props that carry text a user sees or a screen reader reads out. +const TEXT_PROPS = new Set([ + 'title', 'label', 'description', 'placeholder', 'message', 'subtitle', + 'accessibilityLabel', 'accessibilityHint', 'confirmText', 'cancelText', + 'emptyText', 'text', 'dialogTitle', +]); + +// Calls whose string arguments end up on screen. +const DISPLAY_CALL_RE = /^(Alert\.alert|Alert\.prompt|toast\.(success|error|info|warning)|setError|setStatus)$/; + +// A literal counts as English text when it has a word of two or more letters. +const hasWord = (text: string) => /[A-Za-z]{2,}/.test(text); + +function literalText(node: ts.Node): string | null { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isTemplateExpression(node)) { + return [node.head.text, ...node.templateSpans.map((s) => s.literal.text)].join(' '); + } + return null; +} + +// The string literals an expression can evaluate to directly: `'a'`, +// `x ? 'a' : 'b'`, `x || 'a'`, `('a')`. Not literals passed into calls. +function directLiterals(node: ts.Expression): ts.Node[] { + if (ts.isParenthesizedExpression(node)) return directLiterals(node.expression); + if (ts.isConditionalExpression(node)) return [...directLiterals(node.whenTrue), ...directLiterals(node.whenFalse)]; + if (ts.isBinaryExpression(node)) { + const op = node.operatorToken.kind; + if (op === ts.SyntaxKind.BarBarToken || op === ts.SyntaxKind.QuestionQuestionToken || op === ts.SyntaxKind.AmpersandAmpersandToken) { + return [...directLiterals(node.left), ...directLiterals(node.right)]; + } + if (op === ts.SyntaxKind.PlusToken) return [...directLiterals(node.left), ...directLiterals(node.right)]; + return []; + } + return literalText(node) !== null ? [node] : []; +} + +/** + * `file:line text` for every hard-coded English string in `path`. + * `allow` lists texts that are meant to stay as they are (product names, + * format patterns). + */ +export function findUntranslatedLiterals(path: string, allow: string[] = []): string[] { + const source = readFileSync(path, 'utf8'); + const sf = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const found: string[] = []; + const report = (node: ts.Node, text: string) => { + const trimmed = text.trim(); + if (!hasWord(trimmed) || allow.includes(trimmed)) return; + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + found.push(`${line + 1} ${JSON.stringify(trimmed)}`); + }; + const reportExpression = (expr: ts.Expression) => { + for (const lit of directLiterals(expr)) report(lit, literalText(lit) ?? ''); + }; + + const visit = (node: ts.Node) => { + if (ts.isJsxText(node)) { + report(node, node.text.replace(/\s+/g, ' ')); + } else if (ts.isJsxExpression(node) && node.expression && ts.isJsxElement(node.parent)) { + // {'Text'} or {cond ? 'A' : 'B'} as a child. + reportExpression(node.expression); + } else if (ts.isJsxAttribute(node) && TEXT_PROPS.has(node.name.getText(sf)) && node.initializer) { + const init = node.initializer; + if (ts.isStringLiteral(init)) report(init, init.text); + else if (ts.isJsxExpression(init) && init.expression) reportExpression(init.expression); + } else if (ts.isPropertyAssignment(node) && TEXT_PROPS.has(node.name.getText(sf))) { + reportExpression(node.initializer); + } else if (ts.isCallExpression(node) && DISPLAY_CALL_RE.test(node.expression.getText(sf))) { + for (const arg of node.arguments) reportExpression(arg); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return found; +} diff --git a/src/components/settings/CalendarSettings.tsx b/src/components/settings/CalendarSettings.tsx index 27217677..fa50d6b8 100644 --- a/src/components/settings/CalendarSettings.tsx +++ b/src/components/settings/CalendarSettings.tsx @@ -57,7 +57,7 @@ export function CalendarSettings() { return [ { value: AUTO_TIME_ZONE, - label: `${t('calendar.settings.time_zone_auto', 'Device time zone')} (${deviceZone})`, + label: t('calendar.settings.time_zone_auto_zone', 'Device time zone ({zone})', { zone: deviceZone }), }, ...[...zones].sort().map((z) => ({ value: z, label: z.replace(/_/g, ' ') })), ]; diff --git a/src/components/settings/ContactsSettings.tsx b/src/components/settings/ContactsSettings.tsx index ea216fb5..da8cb4bc 100644 --- a/src/components/settings/ContactsSettings.tsx +++ b/src/components/settings/ContactsSettings.tsx @@ -57,8 +57,12 @@ export function ContactsSettings() { const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]); const exportLabel = exportable.length === 0 - ? 'No contacts to export' - : `Export ${exportable.length} contact${exportable.length === 1 ? '' : 's'} as a single vCard file.`; + ? t('settings.contacts.export_empty', 'No contacts to export') + : t( + 'settings.contacts.export_description_count', + '{count, plural, one {Export # contact as a single vCard file.} other {Export # contacts as a single vCard file.}}', + { count: exportable.length }, + ); // Imports land in the account's default book unless the user picks another. const resolvedImportTarget = importTargetBookId ?? getDefaultAddressBookId(); const importTargetName = books.find((b) => b.id === resolvedImportTarget)?.name; @@ -69,7 +73,7 @@ export function ContactsSettings() { try { await setDefaultAddressBook(id); } catch (err) { - Alert.alert('Could not set default', err instanceof Error ? err.message : 'Unknown error'); + Alert.alert(t('contacts.address_books.set_default_failed', 'Failed to set default address book'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error')); } finally { setBusy(false); } @@ -87,13 +91,13 @@ export function ContactsSettings() { await Sharing.shareAsync(path, { mimeType: 'text/vcard', UTI: 'public.vcard', - dialogTitle: 'Export contacts', + dialogTitle: t('contacts.export.title', 'Export Contacts'), }); } else { await Share.share({ message: vcf }); } } catch (err) { - Alert.alert('Export failed', err instanceof Error ? err.message : 'Unknown error'); + Alert.alert(t('contacts.export.failed', 'Export failed'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error')); } finally { setExporting(false); } @@ -113,7 +117,7 @@ export function ContactsSettings() { await renameAddressBook(editingId, name); setEditingId(null); } catch (err) { - Alert.alert('Rename failed', err instanceof Error ? err.message : 'Unknown error'); + Alert.alert(t('contacts.address_books.rename_failed', 'Failed to rename address book'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error')); } finally { setBusy(false); } @@ -128,7 +132,7 @@ export function ContactsSettings() { setAdding(false); setNewName(''); } 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); } @@ -141,19 +145,19 @@ export function ContactsSettings() { try { await deleteAddressBook(id); } catch (err) { - Alert.alert('Delete failed', err instanceof Error ? err.message : 'Unknown error'); + Alert.alert(t('contacts.address_books.delete_failed', 'Failed to delete address book'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error')); } }; return ( <> @@ -172,8 +176,8 @@ export function ContactsSettings() { {books.map((book) => ( @@ -221,10 +225,22 @@ export function ContactsSettings() { onSubmitEditing={() => { void commitRename(); }} placeholderTextColor={c.textMuted} /> - { void commitRename(); }} hitSlop={6} style={styles.iconBtn}> + { void commitRename(); }} + hitSlop={6} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={t('common.save', 'Save')} + > - setEditingId(null)} hitSlop={6} style={styles.iconBtn}> + setEditingId(null)} + hitSlop={6} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={t('common.cancel', 'Cancel')} + > @@ -235,8 +251,8 @@ export function ContactsSettings() { {book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name} - {book.count} contact{book.count === 1 ? '' : 's'} - {book.isDefault ? ' · Default' : ''} + {t('settings.contacts.book_count', '{count, plural, one {# contact} other {# contacts}}', { count: book.count })} + {book.isDefault ? ` · ${t('contacts.address_books.default', 'Default')}` : ''} {!book.isShared && ( @@ -245,7 +261,10 @@ export function ContactsSettings() { hitSlop={6} style={styles.iconBtn} disabled={!!book.isDefault} - accessibilityLabel={book.isDefault ? 'Default address book' : 'Set as default'} + accessibilityRole="button" + accessibilityLabel={book.isDefault + ? t('settings.contacts.default_book', 'Default address book') + : t('contacts.address_books.set_default', 'Set as default')} > )} {book.myRights?.mayWrite !== false && ( - startRename(book.id, book.name)} hitSlop={6} style={styles.iconBtn}> + startRename(book.id, book.name)} + hitSlop={6} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={t('contacts.address_books.rename', 'Rename address book')} + > )} @@ -265,6 +290,8 @@ export function ContactsSettings() { onPress={() => setDeleteTarget({ id: book.id, name: book.name, count: book.count })} hitSlop={6} style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={t('contacts.address_books.delete', 'Delete address book')} > @@ -281,7 +308,7 @@ export function ContactsSettings() { style={styles.bookInput} value={newName} onChangeText={setNewName} - placeholder="New address book name" + placeholder={t('contacts.address_books.name_label', 'Address book name')} placeholderTextColor={c.textMuted} autoFocus returnKeyType="done" @@ -292,10 +319,18 @@ export function ContactsSettings() { disabled={!newName.trim() || busy} hitSlop={6} style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={t('contacts.address_books.create', 'New address book')} > - { setAdding(false); setNewName(''); }} hitSlop={6} style={styles.iconBtn}> + { setAdding(false); setNewName(''); }} + hitSlop={6} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={t('common.cancel', 'Cancel')} + > @@ -303,9 +338,10 @@ export function ContactsSettings() { { setEditingId(null); setAdding(true); }} + accessibilityRole="button" > - New address book + {t('contacts.address_books.create', 'New address book')} )} @@ -315,7 +351,7 @@ export function ContactsSettings() { size="sm" onPress={() => { void fetchAddressBooks(); void fetchContacts(); }} > - Refresh + {t('common.refresh', 'Refresh')} @@ -333,20 +369,24 @@ export function ContactsSettings() { visible={importTargetOpen} onClose={() => setImportTargetOpen(false)} currentBookId={resolvedImportTarget} - title="Import into address book" + title={t('contacts.import.into_address_book', 'Import into address book')} onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }} /> 0 ? ` Its ${deleteTarget.count} contact${deleteTarget.count === 1 ? '' : 's'} will be deleted with it.` : ''} This cannot be undone.` + ? t( + 'contacts.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.}}', + { name: deleteTarget.name, count: deleteTarget.count }, + ) : '' } variant="destructive" - confirmText="Delete" + confirmText={t('common.delete', 'Delete')} onConfirm={() => { void confirmDelete(); }} onCancel={() => setDeleteTarget(null)} /> diff --git a/src/components/settings/ContentSendersSettings.tsx b/src/components/settings/ContentSendersSettings.tsx index a356a980..88a892c4 100644 --- a/src/components/settings/ContentSendersSettings.tsx +++ b/src/components/settings/ContentSendersSettings.tsx @@ -10,6 +10,7 @@ import { useColors } from '../../theme/colors'; import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store'; import { useContactsStore } from '../../stores/contacts-store'; import { useHasContacts } from '../../lib/capabilities'; +import { useLocaleStore } from '../../stores/locale-store'; interface TrustedRow { email: string; @@ -22,6 +23,7 @@ interface TrustedRow { export function ContentSendersSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy); const setExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy); const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode); @@ -77,7 +79,11 @@ export function ContentSendersSettings() { }, [trustedSenders, trustedSenderEmails, syncEnabled]); const trustedCount = rows.length; - const trustedLabel = trustedCount === 0 ? 'None' : trustedCount === 1 ? '1 sender' : `${trustedCount} senders`; + const trustedLabel = trustedCount === 0 + ? t('settings.email_behavior.trusted_senders.count_zero', 'None') + : trustedCount === 1 + ? t('settings.email_behavior.trusted_senders.count_one', '1 sender') + : t('settings.email_behavior.trusted_senders.count_other', '{count} senders', { count: trustedCount }); const syncedCount = rows.filter((r) => r.synced).length; const handleAdd = async () => { @@ -90,7 +96,10 @@ export function ContentSendersSettings() { try { await addToTrustedSendersBook(value); } catch (err) { - Alert.alert('Could not sync to address book', err instanceof Error ? err.message : 'Unknown error'); + Alert.alert( + t('settings.email_behavior.trusted_senders.sync_add_failed', 'Could not sync to address book'), + err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'), + ); } finally { setBusy(null); } @@ -104,7 +113,10 @@ export function ContentSendersSettings() { try { await removeFromTrustedSendersBook(row.email); } catch (err) { - Alert.alert('Could not remove from address book', err instanceof Error ? err.message : 'Unknown error'); + Alert.alert( + t('settings.email_behavior.trusted_senders.sync_remove_failed', 'Could not remove from address book'), + err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'), + ); } finally { setBusy(null); } @@ -114,41 +126,46 @@ export function ContentSendersSettings() { return ( setExternalContentPolicy(v as ExternalContentPolicy)} options={[ - { value: 'ask', label: 'Ask' }, - { value: 'block', label: 'Block' }, - { value: 'allow', label: 'Allow' }, + { value: 'ask', label: t('settings.email_behavior.external_content.ask_mobile', 'Ask') }, + { value: 'block', label: t('settings.email_behavior.external_content.block_mobile', 'Block') }, + { value: 'allow', label: t('settings.email_behavior.external_content.allow_mobile', 'Allow') }, ]} /> 0 - ? `External content always loads for senders on this list. ${syncedCount} synced via your address book.` - : 'External content always loads for senders on this list.' + ? t( + 'settings.email_behavior.trusted_senders.description_synced_mobile', + 'External content always loads for senders on this list. {count} synced via your address book.', + { count: syncedCount }, + ) + : t('settings.email_behavior.trusted_senders.description_mobile', 'External content always loads for senders on this list.') } > setModalOpen(true)} + accessibilityRole="button" style={({ pressed }) => [styles.trustedButton, pressed && styles.trustedButtonPressed]} > {trustedLabel} @@ -158,8 +175,8 @@ export function ContentSendersSettings() { {hasContacts && ( setModalOpen(false)}> {}}> - Trusted senders - setModalOpen(false)} hitSlop={6}> + {t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')} + setModalOpen(false)} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('settings.email_behavior.trusted_senders.close', 'Close')} + > @@ -191,7 +213,7 @@ export function ContentSendersSettings() { } > - Add + {t('settings.email_behavior.trusted_senders.add_button', 'Add')} {rows.length === 0 ? ( {syncEnabled && !trustedSendersLoaded - ? 'Loading…' - : 'No trusted senders yet.'} + ? t('common.loading', 'Loading...') + : t('settings.email_behavior.trusted_senders.empty_title', 'No trusted senders yet')} ) : ( {item.email} {item.synced && ( - + )} @@ -234,6 +256,8 @@ export function ContentSendersSettings() { onPress={() => { void handleRemove(item); }} disabled={busy === item.email} hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('settings.email_behavior.trusted_senders.remove', 'Remove')} style={({ pressed }) => [styles.removeBtn, pressed && styles.removeBtnPressed]} > diff --git a/src/components/settings/IdentitySettings.tsx b/src/components/settings/IdentitySettings.tsx index d951df0b..898f3a9a 100644 --- a/src/components/settings/IdentitySettings.tsx +++ b/src/components/settings/IdentitySettings.tsx @@ -314,7 +314,7 @@ export function IdentitySettings() { setEditing((d) => (d ? { ...d, name } : d))} - placeholder="Jane Doe" + placeholder={t('settings.identities.display_name_placeholder', 'Jane Doe')} placeholderTextColor={c.textMuted} style={styles.input} /> @@ -322,7 +322,7 @@ export function IdentitySettings() { setEditing((d) => (d ? { ...d, email } : d))} - placeholder="jane@example.com" + placeholder={t('identities.form.email_placeholder', 'your.email@example.com')} placeholderTextColor={c.textMuted} style={[styles.input, !!editing?.id && styles.inputDisabled]} editable={!editing?.id} diff --git a/src/components/settings/LayoutSettings.tsx b/src/components/settings/LayoutSettings.tsx index a5c6fcef..7d63302c 100644 --- a/src/components/settings/LayoutSettings.tsx +++ b/src/components/settings/LayoutSettings.tsx @@ -189,10 +189,12 @@ export function LayoutSettings() { - Swipe behavior + {t('settings.layout.swipe_mode.label', 'Swipe behavior')} - Pick instant (swipe past the threshold to fire the action) or reveal - (swipe to expose an action band, then tap to confirm). + {t( + 'settings.layout.swipe_mode.description', + 'Pick instant (swipe past the threshold to fire the action) or reveal (swipe to expose an action band, then tap to confirm).', + )}