vojo/src/app/utils/pushLanguageBridge.ts

103 lines
4.2 KiB
TypeScript

import i18n from '../i18n';
import { isNativePlatform } from './capacitor';
// Mirrors the Java-side LANG_KEY + SupportedLang clamp in PushStrings.java
// and the normalizeLang() in sw.ts. Keep the three implementations in sync:
// supported set = {en, ru}, anything else collapses to en (i18n.ts sets
// fallbackLng: 'en'). Normalising on the WRITE side means readers never see
// an unsupported tag — for a user whose device locale is e.g. 'fr', the
// resource-qualifier picker would otherwise land on values/ (English) while
// i18next on the main thread rendered some other default; we make both
// surfaces agree on English as the lingua franca fallback.
const PREFS_LANG_KEY = 'vojo.appLanguage';
type SupportedLang = 'en' | 'ru';
const normalize = (raw: string | undefined | null): SupportedLang => {
if (!raw) return 'en';
const lower = raw.toLowerCase();
if (lower.startsWith('ru')) return 'ru';
return 'en';
};
const writeNativePref = async (lang: SupportedLang): Promise<void> => {
if (!isNativePlatform()) return;
try {
const { Preferences } = await import('@capacitor/preferences');
await Preferences.set({ key: PREFS_LANG_KEY, value: lang });
} catch {
/* non-fatal — PushStrings falls back to device locale, normalised
to {en, ru} with en as the default for anything else */
}
};
// Post to the active Service Worker so its push-fallback loader picks
// the same language the main app is rendering. The SW side persists in
// IndexedDB for cold wake; see sw.ts setLanguage handler.
//
// First-load race: on the very first page visit after SW install,
// `navigator.serviceWorker.controller` is null — the SW has just
// registered and hasn't taken over yet. A direct postMessage would
// silently drop. We keep the latest language in `pendingSwLang` and
// re-attempt when (a) the registration becomes ready and (b) on every
// `controllerchange` event. Both paths no-op once the controller is
// set and the post has already landed, so duplicates are cheap.
let pendingSwLang: SupportedLang | undefined;
let swControllerWatchInstalled = false;
const trySendSwLang = (): void => {
if (pendingSwLang === undefined) return;
if (typeof navigator === 'undefined') return;
const sw = navigator.serviceWorker;
if (!sw?.controller) return;
try {
sw.controller.postMessage({ type: 'setLanguage', lang: pendingSwLang });
} catch {
/* ignore — next languageChanged / controllerchange re-posts */
}
};
const installSwControllerWatch = (): void => {
if (swControllerWatchInstalled) return;
if (typeof navigator === 'undefined') return;
const sw = navigator.serviceWorker;
if (!sw) return;
swControllerWatchInstalled = true;
// `ready` resolves when there is an active registration — usually
// around install/activate. Our SW calls `self.clients.claim()` in
// its activate handler, so shortly after `ready` the page gains a
// controller and `controllerchange` fires; we listen to both so the
// first-install race window is covered regardless of which fires
// first.
sw.ready.then(trySendSwLang).catch(() => undefined);
sw.addEventListener('controllerchange', trySendSwLang);
};
const postToServiceWorker = (lang: SupportedLang): void => {
pendingSwLang = lang;
trySendSwLang();
};
const broadcast = (raw: string | undefined | null): void => {
const lang = normalize(raw);
writeNativePref(lang).catch(() => undefined);
postToServiceWorker(lang);
};
// One-way JS → {Android FCM, web SW} bridge for the in-app i18next
// language. Idempotent in practice: the i18next listener API dedupes
// equal-valued changes. Intended to be invoked exactly once from App
// bootstrap; we never detach the listener for the process lifetime.
export const installPushLanguageBridge = (): void => {
installSwControllerWatch();
// `resolvedLanguage` is what i18next actually serves — it already
// applies `fallbackLng`/`supportedLngs` so a detector result of `fr`
// arrives here as `en`. Falling back to `language` covers the narrow
// window before the first `languageChanged` where `resolvedLanguage`
// may still be undefined (i18next API guidance).
const initial = i18n.resolvedLanguage ?? i18n.language;
if (initial) broadcast(initial);
i18n.on('languageChanged', (lng) => {
broadcast(lng);
});
};