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).', + )}