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
+13 -6
View File
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -15,18 +16,24 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const online = useNetworkStore((s) => s.online);
const t = useLocaleStore((s) => s.t);
// Pending offline mutations that will replay once we're back online.
const queued = useOutboxStore((s) => s.entries.length);
if (online) return null;
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;
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
const label = t('offline.banner', 'You are offline');
return (
<View style={styles.bar}>
<CloudOff size={14} color={c.primaryForeground} />
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
<CloudOff size={14} color={c.warningForeground} />
<Text style={styles.text} numberOfLines={1}>
You are offline{suffix ? ` — ${suffix}` : ''}
{suffix ? `${label} — ${suffix}` : label}
</Text>
</View>
);
@@ -40,11 +47,11 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: '#a16207',
backgroundColor: c.warning,
},
text: {
...typography.caption,
color: c.primaryForeground,
color: c.warningForeground,
flex: 1,
},
});
+14 -1
View File
@@ -57,6 +57,7 @@ export function LanguageSettings() {
const deviceLabel =
SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English';
const directionChangePending = useLocaleStore((s) => s.directionChangePending);
return (
<View style={{ gap: spacing.xxxl }}>
@@ -65,10 +66,21 @@ export function LanguageSettings() {
description={t('settings.appearance.language.description', 'Choose your preferred language')}
>
<View style={styles.list}>
{renderRow('system', `System default (${deviceLabel})`)}
{renderRow(
'system',
`${t('settings.appearance.language.system_default', 'System default')} (${deviceLabel})`,
)}
<View style={styles.divider} />
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
</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
@@ -131,6 +143,7 @@ function makeStyles(c: ThemePalette) {
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
label: { ...typography.body, color: c.text },
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
hint: { ...typography.caption, color: c.warning, marginTop: spacing.sm },
previewBox: {
marginTop: 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
// dictionary loaded from ./locales/<lang>/common.json. Picks language from
// the user override (locale-store) → device locale → English fallback.
// Minimal i18n: lookup keys like `settings.tabs.account` against a nested
// dictionary loaded from ../../locales/<lang>/common.json (vendored from the
// 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 en from '../../locales/en/common.json';
import de from '../../locales/de/common.json';
import { formatMessage, type MessageParams } from './format';
import ar from '../../locales/ar/common.json';
import ca from '../../locales/ca/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 fa from '../../locales/fa/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 ja from '../../locales/ja/common.json';
import ko from '../../locales/ko/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 pl from '../../locales/pl/common.json';
import pt from '../../locales/pt/common.json';
import ro from '../../locales/ro/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 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 = [
{ code: 'en', label: 'English' },
{ code: 'cs', label: 'Čeština' },
{ code: 'ar', label: 'العربية' },
{ code: 'ca', label: 'Català' },
{ code: 'cs', label: 'Česky' },
{ code: 'sk', label: 'Slovenčina' },
{ code: 'da', label: 'Dansk' },
{ code: 'de', label: 'Deutsch' },
{ code: 'en', label: 'English' },
{ code: 'fa', label: 'فارسی' },
{ code: 'es', label: 'Español' },
{ code: 'fr', label: 'Français' },
{ code: 'he', label: 'עברית' },
{ code: 'it', label: 'Italiano' },
{ code: 'hu', label: 'Magyar' },
{ code: 'lv', label: 'Latviešu' },
{ code: 'nl', label: 'Nederlands' },
{ code: 'nb', label: 'Norsk bokmål' },
{ code: 'pl', label: 'Polski' },
{ code: 'pt', label: 'Português' },
{ code: 'ro', label: 'Română' },
{ code: 'tr', label: 'Türkçe' },
{ code: 'ru', label: 'Русский' },
{ code: 'uk', label: 'Українська' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'zh', label: '中文' },
{ code: 'lv', label: 'Latviešu' },
{ code: 'ja', label: '日本語' },
{ code: 'mn', label: 'Монгол' },
{ code: 'zh', label: '简体中文' },
{ code: 'zh-TW', label: '繁體中文(台灣)' },
] as const;
export type LocaleCode = typeof SUPPORTED_LOCALES[number]['code'];
const dictionaries: Record<LocaleCode, Record<string, unknown>> = {
en, cs, de, es, fr, it, nl, pl, pt, ru, uk, ja, ko, zh, lv,
type Dictionary = Record<string, unknown>;
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 {
const codes = SUPPORTED_LOCALES.map((l) => l.code) as readonly string[];
for (const l of getLocales()) {
const lang = l.languageCode?.toLowerCase();
if (lang && codes.includes(lang)) return lang as LocaleCode;
const resolved = resolveLocaleTag(l);
if (resolved) return resolved;
}
return 'en';
}
function lookup(dict: Record<string, unknown>, key: string): string | undefined {
function lookup(dict: Dictionary, key: string): string | undefined {
let current: unknown = dict;
for (const part of key.split('.')) {
if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) {
current = (current as Record<string, unknown>)[part];
if (current && typeof current === 'object' && part in (current as Dictionary)) {
current = (current as Dictionary)[part];
} else {
return undefined;
}
@@ -63,9 +186,15 @@ function lookup(dict: Record<string, unknown>, key: string): string | undefined
return typeof current === 'string' ? current : undefined;
}
export function translate(locale: LocaleCode, key: string, fallback?: string): string {
return lookup(dictionaries[locale], key)
export function translate(
locale: LocaleCode,
key: string,
fallback?: string,
params?: MessageParams,
): string {
const message = lookup(dictionaries[locale], key)
?? lookup(dictionaries.en, key)
?? fallback
?? key;
return formatMessage(message, params, locale);
}
+50 -6
View File
@@ -1,30 +1,68 @@
import { create } from 'zustand';
import { I18nManager } from 'react-native';
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';
export type TranslateFn = (key: string, fallback?: string, params?: MessageParams) => string;
interface LocaleState {
locale: LocaleCode;
override: LocaleCode | null;
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>;
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) => ({
locale: detectDeviceLocale(),
override: null,
hydrated: false,
directionChangePending: false,
hydrate: async () => {
if (get().hydrated) return;
try {
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();
set({ override, locale, hydrated: true });
const directionChangePending = applyLayoutDirection(locale);
set({ override, locale, hydrated: true, directionChangePending });
} catch {
set({ hydrated: true });
}
@@ -32,9 +70,15 @@ export const useLocaleStore = create<LocaleState>((set, get) => ({
setOverride: (override) => {
const locale = override ?? detectDeviceLocale();
set({ override, locale });
const directionChangePending = applyLayoutDirection(locale);
set({ override, locale, directionChangePending });
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);
}