From 7af04a429d88ce4c31127202cb3f91042609f7eb Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Fri, 24 Apr 2026 23:51:07 +0300 Subject: [PATCH] Localize invite push notifications across web SW and Android FCM via shared JSON Push namespace. --- .../main/java/chat/vojo/app/PushStrings.java | 121 ++++++ .../app/VojoFirebaseMessagingService.java | 51 ++- .../src/main/res/values-ru/push_strings.xml | 15 + .../app/src/main/res/values/push_strings.xml | 15 + docs/ai/i18n.md | 5 +- package.json | 3 +- public/locales/en.json | 13 + public/locales/ru.json | 13 + scripts/gen-push-strings.mjs | 199 +++++++++ src/app/i18n.ts | 7 +- src/app/pages/App.tsx | 13 +- src/app/utils/pushLanguageBridge.ts | 103 +++++ src/sw.ts | 390 ++++++++++++++++-- 13 files changed, 900 insertions(+), 48 deletions(-) create mode 100644 android/app/src/main/java/chat/vojo/app/PushStrings.java create mode 100644 android/app/src/main/res/values-ru/push_strings.xml create mode 100644 android/app/src/main/res/values/push_strings.xml create mode 100644 scripts/gen-push-strings.mjs create mode 100644 src/app/utils/pushLanguageBridge.ts diff --git a/android/app/src/main/java/chat/vojo/app/PushStrings.java b/android/app/src/main/java/chat/vojo/app/PushStrings.java new file mode 100644 index 00000000..ff0437f0 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/PushStrings.java @@ -0,0 +1,121 @@ +package chat.vojo.app; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; + +import java.util.Locale; + +/** + * Locale-aware push-notification strings. Resources (values{,-ru}/ + * push_strings.xml) are auto-generated at build time by + * scripts/gen-push-strings.mjs from public/locales/{en,ru}.json so + * the Push namespace has a single source of truth shared with the + * web Service Worker. Do not edit push_strings.xml by hand — it will + * be overwritten on the next `npm run android:sync`. + * + * Locale selection: Vojo ships an explicit in-app language picker that + * does NOT have to match the device locale. pushLanguageBridge.ts + * mirrors i18next's current language into Capacitor Preferences + * (shared_prefs/CapacitorStorage.xml, key "vojo.appLanguage") on every + * `languageChanged` event; we read it here and force it onto a + * Configuration-scoped Context before the getString call. + * + * Killed-process pushes may arrive before JS has ever booted — no pref + * to read. In that case we fall back to the device locale, normalised + * to {en, ru}; anything else maps to en, matching i18n.ts + * fallbackLng: 'en' on the main thread. + */ +final class PushStrings { + + private static final String PREFS_GROUP = "CapacitorStorage"; + private static final String LANG_KEY = "vojo.appLanguage"; + + private PushStrings() {} + + static String messageFallback(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_new_message); + } + + static String messagesFallback(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_new_messages); + } + + static String inviteTitle(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_invitation); + } + + /** + * Build the invite-notification body from inviter + room name, falling + * back through four variants when one or both are absent. The res IDs + * all declare positional formatters (%1$s = inviter, %2$s = roomName) + * to keep the order stable across locales; we always pass both args + * even when only one is used, because Android's formatter requires + * every referenced position to be supplied. + */ + static String inviteBody(Context ctx, String inviter, String roomName) { + boolean hasInviter = inviter != null && !inviter.isEmpty(); + boolean hasRoom = roomName != null && !roomName.isEmpty(); + int resId; + if (hasInviter && hasRoom) { + resId = R.string.push_invite_body; + } else if (hasInviter) { + resId = R.string.push_invite_body_no_room; + } else if (hasRoom) { + resId = R.string.push_invite_body_no_inviter; + } else { + resId = R.string.push_invite_body_generic; + } + String safeInviter = hasInviter ? inviter : ""; + String safeRoom = hasRoom ? roomName : ""; + return forAppLocale(ctx).getString(resId, safeInviter, safeRoom); + } + + private static Context forAppLocale(Context ctx) { + String lang = chooseLang(ctx); + try { + Configuration cfg = new Configuration(ctx.getResources().getConfiguration()); + cfg.setLocale(Locale.forLanguageTag(lang)); + return ctx.createConfigurationContext(cfg); + } catch (Throwable t) { + return ctx; + } + } + + private static String chooseLang(Context ctx) { + String fromPref = readAppLanguage(ctx); + if (fromPref != null) return fromPref; + try { + Locale loc = Locale.getDefault(); + if (loc != null) return normalize(loc.getLanguage()); + } catch (Throwable ignored) { + // Locale.getDefault is unusually robust but defensively fall through. + } + return "en"; + } + + private static String readAppLanguage(Context ctx) { + try { + SharedPreferences prefs = + ctx.getSharedPreferences(PREFS_GROUP, Context.MODE_PRIVATE); + return normalize(prefs.getString(LANG_KEY, null)); + } catch (Throwable t) { + return null; + } + } + + // Match i18next config: `fallbackLng: 'en'`, `load: 'languageOnly'`. + // Vojo only ships en.json + ru.json; any other Configuration locale + // (e.g. "fr") would bypass both values-ru/ and our bundled resources + // and land on values/ anyway — we collapse to "en" explicitly so the + // web and native surfaces render the same lingua-franca default and + // readers of this method don't have to reason about fall-through. + // Clamp on both write (pushLanguageBridge.ts) and read (here) so a + // stale or tampered pref value can't leak through. + private static String normalize(String raw) { + if (raw == null) return null; + String lower = raw.toLowerCase(Locale.ROOT); + if (lower.startsWith("ru")) return "ru"; + return "en"; + } +} diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java index 678e2aaf..3c06eaea 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -224,13 +224,30 @@ public class VojoFirebaseMessagingService extends MessagingService { // Sygnal flattens nested notification fields with `_` separator: // sender_display_name, content_body, content_msgtype, etc. - String title = firstNonEmpty( - data.get("room_name"), - data.get("sender_display_name"), - data.get("sender"), - "Vojo" - ); - String body = firstNonEmpty(data.get("content_body"), "New message"); + boolean isInvite = "m.room.member".equals(data.get("type")) + && "invite".equals(data.get("content_membership")); + + String title; + String body; + if (isInvite) { + // m.room.member invites carry no content.body — the generic + // path would show "New message" and drop the invite semantic. + // Title marks the category ("Invitation"); inviter + room land + // in body (WhatsApp/Telegram convention; keeps shade scannable). + title = PushStrings.inviteTitle(this); + body = PushStrings.inviteBody(this, humanInviter(data), data.get("room_name")); + } else { + title = firstNonEmpty( + data.get("room_name"), + data.get("sender_display_name"), + data.get("sender"), + "Vojo" + ); + body = firstNonEmpty( + data.get("content_body"), + PushStrings.messageFallback(this) + ); + } // Reuse Capacitor plugin's intent shape so its handleOnNewIntent() fires // `pushNotificationActionPerformed` and the existing JS listener navigates. @@ -285,7 +302,7 @@ public class VojoFirebaseMessagingService extends MessagingService { NotificationCompat.Builder summary = new NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle("Vojo") - .setContentText("New messages") + .setContentText(PushStrings.messagesFallback(this)) .setGroup(GROUP_KEY) .setGroupSummary(true) .setAutoCancel(true); @@ -928,4 +945,22 @@ public class VojoFirebaseMessagingService extends MessagingService { } return ""; } + + // Prefer the Sygnal-flattened display name; if that is absent, strip the + // MXID to its local-part (`@alice:hs.tld` → `alice`) — a raw MXID in a + // push body reads worse than a bare name. Returns empty when nothing + // usable is present; PushStrings.inviteBody falls through to the + // "no inviter" variant in that case. + private static String humanInviter(Map data) { + String displayName = data.get("sender_display_name"); + if (displayName != null && !displayName.isEmpty()) return displayName; + String mxid = data.get("sender"); + if (mxid == null || mxid.isEmpty()) return ""; + if (mxid.startsWith("@")) { + int colon = mxid.indexOf(':'); + if (colon > 1) return mxid.substring(1, colon); + return mxid.substring(1); + } + return mxid; + } } diff --git a/android/app/src/main/res/values-ru/push_strings.xml b/android/app/src/main/res/values-ru/push_strings.xml new file mode 100644 index 00000000..203d2140 --- /dev/null +++ b/android/app/src/main/res/values-ru/push_strings.xml @@ -0,0 +1,15 @@ + + + + Новое сообщение + Новые сообщения + Приглашение + %1$s приглашает вас в %2$s + %1$s приглашает вас в комнату + Приглашение в %2$s + Новое приглашение + diff --git a/android/app/src/main/res/values/push_strings.xml b/android/app/src/main/res/values/push_strings.xml new file mode 100644 index 00000000..675b35fa --- /dev/null +++ b/android/app/src/main/res/values/push_strings.xml @@ -0,0 +1,15 @@ + + + + New message + New messages + Invitation + %1$s invited you to %2$s + %1$s invited you to a room + Invited you to %2$s + New invitation + diff --git a/docs/ai/i18n.md b/docs/ai/i18n.md index 5ea6ce52..3bdea44f 100644 --- a/docs/ai/i18n.md +++ b/docs/ai/i18n.md @@ -3,9 +3,10 @@ ## Setup - **Config**: [`src/app/i18n.ts`](../../src/app/i18n.ts) — i18next + HTTP backend + language detector -- **Fallback language**: `ru` +- **Fallback language**: `en` (lingua franca for unsupported detected locales; keeps web/SW/Android push surfaces aligned — see §5.27 in docs/plans/dm_calls_techdebt.md) +- **Supported languages**: `en`, `ru` (set in `supportedLngs`; anything else normalises to `en`) - **Locale files**: [`public/locales/en.json`](../../public/locales/en.json), [`public/locales/ru.json`](../../public/locales/ru.json) -- **Namespaces** (top-level keys in the JSON files): `Organisms`, `Auth`, `Settings`, `Search`, `Home`, `Direct`, `Room`, `Inbox`, `Explore`, `Create`, `RoomSettings` +- **Namespaces** (top-level keys in the JSON files): `Organisms`, `Auth`, `Settings`, `Search`, `Home`, `Direct`, `Room`, `Inbox`, `Explore`, `Create`, `RoomSettings`, `Push` ## Usage pattern diff --git a/package.json b/package.json index 49724638..88099de9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "check:prettier": "prettier --check .", "fix:prettier": "prettier --write .", "typecheck": "tsc --noEmit", - "android:sync": "npx cap sync android", + "gen:push-strings": "node scripts/gen-push-strings.mjs", + "android:sync": "npm run gen:push-strings && npx cap sync android", "android:open": "npx cap open android", "android:apk:debug": "cd android && ./gradlew assembleDebug", "android:apk:release": "cd android && ./gradlew assembleRelease", diff --git a/public/locales/en.json b/public/locales/en.json index 1b5b7ba1..c42a4ed8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -856,5 +856,18 @@ "power_member": "Member", "power_muted": "Muted", "power_team": "Team" + }, + + "Push": { + "new_message": "New message", + "new_messages": "New messages", + "encrypted_message": "New encrypted message", + "incoming_call": "Incoming call", + "open_to_answer": "Open Vojo to answer", + "invitation": "Invitation", + "invite_body": "{{inviter}} invited you to {{roomName}}", + "invite_body_no_room": "{{inviter}} invited you to a room", + "invite_body_no_inviter": "Invited you to {{roomName}}", + "invite_body_generic": "New invitation" } } diff --git a/public/locales/ru.json b/public/locales/ru.json index fc6d943a..b2f4924c 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -858,5 +858,18 @@ "power_member": "Участник", "power_muted": "Без голоса", "power_team": "Команда" + }, + + "Push": { + "new_message": "Новое сообщение", + "new_messages": "Новые сообщения", + "encrypted_message": "Новое зашифрованное сообщение", + "incoming_call": "Входящий звонок", + "open_to_answer": "Откройте Vojo, чтобы ответить", + "invitation": "Приглашение", + "invite_body": "{{inviter}} приглашает вас в {{roomName}}", + "invite_body_no_room": "{{inviter}} приглашает вас в комнату", + "invite_body_no_inviter": "Приглашение в {{roomName}}", + "invite_body_generic": "Новое приглашение" } } diff --git a/scripts/gen-push-strings.mjs b/scripts/gen-push-strings.mjs new file mode 100644 index 00000000..865fdbea --- /dev/null +++ b/scripts/gen-push-strings.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/* + * Emit Android string resources for push-notification fallback text. + * + * Single source of truth: public/locales/{en,ru}.json under the `Push` + * namespace (the same file i18next loads on web). This script takes the + * Android-relevant subset and writes it to + * android/app/src/main/res/values{,-ru}/push_strings.xml — a dedicated + * file that sits next to the app's existing strings.xml without mixing + * with hand-maintained resources. + * + * Runs as part of `npm run android:sync` so every Capacitor sync rebuilds + * the push resources from the JSON. Editing push_strings.xml by hand is + * useless — it gets overwritten. + * + * Why not parse the JSON at runtime in VojoFirebaseMessagingService? + * Because that hot path runs before WebView, before JS, under killed + * process — any JSONObject parse failure becomes a missed push. The + * review agent's verdict (see §5.27): keep Android on aapt-validated + * resources, pay the drift cost at build time, not at push time. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, '..'); +const LOCALES_DIR = path.join(ROOT, 'public', 'locales'); +const ANDROID_RES = path.join(ROOT, 'android', 'app', 'src', 'main', 'res'); + +// Keys from the Push namespace that end up in Android resources. +// SW-only strings (encrypted_message, incoming_call, open_to_answer) are +// consumed by the Service Worker at runtime against the JSON bundle — no +// need to ship them as Android resources. +const ANDROID_KEYS = [ + 'new_message', + 'new_messages', + 'invitation', + 'invite_body', + 'invite_body_no_room', + 'invite_body_no_inviter', + 'invite_body_generic', +]; + +// i18next uses named placeholders ({{inviter}}); Android string resources +// use positional formatters (%1$s). Hardcoding the name→position map here +// keeps the Java call signature stable — PushStrings.inviteBody(ctx, +// inviter, roomName) always passes inviter in position 1, roomName in +// position 2, regardless of how the translators order them in the JSON. +// Adding a new placeholder: add it here AND update PushStrings accordingly. +const PLACEHOLDER_POSITIONS = { + inviter: 1, + roomName: 2, +}; + +const LANGS = { + en: 'values', + ru: 'values-ru', +}; + +function convertPlaceholders(text, locale, key) { + const seen = new Set(); + const converted = text.replace(/\{\{(\w+)\}\}/g, (_, name) => { + const pos = PLACEHOLDER_POSITIONS[name]; + if (!pos) { + throw new Error( + `[${locale}] Push.${key}: unknown placeholder {{${name}}}. ` + + `Add it to PLACEHOLDER_POSITIONS in gen-push-strings.mjs ` + + `and thread the value through PushStrings.java.` + ); + } + seen.add(name); + return `%${pos}$s`; + }); + return { text: converted, placeholders: seen }; +} + +// Minimal XML escaping for string resources. Single/double quotes must be +// backslash-escaped per Android resource rules; ampersand/angle brackets +// get entity-encoded. No string in the Push namespace today contains any +// of these, but the check has to exist because translators will edit +// the source JSON and the script must not silently corrupt their copy. +function xmlEscape(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, "\\'") + .replace(/"/g, '\\"'); +} + +function readBundle(locale) { + const jsonPath = path.join(LOCALES_DIR, `${locale}.json`); + const raw = fs.readFileSync(jsonPath, 'utf8'); + const parsed = JSON.parse(raw); + const push = parsed?.Push; + if (!push || typeof push !== 'object') { + throw new Error(`${jsonPath} is missing the "Push" namespace`); + } + return push; +} + +function verifyParity(bundles) { + const locales = Object.keys(bundles); + const [first, ...rest] = locales; + const firstKeys = new Set(Object.keys(bundles[first])); + for (const locale of rest) { + const keys = new Set(Object.keys(bundles[locale])); + const missingInOther = [...firstKeys].filter((k) => !keys.has(k)); + const extraInOther = [...keys].filter((k) => !firstKeys.has(k)); + if (missingInOther.length || extraInOther.length) { + throw new Error( + `Push namespace key drift between "${first}" and "${locale}".\n` + + ` Missing in ${locale}: ${JSON.stringify(missingInOther)}\n` + + ` Extra in ${locale}: ${JSON.stringify(extraInOther)}` + ); + } + } + for (const key of ANDROID_KEYS) { + for (const locale of locales) { + if (typeof bundles[locale][key] !== 'string') { + throw new Error(`Push.${key} missing or non-string in ${locale}.json`); + } + } + // Placeholder tokens must match across locales for any given key — + // a translator adding {{user}} on one side silently produces + // literal-curly-brace output on the other surface. + const tokenSets = locales.map((locale) => { + const text = bundles[locale][key]; + const tokens = new Set(); + text.replace(/\{\{(\w+)\}\}/g, (_, name) => { + tokens.add(name); + return ''; + }); + return { locale, tokens }; + }); + const baseline = tokenSets[0]; + for (const entry of tokenSets.slice(1)) { + const baselineArr = [...baseline.tokens].sort(); + const entryArr = [...entry.tokens].sort(); + if ( + baselineArr.length !== entryArr.length || + baselineArr.some((t, i) => t !== entryArr[i]) + ) { + throw new Error( + `Placeholder drift on Push.${key}: ` + + `${baseline.locale}=${JSON.stringify(baselineArr)}, ` + + `${entry.locale}=${JSON.stringify(entryArr)}` + ); + } + } + } +} + +function emitResource(locale, bundle) { + const lines = [ + "", + '', + '', + ]; + for (const key of ANDROID_KEYS) { + const raw = bundle[key]; + const { text, placeholders } = convertPlaceholders(raw, locale, key); + const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : ''; + lines.push( + ` ${xmlEscape(text)}` + ); + } + lines.push(''); + lines.push(''); + const outPath = path.join(ANDROID_RES, LANGS[locale], 'push_strings.xml'); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, lines.join('\n'), 'utf8'); + return outPath; +} + +function main() { + const bundles = {}; + for (const locale of Object.keys(LANGS)) { + bundles[locale] = readBundle(locale); + } + verifyParity(bundles); + for (const locale of Object.keys(LANGS)) { + const outPath = emitResource(locale, bundles[locale]); + process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`); + } +} + +try { + main(); +} catch (err) { + process.stderr.write(`gen-push-strings: ${err.message}\n`); + process.exit(1); +} diff --git a/src/app/i18n.ts b/src/app/i18n.ts index 0616c9a1..7ba67fe6 100644 --- a/src/app/i18n.ts +++ b/src/app/i18n.ts @@ -18,7 +18,12 @@ i18n // for all options read: https://www.i18next.com/overview/configuration-options .init({ debug: false, - fallbackLng: 'ru', + fallbackLng: 'en', + // Explicit whitelist so the detector can't resolve to a language we + // don't ship (e.g. `fr`) and then 404 silently. Matches the native + // PushStrings / SW normalizeLang clamp — all three surfaces live in + // the same `{en, ru}` set. + supportedLngs: ['en', 'ru'], interpolation: { escapeValue: false, // not needed for react as it escapes by default }, diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 52ec7f20..c8d129f2 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; import { RouterProvider } from 'react-router-dom'; @@ -12,6 +12,7 @@ import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize'; import { useCompositionEndTracking } from '../hooks/useComposingCheck'; +import { installPushLanguageBridge } from '../utils/pushLanguageBridge'; const queryClient = new QueryClient(); @@ -19,6 +20,16 @@ function App() { const screenSize = useScreenSize(); useCompositionEndTracking(); + // Mirror i18next's current language to both native (Capacitor + // Preferences → Java PushStrings) and the Service Worker + // (postMessage → IndexedDB) so push-notification fallbacks render + // in the language the user picked in-app, not whichever locale the + // device / browser happens to report. No cleanup — the listener is + // intentionally global for the app lifetime. + useEffect(() => { + installPushLanguageBridge(); + }, []); + const portalContainer = document.getElementById('portalContainer') ?? undefined; return ( diff --git a/src/app/utils/pushLanguageBridge.ts b/src/app/utils/pushLanguageBridge.ts new file mode 100644 index 00000000..956b8c12 --- /dev/null +++ b/src/app/utils/pushLanguageBridge.ts @@ -0,0 +1,103 @@ +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 => { + 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); + }); +}; diff --git a/src/sw.ts b/src/sw.ts index 6aebade5..9e2c89cd 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -89,18 +89,103 @@ self.addEventListener('activate', (event: ExtendableEvent) => { ); }); +// ──────────────────────────────────────────────────────────────────── +// In-app language bridge +// +// Main thread mirrors i18next's current language to the SW via +// `setLanguage` postMessage (handled just below); the SW persists it +// in IndexedDB so a cold wake (process restart) still knows the +// user's pref before any client connects. Fallback chain if the +// bridge never fired: IDB → navigator.language → 'en' (matches +// i18n.ts fallbackLng). +// ──────────────────────────────────────────────────────────────────── + +type SupportedLang = 'en' | 'ru'; + +// Three-state: `undefined` = not yet checked, `null` = checked and +// absent in IDB (→ use navigator.language), `'en' | 'ru'` = resolved. +let swLanguage: SupportedLang | null | undefined; + +const LANG_DB_NAME = 'vojo-sw-meta'; +const LANG_DB_STORE = 'kv'; +const LANG_DB_KEY = 'language'; + +function openLangDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(LANG_DB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(LANG_DB_STORE)) { + db.createObjectStore(LANG_DB_STORE); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function readStoredLang(): Promise { + try { + const db = await openLangDb(); + return await new Promise((resolve) => { + const tx = db.transaction(LANG_DB_STORE, 'readonly'); + const req = tx.objectStore(LANG_DB_STORE).get(LANG_DB_KEY); + req.onsuccess = () => { + const val = req.result; + resolve(val === 'en' || val === 'ru' ? val : null); + }; + req.onerror = () => resolve(null); + }); + } catch { + return null; + } +} + +async function writeStoredLang(lang: SupportedLang): Promise { + try { + const db = await openLangDb(); + await new Promise((resolve) => { + const tx = db.transaction(LANG_DB_STORE, 'readwrite'); + tx.objectStore(LANG_DB_STORE).put(lang, LANG_DB_KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + tx.onabort = () => resolve(); + }); + } catch { + /* non-fatal — `swLanguage` still holds the value in-memory */ + } +} + +function normalizeLang(raw: string | undefined | null): SupportedLang { + if (!raw) return 'en'; + const lower = raw.toLowerCase(); + if (lower.startsWith('ru')) return 'ru'; + // i18n.ts sets fallbackLng: 'en' — match that so the SW stays in lock + // step with what the main app renders for unfamiliar locales. English + // is a more useful lingua franca than Russian for a user whose device + // is in some other language entirely. + return 'en'; +} + /** - * Receive session updates from clients + * Receive session + language updates from clients */ self.addEventListener('message', (event: ExtendableMessageEvent) => { const client = event.source as Client | null; if (!client) return; - const { type, accessToken, baseUrl } = event.data || {}; + const data = event.data || {}; + const { type } = data; if (type === 'setSession') { - setSession(client.id, accessToken, baseUrl); + setSession(client.id, data.accessToken, data.baseUrl); cleanupDeadClients(); + return; + } + if (type === 'setLanguage') { + const lang = normalizeLang(data.lang); + swLanguage = lang; + event.waitUntil(writeStoredLang(lang)); } }); @@ -206,8 +291,17 @@ async function anySession(): Promise { } // Fallback strings for when we can't fetch event content (offline / encrypted / -// no session). The main app runs i18next, but the SW is a separate context — -// we read navigator.language here and ship a small map for the locales we support. +// no session). Single source of truth is public/locales/{en,ru}.json under +// the `Push` namespace — the same files i18next loads on the web. Android +// mirrors those keys into res/values{,-ru}/push_strings.xml at build time +// (scripts/gen-push-strings.mjs); this SW loader stays on the same source +// at runtime instead of holding a hand-maintained copy that would drift. +// +// The chosen language tracks the main-thread i18next pref via the +// setLanguage postMessage bridge + IndexedDB persistence (see +// `In-app language bridge` block above). If the bridge never fired +// (fresh SW with no controller interaction yet), we fall through to +// normalize(navigator.language) → 'en'. type PushFallback = { brand: string; newMessage: string; @@ -215,9 +309,22 @@ type PushFallback = { incomingCall: string; openToAnswer: string; invitation: string; - invitedYou: (roomName?: string) => string; + inviteBody: (args: { inviter?: string; roomName?: string }) => string; }; -const PUSH_FALLBACKS: Record = { + +// Minimal hardcoded safety-net used only when the JSON bundle can't be +// fetched (cold SW wake with no Cache entry + offline, or a 5xx from the +// static hosting). Rich wording lives in public/locales/{lang}.json. +type HardcodedStrings = { + brand: string; + newMessage: string; + encrypted: string; + incomingCall: string; + openToAnswer: string; + invitation: string; + inviteGeneric: string; +}; +const HARDCODED: Record<'en' | 'ru', HardcodedStrings> = { en: { brand: 'Vojo', newMessage: 'New message', @@ -225,26 +332,232 @@ const PUSH_FALLBACKS: Record = { incomingCall: 'Incoming call', openToAnswer: 'Open Vojo to answer', invitation: 'Invitation', - invitedYou: (roomName) => - roomName ? `Invited you to ${roomName}` : 'Invited you to a room', + inviteGeneric: 'New invitation', }, ru: { brand: 'Vojo', newMessage: 'Новое сообщение', encrypted: 'Новое зашифрованное сообщение', incomingCall: 'Входящий звонок', - openToAnswer: 'Откройте Vojo чтобы ответить', + openToAnswer: 'Откройте Vojo, чтобы ответить', invitation: 'Приглашение', - invitedYou: (roomName) => - roomName ? `Приглашает вас в ${roomName}` : 'Приглашает вас в комнату', + inviteGeneric: 'Новое приглашение', }, }; -function pushFallback(): PushFallback { - const lang = (typeof navigator !== 'undefined' ? navigator.language : 'en') - .slice(0, 2) - .toLowerCase(); - return PUSH_FALLBACKS[lang] ?? PUSH_FALLBACKS.en; +type LocaleBundle = Record; + +// Successful bundle parse per language. Failures are NOT cached: a +// transient fetch 5xx on the first push shouldn't lock us to HARDCODED +// for the whole SW lifetime, and a successful re-fetch on the next push +// is cheap. Compare to the session cache — different policy on purpose. +const bundleCache = new Map(); + +async function currentSwLanguage(): Promise { + if (swLanguage === 'en' || swLanguage === 'ru') return swLanguage; + if (swLanguage === undefined) { + const stored = await readStoredLang(); + swLanguage = stored; // `null` marks "checked, empty" — skip IDB next call + if (stored) return stored; + } + return normalizeLang(typeof navigator !== 'undefined' ? navigator.language : undefined); +} + +async function loadBundle(lang: SupportedLang): Promise { + const cached = bundleCache.get(lang); + if (cached) return cached; + try { + // Scope-relative URL so a Vite `base` change (served from `/app/` + // instead of `/`) doesn't silently break the fetch. Same physical + // file as i18next loads on the main thread. + const url = new URL(`public/locales/${lang}.json`, self.registration.scope).href; + const res = await fetch(url); + if (!res.ok) return null; + const json = (await res.json()) as { Push?: LocaleBundle }; + const push = json?.Push; + if (!push) return null; + bundleCache.set(lang, push); + return push; + } catch { + return null; + } +} + +function interpolate(template: string, vars: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => vars[name] ?? ''); +} + +async function pushFallback(): Promise { + const lang = await currentSwLanguage(); + const bundle = await loadBundle(lang); + const safety = HARDCODED[lang]; + const lookup = (key: string, fallback: string): string => { + const value = bundle?.[key]; + return typeof value === 'string' && value.length > 0 ? value : fallback; + }; + return { + brand: safety.brand, + newMessage: lookup('new_message', safety.newMessage), + encrypted: lookup('encrypted_message', safety.encrypted), + incomingCall: lookup('incoming_call', safety.incomingCall), + openToAnswer: lookup('open_to_answer', safety.openToAnswer), + invitation: lookup('invitation', safety.invitation), + inviteBody: ({ inviter, roomName }) => { + const hasInviter = !!inviter; + const hasRoom = !!roomName; + let key: string; + if (hasInviter && hasRoom) key = 'invite_body'; + else if (hasInviter) key = 'invite_body_no_room'; + else if (hasRoom) key = 'invite_body_no_inviter'; + else key = 'invite_body_generic'; + const template = bundle?.[key]; + if (typeof template === 'string' && template.length > 0) { + return interpolate(template, { + inviter: inviter ?? '', + roomName: roomName ?? '', + }); + } + // Safety-net: bundle missing or key absent. Keep the sentence + // shape reasonable in both languages. + if (hasInviter && hasRoom) { + return lang === 'ru' + ? `${inviter} приглашает вас в ${roomName}` + : `${inviter} invited you to ${roomName}`; + } + if (hasInviter) { + return lang === 'ru' + ? `${inviter} приглашает вас в комнату` + : `${inviter} invited you to a room`; + } + if (hasRoom) { + return lang === 'ru' + ? `Приглашение в ${roomName}` + : `Invited you to ${roomName}`; + } + return safety.inviteGeneric; + }, + }; +} + +function mxidLocalPart(mxid: string): string { + if (!mxid.startsWith('@')) return mxid; + const colon = mxid.indexOf(':'); + return colon > 1 ? mxid.slice(1, colon) : mxid.slice(1); +} + +// Fallback path for invitees: /rooms/{id}/event/{id} is 404 and +// /rooms/{id}/state/... is 403 for users who have never joined. The +// Matrix spec carves out /notifications for exactly this case — it +// returns events the user was (or would have been) notified about, +// accessible before the join. Synapse also ships stripped invite state +// under event.unsigned.invite_room_state, which carries m.room.name +// and the inviter's m.room.member so we can render a humane banner +// without ever joining. +// +// One retry at 400ms: Synapse indexes a just-dispatched push slightly +// behind the push itself, and the first call sometimes misses the +// event. After the retry we fall through and the caller shows the +// generic fallback. limit=30 is comfortably larger than any realistic +// burst between push dispatch and SW wake. +type InviteLookup = { isInvite: boolean; inviter?: string; roomName?: string }; +type NotifEntry = { + room_id?: string; + event?: { + event_id?: string; + type?: string; + sender?: string; + content?: { membership?: string }; + unsigned?: { invite_room_state?: Array> }; + }; +}; +type StrippedEvent = { + type?: string; + sender?: string; + content?: { name?: string; displayname?: string }; +}; + +async function tryFetchInvite( + session: SessionInfo, + roomId: string, + eventId: string, + headers: { Authorization: string } +): Promise<{ kind: 'result'; value: InviteLookup } | { kind: 'miss' } | { kind: 'error' }> { + let res: Response; + try { + res = await fetch( + `${session.baseUrl}/_matrix/client/v3/notifications?limit=30`, + { headers } + ); + } catch { + return { kind: 'error' }; + } + if (!res.ok) return { kind: 'error' }; + let json: { notifications?: NotifEntry[] }; + try { + json = (await res.json()) as { notifications?: NotifEntry[] }; + } catch { + return { kind: 'error' }; + } + const entries = Array.isArray(json?.notifications) ? json.notifications : []; + const hit = entries.find( + (n) => n?.room_id === roomId && n?.event?.event_id === eventId + ); + if (!hit) return { kind: 'miss' }; + const ev = hit.event; + if ( + ev?.type !== 'm.room.member' || + ev?.content?.membership !== 'invite' || + typeof ev?.sender !== 'string' + ) { + return { kind: 'result', value: { isInvite: false } }; + } + const rawStripped = ev?.unsigned?.invite_room_state; + const stripped: StrippedEvent[] = Array.isArray(rawStripped) + ? (rawStripped as StrippedEvent[]) + : []; + const inviterMember = stripped.find( + (s) => + s?.type === 'm.room.member' && + s?.sender === ev.sender && + typeof s?.content?.displayname === 'string' && + !!s.content.displayname.trim() + ); + const nameState = stripped.find( + (s) => s?.type === 'm.room.name' && typeof s?.content?.name === 'string' && !!s.content.name.trim() + ); + const inviter = inviterMember?.content?.displayname ?? mxidLocalPart(ev.sender); + const roomName = nameState?.content?.name; + return { kind: 'result', value: { isInvite: true, inviter, roomName } }; +} + +// Fallback path for invitees: /rooms/{id}/event/{id} is 404 and +// /rooms/{id}/state/... is 403 for users who have never joined. The +// Matrix spec carves out /notifications for exactly this case — it +// returns events the user was (or would have been) notified about, +// accessible before the join. Synapse also ships stripped invite state +// under event.unsigned.invite_room_state, which carries m.room.name +// and the inviter's m.room.member so we can render a humane banner +// without ever joining. +// +// One retry at 400ms: Synapse indexes a just-dispatched push slightly +// behind the push itself, and the first call sometimes misses the +// event. After the retry we fall through and the caller shows the +// generic fallback. limit=30 is comfortably larger than any realistic +// burst between push dispatch and SW wake. +async function fetchInviteFromNotifications( + session: SessionInfo, + roomId: string, + eventId: string, + headers: { Authorization: string } +): Promise { + const first = await tryFetchInvite(session, roomId, eventId, headers); + if (first.kind === 'error') return undefined; + if (first.kind === 'result') return first.value; + await new Promise((r) => { + setTimeout(r, 400); + }); + const second = await tryFetchInvite(session, roomId, eventId, headers); + return second.kind === 'result' ? second.value : undefined; } async function fetchEventDetails( @@ -253,6 +566,10 @@ async function fetchEventDetails( eventId: string ): Promise<{ title: string; body: string; isCall: boolean; isInvite: boolean }> { const headers = { Authorization: `Bearer ${session.accessToken}` }; + // /event/{id} and /state/... share the same "joined-or-former-joined" + // access gate — for invitees both return 404/403 respectively. For + // joined users they both succeed and the parallel fetch saves a round + // trip; for invitees we fall through to /notifications below. const [evRes, nameRes] = await Promise.all([ fetch( `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`, @@ -264,7 +581,7 @@ async function fetchEventDetails( ).catch(() => undefined), ]); - const fb = pushFallback(); + const fb = await pushFallback(); let title = fb.brand; let body = fb.newMessage; let isCall = false; @@ -295,10 +612,9 @@ async function fetchEventDetails( event?.type === 'm.room.member' && event?.content?.membership === 'invite' ) { - // Invite state events surface the invitee in state_key; the inviter - // (event.sender) is the person we want to show. `content.displayname` - // is the invitee's, so we need a second round-trip against the inviter's - // member state to get a nice human name. + // Invite path for an already-joined user (rare — re-invite after + // leave, or multi-device where another session joined). The 404 + // path below handles the pre-join case. isInvite = true; if (typeof event?.sender === 'string') inviterMxid = event.sender; } else if (event?.type === 'm.room.encrypted') { @@ -306,12 +622,21 @@ async function fetchEventDetails( } else if (typeof event?.content?.body === 'string') { body = event.content.body.slice(0, 200); } + } else { + // /event/{id} 404: invitee pre-join, or an event the user lacks + // history-visibility for. /notifications is the only spec'd endpoint + // that serves invite context here (state/* is also 403 for invitees). + const invite = await fetchInviteFromNotifications(session, roomId, eventId, headers); + if (invite?.isInvite) { + isInvite = true; + inviterDisplay = invite.inviter; + if (invite.roomName) roomName = invite.roomName; + } } - if (isInvite && inviterMxid) { - // Separate fetch (not batched with the initial Promise.all) because we - // only know the sender MXID after the event request returns. Failure is - // non-fatal — we still have the MXID local-part as a last resort. + if (isInvite && !inviterDisplay && inviterMxid) { + // Joined-user invite path: look up the inviter's display name via + // their member state. Failure is non-fatal — local-part fallback. try { const memberRes = await fetch( `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent( @@ -328,17 +653,12 @@ async function fetchEventDetails( } catch { /* keep inviterDisplay undefined; local-part fallback below */ } - if (!inviterDisplay) { - const local = inviterMxid.startsWith('@') - ? inviterMxid.slice(1).split(':')[0] - : inviterMxid; - inviterDisplay = local; - } + if (!inviterDisplay) inviterDisplay = mxidLocalPart(inviterMxid); } if (isInvite) { - title = inviterDisplay ?? fb.invitation; - body = fb.invitedYou(roomName); + title = fb.invitation; + body = fb.inviteBody({ inviter: inviterDisplay, roomName }); } else if (isCall) { // For DM calls room_name is typically the peer's display name, so // surface it in body (title stays "Incoming call" as the primary signal). @@ -372,7 +692,7 @@ self.addEventListener('push', (event: PushEvent) => { // room/event — clicking it goes nowhere useful. if (!eventId || !roomId) return; - const fb = pushFallback(); + const fb = await pushFallback(); let title = notif?.room_name ?? fb.brand; let body = notif?.content?.body ?? fb.newMessage; let isCall = false;