mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: add ICU interpolation, all 27 webmail locales and a key-parity test to i18n
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user