feat: add ICU interpolation, all 27 webmail locales and a key-parity test to i18n

This commit is contained in:
Linus Rath
2026-08-29 17:44:03 +02:00
parent 0b1c240802
commit 990cd84ee3
37 changed files with 55592 additions and 1492 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+824 -70
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+825 -66
View File
File diff suppressed because it is too large Load Diff
+813 -67
View File
File diff suppressed because it is too large Load Diff
+858 -99
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+860 -101
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+860 -101
View File
File diff suppressed because it is too large Load Diff
+860 -101
View File
File diff suppressed because it is too large Load Diff
+860 -101
View File
File diff suppressed because it is too large Load Diff
+858 -99
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+859 -100
View File
File diff suppressed because it is too large Load Diff
+860 -101
View File
File diff suppressed because it is too large Load Diff
+894 -135
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"context_menu": {
"color_tag": "Tag",
"remove_color": "Remove tag"
},
"email_composer": {
"attachment_reminder_title": "Forgot an attachment?",
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"save_draft": "Save Draft",
"schedule_custom": "Pick date & time…",
"schedule_in_1h": "In 1 hour",
"schedule_in_3h": "In 3 hours",
"schedule_past_body": "The scheduled time must be in the future.",
"schedule_past_title": "Pick a future time",
"schedule_too_late_body": "That is later than this server allows. Pick an earlier time.",
"schedule_too_late_title": "Too far ahead",
"schedule_tomorrow": "Tomorrow, 8:00 AM",
"schedule_unsupported_body": "This mail server does not support scheduled send.",
"schedule_unsupported_title": "Scheduling unavailable",
"scheduled_body": "Your message will be sent at {time}.",
"scheduled_title": "Scheduled",
"validation": {
"subject_required": "Add a subject"
}
},
"email_list": {
"error": "Error",
"no_archive_folder": "Could not find an Archive folder on the server.",
"no_junk_folder": "Could not find a Spam/Junk folder on the server.",
"no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration."
},
"email_viewer": {
"set_color": "Set tag",
"remove_color": "Remove tag",
"color_tag": {
"title": "Color Tag",
"red": "Red",
"orange": "Orange",
"yellow": "Yellow",
"green": "Green",
"blue": "Blue",
"purple": "Purple",
"pink": "Pink",
"none": "None"
}
},
"settings": {
"appearance": {
"language": {
"system_default": "System default",
"restart_for_direction": "Restart the app to apply the new text direction."
}
},
"keywords": {
"reset_defaults": "Reset to Defaults"
}
},
"offline": {
"banner": "You are offline",
"queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect"
}
}
File diff suppressed because it is too large Load Diff
+859 -100
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+860 -101
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+858 -99
View File
File diff suppressed because it is too large Load Diff
+65 -18
View File
@@ -1,43 +1,90 @@
// Copy locale JSON files from the parent webmail repo into ./locales/. // Vendor the webmail locale catalogs into ./locales/<lang>/common.json.
// Run from the RN repo root: `node scripts/sync-locales.mjs`. // Run from the RN repo root: `node scripts/sync-locales.mjs [--check]`.
//
// The webmail catalog is the source of truth and is copied verbatim. Keys the
// native app needs that the webmail does not have live in ./locales/rn/<lang>.json
// and are merged on top at runtime (src/i18n/index.ts) - they are never written
// into the vendored files, so a re-sync cannot lose them. Keys that later land
// in the webmail catalog are reported so the overlay can be trimmed.
//
// In environments where the parent repo isn't checked out (e.g. CI clone of // In environments where the parent repo isn't checked out (e.g. CI clone of
// the standalone RN repo), this is a no-op — vendored files stay as-is. // the standalone RN repo), this is a no-op — vendored files stay as-is.
import { readdir, mkdir, copyFile, stat } from 'node:fs/promises'; import { readdir, mkdir, copyFile, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const RN_LOCALES = join(__dirname, '..', 'locales'); const RN_LOCALES = join(__dirname, '..', 'locales');
const RN_OVERLAYS = join(RN_LOCALES, 'rn');
const WEBMAIL_LOCALES = join(__dirname, '..', '..', '..', 'locales'); const WEBMAIL_LOCALES = join(__dirname, '..', '..', '..', 'locales');
const CHECK_ONLY = process.argv.includes('--check');
if (!existsSync(WEBMAIL_LOCALES)) { if (!existsSync(WEBMAIL_LOCALES)) {
console.log(`No webmail locales at ${WEBMAIL_LOCALES} — skipping (vendored files will be used).`); console.log(`No webmail locales at ${WEBMAIL_LOCALES} — skipping (vendored files will be used).`);
process.exit(0); process.exit(0);
} }
async function copyDir(src, dest) { function flatten(obj, prefix = '', out = new Map()) {
await mkdir(dest, { recursive: true }); for (const [k, v] of Object.entries(obj)) {
for (const entry of await readdir(src, { withFileTypes: true })) { const key = prefix ? `${prefix}.${k}` : k;
const s = join(src, entry.name); if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
const d = join(dest, entry.name); else out.set(key, v);
if (entry.isDirectory()) {
await copyDir(s, d);
} else if (entry.isFile() && entry.name.endsWith('.json')) {
await copyFile(s, d);
}
} }
return out;
}
async function readJson(path) {
return JSON.parse(await readFile(path, 'utf8'));
} }
const langs = (await readdir(WEBMAIL_LOCALES, { withFileTypes: true })) const langs = (await readdir(WEBMAIL_LOCALES, { withFileTypes: true }))
.filter((e) => e.isDirectory()) .filter((e) => e.isDirectory())
.map((e) => e.name); .map((e) => e.name)
.sort();
let copied = 0; let copied = 0;
let problems = 0;
for (const lang of langs) { for (const lang of langs) {
const src = join(WEBMAIL_LOCALES, lang); const src = join(WEBMAIL_LOCALES, lang, 'common.json');
const dest = join(RN_LOCALES, lang); if (!existsSync(src)) continue;
await copyDir(src, dest); const destDir = join(RN_LOCALES, lang);
copied++; const dest = join(destDir, 'common.json');
if (CHECK_ONLY) {
if (!existsSync(dest)) {
console.log(`missing: locales/${lang}/common.json`);
problems++;
continue;
}
const [a, b] = await Promise.all([readFile(src, 'utf8'), readFile(dest, 'utf8')]);
if (a !== b) {
console.log(`stale: locales/${lang}/common.json differs from the webmail`);
problems++;
}
} else {
await mkdir(destDir, { recursive: true });
await copyFile(src, dest);
copied++;
}
// Report overlay keys the webmail now ships (safe to delete from the overlay)
// so the RN-only set stays minimal.
const overlayPath = join(RN_OVERLAYS, `${lang}.json`);
if (existsSync(overlayPath)) {
const webKeys = flatten(await readJson(src));
const overlayKeys = flatten(await readJson(overlayPath));
const shadowed = [...overlayKeys.keys()].filter((k) => webKeys.has(k));
if (shadowed.length > 0) {
console.log(
`locales/rn/${lang}.json: ${shadowed.length} key(s) now exist in the webmail catalog and shadow it:\n ${shadowed.join('\n ')}`,
);
}
}
}
if (CHECK_ONLY) {
console.log(problems === 0 ? 'Vendored locales are up to date.' : `${problems} locale(s) out of date.`);
process.exit(problems === 0 ? 0 : 1);
} }
console.log(`Synced ${copied} locale(s) from ${WEBMAIL_LOCALES} → ${RN_LOCALES}`); console.log(`Synced ${copied} locale(s) from ${WEBMAIL_LOCALES} → ${RN_LOCALES}`);
+13 -6
View File
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native'; import { CloudOff } from 'lucide-react-native';
import { useNetworkStore } from '../stores/network-store'; import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store'; import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens'; import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
@@ -15,18 +16,24 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const online = useNetworkStore((s) => s.online); const online = useNetworkStore((s) => s.online);
const t = useLocaleStore((s) => s.t);
// Pending offline mutations that will replay once we're back online. // Pending offline mutations that will replay once we're back online.
const queued = useOutboxStore((s) => s.entries.length); const queued = useOutboxStore((s) => s.entries.length);
if (online) return null; if (online) return null;
const queuedHint = queued > 0 const queuedHint = queued > 0
? `${queued} change${queued === 1 ? '' : 's'} will sync when you reconnect` ? t(
'offline.queued_changes',
'{count, plural, one {# change} other {# changes}} will sync when you reconnect',
{ count: queued },
)
: null; : null;
const suffix = [hint, queuedHint].filter(Boolean).join(' · '); const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
const label = t('offline.banner', 'You are offline');
return ( return (
<View style={styles.bar}> <View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
<CloudOff size={14} color={c.primaryForeground} /> <CloudOff size={14} color={c.warningForeground} />
<Text style={styles.text} numberOfLines={1}> <Text style={styles.text} numberOfLines={1}>
You are offline{suffix ? ` — ${suffix}` : ''} {suffix ? `${label} — ${suffix}` : label}
</Text> </Text>
</View> </View>
); );
@@ -40,11 +47,11 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm, gap: spacing.sm,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: 6, paddingVertical: 6,
backgroundColor: '#a16207', backgroundColor: c.warning,
}, },
text: { text: {
...typography.caption, ...typography.caption,
color: c.primaryForeground, color: c.warningForeground,
flex: 1, flex: 1,
}, },
}); });
+14 -1
View File
@@ -57,6 +57,7 @@ export function LanguageSettings() {
const deviceLabel = const deviceLabel =
SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English'; SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English';
const directionChangePending = useLocaleStore((s) => s.directionChangePending);
return ( return (
<View style={{ gap: spacing.xxxl }}> <View style={{ gap: spacing.xxxl }}>
@@ -65,10 +66,21 @@ export function LanguageSettings() {
description={t('settings.appearance.language.description', 'Choose your preferred language')} description={t('settings.appearance.language.description', 'Choose your preferred language')}
> >
<View style={styles.list}> <View style={styles.list}>
{renderRow('system', `System default (${deviceLabel})`)} {renderRow(
'system',
`${t('settings.appearance.language.system_default', 'System default')} (${deviceLabel})`,
)}
<View style={styles.divider} /> <View style={styles.divider} />
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))} {SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
</View> </View>
{directionChangePending && (
<Text style={styles.hint}>
{t(
'settings.appearance.language.restart_for_direction',
'Restart the app to apply the new text direction.',
)}
</Text>
)}
</SettingsSection> </SettingsSection>
<SettingsSection <SettingsSection
@@ -131,6 +143,7 @@ function makeStyles(c: ThemePalette) {
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md }, divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
label: { ...typography.body, color: c.text }, label: { ...typography.body, color: c.text },
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 }, sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
hint: { ...typography.caption, color: c.warning, marginTop: spacing.sm },
previewBox: { previewBox: {
marginTop: spacing.md, marginTop: spacing.md,
padding: spacing.md, padding: spacing.md,
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { formatMessage, pluralCategory } from '../format';
describe('formatMessage', () => {
it('substitutes simple arguments', () => {
expect(formatMessage('Hello {name}!', { name: 'Ada' }, 'en')).toBe('Hello Ada!');
expect(formatMessage('{a} and {b}', { a: 1, b: 'two' }, 'en')).toBe('1 and two');
});
it('leaves unknown placeholders untouched so legacy .replace() callers still work', () => {
expect(formatMessage('Failed to upload {filename}', {}, 'en')).toBe('Failed to upload {filename}');
expect(formatMessage('Failed to upload {filename}', undefined, 'en')).toBe('Failed to upload {filename}');
expect(formatMessage('{a} {b}', { a: 'x' }, 'en')).toBe('x {b}');
});
it('formats ICU plurals with # substitution', () => {
const msg = '{count, plural, one {1 email} other {# emails}} selected';
expect(formatMessage(msg, { count: 1 }, 'en')).toBe('1 email selected');
expect(formatMessage(msg, { count: 5 }, 'en')).toBe('5 emails selected');
expect(formatMessage(msg, { count: 0 }, 'en')).toBe('0 emails selected');
});
it('prefers exact =N branches', () => {
const msg = '{count, plural, =0 {No attendees} one {# attendee} other {# attendees}}';
expect(formatMessage(msg, { count: 0 }, 'en')).toBe('No attendees');
expect(formatMessage(msg, { count: 1 }, 'en')).toBe('1 attendee');
expect(formatMessage(msg, { count: 2 }, 'en')).toBe('2 attendees');
});
it('uses locale plural rules when available', () => {
// Russian: 1 → one, 2 → few, 5 → many
const msg = '{count, plural, one {# письмо} few {# письма} many {# писем} other {# письма}}';
expect(formatMessage(msg, { count: 1 }, 'ru')).toBe('1 письмо');
expect(formatMessage(msg, { count: 2 }, 'ru')).toBe('2 письма');
expect(formatMessage(msg, { count: 5 }, 'ru')).toBe('5 писем');
});
it('handles nested arguments inside plural branches', () => {
const msg = '{count, plural, one {{name} has # item} other {{name} has # items}}';
expect(formatMessage(msg, { count: 3, name: 'Box' }, 'en')).toBe('Box has 3 items');
});
it('accepts numeric strings for plural arguments', () => {
expect(formatMessage('{n, plural, one {one} other {many}}', { n: '1' }, 'en')).toBe('one');
});
it('returns the raw placeholder when a plural argument is missing', () => {
const msg = '{count, plural, one {a} other {b}}';
expect(formatMessage(msg, {}, 'en')).toBe(msg);
});
it('tolerates unbalanced braces', () => {
expect(formatMessage('oops {name', { name: 'x' }, 'en')).toBe('oops {name');
});
});
describe('pluralCategory', () => {
it('follows English rules', () => {
expect(pluralCategory(1, 'en')).toBe('one');
expect(pluralCategory(0, 'en')).toBe('other');
expect(pluralCategory(2, 'en')).toBe('other');
});
it('falls back to the English rule for unknown locales', () => {
expect(pluralCategory(1, 'zz-not-a-locale')).toBe('one');
expect(pluralCategory(7, 'zz-not-a-locale')).toBe('other');
});
});
+83
View File
@@ -0,0 +1,83 @@
// Translation coverage: every literal key the app passes to t() must exist in
// the merged English catalog, and every vendored locale must be a subset of
// English (the webmail catalogs are generated from en, so an extra key means
// a sync went wrong). Mirrors the webmail's translation coverage test.
import { describe, it, expect, vi } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
vi.mock('expo-localization', () => ({ getLocales: () => [] }));
import { getDictionary, SUPPORTED_LOCALES, type LocaleCode } from '../index';
const SRC_ROOT = join(__dirname, '..', '..');
function flatten(obj: Record<string, unknown>, prefix = '', out = new Set<string>()): Set<string> {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v as Record<string, unknown>, key, out);
else out.add(key);
}
return out;
}
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) {
if (entry === '__tests__' || entry === 'node_modules') continue;
walk(p, out);
} else if (/\.tsx?$/.test(entry)) {
out.push(p);
}
}
return out;
}
// Literal keys: t('a.b.c', …). Template-literal keys (`settings.tabs.${id}`)
// are checked by prefix below.
const LITERAL_KEY_RE = /\bt\(\s*'([^']+)'/g;
const TEMPLATE_KEY_RE = /\bt\(\s*`([^`$]+)\$\{/g;
describe('translation coverage', () => {
const en = flatten(getDictionary('en'));
it('every literal t() key in src exists in the English catalog', () => {
const missing: string[] = [];
for (const file of walk(SRC_ROOT)) {
const text = readFileSync(file, 'utf8');
for (const m of text.matchAll(LITERAL_KEY_RE)) {
if (!en.has(m[1])) missing.push(`${m[1]} (${relative(SRC_ROOT, file)})`);
}
}
expect(missing, `keys missing from locales/en/common.json + locales/rn/en.json:\n${missing.join('\n')}`).toEqual([]);
});
it('every template t() key prefix has at least one English entry', () => {
const missing: string[] = [];
for (const file of walk(SRC_ROOT)) {
const text = readFileSync(file, 'utf8');
for (const m of text.matchAll(TEMPLATE_KEY_RE)) {
const prefix = m[1];
let found = false;
for (const key of en) {
if (key.startsWith(prefix)) { found = true; break; }
}
if (!found) missing.push(`${prefix}* (${relative(SRC_ROOT, file)})`);
}
}
expect(missing).toEqual([]);
});
it('every other locale is a subset of English', () => {
for (const { code } of SUPPORTED_LOCALES) {
if (code === 'en') continue;
const keys = flatten(getDictionary(code as LocaleCode));
const extra = [...keys].filter((k) => !en.has(k));
expect(extra, `${code} has keys English lacks`).toEqual([]);
// Catalogs are near-complete translations; a locale with fewer than
// 90% of the English keys is a sign of a broken vendored file.
expect(keys.size).toBeGreaterThan(en.size * 0.9);
}
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('expo-localization', () => ({ getLocales: () => [] }));
import { getLocaleDirection, resolveLocaleTag, translate } from '../index';
describe('resolveLocaleTag', () => {
it('matches on the bare language code', () => {
expect(resolveLocaleTag({ languageCode: 'de', languageTag: 'de-AT' })).toBe('de');
expect(resolveLocaleTag({ languageCode: 'pt', languageTag: 'pt-BR' })).toBe('pt');
});
it('routes Traditional Chinese to zh-TW', () => {
expect(resolveLocaleTag({ languageCode: 'zh', languageTag: 'zh-Hant-TW', languageScriptCode: 'Hant', regionCode: 'TW' })).toBe('zh-TW');
expect(resolveLocaleTag({ languageCode: 'zh', languageTag: 'zh-TW' })).toBe('zh-TW');
expect(resolveLocaleTag({ languageCode: 'zh', languageTag: 'zh-HK' })).toBe('zh-TW');
expect(resolveLocaleTag({ languageCode: 'zh', languageTag: 'zh-Hans-CN', languageScriptCode: 'Hans', regionCode: 'CN' })).toBe('zh');
expect(resolveLocaleTag({ languageCode: 'zh', languageTag: 'zh' })).toBe('zh');
});
it('maps Norwegian variants to nb', () => {
expect(resolveLocaleTag({ languageCode: 'no', languageTag: 'no-NO' })).toBe('nb');
expect(resolveLocaleTag({ languageCode: 'nb', languageTag: 'nb-NO' })).toBe('nb');
});
it('returns null for unsupported languages', () => {
expect(resolveLocaleTag({ languageCode: 'xx', languageTag: 'xx-XX' })).toBeNull();
expect(resolveLocaleTag({})).toBeNull();
});
});
describe('getLocaleDirection', () => {
it('flags Arabic, Hebrew and Persian as RTL', () => {
expect(getLocaleDirection('ar')).toBe('rtl');
expect(getLocaleDirection('he')).toBe('rtl');
expect(getLocaleDirection('fa')).toBe('rtl');
expect(getLocaleDirection('en')).toBe('ltr');
expect(getLocaleDirection('de')).toBe('ltr');
});
});
describe('translate', () => {
it('falls back locale → en → fallback → key', () => {
expect(translate('de', 'settings.title')).not.toBe('settings.title');
expect(translate('de', 'nope.missing', 'Fallback')).toBe('Fallback');
expect(translate('de', 'nope.missing')).toBe('nope.missing');
});
it('interpolates params and plurals from the catalog', () => {
const one = translate('en', 'email_list.batch_actions.selected_messages', undefined, { count: 1 });
const many = translate('en', 'email_list.batch_actions.selected_messages', undefined, { count: 3 });
expect(one).toBe('1 email selected');
expect(many).toBe('3 emails selected');
});
it('serves RN-only overlay keys in every locale via the en fallback', () => {
expect(translate('fr', 'email_list.no_trash_folder')).toContain('Trash');
});
});
+168
View File
@@ -0,0 +1,168 @@
// Minimal ICU MessageFormat subset, enough for the webmail catalogs:
//
// "Hello {name}" → argument
// "{count, plural, one {# email} other {# emails}}" → plural
// "{count, plural, =0 {none} one {one} other {# more}}" → exact matches
//
// next-intl (the webmail) uses the full ICU grammar, but a scan of every
// catalog shows only these two forms in use (no select/number/date), so a
// hand-rolled parser keeps a heavy dependency out of the Hermes bundle.
// Unknown placeholders are left untouched so callers that still do
// `.replace('{x}', …)` on the result keep working.
export type MessageParams = Record<string, string | number | null | undefined>;
type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
const pluralRulesCache = new Map<string, Intl.PluralRules | null>();
function pluralRulesFor(locale: string): Intl.PluralRules | null {
if (pluralRulesCache.has(locale)) return pluralRulesCache.get(locale) ?? null;
let rules: Intl.PluralRules | null = null;
try {
if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') {
rules = new Intl.PluralRules(locale);
}
} catch {
rules = null;
}
pluralRulesCache.set(locale, rules);
return rules;
}
/**
* CLDR plural category for `n` in `locale`. Falls back to the English rule
* (1 → one, everything else → other) when Intl.PluralRules is unavailable
* (older Hermes builds) or rejects the locale.
*/
export function pluralCategory(n: number, locale: string): PluralCategory {
const rules = pluralRulesFor(locale);
if (rules) {
try {
return rules.select(n) as PluralCategory;
} catch {
// fall through
}
}
return n === 1 ? 'one' : 'other';
}
// Find the index of the `}` that closes the `{` at `open`, honouring nesting.
function findClosingBrace(text: string, open: number): number {
let depth = 0;
for (let i = open; i < text.length; i++) {
const ch = text[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
// Parse the `one {…} other {…}` branch list of a plural argument.
function parsePluralBranches(body: string): Map<string, string> {
const branches = new Map<string, string>();
let i = 0;
while (i < body.length) {
while (i < body.length && /\s/.test(body[i])) i++;
if (i >= body.length) break;
let selector = '';
while (i < body.length && body[i] !== '{' && !/\s/.test(body[i])) {
selector += body[i];
i++;
}
while (i < body.length && /\s/.test(body[i])) i++;
if (body[i] !== '{') break;
const close = findClosingBrace(body, i);
if (close === -1) break;
branches.set(selector, body.slice(i + 1, close));
i = close + 1;
}
return branches;
}
function formatNumber(n: number, locale: string): string {
try {
if (typeof Intl !== 'undefined' && typeof Intl.NumberFormat === 'function') {
return new Intl.NumberFormat(locale).format(n);
}
} catch {
// fall through
}
return String(n);
}
function selectPluralBranch(
branches: Map<string, string>,
n: number,
locale: string,
): string | undefined {
const exact = branches.get(`=${n}`);
if (exact !== undefined) return exact;
const category = pluralCategory(n, locale);
return branches.get(category) ?? branches.get('other');
}
/**
* Substitute `{name}` and `{count, plural, …}` arguments in `message`.
* Placeholders whose argument is not in `params` are returned verbatim.
*/
export function formatMessage(
message: string,
params: MessageParams | undefined,
locale: string,
): string {
if (!params || message.indexOf('{') === -1) return message;
let out = '';
let i = 0;
while (i < message.length) {
const open = message.indexOf('{', i);
if (open === -1) {
out += message.slice(i);
break;
}
const close = findClosingBrace(message, open);
if (close === -1) {
out += message.slice(i);
break;
}
out += message.slice(i, open);
const inner = message.slice(open + 1, close);
const raw = message.slice(open, close + 1);
const comma = inner.indexOf(',');
if (comma === -1) {
const name = inner.trim();
const value = params[name];
out += value === undefined || value === null ? raw : String(value);
} else {
const name = inner.slice(0, comma).trim();
const rest = inner.slice(comma + 1);
const comma2 = rest.indexOf(',');
const type = (comma2 === -1 ? rest : rest.slice(0, comma2)).trim();
const value = params[name];
if (type === 'plural' && comma2 !== -1 && value !== undefined && value !== null) {
const n = typeof value === 'number' ? value : Number(value);
const branches = parsePluralBranches(rest.slice(comma2 + 1));
const branch = selectPluralBranch(branches, n, locale);
if (branch === undefined) {
out += raw;
} else {
// `#` inside a branch is the formatted number; branches may nest
// further arguments, so format them recursively.
out += formatMessage(branch.replace(/#/g, formatNumber(n, locale)), params, locale);
}
} else if (value !== undefined && value !== null) {
// `{name, number}` / unknown types: plain substitution.
out += String(value);
} else {
out += raw;
}
}
i = close + 1;
}
return out;
}
+149 -20
View File
@@ -1,61 +1,184 @@
// Minimal i18n: lookup keys like `settings.tabs.account` against a flat // Minimal i18n: lookup keys like `settings.tabs.account` against a nested
// dictionary loaded from ./locales/<lang>/common.json. Picks language from // dictionary loaded from ../../locales/<lang>/common.json (vendored from the
// the user override (locale-store) → device locale → English fallback. // webmail via scripts/sync-locales.mjs), overlaid with RN-only keys from
// ../../locales/rn/<lang>.json. Picks language from the user override
// (locale-store) → device locale → English fallback. Messages support the
// ICU subset in ./format (arguments + plural).
import { I18nManager } from 'react-native';
import { getLocales } from 'expo-localization'; import { getLocales } from 'expo-localization';
import en from '../../locales/en/common.json'; import { formatMessage, type MessageParams } from './format';
import de from '../../locales/de/common.json';
import ar from '../../locales/ar/common.json';
import ca from '../../locales/ca/common.json';
import cs from '../../locales/cs/common.json'; import cs from '../../locales/cs/common.json';
import da from '../../locales/da/common.json';
import de from '../../locales/de/common.json';
import en from '../../locales/en/common.json';
import es from '../../locales/es/common.json'; import es from '../../locales/es/common.json';
import fa from '../../locales/fa/common.json';
import fr from '../../locales/fr/common.json'; import fr from '../../locales/fr/common.json';
import he from '../../locales/he/common.json';
import hu from '../../locales/hu/common.json';
import it from '../../locales/it/common.json'; import it from '../../locales/it/common.json';
import ja from '../../locales/ja/common.json'; import ja from '../../locales/ja/common.json';
import ko from '../../locales/ko/common.json'; import ko from '../../locales/ko/common.json';
import lv from '../../locales/lv/common.json'; import lv from '../../locales/lv/common.json';
import mn from '../../locales/mn/common.json';
import nb from '../../locales/nb/common.json';
import nl from '../../locales/nl/common.json'; import nl from '../../locales/nl/common.json';
import pl from '../../locales/pl/common.json'; import pl from '../../locales/pl/common.json';
import pt from '../../locales/pt/common.json'; import pt from '../../locales/pt/common.json';
import ro from '../../locales/ro/common.json';
import ru from '../../locales/ru/common.json'; import ru from '../../locales/ru/common.json';
import sk from '../../locales/sk/common.json';
import tr from '../../locales/tr/common.json';
import uk from '../../locales/uk/common.json'; import uk from '../../locales/uk/common.json';
import zh from '../../locales/zh/common.json'; import zh from '../../locales/zh/common.json';
import zhTW from '../../locales/zh-TW/common.json';
// Keys the native app needs that the webmail catalog does not carry. Only an
// English overlay exists today; other languages fall through to it via the
// en fallback below.
import rnEn from '../../locales/rn/en.json';
export type { MessageParams } from './format';
// Same order and labels as the webmail language switcher
// (components/ui/language-switcher.tsx).
export const SUPPORTED_LOCALES = [ export const SUPPORTED_LOCALES = [
{ code: 'en', label: 'English' }, { code: 'ar', label: 'العربية' },
{ code: 'cs', label: 'Čeština' }, { code: 'ca', label: 'Català' },
{ code: 'cs', label: 'Česky' },
{ code: 'sk', label: 'Slovenčina' },
{ code: 'da', label: 'Dansk' },
{ code: 'de', label: 'Deutsch' }, { code: 'de', label: 'Deutsch' },
{ code: 'en', label: 'English' },
{ code: 'fa', label: 'فارسی' },
{ code: 'es', label: 'Español' }, { code: 'es', label: 'Español' },
{ code: 'fr', label: 'Français' }, { code: 'fr', label: 'Français' },
{ code: 'he', label: 'עברית' },
{ code: 'it', label: 'Italiano' }, { code: 'it', label: 'Italiano' },
{ code: 'hu', label: 'Magyar' },
{ code: 'lv', label: 'Latviešu' },
{ code: 'nl', label: 'Nederlands' }, { code: 'nl', label: 'Nederlands' },
{ code: 'nb', label: 'Norsk bokmål' },
{ code: 'pl', label: 'Polski' }, { code: 'pl', label: 'Polski' },
{ code: 'pt', label: 'Português' }, { code: 'pt', label: 'Português' },
{ code: 'ro', label: 'Română' },
{ code: 'tr', label: 'Türkçe' },
{ code: 'ru', label: 'Русский' }, { code: 'ru', label: 'Русский' },
{ code: 'uk', label: 'Українська' }, { code: 'uk', label: 'Українська' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' }, { code: 'ko', label: '한국어' },
{ code: 'zh', label: '中文' }, { code: 'ja', label: '日本語' },
{ code: 'lv', label: 'Latviešu' }, { code: 'mn', label: 'Монгол' },
{ code: 'zh', label: '简体中文' },
{ code: 'zh-TW', label: '繁體中文(台灣)' },
] as const; ] as const;
export type LocaleCode = typeof SUPPORTED_LOCALES[number]['code']; export type LocaleCode = typeof SUPPORTED_LOCALES[number]['code'];
const dictionaries: Record<LocaleCode, Record<string, unknown>> = { type Dictionary = Record<string, unknown>;
en, cs, de, es, fr, it, nl, pl, pt, ru, uk, ja, ko, zh, lv,
function deepMerge(base: Dictionary, overlay: Dictionary): Dictionary {
const out: Dictionary = { ...base };
for (const [k, v] of Object.entries(overlay)) {
const existing = out[k];
if (
v && typeof v === 'object' && !Array.isArray(v)
&& existing && typeof existing === 'object' && !Array.isArray(existing)
) {
out[k] = deepMerge(existing as Dictionary, v as Dictionary);
} else {
out[k] = v;
}
}
return out;
}
const RN_OVERLAYS: Partial<Record<LocaleCode, Dictionary>> = {
en: rnEn as Dictionary,
}; };
const BASE_DICTIONARIES: Record<LocaleCode, Dictionary> = {
ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, mn, nb, nl, pl,
pt, ro, ru, sk, tr, uk, zh, 'zh-TW': zhTW,
};
const dictionaries: Record<LocaleCode, Dictionary> = Object.fromEntries(
(Object.keys(BASE_DICTIONARIES) as LocaleCode[]).map((code) => {
const overlay = RN_OVERLAYS[code];
return [code, overlay ? deepMerge(BASE_DICTIONARIES[code], overlay) : BASE_DICTIONARIES[code]];
}),
) as Record<LocaleCode, Dictionary>;
// Test/tooling hook: the fully merged catalog for a locale.
export function getDictionary(locale: LocaleCode): Dictionary {
return dictionaries[locale];
}
export function isSupportedLocale(code: string): code is LocaleCode {
return (SUPPORTED_LOCALES as readonly { code: string }[]).some((l) => l.code === code);
}
// RTL locales: Arabic, Hebrew and Persian. Mirrors the webmail i18n/direction.ts.
const RTL_LOCALES: ReadonlySet<string> = new Set(['ar', 'he', 'fa']);
export function getLocaleDirection(locale: string): 'ltr' | 'rtl' {
return RTL_LOCALES.has(locale) ? 'rtl' : 'ltr';
}
/**
* Whether the running app is laid out right-to-left. Gesture code that maps
* "swipe left/right" to actions should swap sides when this is true - RN
* mirrors flexbox but not the physical direction of a pan.
*/
export function isLayoutRTL(): boolean {
const manager = I18nManager as typeof I18nManager | undefined;
return Boolean(manager?.isRTL);
}
/**
* Map a device locale to a catalog. Traditional Chinese (any `zh-Hant-*`
* tag, or Taiwan/Hong Kong/Macau regions) resolves to `zh-TW`; every other
* language matches on the bare language code.
*/
export function resolveLocaleTag(input: {
languageTag?: string | null;
languageCode?: string | null;
languageScriptCode?: string | null;
regionCode?: string | null;
}): LocaleCode | null {
const lang = input.languageCode?.toLowerCase() ?? input.languageTag?.split('-')[0]?.toLowerCase();
if (!lang) return null;
if (lang === 'zh') {
const tag = (input.languageTag ?? '').toLowerCase();
const script = input.languageScriptCode?.toLowerCase();
const region = input.regionCode?.toUpperCase();
const traditional =
script === 'hant'
|| tag.includes('hant')
|| tag.endsWith('-tw') || tag.endsWith('-hk') || tag.endsWith('-mo')
|| region === 'TW' || region === 'HK' || region === 'MO';
return traditional ? 'zh-TW' : 'zh';
}
// Norwegian devices report `no`/`nn` as often as `nb`.
if (lang === 'no' || lang === 'nn') return 'nb';
return isSupportedLocale(lang) ? lang : null;
}
export function detectDeviceLocale(): LocaleCode { export function detectDeviceLocale(): LocaleCode {
const codes = SUPPORTED_LOCALES.map((l) => l.code) as readonly string[];
for (const l of getLocales()) { for (const l of getLocales()) {
const lang = l.languageCode?.toLowerCase(); const resolved = resolveLocaleTag(l);
if (lang && codes.includes(lang)) return lang as LocaleCode; if (resolved) return resolved;
} }
return 'en'; return 'en';
} }
function lookup(dict: Record<string, unknown>, key: string): string | undefined { function lookup(dict: Dictionary, key: string): string | undefined {
let current: unknown = dict; let current: unknown = dict;
for (const part of key.split('.')) { for (const part of key.split('.')) {
if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) { if (current && typeof current === 'object' && part in (current as Dictionary)) {
current = (current as Record<string, unknown>)[part]; current = (current as Dictionary)[part];
} else { } else {
return undefined; return undefined;
} }
@@ -63,9 +186,15 @@ function lookup(dict: Record<string, unknown>, key: string): string | undefined
return typeof current === 'string' ? current : undefined; return typeof current === 'string' ? current : undefined;
} }
export function translate(locale: LocaleCode, key: string, fallback?: string): string { export function translate(
return lookup(dictionaries[locale], key) locale: LocaleCode,
key: string,
fallback?: string,
params?: MessageParams,
): string {
const message = lookup(dictionaries[locale], key)
?? lookup(dictionaries.en, key) ?? lookup(dictionaries.en, key)
?? fallback ?? fallback
?? key; ?? key;
return formatMessage(message, params, locale);
} }
+50 -6
View File
@@ -1,30 +1,68 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { I18nManager } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { detectDeviceLocale, translate, type LocaleCode } from '../i18n'; import {
detectDeviceLocale,
getLocaleDirection,
isSupportedLocale,
translate,
type LocaleCode,
type MessageParams,
} from '../i18n';
const STORAGE_KEY = 'webmail:locale:v1'; const STORAGE_KEY = 'webmail:locale:v1';
export type TranslateFn = (key: string, fallback?: string, params?: MessageParams) => string;
interface LocaleState { interface LocaleState {
locale: LocaleCode; locale: LocaleCode;
override: LocaleCode | null; override: LocaleCode | null;
hydrated: boolean; hydrated: boolean;
/**
* True when the layout direction the current locale needs differs from
* what the running app was laid out with. RN only applies forceRTL on the
* next launch, so the language pane shows a "restart to apply" hint.
*/
directionChangePending: boolean;
hydrate: () => Promise<void>; hydrate: () => Promise<void>;
setOverride: (locale: LocaleCode | null) => void; setOverride: (locale: LocaleCode | null) => void;
t: (key: string, fallback?: string) => string; t: TranslateFn;
}
/**
* Ask RN to mirror layouts for RTL locales (ar/he/fa). The device locale is
* handled by the OS (`android:supportsRtl`), but an in-app override needs an
* explicit forceRTL, which only takes effect after a restart. Returns true
* when the running layout does not match the requested direction yet.
*/
function applyLayoutDirection(locale: LocaleCode): boolean {
const rtl = getLocaleDirection(locale) === 'rtl';
const manager = I18nManager as typeof I18nManager | undefined;
if (!manager || typeof manager.forceRTL !== 'function') return false;
try {
manager.allowRTL(rtl);
manager.forceRTL(rtl);
} catch {
return false;
}
return manager.isRTL !== rtl;
} }
export const useLocaleStore = create<LocaleState>((set, get) => ({ export const useLocaleStore = create<LocaleState>((set, get) => ({
locale: detectDeviceLocale(), locale: detectDeviceLocale(),
override: null, override: null,
hydrated: false, hydrated: false,
directionChangePending: false,
hydrate: async () => { hydrate: async () => {
if (get().hydrated) return; if (get().hydrated) return;
try { try {
const raw = await AsyncStorage.getItem(STORAGE_KEY); const raw = await AsyncStorage.getItem(STORAGE_KEY);
const override = raw ? (JSON.parse(raw).override as LocaleCode | null) : null; const stored = raw ? (JSON.parse(raw).override as string | null) : null;
const override = stored && isSupportedLocale(stored) ? stored : null;
const locale = override ?? detectDeviceLocale(); const locale = override ?? detectDeviceLocale();
set({ override, locale, hydrated: true }); const directionChangePending = applyLayoutDirection(locale);
set({ override, locale, hydrated: true, directionChangePending });
} catch { } catch {
set({ hydrated: true }); set({ hydrated: true });
} }
@@ -32,9 +70,15 @@ export const useLocaleStore = create<LocaleState>((set, get) => ({
setOverride: (override) => { setOverride: (override) => {
const locale = override ?? detectDeviceLocale(); const locale = override ?? detectDeviceLocale();
set({ override, locale }); const directionChangePending = applyLayoutDirection(locale);
set({ override, locale, directionChangePending });
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ override })).catch(() => {}); void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ override })).catch(() => {});
}, },
t: (key, fallback) => translate(get().locale, key, fallback), t: (key, fallback, params) => translate(get().locale, key, fallback, params),
})); }));
// Non-hook accessor for code outside React (background tasks, stores).
export function t(key: string, fallback?: string, params?: MessageParams): string {
return useLocaleStore.getState().t(key, fallback, params);
}