Localize invite push notifications across web SW and Android FCM via shared JSON Push namespace.

This commit is contained in:
heaven 2026-04-24 23:51:07 +03:00
parent d15a3b336b
commit 15f86bfcae
13 changed files with 900 additions and 48 deletions

View file

@ -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";
}
}

View file

@ -224,13 +224,30 @@ public class VojoFirebaseMessagingService extends MessagingService {
// Sygnal flattens nested notification fields with `_` separator: // Sygnal flattens nested notification fields with `_` separator:
// sender_display_name, content_body, content_msgtype, etc. // sender_display_name, content_body, content_msgtype, etc.
String title = firstNonEmpty( boolean isInvite = "m.room.member".equals(data.get("type"))
data.get("room_name"), && "invite".equals(data.get("content_membership"));
data.get("sender_display_name"),
data.get("sender"), String title;
"Vojo" String body;
); if (isInvite) {
String body = firstNonEmpty(data.get("content_body"), "New message"); // 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 // Reuse Capacitor plugin's intent shape so its handleOnNewIntent() fires
// `pushNotificationActionPerformed` and the existing JS listener navigates. // `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) NotificationCompat.Builder summary = new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Vojo") .setContentTitle("Vojo")
.setContentText("New messages") .setContentText(PushStrings.messagesFallback(this))
.setGroup(GROUP_KEY) .setGroup(GROUP_KEY)
.setGroupSummary(true) .setGroupSummary(true)
.setAutoCancel(true); .setAutoCancel(true);
@ -928,4 +945,22 @@ public class VojoFirebaseMessagingService extends MessagingService {
} }
return ""; 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<String, String> 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;
}
} }

View file

@ -0,0 +1,15 @@
<?xml version='1.0' encoding='utf-8'?>
<!--
AUTO-GENERATED by scripts/gen-push-strings.mjs.
DO NOT EDIT — edit public/locales/ru.json and rerun
`npm run gen:push-strings` (or any `npm run android:sync`).
-->
<resources>
<string name="push_new_message">Новое сообщение</string>
<string name="push_new_messages">Новые сообщения</string>
<string name="push_invitation">Приглашение</string>
<string name="push_invite_body" formatted="true">%1$s приглашает вас в %2$s</string>
<string name="push_invite_body_no_room" formatted="true">%1$s приглашает вас в комнату</string>
<string name="push_invite_body_no_inviter" formatted="true">Приглашение в %2$s</string>
<string name="push_invite_body_generic">Новое приглашение</string>
</resources>

View file

@ -0,0 +1,15 @@
<?xml version='1.0' encoding='utf-8'?>
<!--
AUTO-GENERATED by scripts/gen-push-strings.mjs.
DO NOT EDIT — edit public/locales/en.json and rerun
`npm run gen:push-strings` (or any `npm run android:sync`).
-->
<resources>
<string name="push_new_message">New message</string>
<string name="push_new_messages">New messages</string>
<string name="push_invitation">Invitation</string>
<string name="push_invite_body" formatted="true">%1$s invited you to %2$s</string>
<string name="push_invite_body_no_room" formatted="true">%1$s invited you to a room</string>
<string name="push_invite_body_no_inviter" formatted="true">Invited you to %2$s</string>
<string name="push_invite_body_generic">New invitation</string>
</resources>

View file

@ -3,9 +3,10 @@
## Setup ## Setup
- **Config**: [`src/app/i18n.ts`](../../src/app/i18n.ts) — i18next + HTTP backend + language detector - **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) - **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 ## Usage pattern

View file

@ -16,7 +16,8 @@
"check:prettier": "prettier --check .", "check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .", "fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit", "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:open": "npx cap open android",
"android:apk:debug": "cd android && ./gradlew assembleDebug", "android:apk:debug": "cd android && ./gradlew assembleDebug",
"android:apk:release": "cd android && ./gradlew assembleRelease", "android:apk:release": "cd android && ./gradlew assembleRelease",

View file

@ -856,5 +856,18 @@
"power_member": "Member", "power_member": "Member",
"power_muted": "Muted", "power_muted": "Muted",
"power_team": "Team" "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"
} }
} }

View file

@ -858,5 +858,18 @@
"power_member": "Участник", "power_member": "Участник",
"power_muted": "Без голоса", "power_muted": "Без голоса",
"power_team": "Команда" "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": "Новое приглашение"
} }
} }

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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 = [
"<?xml version='1.0' encoding='utf-8'?>",
'<!--',
' AUTO-GENERATED by scripts/gen-push-strings.mjs.',
` DO NOT EDIT — edit public/locales/${locale}.json and rerun`,
' `npm run gen:push-strings` (or any `npm run android:sync`).',
'-->',
'<resources>',
];
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(
` <string name="push_${key}"${formattedAttr}>${xmlEscape(text)}</string>`
);
}
lines.push('</resources>');
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);
}

View file

@ -18,7 +18,12 @@ i18n
// for all options read: https://www.i18next.com/overview/configuration-options // for all options read: https://www.i18next.com/overview/configuration-options
.init<HttpBackendOptions>({ .init<HttpBackendOptions>({
debug: false, 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: { interpolation: {
escapeValue: false, // not needed for react as it escapes by default escapeValue: false, // not needed for react as it escapes by default
}, },

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Provider as JotaiProvider } from 'jotai'; import { Provider as JotaiProvider } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router-dom';
@ -12,6 +12,7 @@ import { FeatureCheck } from './FeatureCheck';
import { createRouter } from './Router'; import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize'; import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck'; import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { installPushLanguageBridge } from '../utils/pushLanguageBridge';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -19,6 +20,16 @@ function App() {
const screenSize = useScreenSize(); const screenSize = useScreenSize();
useCompositionEndTracking(); 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; const portalContainer = document.getElementById('portalContainer') ?? undefined;
return ( return (

View file

@ -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<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);
});
};

390
src/sw.ts
View file

@ -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<IDBDatabase> {
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<SupportedLang | null> {
try {
const db = await openLangDb();
return await new Promise<SupportedLang | null>((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<void> {
try {
const db = await openLangDb();
await new Promise<void>((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) => { self.addEventListener('message', (event: ExtendableMessageEvent) => {
const client = event.source as Client | null; const client = event.source as Client | null;
if (!client) return; if (!client) return;
const { type, accessToken, baseUrl } = event.data || {}; const data = event.data || {};
const { type } = data;
if (type === 'setSession') { if (type === 'setSession') {
setSession(client.id, accessToken, baseUrl); setSession(client.id, data.accessToken, data.baseUrl);
cleanupDeadClients(); cleanupDeadClients();
return;
}
if (type === 'setLanguage') {
const lang = normalizeLang(data.lang);
swLanguage = lang;
event.waitUntil(writeStoredLang(lang));
} }
}); });
@ -206,8 +291,17 @@ async function anySession(): Promise<SessionInfo | undefined> {
} }
// Fallback strings for when we can't fetch event content (offline / encrypted / // 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 — // no session). Single source of truth is public/locales/{en,ru}.json under
// we read navigator.language here and ship a small map for the locales we support. // 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 = { type PushFallback = {
brand: string; brand: string;
newMessage: string; newMessage: string;
@ -215,9 +309,22 @@ type PushFallback = {
incomingCall: string; incomingCall: string;
openToAnswer: string; openToAnswer: string;
invitation: string; invitation: string;
invitedYou: (roomName?: string) => string; inviteBody: (args: { inviter?: string; roomName?: string }) => string;
}; };
const PUSH_FALLBACKS: Record<string, PushFallback> = {
// 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: { en: {
brand: 'Vojo', brand: 'Vojo',
newMessage: 'New message', newMessage: 'New message',
@ -225,26 +332,232 @@ const PUSH_FALLBACKS: Record<string, PushFallback> = {
incomingCall: 'Incoming call', incomingCall: 'Incoming call',
openToAnswer: 'Open Vojo to answer', openToAnswer: 'Open Vojo to answer',
invitation: 'Invitation', invitation: 'Invitation',
invitedYou: (roomName) => inviteGeneric: 'New invitation',
roomName ? `Invited you to ${roomName}` : 'Invited you to a room',
}, },
ru: { ru: {
brand: 'Vojo', brand: 'Vojo',
newMessage: 'Новое сообщение', newMessage: 'Новое сообщение',
encrypted: 'Новое зашифрованное сообщение', encrypted: 'Новое зашифрованное сообщение',
incomingCall: 'Входящий звонок', incomingCall: 'Входящий звонок',
openToAnswer: 'Откройте Vojo чтобы ответить', openToAnswer: 'Откройте Vojo, чтобы ответить',
invitation: 'Приглашение', invitation: 'Приглашение',
invitedYou: (roomName) => inviteGeneric: 'Новое приглашение',
roomName ? `Приглашает вас в ${roomName}` : 'Приглашает вас в комнату',
}, },
}; };
function pushFallback(): PushFallback { type LocaleBundle = Record<string, string>;
const lang = (typeof navigator !== 'undefined' ? navigator.language : 'en')
.slice(0, 2) // Successful bundle parse per language. Failures are NOT cached: a
.toLowerCase(); // transient fetch 5xx on the first push shouldn't lock us to HARDCODED
return PUSH_FALLBACKS[lang] ?? PUSH_FALLBACKS.en; // 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<SupportedLang, LocaleBundle>();
async function currentSwLanguage(): Promise<SupportedLang> {
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<LocaleBundle | null> {
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, string>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => vars[name] ?? '');
}
async function pushFallback(): Promise<PushFallback> {
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<Record<string, unknown>> };
};
};
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<InviteLookup | undefined> {
const first = await tryFetchInvite(session, roomId, eventId, headers);
if (first.kind === 'error') return undefined;
if (first.kind === 'result') return first.value;
await new Promise<void>((r) => {
setTimeout(r, 400);
});
const second = await tryFetchInvite(session, roomId, eventId, headers);
return second.kind === 'result' ? second.value : undefined;
} }
async function fetchEventDetails( async function fetchEventDetails(
@ -253,6 +566,10 @@ async function fetchEventDetails(
eventId: string eventId: string
): Promise<{ title: string; body: string; isCall: boolean; isInvite: boolean }> { ): Promise<{ title: string; body: string; isCall: boolean; isInvite: boolean }> {
const headers = { Authorization: `Bearer ${session.accessToken}` }; 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([ const [evRes, nameRes] = await Promise.all([
fetch( fetch(
`${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`, `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`,
@ -264,7 +581,7 @@ async function fetchEventDetails(
).catch(() => undefined), ).catch(() => undefined),
]); ]);
const fb = pushFallback(); const fb = await pushFallback();
let title = fb.brand; let title = fb.brand;
let body = fb.newMessage; let body = fb.newMessage;
let isCall = false; let isCall = false;
@ -295,10 +612,9 @@ async function fetchEventDetails(
event?.type === 'm.room.member' && event?.type === 'm.room.member' &&
event?.content?.membership === 'invite' event?.content?.membership === 'invite'
) { ) {
// Invite state events surface the invitee in state_key; the inviter // Invite path for an already-joined user (rare — re-invite after
// (event.sender) is the person we want to show. `content.displayname` // leave, or multi-device where another session joined). The 404
// is the invitee's, so we need a second round-trip against the inviter's // path below handles the pre-join case.
// member state to get a nice human name.
isInvite = true; isInvite = true;
if (typeof event?.sender === 'string') inviterMxid = event.sender; if (typeof event?.sender === 'string') inviterMxid = event.sender;
} else if (event?.type === 'm.room.encrypted') { } else if (event?.type === 'm.room.encrypted') {
@ -306,12 +622,21 @@ async function fetchEventDetails(
} else if (typeof event?.content?.body === 'string') { } else if (typeof event?.content?.body === 'string') {
body = event.content.body.slice(0, 200); 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) { if (isInvite && !inviterDisplay && inviterMxid) {
// Separate fetch (not batched with the initial Promise.all) because we // Joined-user invite path: look up the inviter's display name via
// only know the sender MXID after the event request returns. Failure is // their member state. Failure is non-fatal — local-part fallback.
// non-fatal — we still have the MXID local-part as a last resort.
try { try {
const memberRes = await fetch( const memberRes = await fetch(
`${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent( `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(
@ -328,17 +653,12 @@ async function fetchEventDetails(
} catch { } catch {
/* keep inviterDisplay undefined; local-part fallback below */ /* keep inviterDisplay undefined; local-part fallback below */
} }
if (!inviterDisplay) { if (!inviterDisplay) inviterDisplay = mxidLocalPart(inviterMxid);
const local = inviterMxid.startsWith('@')
? inviterMxid.slice(1).split(':')[0]
: inviterMxid;
inviterDisplay = local;
}
} }
if (isInvite) { if (isInvite) {
title = inviterDisplay ?? fb.invitation; title = fb.invitation;
body = fb.invitedYou(roomName); body = fb.inviteBody({ inviter: inviterDisplay, roomName });
} else if (isCall) { } else if (isCall) {
// For DM calls room_name is typically the peer's display name, so // 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). // 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. // room/event — clicking it goes nowhere useful.
if (!eventId || !roomId) return; if (!eventId || !roomId) return;
const fb = pushFallback(); const fb = await pushFallback();
let title = notif?.room_name ?? fb.brand; let title = notif?.room_name ?? fb.brand;
let body = notif?.content?.body ?? fb.newMessage; let body = notif?.content?.body ?? fb.newMessage;
let isCall = false; let isCall = false;