Localize invite push notifications across web SW and Android FCM via shared JSON Push namespace.
This commit is contained in:
parent
d15a3b336b
commit
15f86bfcae
13 changed files with 900 additions and 48 deletions
121
android/app/src/main/java/chat/vojo/app/PushStrings.java
Normal file
121
android/app/src/main/java/chat/vojo/app/PushStrings.java
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
android/app/src/main/res/values-ru/push_strings.xml
Normal file
15
android/app/src/main/res/values-ru/push_strings.xml
Normal 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>
|
||||
15
android/app/src/main/res/values/push_strings.xml
Normal file
15
android/app/src/main/res/values/push_strings.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Новое приглашение"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
199
scripts/gen-push-strings.mjs
Normal file
199
scripts/gen-push-strings.mjs
Normal 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, '&')
|
||||
.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 = [
|
||||
"<?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);
|
||||
}
|
||||
|
|
@ -18,7 +18,12 @@ i18n
|
|||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init<HttpBackendOptions>({
|
||||
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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
103
src/app/utils/pushLanguageBridge.ts
Normal file
103
src/app/utils/pushLanguageBridge.ts
Normal 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
390
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<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) => {
|
||||
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<SessionInfo | undefined> {
|
|||
}
|
||||
|
||||
// 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<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: {
|
||||
brand: 'Vojo',
|
||||
newMessage: 'New message',
|
||||
|
|
@ -225,26 +332,232 @@ const PUSH_FALLBACKS: Record<string, PushFallback> = {
|
|||
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<string, string>;
|
||||
|
||||
// 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<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(
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue