Skip to content

Commit 33ba17f

Browse files
committed
Add app locale loading and intl provider
- Persist app locale preference in settings - Resolve system locale and load translated messages - Wire react-intl helpers for formatting and message lookup
1 parent b17502f commit 33ba17f

8 files changed

Lines changed: 308 additions & 0 deletions

File tree

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"oxfmt": "^0.42.0",
4545
"react": "^19.0.0",
4646
"react-dom": "^19.0.0",
47+
"react-intl": "^10.1.1",
4748
"react-markdown": "^10.1.0",
4849
"remark-gfm": "^4.0.1",
4950
"tailwind-merge": "^3.4.0",

apps/web/src/appSettings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
normalizeModelSlug,
1212
resolveSelectableModel,
1313
} from "@okcode/shared/model";
14+
import { APP_LOCALE_PREFERENCES } from "./i18n/types";
1415
import { useLocalStorage } from "./hooks/useLocalStorage";
1516
import { EnvMode } from "./components/BranchToolbar.logic";
1617

@@ -21,6 +22,9 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256;
2122
export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
2223
export type TimestampFormat = typeof TimestampFormat.Type;
2324
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
25+
export const AppLocale = Schema.Literals(APP_LOCALE_PREFERENCES);
26+
export type AppLocale = typeof AppLocale.Type;
27+
export const DEFAULT_APP_LOCALE: AppLocale = "system";
2428
export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
2529
export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
2630
export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
@@ -64,6 +68,7 @@ export const AppSettingsSchema = Schema.Struct({
6468
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
6569
diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
6670
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
71+
locale: AppLocale.pipe(withDefaults(() => DEFAULT_APP_LOCALE)),
6772
openLinksExternally: Schema.Boolean.pipe(withDefaults(() => false)),
6873
sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(
6974
withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER),

apps/web/src/i18n/I18nProvider.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
2+
import { IntlErrorCode, IntlProvider } from "react-intl";
3+
import { useAppSettings } from "../appSettings";
4+
import { getNavigatorLocaleSnapshot, resolveAppLocale } from "./locale";
5+
import { EN_MESSAGES, loadMessages } from "./loadMessages";
6+
import type { AppLocalePreference, AppMessages, ResolvedAppLocale } from "./types";
7+
8+
type I18nContextValue = {
9+
locale: AppLocalePreference;
10+
resolvedLocale: ResolvedAppLocale;
11+
messages: AppMessages;
12+
};
13+
14+
const I18nContext = createContext<I18nContextValue | null>(null);
15+
16+
export function I18nProvider({ children }: { children: ReactNode }) {
17+
const { settings } = useAppSettings();
18+
const [navigatorLocale, setNavigatorLocale] = useState(() => getNavigatorLocaleSnapshot());
19+
const [loadedLocale, setLoadedLocale] = useState<ResolvedAppLocale>("en");
20+
const [loadedMessages, setLoadedMessages] = useState<AppMessages>(EN_MESSAGES);
21+
22+
const resolvedLocale = useMemo(
23+
() => resolveAppLocale(settings.locale, navigatorLocale.languages, navigatorLocale.language),
24+
[navigatorLocale.language, navigatorLocale.languages, settings.locale],
25+
);
26+
27+
useEffect(() => {
28+
if (typeof window === "undefined") {
29+
return undefined;
30+
}
31+
32+
const syncNavigatorLocale = () => {
33+
setNavigatorLocale(getNavigatorLocaleSnapshot());
34+
};
35+
36+
window.addEventListener("languagechange", syncNavigatorLocale);
37+
return () => {
38+
window.removeEventListener("languagechange", syncNavigatorLocale);
39+
};
40+
}, []);
41+
42+
useEffect(() => {
43+
let cancelled = false;
44+
45+
if (resolvedLocale === "en") {
46+
setLoadedLocale("en");
47+
setLoadedMessages(EN_MESSAGES);
48+
return undefined;
49+
}
50+
51+
void loadMessages(resolvedLocale).then((messages) => {
52+
if (cancelled) {
53+
return;
54+
}
55+
56+
setLoadedLocale(resolvedLocale);
57+
setLoadedMessages(messages);
58+
});
59+
60+
return () => {
61+
cancelled = true;
62+
};
63+
}, [resolvedLocale]);
64+
65+
useEffect(() => {
66+
if (typeof document === "undefined") {
67+
return;
68+
}
69+
70+
document.documentElement.lang = resolvedLocale;
71+
document.documentElement.dir = "ltr";
72+
}, [resolvedLocale]);
73+
74+
const activeMessages = loadedLocale === resolvedLocale ? loadedMessages : EN_MESSAGES;
75+
const contextValue = useMemo<I18nContextValue>(
76+
() => ({
77+
locale: settings.locale,
78+
resolvedLocale,
79+
messages: activeMessages,
80+
}),
81+
[activeMessages, resolvedLocale, settings.locale],
82+
);
83+
84+
return (
85+
<I18nContext.Provider value={contextValue}>
86+
<IntlProvider
87+
key={resolvedLocale}
88+
locale={resolvedLocale}
89+
defaultLocale="en"
90+
messages={activeMessages}
91+
onError={(error) => {
92+
if (error.code === IntlErrorCode.MISSING_TRANSLATION) {
93+
return;
94+
}
95+
96+
console.error(error);
97+
}}
98+
>
99+
{children}
100+
</IntlProvider>
101+
</I18nContext.Provider>
102+
);
103+
}
104+
105+
export function useI18nContext(): I18nContextValue {
106+
const context = useContext(I18nContext);
107+
if (!context) {
108+
throw new Error("useI18nContext must be used within an I18nProvider.");
109+
}
110+
111+
return context;
112+
}

apps/web/src/i18n/loadMessages.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { AppMessages, ResolvedAppLocale } from "./types";
2+
import enMessagesJson from "./messages/en.json";
3+
4+
export const EN_MESSAGES: AppMessages = enMessagesJson;
5+
6+
export type MessageLoaderMap = Record<ResolvedAppLocale, () => Promise<AppMessages>>;
7+
8+
const MESSAGE_LOADERS: MessageLoaderMap = {
9+
en: () => Promise.resolve(EN_MESSAGES),
10+
es: () => import("./messages/es.json").then((module) => module.default),
11+
fr: () => import("./messages/fr.json").then((module) => module.default),
12+
"zh-CN": () => import("./messages/zh-CN.json").then((module) => module.default),
13+
};
14+
15+
const messageCache = new Map<ResolvedAppLocale, Promise<AppMessages>>();
16+
const failedLocaleLogs = new Set<ResolvedAppLocale>();
17+
18+
function logLocaleLoadFailure(locale: ResolvedAppLocale, error: unknown) {
19+
if (failedLocaleLogs.has(locale)) {
20+
return;
21+
}
22+
23+
failedLocaleLogs.add(locale);
24+
console.error(`[i18n] Failed to load locale "${locale}". Falling back to English.`, error);
25+
}
26+
27+
export async function loadMessagesFromLoaders(
28+
locale: ResolvedAppLocale,
29+
loaders: MessageLoaderMap,
30+
): Promise<AppMessages> {
31+
try {
32+
return await loaders[locale]();
33+
} catch (error) {
34+
logLocaleLoadFailure(locale, error);
35+
return EN_MESSAGES;
36+
}
37+
}
38+
39+
export function loadMessages(locale: ResolvedAppLocale): Promise<AppMessages> {
40+
const cached = messageCache.get(locale);
41+
if (cached) {
42+
return cached;
43+
}
44+
45+
const pending = loadMessagesFromLoaders(locale, MESSAGE_LOADERS);
46+
messageCache.set(locale, pending);
47+
return pending;
48+
}

apps/web/src/i18n/locale.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { AppLocalePreference, ResolvedAppLocale } from "./types";
2+
3+
function matchSupportedLocale(candidate: string | null | undefined): ResolvedAppLocale | null {
4+
if (typeof candidate !== "string") {
5+
return null;
6+
}
7+
8+
const normalized = candidate.trim().toLowerCase();
9+
if (normalized.length === 0) {
10+
return null;
11+
}
12+
13+
if (normalized === "en" || normalized.startsWith("en-")) {
14+
return "en";
15+
}
16+
17+
if (normalized === "es" || normalized.startsWith("es-")) {
18+
return "es";
19+
}
20+
21+
if (normalized === "fr" || normalized.startsWith("fr-")) {
22+
return "fr";
23+
}
24+
25+
if (
26+
normalized === "zh" ||
27+
normalized === "zh-cn" ||
28+
normalized === "zh-sg" ||
29+
normalized === "zh-hans" ||
30+
normalized.startsWith("zh-hans-")
31+
) {
32+
return "zh-CN";
33+
}
34+
35+
return null;
36+
}
37+
38+
export function resolveAppLocale(
39+
localePreference: AppLocalePreference,
40+
navigatorLanguages: readonly string[] = [],
41+
navigatorLanguage?: string | null,
42+
): ResolvedAppLocale {
43+
if (localePreference !== "system") {
44+
return matchSupportedLocale(localePreference) ?? "en";
45+
}
46+
47+
for (const candidate of [...navigatorLanguages, navigatorLanguage]) {
48+
const resolved = matchSupportedLocale(candidate);
49+
if (resolved) {
50+
return resolved;
51+
}
52+
}
53+
54+
return "en";
55+
}
56+
57+
export function getNavigatorLocaleSnapshot(): {
58+
language: string | null;
59+
languages: readonly string[];
60+
} {
61+
if (typeof navigator === "undefined") {
62+
return { language: null, languages: [] };
63+
}
64+
65+
return {
66+
language: navigator.language ?? null,
67+
languages: Array.isArray(navigator.languages) ? [...navigator.languages] : [],
68+
};
69+
}

apps/web/src/i18n/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const APP_LOCALE_PREFERENCES = ["system", "en", "es", "fr", "zh-CN"] as const;
2+
export type AppLocalePreference = (typeof APP_LOCALE_PREFERENCES)[number];
3+
4+
export const RESOLVED_APP_LOCALES = ["en", "es", "fr", "zh-CN"] as const;
5+
export type ResolvedAppLocale = (typeof RESOLVED_APP_LOCALES)[number];
6+
7+
export type AppMessages = Record<string, string>;
8+
export type TranslationValues = Record<string, unknown>;

apps/web/src/i18n/useI18n.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useCallback } from "react";
2+
import { useIntl, type IntlShape } from "react-intl";
3+
import { useI18nContext } from "./I18nProvider";
4+
import type { TranslationValues } from "./types";
5+
6+
type FormatDateOptions = Parameters<IntlShape["formatDate"]>[1];
7+
type FormatTimeOptions = Parameters<IntlShape["formatTime"]>[1];
8+
type FormatNumberOptions = Parameters<IntlShape["formatNumber"]>[1];
9+
10+
export function useI18n() {
11+
return useI18nContext();
12+
}
13+
14+
export function useT() {
15+
const intl = useIntl();
16+
const context = useI18nContext();
17+
18+
const t = useCallback(
19+
(id: string, values?: TranslationValues) => intl.formatMessage({ id }, values as never),
20+
[intl],
21+
);
22+
const formatDate = useCallback(
23+
(value: Parameters<IntlShape["formatDate"]>[0], options?: FormatDateOptions) =>
24+
intl.formatDate(value, options),
25+
[intl],
26+
);
27+
const formatTime = useCallback(
28+
(value: Parameters<IntlShape["formatTime"]>[0], options?: FormatTimeOptions) =>
29+
intl.formatTime(value, options),
30+
[intl],
31+
);
32+
const formatNumber = useCallback(
33+
(value: Parameters<IntlShape["formatNumber"]>[0], options?: FormatNumberOptions) =>
34+
intl.formatNumber(value, options),
35+
[intl],
36+
);
37+
38+
return {
39+
...context,
40+
intl,
41+
t,
42+
formatDate,
43+
formatTime,
44+
formatNumber,
45+
} as const;
46+
}

bun.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)