mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: add ICU interpolation, all 27 webmail locales and a key-parity test to i18n
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+824
-70
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+825
-66
File diff suppressed because it is too large
Load Diff
+813
-67
File diff suppressed because it is too large
Load Diff
+858
-99
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+860
-101
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
File diff suppressed because it is too large
Load Diff
+860
-101
File diff suppressed because it is too large
Load Diff
+860
-101
File diff suppressed because it is too large
Load Diff
+858
-99
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
File diff suppressed because it is too large
Load Diff
+860
-101
File diff suppressed because it is too large
Load Diff
+894
-135
File diff suppressed because it is too large
Load Diff
@@ -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
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+858
-99
File diff suppressed because it is too large
Load Diff
+65
-18
@@ -1,43 +1,90 @@
|
||||
// Copy locale JSON files from the parent webmail repo into ./locales/.
|
||||
// Run from the RN repo root: `node scripts/sync-locales.mjs`.
|
||||
// Vendor the webmail locale catalogs into ./locales/<lang>/common.json.
|
||||
// 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
|
||||
// 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 { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const RN_LOCALES = join(__dirname, '..', 'locales');
|
||||
const RN_OVERLAYS = join(RN_LOCALES, 'rn');
|
||||
const WEBMAIL_LOCALES = join(__dirname, '..', '..', '..', 'locales');
|
||||
const CHECK_ONLY = process.argv.includes('--check');
|
||||
|
||||
if (!existsSync(WEBMAIL_LOCALES)) {
|
||||
console.log(`No webmail locales at ${WEBMAIL_LOCALES} — skipping (vendored files will be used).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function copyDir(src, dest) {
|
||||
await mkdir(dest, { recursive: true });
|
||||
for (const entry of await readdir(src, { withFileTypes: true })) {
|
||||
const s = join(src, entry.name);
|
||||
const d = join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(s, d);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||
await copyFile(s, d);
|
||||
}
|
||||
function flatten(obj, prefix = '', out = new Map()) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out.set(key, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
}
|
||||
|
||||
const langs = (await readdir(WEBMAIL_LOCALES, { withFileTypes: true }))
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name);
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
let copied = 0;
|
||||
let problems = 0;
|
||||
for (const lang of langs) {
|
||||
const src = join(WEBMAIL_LOCALES, lang);
|
||||
const dest = join(RN_LOCALES, lang);
|
||||
await copyDir(src, dest);
|
||||
copied++;
|
||||
const src = join(WEBMAIL_LOCALES, lang, 'common.json');
|
||||
if (!existsSync(src)) continue;
|
||||
const destDir = join(RN_LOCALES, lang);
|
||||
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}`);
|
||||
|
||||
@@ -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