From 57e43b22c732235d1d8e3a3648991f099410534d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:55:45 +0000 Subject: [PATCH 1/3] feat(i18n): ship the Console's ten locales and follow the phone language Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- __tests__/components/RtlLayout.test.tsx | 110 +++++++++++ __tests__/lib/i18n.test.ts | 237 +++++++++++++++++++++--- app/_layout.tsx | 16 +- app/server-config.tsx | 6 +- components/shell/OfflineFallback.tsx | 3 + lib/i18n.ts | 153 +++++++++++---- lib/rtl.ts | 25 +++ locales/de.json | 59 ++++++ locales/es.json | 59 ++++++ locales/fr.json | 59 ++++++ locales/ja.json | 59 ++++++ locales/ko.json | 59 ++++++ locales/pt.json | 59 ++++++ locales/ru.json | 59 ++++++ 14 files changed, 900 insertions(+), 63 deletions(-) create mode 100644 __tests__/components/RtlLayout.test.tsx create mode 100644 locales/de.json create mode 100644 locales/es.json create mode 100644 locales/fr.json create mode 100644 locales/ja.json create mode 100644 locales/ko.json create mode 100644 locales/pt.json create mode 100644 locales/ru.json diff --git a/__tests__/components/RtlLayout.test.tsx b/__tests__/components/RtlLayout.test.tsx new file mode 100644 index 0000000..fe83a0c --- /dev/null +++ b/__tests__/components/RtlLayout.test.tsx @@ -0,0 +1,110 @@ +/** + * Native screens lay out right-to-left under `ar`, as the Console does, and + * left-to-right under every other shell language. The direction is Yoga's + * `direction` style on each screen root, so it applies on first render and + * follows a language change without a restart. + */ +import React from "react"; +import { act, render, screen } from "@testing-library/react-native"; +import { StyleSheet } from "react-native"; +// Types for the matchers jest.config registers through setupFilesAfterEnv. +import type {} from "@testing-library/react-native/extend-expect"; +import i18n from "~/lib/i18n"; +import { layoutDirection } from "~/lib/rtl"; + +jest.mock("expo-router", () => ({ + useRouter: () => ({ dismissTo: jest.fn(), push: jest.fn(), replace: jest.fn() }), +})); + +jest.mock("react-native-safe-area-context", () => { + const { View } = require("react-native"); + return { SafeAreaView: View }; +}); + +import ServerConfigScreen from "~/app/server-config"; +import { OfflineFallback } from "~/components/shell/OfflineFallback"; + +/** The direction on the outermost host view the screen renders. */ +function rootDirection(): unknown { + const tree = screen.toJSON(); + const root = Array.isArray(tree) ? tree[0] : tree; + return StyleSheet.flatten(root?.props.style)?.direction; +} + +const offline = ( + +); + +/** + * Cold-load cost, paid once outside any test's 5 s budget — the same warm-up as + * ServerConfigScreen.test.tsx: react-native's lazy component getters are + * required and transformed on the first render, which on a cold CI transform + * cache exceeded the per-test timeout. + */ +const COLD_LOAD_TIMEOUT_MS = 30_000; +beforeAll(() => { + render().unmount(); + render(offline).unmount(); +}, COLD_LOAD_TIMEOUT_MS); + +afterEach(async () => { + await act(async () => { + await i18n.changeLanguage("en"); + }); +}); + +describe("layoutDirection", () => { + it("is rtl for Arabic and ltr for the other nine shell languages", () => { + expect(layoutDirection("ar")).toBe("rtl"); + for (const code of ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru"]) { + expect(layoutDirection(code)).toBe("ltr"); + } + }); +}); + +describe("server picker", () => { + it("renders left-to-right under en", () => { + render(); + expect(rootDirection()).toBe("ltr"); + }); + + it("renders right-to-left, in Arabic, under ar", async () => { + await act(async () => { + await i18n.changeLanguage("ar"); + }); + render(); + expect(rootDirection()).toBe("rtl"); + expect(screen.getByText("الاتصال بالخادم")).toBeTruthy(); + }); + + it("flips direction live when the language changes while mounted", async () => { + render(); + expect(rootDirection()).toBe("ltr"); + await act(async () => { + await i18n.changeLanguage("ar"); + }); + expect(rootDirection()).toBe("rtl"); + await act(async () => { + await i18n.changeLanguage("ja"); + }); + expect(rootDirection()).toBe("ltr"); + expect(screen.getByText("サーバーに接続")).toBeTruthy(); + }); +}); + +describe("offline page", () => { + it("renders right-to-left under ar and left-to-right under en", async () => { + render(offline); + expect(rootDirection()).toBe("ltr"); + await act(async () => { + await i18n.changeLanguage("ar"); + }); + expect(rootDirection()).toBe("rtl"); + expect(screen.getByText("أنت غير متصل")).toBeTruthy(); + }); +}); diff --git a/__tests__/lib/i18n.test.ts b/__tests__/lib/i18n.test.ts index 8e68762..16b4330 100644 --- a/__tests__/lib/i18n.test.ts +++ b/__tests__/lib/i18n.test.ts @@ -1,18 +1,42 @@ +import { readdirSync, readFileSync } from "fs"; +import { join } from "path"; +import { AppState } from "react-native"; +import * as Localization from "expo-localization"; import i18n, { + DEFAULT_LANGUAGE, detectDeviceLanguage, + followDeviceLanguage, + followDeviceLanguageOnForeground, isRTL, + matchLanguageTag, + resolveLanguage, SUPPORTED_LANGUAGES, RTL_LANGUAGES, } from "~/lib/i18n"; +/** + * The Console UI's ten built-in catalogue codes, in its order (objectstack + * `ui/packages/i18n/src/locales/registry.ts`, `BUILT_IN_LANGUAGE_CODES`). + * The shell must match this list one-to-one. + */ +const CONSOLE_LANGUAGE_CODES = ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru", "ar"]; + +const LOCALES_DIR = join(__dirname, "..", "..", "locales"); + +const tags = (...languageTags: string[]) => languageTags.map((languageTag) => ({ languageTag })); + +afterEach(async () => { + await i18n.changeLanguage("en"); +}); + describe("i18n configuration", () => { it("exports a configured i18next instance", () => { expect(i18n).toBeDefined(); expect(i18n.isInitialized).toBe(true); }); - it("defaults to English", () => { - // jest.setup.ts mocks getLocales to return en + it("starts in English when the phone is set to English", () => { + // jest.setup.ts mocks getLocales to return en-US expect(i18n.language).toBe("en"); }); @@ -20,19 +44,192 @@ describe("i18n configuration", () => { expect(i18n.t("common.ok")).toBe("OK"); }); - it("falls back to English for missing keys", () => { - // Switch to zh then ask for a key we know exists - i18n.changeLanguage("zh"); + it("translates through the active bundle", async () => { + await i18n.changeLanguage("zh"); expect(i18n.t("common.ok")).toBe("确定"); - i18n.changeLanguage("en"); // restore + await i18n.changeLanguage("ja"); + expect(i18n.t("serverConfirm.signIn.emailPassword")).toBe("メールアドレスとパスワード"); + }); +}); + +describe("SUPPORTED_LANGUAGES", () => { + it("is exactly the Console UI's ten locale codes, in the Console's order", () => { + expect(SUPPORTED_LANGUAGES.map((l) => l.code)).toEqual(CONSOLE_LANGUAGE_CODES); + }); + + it("names every language in its own language", () => { + for (const { label } of SUPPORTED_LANGUAGES) expect(label).not.toBe(""); + }); + + it("has one bundle per language and no other bundle", () => { + const bundles = readdirSync(LOCALES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")) + .sort(); + expect(bundles).toEqual([...CONSOLE_LANGUAGE_CODES].sort()); + }); +}); + +describe("locale bundles", () => { + type Tree = { [key: string]: string | Tree }; + const read = (code: string): Tree => + JSON.parse(readFileSync(join(LOCALES_DIR, `${code}.json`), "utf8")) as Tree; + const flatten = (tree: Tree, prefix = ""): Record => + Object.entries(tree).reduce>((out, [key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof value === "string" + ? { ...out, [path]: value } + : { ...out, ...flatten(value, path) }; + }, {}); + const placeholders = (s: string) => (s.match(/\{\{\w+\}\}/g) ?? []).sort(); + + const en = flatten(read("en")); + + it.each(CONSOLE_LANGUAGE_CODES.filter((c) => c !== "en"))( + "%s carries every native string key en has, and no other", + (code) => { + const bundle = flatten(read(code)); + const missing = Object.keys(en).filter((k) => !(k in bundle)); + const extra = Object.keys(bundle).filter((k) => !(k in en)); + expect({ missing, extra }).toEqual({ missing: [], extra: [] }); + } + ); + + it.each(CONSOLE_LANGUAGE_CODES)("%s has no empty string and keeps every placeholder", (code) => { + const bundle = flatten(read(code)); + for (const [key, value] of Object.entries(bundle)) { + expect({ key, empty: value.trim() === "" }).toEqual({ key, empty: false }); + expect({ key, placeholders: placeholders(value) }).toEqual({ + key, + placeholders: placeholders(en[key] ?? ""), + }); + } + }); +}); + +describe("matchLanguageTag — the Console's exact-then-base rule", () => { + // The Console's own pins (region-locale-fallback-11326.test.tsx), ported. + it("prefers an exact match over the base language, and falls back to the base", () => { + const has = (code: string) => code === "pt" || code === "pt-BR"; + expect(matchLanguageTag("pt-BR", has)).toBe("pt-BR"); + expect(matchLanguageTag("pt-PT", has)).toBe("pt"); + expect(matchLanguageTag("pt", has)).toBe("pt"); + expect(matchLanguageTag("xx-YY", has)).toBeNull(); }); }); describe("detectDeviceLanguage", () => { - it("returns a supported language code", () => { - const lang = detectDeviceLanguage(); - const codes = SUPPORTED_LANGUAGES.map((l) => l.code) as readonly string[]; - expect(codes).toContain(lang); + it.each(CONSOLE_LANGUAGE_CODES)("resolves the bare code %s to itself", (code) => { + expect(detectDeviceLanguage(tags(code))).toBe(code); + }); + + it.each([ + ["en-US", "en"], + ["zh-CN", "zh"], + ["ja-JP", "ja"], + ["ko-KR", "ko"], + ["de-DE", "de"], + ["fr-FR", "fr"], + ["es-ES", "es"], + ["pt-PT", "pt"], + ["ru-RU", "ru"], + ["ar-SA", "ar"], + ])("resolves the region tag %s to %s", (tag, code) => { + expect(detectDeviceLanguage(tags(tag))).toBe(code); + }); + + it.each([ + ["zh-Hans-CN", "zh"], + ["pt-BR", "pt"], + ["es-MX", "es"], + ])("resolves %s to %s", (tag, code) => { + expect(detectDeviceLanguage(tags(tag))).toBe(code); + }); + + // The Console has no Traditional Chinese catalogue, and its rule resolves a + // `zh-*` tag to its base `zh` (registry.ts `matchLanguageTag`). The shell + // follows that rule rather than inventing a different one. + it.each(["zh-TW", "zh-HK", "zh-Hant", "zh-Hant-TW", "zh-Hant-HK"])( + "resolves Traditional Chinese %s to zh, as the Console does", + (tag) => { + expect(detectDeviceLanguage(tags(tag))).toBe("zh"); + } + ); + + it("falls through an unsupported first language to the next supported one", () => { + expect(detectDeviceLanguage(tags("he-IL", "ja-JP", "en-US"))).toBe("ja"); + expect(detectDeviceLanguage(tags("tlh", "it-IT", "pt-BR"))).toBe("pt"); + }); + + it("falls back to en when no preferred language is supported", () => { + expect(detectDeviceLanguage(tags("it-IT", "nl-NL"))).toBe(DEFAULT_LANGUAGE); + expect(DEFAULT_LANGUAGE).toBe("en"); + }); + + it("falls back to en for an empty locale list", () => { + expect(detectDeviceLanguage([])).toBe("en"); + }); + + it("uses languageCode when a locale has no languageTag", () => { + expect(detectDeviceLanguage([{ languageTag: "", languageCode: "ko" }])).toBe("ko"); + }); + + it("reads the phone's languages when none are passed", () => { + const spy = jest + .spyOn(Localization, "getLocales") + .mockReturnValue(tags("fr-CA") as ReturnType); + expect(detectDeviceLanguage()).toBe("fr"); + spy.mockRestore(); + }); + + it("falls back to en when reading the phone's languages throws", () => { + const spy = jest.spyOn(Localization, "getLocales").mockImplementation(() => { + throw new Error("native module unavailable"); + }); + expect(detectDeviceLanguage()).toBe("en"); + spy.mockRestore(); + }); +}); + +describe("resolveLanguage", () => { + it("skips empty entries", () => { + expect(resolveLanguage([null, undefined, "", "ru-RU"])).toBe("ru"); + }); +}); + +describe("following the phone language", () => { + it("switches the shell to the phone's current language", async () => { + await expect(followDeviceLanguage(tags("de-AT"))).resolves.toBe("de"); + expect(i18n.language).toBe("de"); + expect(i18n.t("shell.changeServer")).toBe("Anderen Server verwenden"); + }); + + it("re-reads the phone's languages when the app comes back to the foreground", async () => { + const addEventListener = AppState.addEventListener as jest.Mock; + const remove = jest.fn(); + addEventListener.mockReturnValueOnce({ remove }); + const spy = jest + .spyOn(Localization, "getLocales") + .mockReturnValue(tags("ar-EG") as ReturnType); + + const stop = followDeviceLanguageOnForeground(); + const [event, listener] = addEventListener.mock.calls.at(-1) as [ + string, + (state: string) => void, + ]; + expect(event).toBe("change"); + + // Going to the background changes nothing yet. + listener("background"); + expect(i18n.language).toBe("en"); + + listener("active"); + await new Promise((r) => setTimeout(r, 0)); + expect(i18n.language).toBe("ar"); + + stop(); + expect(remove).toHaveBeenCalled(); + spy.mockRestore(); }); }); @@ -45,23 +242,15 @@ describe("isRTL", () => { expect(isRTL("he")).toBe(true); }); - it("returns false for English", () => { - expect(isRTL("en")).toBe(false); + it("returns false for every other shell language", () => { + for (const code of CONSOLE_LANGUAGE_CODES.filter((c) => c !== "ar")) { + expect(isRTL(code)).toBe(false); + } }); }); describe("RTL_LANGUAGES", () => { - it("contains at least Arabic and Hebrew", () => { - expect(RTL_LANGUAGES.has("ar")).toBe(true); - expect(RTL_LANGUAGES.has("he")).toBe(true); - }); -}); - -describe("SUPPORTED_LANGUAGES", () => { - it("includes en, zh, and ar", () => { - const codes = SUPPORTED_LANGUAGES.map((l) => l.code); - expect(codes).toContain("en"); - expect(codes).toContain("zh"); - expect(codes).toContain("ar"); + it("matches the Console's RTL list (locales/rtl.ts)", () => { + expect([...RTL_LANGUAGES].sort()).toEqual(["ar", "fa", "he", "ur"]); }); }); diff --git a/app/_layout.tsx b/app/_layout.tsx index 295012c..797fa5e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,6 @@ import "../global.css"; -import i18n from "~/lib/i18n"; // Initialize i18next before any screen calls useTranslation() -import { syncRTL } from "~/lib/rtl"; +import i18n, { followDeviceLanguageOnForeground } from "~/lib/i18n"; // Initialize i18next before any screen calls useTranslation() +import { syncRTL, useLayoutDirection } from "~/lib/rtl"; import { useCallback, useEffect } from "react"; import { Stack, useRouter } from "expo-router"; @@ -35,8 +35,18 @@ export default function RootLayout() { void hydrate(); }, [hydrate]); + // The phone language is the only source: follow a change on next foreground, + // and keep the persisted native direction flag in step with the language. + const direction = useLayoutDirection(); useEffect(() => { syncRTL(i18n.language); + const onLanguageChanged = (lang: string) => syncRTL(lang); + i18n.on("languageChanged", onLanguageChanged); + const stopFollowing = followDeviceLanguageOnForeground(); + return () => { + stopFollowing(); + i18n.off("languageChanged", onLanguageChanged); + }; }, []); const openRoute = useCallback( @@ -46,7 +56,7 @@ export default function RootLayout() { usePushRouting(serverUrl, openRoute); return ( - + diff --git a/app/server-config.tsx b/app/server-config.tsx index df6fd09..4694495 100644 --- a/app/server-config.tsx +++ b/app/server-config.tsx @@ -14,6 +14,7 @@ import { type ProbeFailureKind, } from "~/lib/server-probe"; import { shellConfig } from "~/lib/shell/config"; +import { useLayoutDirection } from "~/lib/rtl"; import { useThemeColors } from "~/lib/theme-colors"; import { useServerStore } from "~/stores/server-store"; @@ -52,6 +53,7 @@ export default function ServerConfigScreen() { const router = useRouter(); const { t } = useTranslation(); const colors = useThemeColors(); + const direction = useLayoutDirection(); const current = useServerStore((s) => s.serverUrl); const connect = useServerStore((s) => s.connect); const [url, setUrl] = React.useState(current ?? shellConfig.defaultServerUrl); @@ -104,7 +106,7 @@ export default function ServerConfigScreen() { if (probed) { return ( - + + diff --git a/lib/i18n.ts b/lib/i18n.ts index d1e28bd..8613653 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -1,82 +1,167 @@ /** * Internationalization configuration. * - * Integrates i18next + react-i18next + expo-localization for locale detection. - * Ships the shell's own strings (server picker, loading, offline page) in - * en, zh and ar. Everything inside the Console is translated by the Console. + * Integrates i18next + react-i18next + expo-localization. Ships the shell's own + * strings (server picker, confirm screen, loading, offline page) in the same + * ten locales, under the same short codes, as the Console UI + * (objectstack `ui/packages/i18n/src/locales/registry.ts`, + * `BUILT_IN_LANGUAGE_CODES`), so a phone language lands on the same locale on + * both sides. Everything inside the Console is translated by the Console. + * + * The phone setting is the only source: there is no in-app language picker for + * the native screens, and the shell follows a phone-language change the next + * time the app comes to the foreground ({@link followDeviceLanguageOnForeground}). */ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -import { getLocales } from "expo-localization"; -import { createMMKV } from "react-native-mmkv"; +import { getLocales, type Locale } from "expo-localization"; +import { AppState, type AppStateStatus } from "react-native"; import en from "~/locales/en.json"; import zh from "~/locales/zh.json"; +import ja from "~/locales/ja.json"; +import ko from "~/locales/ko.json"; +import de from "~/locales/de.json"; +import fr from "~/locales/fr.json"; +import es from "~/locales/es.json"; +import pt from "~/locales/pt.json"; +import ru from "~/locales/ru.json"; import ar from "~/locales/ar.json"; -/** MMKV store holding a persisted language override, when one has been set. */ -const uiStorage = createMMKV({ id: "objectstack-ui" }); -export const LANGUAGE_KEY = "language"; - -/** Languages that use right-to-left layout */ +/** Languages that use right-to-left layout — the Console's list (`locales/rtl.ts`). */ export const RTL_LANGUAGES = new Set(["ar", "he", "fa", "ur"]); -/** All bundled languages with display names */ +/** + * All bundled languages with their native display names: the Console's ten + * codes in the Console's order, named as the Console's language menu names them + * (objectstack `ui/packages/app-shell/src/layout/LocaleSwitcher.tsx`). + */ export const SUPPORTED_LANGUAGES = [ { code: "en", label: "English" }, { code: "zh", label: "中文" }, + { code: "ja", label: "日本語" }, + { code: "ko", label: "한국어" }, + { code: "de", label: "Deutsch" }, + { code: "fr", label: "Français" }, + { code: "es", label: "Español" }, + { code: "pt", label: "Português" }, + { code: "ru", label: "Русский" }, { code: "ar", label: "العربية" }, ] as const; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"]; +/** The language used when no preferred phone language is supported. */ +export const DEFAULT_LANGUAGE: SupportedLanguage = "en"; + +const SUPPORTED_CODES: ReadonlySet = new Set(SUPPORTED_LANGUAGES.map((l) => l.code)); + +function isSupportedLanguage(code: string): code is SupportedLanguage { + return SUPPORTED_CODES.has(code); +} + /** - * Detect the device's preferred language code. - * Falls back to `"en"` if detection fails or the locale is unsupported. + * Exact-then-base language-tag matching: `tag` itself when `has(tag)`, else its + * base language (`zh-CN` → `zh`) when `has(base)`, else `null`. + * + * A port of the Console's `matchLanguageTag` (objectstack + * `ui/packages/i18n/src/locales/registry.ts`), kept byte-for-byte in behaviour + * so a tag resolves to the same locale on both sides. Consequences that follow + * from the Console's rule, not from a choice made here: + * + * - Traditional Chinese (`zh-Hant`, `zh-TW`, `zh-HK`, `zh-Hant-TW`) has no + * catalogue of its own, so it resolves to `zh`, as in the Console. + * - The base is the text before the first `-`; case is not folded. */ -export function detectDeviceLanguage(): SupportedLanguage { - try { - const locales = getLocales(); - const deviceLang = locales?.[0]?.languageCode ?? "en"; - const supported = SUPPORTED_LANGUAGES.map((l) => l.code) as readonly string[]; - return (supported.includes(deviceLang) ? deviceLang : "en") as SupportedLanguage; - } catch { - return "en"; +export function matchLanguageTag(tag: string, has: (code: string) => boolean): string | null { + if (has(tag)) return tag; + const base = tag.split("-")[0]; + if (base && base !== tag && has(base)) return base; + return null; +} + +/** + * Pick the shell language from an ordered list of preferred language tags: the + * first tag that {@link matchLanguageTag} resolves to a bundled language, else + * {@link DEFAULT_LANGUAGE}. + */ +export function resolveLanguage(tags: readonly (string | null | undefined)[]): SupportedLanguage { + for (const tag of tags) { + if (!tag) continue; + const match = matchLanguageTag(tag, isSupportedLanguage); + if (match) return match as SupportedLanguage; } + return DEFAULT_LANGUAGE; } /** - * Initial language: the user's persisted choice wins over device detection, so - * a manual selection survives the reload that an RTL direction change triggers. + * Detect the shell language from the phone's ordered preferred languages + * (`expo-localization`'s `getLocales()`, in the order the user set them). + * Falls back to `"en"` if detection fails or no preferred language is bundled. */ -function loadInitialLanguage(): SupportedLanguage { +export function detectDeviceLanguage(locales?: readonly Partial[]): SupportedLanguage { try { - const stored = uiStorage.getString(LANGUAGE_KEY); - const supported = SUPPORTED_LANGUAGES.map((l) => l.code) as readonly string[]; - if (stored && supported.includes(stored)) return stored as SupportedLanguage; + const list = locales ?? getLocales(); + return resolveLanguage((list ?? []).map((l) => l?.languageTag || l?.languageCode)); } catch { - // fall through to device detection + return DEFAULT_LANGUAGE; } - return detectDeviceLanguage(); } -/** Check whether the current language is RTL */ +/** Check whether a language (default: the current one) is laid out right-to-left. */ export function isRTL(lang?: string): boolean { - const code = lang ?? i18n.language ?? "en"; + const code = lang ?? i18n.language ?? DEFAULT_LANGUAGE; return RTL_LANGUAGES.has(code); } -const resources = { en: { translation: en }, zh: { translation: zh }, ar: { translation: ar } }; +const resources = { + en: { translation: en }, + zh: { translation: zh }, + ja: { translation: ja }, + ko: { translation: ko }, + de: { translation: de }, + fr: { translation: fr }, + es: { translation: es }, + pt: { translation: pt }, + ru: { translation: ru }, + ar: { translation: ar }, +} as const satisfies Record; i18n.use(initReactI18next).init({ resources, - lng: loadInitialLanguage(), - fallbackLng: "en", + lng: detectDeviceLanguage(), + fallbackLng: DEFAULT_LANGUAGE, interpolation: { escapeValue: false }, compatibilityJSON: "v4", }); +/** + * Re-read the phone's languages and switch the shell to the result when it + * differs from the current language. Resolves to the language now in effect. + */ +export async function followDeviceLanguage( + locales?: readonly Partial[] +): Promise { + const next = detectDeviceLanguage(locales); + if (i18n.language !== next) await i18n.changeLanguage(next); + return next; +} + +/** + * Follow a phone-language change on the next foreground: every time the app + * becomes `active` again, {@link followDeviceLanguage} runs. Returns the + * unsubscribe function. + */ +export function followDeviceLanguageOnForeground(): () => void { + let previous: AppStateStatus = AppState.currentState; + const subscription = AppState.addEventListener("change", (next: AppStateStatus) => { + if (next === "active" && previous !== "active") void followDeviceLanguage(); + previous = next; + }); + return () => subscription.remove(); +} + /** * Count-aware translation that does NOT depend on `Intl.PluralRules`. * diff --git a/lib/rtl.ts b/lib/rtl.ts index ea0b16b..e5ea0c5 100644 --- a/lib/rtl.ts +++ b/lib/rtl.ts @@ -1,6 +1,31 @@ import { I18nManager, Platform } from "react-native"; +import { useTranslation } from "react-i18next"; import { isRTL } from "./i18n"; +/** Yoga's `direction` style value for a screen root. */ +export type LayoutDirection = "ltr" | "rtl"; + +/** The layout direction of a language: `rtl` for Arabic (and the other RTL codes), else `ltr`. */ +export function layoutDirection(lang?: string): LayoutDirection { + return isRTL(lang) ? "rtl" : "ltr"; +} + +/** + * The layout direction of the current shell language, re-evaluated when the + * language changes. + * + * Native screens put it on their root view as `style={{ direction }}`. Yoga + * flips `flex-direction: row`, `start`/`end` insets and natural text alignment + * for that subtree at once — unlike `I18nManager.forceRTL` ({@link syncRTL}), + * which only takes effect after the next launch. So a phone switched to Arabic + * gets right-to-left native screens on the first launch, and a language change + * picked up on foreground flips them without a restart. + */ +export function useLayoutDirection(): LayoutDirection { + const { i18n } = useTranslation(); + return layoutDirection(i18n.language); +} + /** * Apply the writing direction for a language. * diff --git a/locales/de.json b/locales/de.json new file mode 100644 index 0000000..72fb9ce --- /dev/null +++ b/locales/de.json @@ -0,0 +1,59 @@ +{ + "common": { + "ok": "OK", + "cancel": "Abbrechen", + "retry": "Erneut versuchen", + "error": "Fehler" + }, + "errorBoundary": { + "title": "Etwas ist schiefgelaufen", + "message": "Ein unerwarteter Fehler ist aufgetreten.", + "retry": "Erneut versuchen" + }, + "serverConfig": { + "title": "Mit Server verbinden", + "subtitle": "Geben Sie die Adresse Ihres ObjectOS-Servers ein.", + "label": "Server-URL", + "hint": "Beispiel: https://erp.example.com", + "connect": "Weiter", + "where": { + "title": "Wo finde ich die Adresse?", + "step1": "Öffnen Sie ObjectOS auf einem Computer im Browser und melden Sie sich an.", + "step2": "Kopieren Sie die Adresse aus der Adressleiste. Sie können den ganzen Link einfügen – die App behält nur den Serverteil.", + "step3": "Unsicher? Fragen Sie Ihren Administrator." + }, + "vpnHint": "Für einen Server im Firmennetz muss dieses Telefon eventuell zuerst mit dem Firmen-VPN verbunden sein.", + "error": { + "empty": "Bitte geben Sie eine Server-URL ein.", + "invalid": "Das sieht nicht nach einer Serveradresse aus.", + "insecure": "Verwenden Sie eine https://-Adresse – unverschlüsseltes http ist nur für lokale Server erlaubt.", + "unreachable": "Der Server ist nicht erreichbar. Prüfen Sie die Adresse und Ihr Netzwerk – ein Firmenserver benötigt eventuell VPN.", + "tls": "Das Zertifikat des Servers ist ungültig, daher ist die Verbindung nicht sicher. Bitten Sie Ihren Administrator, es zu beheben.", + "timeout": "Der Server hat zu lange nicht geantwortet. Prüfen Sie Ihr Netzwerk oder VPN und versuchen Sie es erneut.", + "notObjectOS": "Dies ist kein ObjectOS-Server. Verwenden Sie die Adresse, mit der Sie ObjectOS im Browser öffnen.", + "serverError": "Der Server hat mit einem Fehler geantwortet (HTTP {{status}}). Versuchen Sie es später erneut oder fragen Sie Ihren Administrator.", + "unknown": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." + } + }, + "serverConfirm": { + "title": "Ist das Ihr Server?", + "secure": "Sichere Verbindung (HTTPS)", + "signInLabel": "Anmelden mit", + "signIn": { + "sso": "Single Sign-On (SSO)", + "emailPassword": "E-Mail und Passwort" + }, + "versionLabel": "Plattformversion", + "confirm": "Verbinden und anmelden", + "reject": "Nicht dieser Server" + }, + "shell": { + "loading": "Wird geladen…", + "offlineTitle": "Sie sind offline", + "offlineMessage": "Prüfen Sie Ihre Verbindung. Die Seite lädt automatisch neu, sobald Sie wieder online sind.", + "serverErrorTitle": "Der Server antwortet nicht", + "serverErrorMessage": "Der Server hat mit Fehler {{code}} geantwortet. Versuchen Sie es gleich noch einmal.", + "retry": "Erneut versuchen", + "changeServer": "Anderen Server verwenden" + } +} diff --git a/locales/es.json b/locales/es.json new file mode 100644 index 0000000..a5cb4e8 --- /dev/null +++ b/locales/es.json @@ -0,0 +1,59 @@ +{ + "common": { + "ok": "Aceptar", + "cancel": "Cancelar", + "retry": "Reintentar", + "error": "Error" + }, + "errorBoundary": { + "title": "Algo salió mal", + "message": "Ha ocurrido un error inesperado.", + "retry": "Intentar de nuevo" + }, + "serverConfig": { + "title": "Conectar al servidor", + "subtitle": "Introduzca la dirección de su servidor ObjectOS.", + "label": "URL del servidor", + "hint": "Ejemplo: https://erp.example.com", + "connect": "Continuar", + "where": { + "title": "¿Dónde encuentro la dirección?", + "step1": "En un ordenador, abra ObjectOS en el navegador e inicie sesión.", + "step2": "Copie la dirección de la barra de direcciones. Puede pegar el enlace completo: la aplicación solo conserva la parte del servidor.", + "step3": "¿No está seguro? Consulte a su administrador." + }, + "vpnHint": "Un servidor de la red de la empresa puede requerir que este teléfono se conecte antes a la VPN de la empresa.", + "error": { + "empty": "Introduzca la URL del servidor.", + "invalid": "Eso no parece una dirección de servidor.", + "insecure": "Use una dirección https://: http sin cifrar solo se permite para servidores locales.", + "unreachable": "No se pudo conectar con el servidor. Compruebe la dirección y su red: un servidor de empresa puede requerir VPN.", + "tls": "El certificado del servidor no es válido, por lo que la conexión no es segura. Pida a su administrador que lo corrija.", + "timeout": "El servidor tardó demasiado en responder. Compruebe su red o VPN e inténtelo de nuevo.", + "notObjectOS": "Esto no es un servidor ObjectOS. Use la dirección con la que abre ObjectOS en un navegador.", + "serverError": "El servidor respondió con un error (HTTP {{status}}). Inténtelo más tarde o consulte a su administrador.", + "unknown": "Algo salió mal. Inténtelo de nuevo." + } + }, + "serverConfirm": { + "title": "¿Es este su servidor?", + "secure": "Conexión segura (HTTPS)", + "signInLabel": "Iniciar sesión con", + "signIn": { + "sso": "Inicio de sesión único (SSO)", + "emailPassword": "Correo electrónico y contraseña" + }, + "versionLabel": "Versión de la plataforma", + "confirm": "Conectar e iniciar sesión", + "reject": "No es este servidor" + }, + "shell": { + "loading": "Cargando…", + "offlineTitle": "Sin conexión", + "offlineMessage": "Compruebe su conexión. La página se recargará sola cuando vuelva a estar en línea.", + "serverErrorTitle": "El servidor no responde", + "serverErrorMessage": "El servidor respondió con el error {{code}}. Inténtelo de nuevo en un momento.", + "retry": "Intentar de nuevo", + "changeServer": "Usar otro servidor" + } +} diff --git a/locales/fr.json b/locales/fr.json new file mode 100644 index 0000000..5a1a419 --- /dev/null +++ b/locales/fr.json @@ -0,0 +1,59 @@ +{ + "common": { + "ok": "OK", + "cancel": "Annuler", + "retry": "Réessayer", + "error": "Erreur" + }, + "errorBoundary": { + "title": "Quelque chose s'est mal passé", + "message": "Une erreur inattendue s'est produite.", + "retry": "Réessayer" + }, + "serverConfig": { + "title": "Se connecter à un serveur", + "subtitle": "Saisissez l'adresse de votre serveur ObjectOS.", + "label": "URL du serveur", + "hint": "Exemple : https://erp.example.com", + "connect": "Continuer", + "where": { + "title": "Où trouver l'adresse ?", + "step1": "Sur un ordinateur, ouvrez ObjectOS dans votre navigateur et connectez-vous.", + "step2": "Copiez l'adresse dans la barre d'adresse. Vous pouvez coller le lien complet : l'application n'en garde que la partie serveur.", + "step3": "Vous hésitez ? Demandez à votre administrateur." + }, + "vpnHint": "Pour un serveur du réseau de l'entreprise, ce téléphone devra peut-être d'abord se connecter au VPN de l'entreprise.", + "error": { + "empty": "Veuillez saisir l'URL du serveur.", + "invalid": "Cela ne ressemble pas à une adresse de serveur.", + "insecure": "Utilisez une adresse https:// : le http simple n'est autorisé que pour les serveurs locaux.", + "unreachable": "Impossible de joindre le serveur. Vérifiez l'adresse et votre réseau : un serveur d'entreprise peut nécessiter un VPN.", + "tls": "Le certificat du serveur n'est pas valide : la connexion n'est donc pas sûre. Demandez à votre administrateur de le corriger.", + "timeout": "Le serveur a mis trop de temps à répondre. Vérifiez votre réseau ou votre VPN, puis réessayez.", + "notObjectOS": "Ce n'est pas un serveur ObjectOS. Utilisez l'adresse avec laquelle vous ouvrez ObjectOS dans un navigateur.", + "serverError": "Le serveur a répondu par une erreur (HTTP {{status}}). Réessayez plus tard ou contactez votre administrateur.", + "unknown": "Une erreur est survenue. Veuillez réessayer." + } + }, + "serverConfirm": { + "title": "Est-ce bien votre serveur ?", + "secure": "Connexion sécurisée (HTTPS)", + "signInLabel": "Se connecter avec", + "signIn": { + "sso": "Authentification unique (SSO)", + "emailPassword": "E-mail et mot de passe" + }, + "versionLabel": "Version de la plateforme", + "confirm": "Utiliser ce serveur et se connecter", + "reject": "Ce n'est pas ce serveur" + }, + "shell": { + "loading": "Chargement…", + "offlineTitle": "Vous êtes hors ligne", + "offlineMessage": "Vérifiez votre connexion. La page se recharge d'elle-même dès que vous êtes de nouveau en ligne.", + "serverErrorTitle": "Le serveur ne répond pas", + "serverErrorMessage": "Le serveur a répondu avec l'erreur {{code}}. Réessayez dans un instant.", + "retry": "Réessayer", + "changeServer": "Utiliser un autre serveur" + } +} diff --git a/locales/ja.json b/locales/ja.json new file mode 100644 index 0000000..ef322ed --- /dev/null +++ b/locales/ja.json @@ -0,0 +1,59 @@ +{ + "common": { + "ok": "OK", + "cancel": "キャンセル", + "retry": "再試行", + "error": "エラー" + }, + "errorBoundary": { + "title": "問題が発生しました", + "message": "予期しないエラーが発生しました。", + "retry": "再試行" + }, + "serverConfig": { + "title": "サーバーに接続", + "subtitle": "ObjectOS サーバーのアドレスを入力してください。", + "label": "サーバー URL", + "hint": "例: https://erp.example.com", + "connect": "続行", + "where": { + "title": "アドレスはどこで確認できますか?", + "step1": "パソコンのブラウザで ObjectOS を開き、サインインします。", + "step2": "アドレスバーのアドレスをコピーします。リンク全体を貼り付けても、アプリはサーバーの部分だけを使います。", + "step3": "わからない場合は管理者に確認してください。" + }, + "vpnHint": "社内ネットワークのサーバーでは、先にこの端末を社内 VPN に接続する必要がある場合があります。", + "error": { + "empty": "サーバー URL を入力してください。", + "invalid": "サーバーのアドレスではないようです。", + "insecure": "https:// のアドレスを使用してください。http はローカルサーバーでのみ使用できます。", + "unreachable": "サーバーに接続できません。アドレスとネットワークを確認してください。社内サーバーには VPN が必要な場合があります。", + "tls": "サーバーの証明書が無効なため、安全に接続できません。管理者に修正を依頼してください。", + "timeout": "サーバーの応答に時間がかかりすぎています。ネットワークまたは VPN を確認して、もう一度お試しください。", + "notObjectOS": "ObjectOS サーバーではありません。ブラウザで ObjectOS を開くときのアドレスを使用してください。", + "serverError": "サーバーがエラーを返しました(HTTP {{status}})。しばらくしてからもう一度お試しいただくか、管理者に確認してください。", + "unknown": "問題が発生しました。もう一度お試しください。" + } + }, + "serverConfirm": { + "title": "このサーバーで合っていますか?", + "secure": "安全な接続(HTTPS)", + "signInLabel": "サインイン方法", + "signIn": { + "sso": "シングルサインオン(SSO)", + "emailPassword": "メールアドレスとパスワード" + }, + "versionLabel": "プラットフォームのバージョン", + "confirm": "接続してサインイン", + "reject": "このサーバーではない" + }, + "shell": { + "loading": "読み込み中…", + "offlineTitle": "オフラインです", + "offlineMessage": "接続を確認してください。オンラインに戻るとページが自動的に再読み込みされます。", + "serverErrorTitle": "サーバーが応答しません", + "serverErrorMessage": "サーバーがエラー {{code}} を返しました。しばらくしてからもう一度お試しください。", + "retry": "再試行", + "changeServer": "別のサーバーを使用" + } +} diff --git a/locales/ko.json b/locales/ko.json new file mode 100644 index 0000000..4c3db52 --- /dev/null +++ b/locales/ko.json @@ -0,0 +1,59 @@ +{ + "common": { + "ok": "확인", + "cancel": "취소", + "retry": "다시 시도", + "error": "오류" + }, + "errorBoundary": { + "title": "문제가 발생했습니다", + "message": "예기치 않은 오류가 발생했습니다.", + "retry": "다시 시도" + }, + "serverConfig": { + "title": "서버에 연결", + "subtitle": "ObjectOS 서버 주소를 입력하세요.", + "label": "서버 URL", + "hint": "예: https://erp.example.com", + "connect": "계속", + "where": { + "title": "주소는 어디에서 찾나요?", + "step1": "컴퓨터의 브라우저에서 ObjectOS를 열고 로그인하세요.", + "step2": "주소 표시줄의 주소를 복사하세요. 전체 링크를 붙여넣어도 앱은 서버 부분만 사용합니다.", + "step3": "잘 모르겠다면 관리자에게 문의하세요." + }, + "vpnHint": "회사 네트워크의 서버라면 먼저 이 휴대폰을 회사 VPN에 연결해야 할 수 있습니다.", + "error": { + "empty": "서버 URL을 입력하세요.", + "invalid": "서버 주소가 아닌 것 같습니다.", + "insecure": "https:// 주소를 사용하세요. http는 로컬 서버에서만 허용됩니다.", + "unreachable": "서버에 연결할 수 없습니다. 주소와 네트워크를 확인하세요. 회사 서버는 VPN이 필요할 수 있습니다.", + "tls": "서버 인증서가 유효하지 않아 안전하게 연결할 수 없습니다. 관리자에게 수정을 요청하세요.", + "timeout": "서버 응답 시간이 너무 오래 걸립니다. 네트워크 또는 VPN을 확인한 후 다시 시도하세요.", + "notObjectOS": "ObjectOS 서버가 아닙니다. 브라우저에서 ObjectOS를 열 때 사용하는 주소를 입력하세요.", + "serverError": "서버에서 오류를 반환했습니다(HTTP {{status}}). 나중에 다시 시도하거나 관리자에게 문의하세요.", + "unknown": "문제가 발생했습니다. 다시 시도해 주세요." + } + }, + "serverConfirm": { + "title": "이 서버가 맞나요?", + "secure": "보안 연결(HTTPS)", + "signInLabel": "로그인 방법", + "signIn": { + "sso": "싱글 사인온(SSO)", + "emailPassword": "이메일 및 비밀번호" + }, + "versionLabel": "플랫폼 버전", + "confirm": "연결하고 로그인", + "reject": "이 서버가 아닙니다" + }, + "shell": { + "loading": "로딩 중…", + "offlineTitle": "오프라인 상태입니다", + "offlineMessage": "연결을 확인하세요. 다시 온라인 상태가 되면 페이지가 자동으로 새로고침됩니다.", + "serverErrorTitle": "서버가 응답하지 않습니다", + "serverErrorMessage": "서버에서 오류 {{code}}을(를) 반환했습니다. 잠시 후 다시 시도하세요.", + "retry": "다시 시도", + "changeServer": "다른 서버 사용" + } +} diff --git a/locales/pt.json b/locales/pt.json new file mode 100644 index 0000000..9ee9fb2 --- /dev/null +++ b/locales/pt.json @@ -0,0 +1,59 @@ +{ + "common": { + "ok": "OK", + "cancel": "Cancelar", + "retry": "Tentar novamente", + "error": "Erro" + }, + "errorBoundary": { + "title": "Algo deu errado", + "message": "Ocorreu um erro inesperado.", + "retry": "Tentar novamente" + }, + "serverConfig": { + "title": "Conectar ao servidor", + "subtitle": "Digite o endereço do seu servidor ObjectOS.", + "label": "URL do servidor", + "hint": "Exemplo: https://erp.example.com", + "connect": "Continuar", + "where": { + "title": "Onde encontro o endereço?", + "step1": "Em um computador, abra o ObjectOS no navegador e entre.", + "step2": "Copie o endereço da barra de endereços. Você pode colar o link inteiro: o app mantém só a parte do servidor.", + "step3": "Não tem certeza? Pergunte ao seu administrador." + }, + "vpnHint": "Um servidor na rede da empresa pode exigir que este celular esteja conectado à VPN da empresa primeiro.", + "error": { + "empty": "Digite a URL do servidor.", + "invalid": "Isso não parece um endereço de servidor.", + "insecure": "Use um endereço https://: http sem criptografia só é permitido para servidores locais.", + "unreachable": "Não foi possível acessar o servidor. Verifique o endereço e sua rede: um servidor da empresa pode exigir VPN.", + "tls": "O certificado do servidor é inválido, então a conexão não é segura. Peça ao seu administrador para corrigi-lo.", + "timeout": "O servidor demorou demais para responder. Verifique sua rede ou VPN e tente novamente.", + "notObjectOS": "Este não é um servidor ObjectOS. Use o endereço com que você abre o ObjectOS no navegador.", + "serverError": "O servidor respondeu com um erro (HTTP {{status}}). Tente novamente mais tarde ou pergunte ao seu administrador.", + "unknown": "Algo deu errado. Tente novamente." + } + }, + "serverConfirm": { + "title": "Este é o seu servidor?", + "secure": "Conexão segura (HTTPS)", + "signInLabel": "Entrar com", + "signIn": { + "sso": "Login único (SSO)", + "emailPassword": "E-mail e senha" + }, + "versionLabel": "Versão da plataforma", + "confirm": "Conectar e entrar", + "reject": "Não é este servidor" + }, + "shell": { + "loading": "Carregando…", + "offlineTitle": "Você está offline", + "offlineMessage": "Verifique sua conexão. A página recarrega sozinha quando você voltar a ficar online.", + "serverErrorTitle": "O servidor não está respondendo", + "serverErrorMessage": "O servidor respondeu com o erro {{code}}. Tente novamente em instantes.", + "retry": "Tentar novamente", + "changeServer": "Usar outro servidor" + } +} diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 0000000..b28274e --- /dev/null +++ b/locales/ru.json @@ -0,0 +1,59 @@ +{ + "common": { + "ok": "ОК", + "cancel": "Отмена", + "retry": "Повторить", + "error": "Ошибка" + }, + "errorBoundary": { + "title": "Что-то пошло не так", + "message": "Произошла непредвиденная ошибка.", + "retry": "Попробовать снова" + }, + "serverConfig": { + "title": "Подключение к серверу", + "subtitle": "Введите адрес вашего сервера ObjectOS.", + "label": "URL сервера", + "hint": "Например: https://erp.example.com", + "connect": "Продолжить", + "where": { + "title": "Где найти адрес?", + "step1": "На компьютере откройте ObjectOS в браузере и войдите.", + "step2": "Скопируйте адрес из адресной строки. Можно вставить ссылку целиком — приложение оставит только адрес сервера.", + "step3": "Не уверены? Обратитесь к администратору." + }, + "vpnHint": "Для сервера в корпоративной сети телефону может понадобиться сначала подключиться к корпоративному VPN.", + "error": { + "empty": "Введите URL сервера.", + "invalid": "Это не похоже на адрес сервера.", + "insecure": "Используйте адрес https:// — незащищённый http разрешён только для локальных серверов.", + "unreachable": "Не удалось связаться с сервером. Проверьте адрес и сеть — для корпоративного сервера может понадобиться VPN.", + "tls": "Сертификат сервера недействителен, поэтому соединение небезопасно. Попросите администратора исправить это.", + "timeout": "Сервер слишком долго не отвечал. Проверьте сеть или VPN и попробуйте ещё раз.", + "notObjectOS": "Это не сервер ObjectOS. Используйте адрес, по которому вы открываете ObjectOS в браузере.", + "serverError": "Сервер вернул ошибку (HTTP {{status}}). Попробуйте позже или обратитесь к администратору.", + "unknown": "Что-то пошло не так. Попробуйте ещё раз." + } + }, + "serverConfirm": { + "title": "Это ваш сервер?", + "secure": "Защищённое соединение (HTTPS)", + "signInLabel": "Способы входа", + "signIn": { + "sso": "Единый вход (SSO)", + "emailPassword": "Электронная почта и пароль" + }, + "versionLabel": "Версия платформы", + "confirm": "Подключиться и войти", + "reject": "Это не тот сервер" + }, + "shell": { + "loading": "Загрузка…", + "offlineTitle": "Нет подключения к сети", + "offlineMessage": "Проверьте подключение. Страница перезагрузится сама, когда сеть появится.", + "serverErrorTitle": "Сервер не отвечает", + "serverErrorMessage": "Сервер вернул ошибку {{code}}. Попробуйте ещё раз чуть позже.", + "retry": "Попробовать снова", + "changeServer": "Выбрать другой сервер" + } +} From e47188df29bc5f1e61861334d688151f26e5d577 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:57:25 +0000 Subject: [PATCH 2/3] docs(i18n): document the shell's language rules; add changeset Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- .changeset/159-ten-locales.md | 17 +++++++++++++++ __tests__/lib/i18n.test.ts | 14 +++++++++++- docs/HYBRID-SHELL.md | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 .changeset/159-ten-locales.md diff --git a/.changeset/159-ten-locales.md b/.changeset/159-ten-locales.md new file mode 100644 index 0000000..203614c --- /dev/null +++ b/.changeset/159-ten-locales.md @@ -0,0 +1,17 @@ +--- +"mobile": minor +--- + +Clause-②: no +Metadata-protocol: none + +The native screens (server picker, confirm step, loading and offline pages) +now ship in the same ten languages as the Console UI, under the same codes: +en, zh, ja, ko, de, fr, es, pt, ru, ar. The app picks the first of the phone's +preferred languages that it supports, with the Console's matching rule (a +region tag falls back to its base language: `pt-BR` → `pt`, `zh-Hans-CN` → +`zh`; Traditional Chinese → `zh`, as in the Console), else English. A +phone-language change is picked up when the app next comes to the foreground. +Under Arabic the native screens lay out right-to-left from the first render. +There is no in-app language picker: the phone setting decides, and a language +stored by an earlier in-app picker is no longer read. diff --git a/__tests__/lib/i18n.test.ts b/__tests__/lib/i18n.test.ts index 16b4330..8093758 100644 --- a/__tests__/lib/i18n.test.ts +++ b/__tests__/lib/i18n.test.ts @@ -1,7 +1,6 @@ import { readdirSync, readFileSync } from "fs"; import { join } from "path"; import { AppState } from "react-native"; -import * as Localization from "expo-localization"; import i18n, { DEFAULT_LANGUAGE, detectDeviceLanguage, @@ -21,6 +20,19 @@ import i18n, { */ const CONSOLE_LANGUAGE_CODES = ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru", "ar"]; +// jest.setup.ts mocks this module without a `default`, which is what +// `react-native`'s `AppState` getter reads; give it one for this file. +jest.mock("react-native/Libraries/AppState/AppState", () => { + const appState = { + currentState: "active", + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + }; + return { __esModule: true, default: appState, ...appState }; +}); + +// The module object `~/lib/i18n` calls `getLocales` on, so a spy reaches it. +const Localization = jest.requireMock("expo-localization") as typeof import("expo-localization"); + const LOCALES_DIR = join(__dirname, "..", "..", "locales"); const tags = (...languageTags: string[]) => languageTags.map((languageTag) => ({ languageTag })); diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index 180653f..7c48e2d 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -33,6 +33,8 @@ components/shell/ ServerConfirm.tsx "is this your server?" step of the picker OfflineFallback.tsx native page for network / 5xx failures lib/ + i18n.ts shell strings in the Console's ten locales; follows the phone language + rtl.ts right-to-left layout for Arabic server-probe.ts anonymous probe of a server before it is saved server-url.ts persistence of the chosen server URL lib/shell/ @@ -55,6 +57,41 @@ and makes no authenticated API calls of its own** — its only requests are the anonymous server probe below. Sign-in, sign-up, 2FA and password reset are the Console's pages. +## Language + +The shell translates only its own native screens (server picker, confirm step, +loading and offline pages); everything inside the WebView is the Console's. + +- **The locale set mirrors the Console UI's.** The shell ships the same ten + locales under the same short codes as the Console's built-in catalogues + (objectstack `ui/packages/i18n/src/locales/`): `en`, `zh`, `ja`, `ko`, `de`, + `fr`, `es`, `pt`, `ru`, `ar`. A test pins `SUPPORTED_LANGUAGES` to that list, + and another fails when any bundle in `locales/` misses a key `en.json` has — + a new native string ships in all ten. +- **The phone language decides, with the Console's matching rule.** The shell + walks the phone's preferred languages in order and takes the first one that + resolves to a bundled locale, else `en`. A tag resolves exactly, else through + its base language: the Console's `matchLanguageTag` + (`ui/packages/i18n/src/locales/registry.ts`), ported as is. So `zh-Hans-CN` → + `zh`, `pt-BR` → `pt`, `es-MX` → `es`, and Traditional Chinese (`zh-TW`, + `zh-HK`, `zh-Hant`) → `zh`, because the Console has no Traditional Chinese + catalogue either. +- **No in-app language picker.** The phone setting is the only source for the + native screens. A phone-language change is picked up the next time the app + comes to the foreground. +- **Arabic lays out right-to-left.** Each native screen root carries Yoga's + `direction` style for the current language, so the switch applies on first + render and on a language change without a restart. `I18nManager.forceRTL` is + kept in step as well; what reads it (for example the navigation stack's + transition direction) follows on the next launch. +- **Once signed in, the user's Console locale wins inside the Console.** The + Console resolves its own language: the user's `sys_user.locale`, else the + request's `Accept-Language` (the WebView sends the phone's languages), else + the deployment default. So a user whose Console locale differs from the + phone sees the native screens in the phone's language and the Console in + their own. Built-in object and field labels exist in fewer locales than the + Console chrome; that gap is the platform's, not the shell's. + ## Server selection The picker (`app/server-config.tsx`) has two steps, and the server is saved @@ -254,6 +291,9 @@ See [PUSH-NOTIFICATIONS.md](./PUSH-NOTIFICATIONS.md). under `__tests__/lib`. - The picker and its confirm step — component tests under `__tests__/components` (`ServerConfigScreen`, `ServerConfirm`). +- Locale list, phone-language matching and bundle completeness — + `__tests__/lib/i18n.test.ts`; right-to-left native screens under `ar` — + `__tests__/components/RtlLayout.test.tsx`. - The bootstrap and navigate scripts are executed against a fake `window` in `__tests__/lib/shell/bridge-scripts.test.ts`. - `ConsoleWebView` is tested with a mocked `react-native-webview` From c5f999f4a4a013fc56af8ec5edca66c6c3246764 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:57:41 +0000 Subject: [PATCH 3/3] docs(i18n): do not claim what the WebView sends as Accept-Language Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- docs/HYBRID-SHELL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index 7c48e2d..ff5d471 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -86,7 +86,7 @@ loading and offline pages); everything inside the WebView is the Console's. transition direction) follows on the next launch. - **Once signed in, the user's Console locale wins inside the Console.** The Console resolves its own language: the user's `sys_user.locale`, else the - request's `Accept-Language` (the WebView sends the phone's languages), else + request's `Accept-Language` (the WebView's own; the shell does not set it), else the deployment default. So a user whose Console locale differs from the phone sees the native screens in the phone's language and the Console in their own. Built-in object and field labels exist in fewer locales than the