mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
'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',
|
||||
'<p><b>Jane Doe</b><br>Bulwark Mail</p>',
|
||||
],
|
||||
'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([]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, ' ') })),
|
||||
];
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SettingsSection
|
||||
title="Contacts"
|
||||
description="Display preferences and address-book tools."
|
||||
title={t('settings.contacts.title', 'Contacts')}
|
||||
description={t('settings.contacts.description_mobile', 'Display preferences and address-book tools.')}
|
||||
>
|
||||
<SettingItem
|
||||
label="Group by first letter"
|
||||
description="Show alphabetical section headers in the contacts list."
|
||||
label={t('settings.contacts.group_by_letter_label', 'Group by first letter')}
|
||||
description={t('settings.contacts.group_by_letter_description', 'Show alphabetical section headers in the contact list')}
|
||||
>
|
||||
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
|
||||
</SettingItem>
|
||||
@@ -172,8 +176,8 @@ export function ContactsSettings() {
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Import contacts"
|
||||
description="Import contacts from a vCard (.vcf) file with duplicate detection."
|
||||
label={t('settings.contacts.import_label', 'Import Contacts')}
|
||||
description={t('settings.contacts.import_description_mobile', 'Import contacts from a vCard (.vcf) file with duplicate detection.')}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -182,12 +186,12 @@ export function ContactsSettings() {
|
||||
onPress={() => setImportOpen(true)}
|
||||
disabled={books.length === 0}
|
||||
>
|
||||
Import
|
||||
{t('contacts.import.import_button', 'Import')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Export contacts"
|
||||
label={t('settings.contacts.export_label', 'Export Contacts')}
|
||||
description={exportLabel}
|
||||
>
|
||||
<Button
|
||||
@@ -198,14 +202,14 @@ export function ContactsSettings() {
|
||||
disabled={exportable.length === 0 || exporting}
|
||||
loading={exporting}
|
||||
>
|
||||
Export
|
||||
{t('contacts.bulk.export', 'Export')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Address books"
|
||||
description="Create, rename, and remove the address books that organize your contacts."
|
||||
title={t('settings.contacts.manage_title', 'Address Books')}
|
||||
description={t('settings.contacts.manage_description_mobile', 'Create, rename, and remove the address books that organize your contacts.')}
|
||||
>
|
||||
{books.map((book) => (
|
||||
<View key={book.id} style={styles.bookRow}>
|
||||
@@ -221,10 +225,22 @@ export function ContactsSettings() {
|
||||
onSubmitEditing={() => { void commitRename(); }}
|
||||
placeholderTextColor={c.textMuted}
|
||||
/>
|
||||
<Pressable onPress={() => { void commitRename(); }} hitSlop={6} style={styles.iconBtn}>
|
||||
<Pressable
|
||||
onPress={() => { void commitRename(); }}
|
||||
hitSlop={6}
|
||||
style={styles.iconBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.save', 'Save')}
|
||||
>
|
||||
<Check size={16} color={c.primary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setEditingId(null)} hitSlop={6} style={styles.iconBtn}>
|
||||
<Pressable
|
||||
onPress={() => setEditingId(null)}
|
||||
hitSlop={6}
|
||||
style={styles.iconBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.cancel', 'Cancel')}
|
||||
>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</>
|
||||
@@ -235,8 +251,8 @@ export function ContactsSettings() {
|
||||
{book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
</Text>
|
||||
<Text style={styles.bookCount}>
|
||||
{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')}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
{!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')}
|
||||
>
|
||||
<Star
|
||||
size={15}
|
||||
@@ -255,7 +274,13 @@ export function ContactsSettings() {
|
||||
</Pressable>
|
||||
)}
|
||||
{book.myRights?.mayWrite !== false && (
|
||||
<Pressable onPress={() => startRename(book.id, book.name)} hitSlop={6} style={styles.iconBtn}>
|
||||
<Pressable
|
||||
onPress={() => startRename(book.id, book.name)}
|
||||
hitSlop={6}
|
||||
style={styles.iconBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('contacts.address_books.rename', 'Rename address book')}
|
||||
>
|
||||
<Pencil size={15} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
)}
|
||||
@@ -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')}
|
||||
>
|
||||
<Trash2 size={15} color={c.error} />
|
||||
</Pressable>
|
||||
@@ -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')}
|
||||
>
|
||||
<Check size={16} color={c.primary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => { setAdding(false); setNewName(''); }} hitSlop={6} style={styles.iconBtn}>
|
||||
<Pressable
|
||||
onPress={() => { setAdding(false); setNewName(''); }}
|
||||
hitSlop={6}
|
||||
style={styles.iconBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.cancel', 'Cancel')}
|
||||
>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -303,9 +338,10 @@ export function ContactsSettings() {
|
||||
<Pressable
|
||||
style={styles.addRow}
|
||||
onPress={() => { setEditingId(null); setAdding(true); }}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Plus size={16} color={c.primary} />
|
||||
<Text style={styles.addLabel}>New address book</Text>
|
||||
<Text style={styles.addLabel}>{t('contacts.address_books.create', 'New address book')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
@@ -315,7 +351,7 @@ export function ContactsSettings() {
|
||||
size="sm"
|
||||
onPress={() => { void fetchAddressBooks(); void fetchContacts(); }}
|
||||
>
|
||||
Refresh
|
||||
{t('common.refresh', 'Refresh')}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
@@ -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); }}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={deleteTarget !== null}
|
||||
title="Delete address book"
|
||||
title={t('contacts.address_books.delete', 'Delete address book')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Delete "${deleteTarget.name}"?${deleteTarget.count > 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)}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<View style={{ gap: spacing.xxxl }}>
|
||||
<SettingsSection
|
||||
title="Content & Senders"
|
||||
description="Control how external resources and tracking pixels are handled."
|
||||
title={t('settings.tabs.content_senders', 'Content & Senders')}
|
||||
description={t('settings.email_behavior.content_senders_description_mobile', 'Control how external resources and tracking pixels are handled.')}
|
||||
>
|
||||
<SettingItem
|
||||
label="External content"
|
||||
description="What to do when an email links to remote images or media."
|
||||
label={t('settings.email_behavior.external_content.label', 'External Content')}
|
||||
description={t('settings.email_behavior.external_content.description', 'How to handle images and external content')}
|
||||
>
|
||||
<RadioGroup
|
||||
value={externalContentPolicy}
|
||||
onChange={(v) => 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') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Always view emails in light mode"
|
||||
description="Force a light background for the message body, even when the app is in dark mode."
|
||||
label={t('settings.email_behavior.always_light_mode.label', 'Always Show Emails in Light Mode')}
|
||||
description={t('settings.email_behavior.always_light_mode.description', 'Render email content in light mode even when the app is in dark mode, avoiding dark mode conversion issues')}
|
||||
>
|
||||
<ToggleSwitch checked={emailAlwaysLightMode} onChange={setEmailAlwaysLightMode} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Trusted senders"
|
||||
label={t('settings.email_behavior.trusted_senders.label', 'Trusted Senders')}
|
||||
description={
|
||||
syncEnabled && syncedCount > 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.')
|
||||
}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => setModalOpen(true)}
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [styles.trustedButton, pressed && styles.trustedButtonPressed]}
|
||||
>
|
||||
<Text style={styles.trustedButtonText}>{trustedLabel}</Text>
|
||||
@@ -158,8 +175,8 @@ export function ContentSendersSettings() {
|
||||
|
||||
{hasContacts && (
|
||||
<SettingItem
|
||||
label="Sync trusted senders to address book"
|
||||
description='Keep the list in a "Trusted Senders" address book so it is shared with the webmail and your other devices.'
|
||||
label={t('settings.email_behavior.trusted_senders.use_address_book_label', 'Sync with address book')}
|
||||
description={t('settings.email_behavior.trusted_senders.use_address_book_description', 'Store trusted senders in a dedicated "Trusted Senders" address book so they sync across all your devices')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={!!trustedSendersAddressBook}
|
||||
@@ -181,8 +198,13 @@ export function ContentSendersSettings() {
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
|
||||
<Pressable style={styles.modalCard} onPress={() => {}}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Trusted senders</Text>
|
||||
<Pressable onPress={() => setModalOpen(false)} hitSlop={6}>
|
||||
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
|
||||
<Pressable
|
||||
onPress={() => setModalOpen(false)}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('settings.email_behavior.trusted_senders.close', 'Close')}
|
||||
>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -191,7 +213,7 @@ export function ContentSendersSettings() {
|
||||
<TextInput
|
||||
value={newSender}
|
||||
onChangeText={setNewSender}
|
||||
placeholder="sender@example.com"
|
||||
placeholder={t('settings.email_behavior.trusted_senders.add_placeholder', 'Enter email address')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -206,15 +228,15 @@ export function ContentSendersSettings() {
|
||||
disabled={!newSender.trim()}
|
||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||
>
|
||||
Add
|
||||
{t('settings.email_behavior.trusted_senders.add_button', 'Add')}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<Text style={styles.emptyText}>
|
||||
{syncEnabled && !trustedSendersLoaded
|
||||
? 'Loading…'
|
||||
: 'No trusted senders yet.'}
|
||||
? t('common.loading', 'Loading...')
|
||||
: t('settings.email_behavior.trusted_senders.empty_title', 'No trusted senders yet')}
|
||||
</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
@@ -226,7 +248,7 @@ export function ContentSendersSettings() {
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowText} numberOfLines={1}>{item.email}</Text>
|
||||
{item.synced && (
|
||||
<View style={styles.syncedBadge} accessibilityLabel="Synced via address book">
|
||||
<View style={styles.syncedBadge} accessibilityLabel={t('settings.email_behavior.trusted_senders.synced_badge', 'Synced via address book')}>
|
||||
<BookUser size={12} color={c.textMuted} />
|
||||
</View>
|
||||
)}
|
||||
@@ -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]}
|
||||
>
|
||||
<Trash2 size={16} color={busy === item.email ? c.textMuted : c.error} />
|
||||
|
||||
@@ -314,7 +314,7 @@ export function IdentitySettings() {
|
||||
<TextInput
|
||||
value={editing?.name ?? ''}
|
||||
onChangeText={(name) => 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() {
|
||||
<TextInput
|
||||
value={editing?.email ?? ''}
|
||||
onChangeText={(email) => 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}
|
||||
|
||||
@@ -189,10 +189,12 @@ export function LayoutSettings() {
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<Text style={styles.rowLabel}>Swipe behavior</Text>
|
||||
<Text style={styles.rowLabel}>{t('settings.layout.swipe_mode.label', 'Swipe behavior')}</Text>
|
||||
<Text style={styles.rowDescription}>
|
||||
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).',
|
||||
)}
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={swipeMode}
|
||||
|
||||
Reference in New Issue
Block a user