Live video on the AT Protocol
1// TypeScript i18next configuration with Fluent and manifest integration
2// modified from https://github.com/inaturalist/iNaturalistReactNative/blob/main/src/i18n/initI18next.js
3
4import i18next from "i18next";
5import Fluent from "i18next-fluent";
6import resourcesToBackend from "i18next-resources-to-backend";
7import "intl-pluralrules";
8import { initReactI18next } from "react-i18next";
9
10// Import our manifest directly to avoid circular dependency
11import manifest from "../../locales/manifest.json";
12import storage from "../storage";
13
14const LOCALE_STORAGE_KEY = "@streamplace/locale";
15
16// Try to import expo-localization, but make it optional
17let Localization: typeof import("expo-localization") | null = null;
18try {
19 const localizationModule = require("expo-localization");
20 // Handle both default and named exports
21 Localization = localizationModule.default
22 ? localizationModule.default
23 : localizationModule;
24} catch {
25 // expo-localization not available, will use browser/fallback detection
26}
27
28function cleanLocaleName(locale: string): string {
29 return locale.replace("_", "-").replace(/@.*/, "");
30}
31
32export function getLocaleFromSystemLocale(): string {
33 let systemLocale = "en";
34
35 // Try to get locale from expo-localization if available
36 if (Localization && typeof Localization.getLocales === "function") {
37 try {
38 const locales = Localization.getLocales();
39 systemLocale = locales?.[0]?.languageTag || "en";
40 } catch (error) {
41 console.warn("Failed to get locales from expo-localization:", error);
42 }
43 } else if (typeof navigator !== "undefined" && navigator.language) {
44 // Fallback to browser navigator.language
45 systemLocale = navigator.language;
46 }
47
48 const candidateLocale = cleanLocaleName(systemLocale);
49
50 // Check if the full locale is supported (e.g., "en-US")
51 if (manifest.supportedLocales.includes(candidateLocale)) {
52 return candidateLocale;
53 }
54
55 // Check if the language part is supported (e.g., "en" from "en-GB")
56 const lang = candidateLocale.split("-")[0];
57 const matchingLocale = manifest.supportedLocales.find((locale) =>
58 locale.startsWith(lang + "-"),
59 );
60
61 if (matchingLocale) {
62 return matchingLocale;
63 }
64
65 // Fall back to default locale from manifest
66 return manifest.fallbackChain[0];
67}
68
69// Cache for the current locale to avoid async lookups
70let cachedLocale: string | null = null;
71
72export async function getCurrentLocale(): Promise<string> {
73 if (cachedLocale) {
74 return cachedLocale;
75 }
76
77 const stored = await storage.getItem(LOCALE_STORAGE_KEY);
78 if (stored && manifest.supportedLocales.includes(stored)) {
79 cachedLocale = stored;
80 return stored;
81 }
82
83 const systemLocale = getLocaleFromSystemLocale();
84 cachedLocale = systemLocale;
85 return systemLocale;
86}
87
88// Synchronous version for initial load - returns cached or system locale
89export function getCurrentLocaleSync(): string {
90 return cachedLocale || getLocaleFromSystemLocale();
91}
92
93// Enhanced fallback logic using manifest
94function getFallbackChain(code: string): string[] {
95 const fallbacks: string[] = [];
96
97 if (!code) return manifest.fallbackChain;
98
99 // Regional fallbacks
100 if (code.match(/^es-/)) {
101 fallbacks.push("es-ES"); // Spanish fallback
102 } else if (code.match(/^fr-/)) {
103 fallbacks.push("fr-FR"); // French fallback
104 } else if (code.match(/^pt-/)) {
105 fallbacks.push("pt-BR"); // Portuguese fallback
106 } else if (code.match(/^zh-/)) {
107 fallbacks.push("zh-Hant"); // Chinese fallback
108 }
109
110 // Add manifest fallback chain
111 return [...fallbacks, ...manifest.fallbackChain];
112}
113
114// Use sync version for initial config - will be updated when storage loads
115const LOCALE = getCurrentLocaleSync();
116
117export const I18NEXT_CONFIG = {
118 lng: LOCALE,
119 ns: ["common", "settings"], // Common should be first as it's most frequently used
120 defaultNS: "common",
121 interpolation: {
122 escapeValue: false, // React already safes from XSS
123 },
124 react: {
125 useSuspense: false, // Prevent Android crashes
126 },
127 i18nFormat: {
128 fluentBundleOptions: {
129 useIsolating: false,
130 functions: {
131 VOWORCON: ([txt]: [string]) =>
132 "aeiou".indexOf(txt[0].toLowerCase()) >= 0 ? "vow" : "con",
133 JOIN: (args: string[], opts: { separator?: string } = {}) =>
134 args
135 .filter(Boolean)
136 .filter((s) => typeof s === "string")
137 .join(opts.separator || ""),
138 },
139 },
140 },
141 load: "currentOnly",
142 cleanCode: true,
143 fallbackLng: getFallbackChain,
144 supportedLngs: [...manifest.supportedLocales],
145 debug: process.env.NODE_ENV === "development",
146};
147
148// Import platform-specific translation loader
149// Metro will use i18n-loader.native.ts for React Native, i18n-loader.ts for web
150import { loadTranslationData as platformLoadTranslationData } from "./i18n-loader";
151
152// Translation loading function with error handling
153async function loadTranslationData(
154 locale: string,
155 namespace: string,
156): Promise<any> {
157 try {
158 const translations = await platformLoadTranslationData(locale, namespace);
159 return translations;
160 } catch (error: any) {
161 console.error(
162 `Failed to load ${namespace} translations for ${locale}:`,
163 error,
164 );
165 // Return minimal fallback
166 return {
167 loading: "Loading...",
168 error: "Error",
169 cancel: "Cancel",
170 };
171 }
172}
173
174// Initialize i18next with our configuration
175let initPromise: Promise<typeof i18next> | null = null;
176
177export default async function initI18next(
178 config: any = {},
179): Promise<typeof i18next> {
180 // Return existing promise if already initializing
181 if (initPromise) {
182 return initPromise;
183 }
184
185 // Load stored locale from storage first
186 const storedLocale = await getCurrentLocale();
187
188 const finalConfig = {
189 ...I18NEXT_CONFIG,
190 lng: storedLocale,
191 ...config,
192 };
193
194 initPromise = i18next
195 .use(initReactI18next)
196 .use(Fluent)
197 .use(
198 resourcesToBackend((locale: string, namespace: string, callback: any) => {
199 // Load translations using our manifest-based namespace system
200 loadTranslationData(locale, namespace)
201 .then((translations) => callback(null, translations))
202 .catch((error) => callback(error, null));
203 }),
204 )
205 .init(finalConfig)
206 .then(() => {
207 // Automatically persist language changes to storage
208 i18next.on("languageChanged", (lng) => {
209 if (lng && manifest.supportedLocales.includes(lng)) {
210 cachedLocale = lng;
211 storage.setItem(LOCALE_STORAGE_KEY, lng);
212 }
213 });
214 return i18next;
215 });
216
217 return initPromise;
218}
219
220// Utility functions for language management
221export async function changeLanguage(locale: string): Promise<void> {
222 // Storage is handled automatically via languageChanged event
223 await i18next.changeLanguage(locale);
224}
225
226export function getCurrentLanguage(): string {
227 // Return the cached locale preference, not i18next's resolved language
228 // i18next.language may return just "zh" instead of "zh-Hant"
229 return getCurrentLocaleSync();
230}
231
232export function getSupportedLocales(): string[] {
233 return [...manifest.supportedLocales];
234}
235
236export function getLanguageInfo(locale: string): any {
237 return manifest.languages[locale] || null;
238}
239
240export function isLocaleSupported(locale: string): boolean {
241 return manifest.supportedLocales.includes(locale);
242}
243
244// Auto-initialize i18next on module load
245// This ensures the instance is ready when used in providers
246initI18next().catch((error) => {
247 console.error("Failed to auto-initialize i18n:", error);
248});
249
250export { i18next };