810 lines
29 KiB
TypeScript
810 lines
29 KiB
TypeScript
/// <reference lib="WebWorker" />
|
|
|
|
export type {};
|
|
declare const self: ServiceWorkerGlobalScope;
|
|
|
|
type SessionInfo = {
|
|
accessToken: string;
|
|
baseUrl: string;
|
|
};
|
|
|
|
/**
|
|
* Store session per client (tab)
|
|
*/
|
|
const sessions = new Map<string, SessionInfo>();
|
|
|
|
const clientToResolve = new Map<string, (value: SessionInfo | undefined) => void>();
|
|
const clientToSessionPromise = new Map<string, Promise<SessionInfo | undefined>>();
|
|
|
|
async function cleanupDeadClients() {
|
|
const activeClients = await self.clients.matchAll();
|
|
const activeIds = new Set(activeClients.map((c) => c.id));
|
|
|
|
Array.from(sessions.keys()).forEach((id) => {
|
|
if (!activeIds.has(id)) {
|
|
sessions.delete(id);
|
|
clientToResolve.delete(id);
|
|
clientToSessionPromise.delete(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
function setSession(clientId: string, accessToken: any, baseUrl: any) {
|
|
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
|
|
sessions.set(clientId, { accessToken, baseUrl });
|
|
} else {
|
|
// Logout or invalid session
|
|
sessions.delete(clientId);
|
|
}
|
|
|
|
const resolveSession = clientToResolve.get(clientId);
|
|
if (resolveSession) {
|
|
resolveSession(sessions.get(clientId));
|
|
clientToResolve.delete(clientId);
|
|
clientToSessionPromise.delete(clientId);
|
|
}
|
|
}
|
|
|
|
function requestSession(client: Client): Promise<SessionInfo | undefined> {
|
|
const promise =
|
|
clientToSessionPromise.get(client.id) ??
|
|
new Promise((resolve) => {
|
|
clientToResolve.set(client.id, resolve);
|
|
client.postMessage({ type: 'requestSession' });
|
|
});
|
|
|
|
if (!clientToSessionPromise.has(client.id)) {
|
|
clientToSessionPromise.set(client.id, promise);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
async function requestSessionWithTimeout(
|
|
clientId: string,
|
|
timeoutMs = 3000
|
|
): Promise<SessionInfo | undefined> {
|
|
const client = await self.clients.get(clientId);
|
|
if (!client) return undefined;
|
|
|
|
const sessionPromise = requestSession(client);
|
|
|
|
const timeout = new Promise<undefined>((resolve) => {
|
|
setTimeout(() => resolve(undefined), timeoutMs);
|
|
});
|
|
|
|
return Promise.race([sessionPromise, timeout]);
|
|
}
|
|
|
|
self.addEventListener('install', () => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener('activate', (event: ExtendableEvent) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
await self.clients.claim();
|
|
await cleanupDeadClients();
|
|
})()
|
|
);
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// 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 + language updates from clients
|
|
*/
|
|
self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
|
const client = event.source as Client | null;
|
|
if (!client) return;
|
|
|
|
const data = event.data || {};
|
|
const { type } = data;
|
|
|
|
if (type === 'setSession') {
|
|
setSession(client.id, data.accessToken, data.baseUrl);
|
|
cleanupDeadClients();
|
|
return;
|
|
}
|
|
if (type === 'setLanguage') {
|
|
const lang = normalizeLang(data.lang);
|
|
swLanguage = lang;
|
|
event.waitUntil(writeStoredLang(lang));
|
|
}
|
|
});
|
|
|
|
const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail'];
|
|
|
|
function mediaPath(url: string): boolean {
|
|
try {
|
|
const { pathname } = new URL(url);
|
|
return MEDIA_PATHS.some((p) => pathname.startsWith(p));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function validMediaRequest(url: string, baseUrl: string): boolean {
|
|
return MEDIA_PATHS.some((p) => {
|
|
const validUrl = new URL(p, baseUrl);
|
|
return url.startsWith(validUrl.href);
|
|
});
|
|
}
|
|
|
|
function fetchConfig(token: string): RequestInit {
|
|
return {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
cache: 'default',
|
|
};
|
|
}
|
|
|
|
self.addEventListener('fetch', (event: FetchEvent) => {
|
|
const { url, method } = event.request;
|
|
|
|
if (method !== 'GET' || !mediaPath(url)) return;
|
|
|
|
const { clientId } = event;
|
|
if (!clientId) return;
|
|
|
|
const session = sessions.get(clientId);
|
|
if (session) {
|
|
if (validMediaRequest(url, session.baseUrl)) {
|
|
event.respondWith(fetch(url, fetchConfig(session.accessToken)));
|
|
}
|
|
return;
|
|
}
|
|
|
|
event.respondWith(
|
|
requestSessionWithTimeout(clientId).then((s) => {
|
|
if (s && validMediaRequest(url, s.baseUrl)) {
|
|
return fetch(url, fetchConfig(s.accessToken));
|
|
}
|
|
return fetch(event.request);
|
|
})
|
|
);
|
|
});
|
|
|
|
// --- Push Notifications ---
|
|
|
|
// Sygnal's WebPush pushkin ships the Matrix push object flat at the top level
|
|
// (`{room_id, event_id, unread, prio}`) — no `notification` wrapper. The 4KB
|
|
// WebPush payload limit pushed Sygnal to drop the extra nesting. Other push
|
|
// gateways (or older Sygnal builds) still wrap — we accept both shapes so the
|
|
// SW doesn't silently drop pushes after a gateway swap.
|
|
type PushPayload = {
|
|
event_id?: string;
|
|
room_id?: string;
|
|
unread?: number;
|
|
prio?: 'high' | 'low';
|
|
notification?: {
|
|
event_id?: string;
|
|
room_id?: string;
|
|
sender?: string;
|
|
sender_display_name?: string;
|
|
room_name?: string;
|
|
type?: string;
|
|
content?: { body?: string; msgtype?: string };
|
|
counts?: { unread?: number; missed_calls?: number };
|
|
prio?: 'high' | 'low';
|
|
};
|
|
};
|
|
|
|
// `WindowClient.focused` requires OS-level window focus on top of the tab being
|
|
// the active one — so Vojo visually open on monitor A while the user types in
|
|
// another window on monitor B reads as `focused: false`, and we'd double-notify
|
|
// a user who is looking right at the app. Relaxing to visibility-only treats
|
|
// "tab is on-screen" as "don't spam me", which matches the intuitive UX.
|
|
// Trade-off: a tab that's visually visible but the user isn't actually looking
|
|
// at (side-by-side layouts, background-visible panes) will also suppress OS
|
|
// notifications — acceptable for MVP; revisit if users report missed pings.
|
|
async function hasVisibleClient(): Promise<boolean> {
|
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
return clients.some((c) => c.visibilityState === 'visible');
|
|
}
|
|
|
|
async function anySession(): Promise<SessionInfo | undefined> {
|
|
const clients = await self.clients.matchAll({ type: 'window' });
|
|
const existing = clients.map((c) => sessions.get(c.id)).find((s): s is SessionInfo => !!s);
|
|
if (existing) return existing;
|
|
if (clients.length > 0) {
|
|
return requestSessionWithTimeout(clients[0].id);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Fallback strings for when we can't fetch event content (offline / encrypted /
|
|
// 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;
|
|
encrypted: string;
|
|
incomingCall: string;
|
|
openToAnswer: string;
|
|
invitation: string;
|
|
inviteBody: (args: { inviter?: string; roomName?: string }) => string;
|
|
};
|
|
|
|
// 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',
|
|
encrypted: 'New encrypted message',
|
|
incomingCall: 'Incoming call',
|
|
openToAnswer: 'Open Vojo to answer',
|
|
invitation: 'Invitation',
|
|
inviteGeneric: 'New invitation',
|
|
},
|
|
ru: {
|
|
brand: 'Vojo',
|
|
newMessage: 'Новое сообщение',
|
|
encrypted: 'Новое зашифрованное сообщение',
|
|
incomingCall: 'Входящий звонок',
|
|
openToAnswer: 'Откройте Vojo, чтобы ответить',
|
|
invitation: 'Приглашение',
|
|
inviteGeneric: 'Новое приглашение',
|
|
},
|
|
};
|
|
|
|
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(
|
|
session: SessionInfo,
|
|
roomId: string,
|
|
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)}`,
|
|
{ headers }
|
|
),
|
|
fetch(
|
|
`${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name/`,
|
|
{ headers }
|
|
).catch(() => undefined),
|
|
]);
|
|
|
|
const fb = await pushFallback();
|
|
let title = fb.brand;
|
|
let body = fb.newMessage;
|
|
let isCall = false;
|
|
let isInvite = false;
|
|
let roomName: string | undefined;
|
|
let inviterDisplay: string | undefined;
|
|
let inviterMxid: string | undefined;
|
|
|
|
if (nameRes?.ok) {
|
|
const json = await nameRes.json();
|
|
if (typeof json?.name === 'string') roomName = json.name;
|
|
}
|
|
|
|
if (evRes.ok) {
|
|
const event = await evRes.json();
|
|
// RTC ring: `format: 'event_id_only'` strips `type` from the push payload,
|
|
// so we can only identify a call after fetching the full event here. Match
|
|
// the same pair as useIncomingRtcNotifications — any other rtc.notification
|
|
// flavour (group "notification") falls through to the message path.
|
|
if (
|
|
event?.type === 'org.matrix.msc4075.rtc.notification' &&
|
|
event?.content?.notification_type === 'ring'
|
|
) {
|
|
isCall = true;
|
|
title = fb.incomingCall;
|
|
body = fb.openToAnswer;
|
|
} else if (event?.type === 'm.room.member' && event?.content?.membership === 'invite') {
|
|
// 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') {
|
|
body = fb.encrypted;
|
|
} 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 && !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(
|
|
roomId
|
|
)}/state/m.room.member/${encodeURIComponent(inviterMxid)}`,
|
|
{ headers }
|
|
);
|
|
if (memberRes.ok) {
|
|
const member = await memberRes.json();
|
|
if (typeof member?.displayname === 'string' && member.displayname.trim()) {
|
|
inviterDisplay = member.displayname;
|
|
}
|
|
}
|
|
} catch {
|
|
/* keep inviterDisplay undefined; local-part fallback below */
|
|
}
|
|
if (!inviterDisplay) inviterDisplay = mxidLocalPart(inviterMxid);
|
|
}
|
|
|
|
if (isInvite) {
|
|
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).
|
|
if (roomName) body = roomName;
|
|
} else if (roomName) {
|
|
title = roomName;
|
|
}
|
|
|
|
return { title, body, isCall, isInvite };
|
|
}
|
|
|
|
self.addEventListener('push', (event: PushEvent) => {
|
|
if (!event.data) return;
|
|
|
|
event.waitUntil(
|
|
(async () => {
|
|
let payload: PushPayload = {};
|
|
try {
|
|
if (event.data) payload = event.data.json();
|
|
} catch {
|
|
payload = {};
|
|
}
|
|
|
|
const notif = payload.notification;
|
|
const roomId = notif?.room_id ?? payload.room_id;
|
|
const eventId = notif?.event_id ?? payload.event_id;
|
|
|
|
// Defensive: if Sygnal sends a notification without event_id (e.g. unread-count-only
|
|
// push, which `events_only: true` on the pusher should suppress but can slip through
|
|
// if config drifts), skip it. We'd otherwise show a generic "New message" tied to no
|
|
// room/event — clicking it goes nowhere useful.
|
|
if (!eventId || !roomId) return;
|
|
|
|
const fb = await pushFallback();
|
|
let title = notif?.room_name ?? fb.brand;
|
|
let body = notif?.content?.body ?? fb.newMessage;
|
|
let isCall = false;
|
|
let isInvite = false;
|
|
|
|
// Fetch event details BEFORE the visible-client gate, because an
|
|
// incoming-call ring must surface even when Vojo is already open in
|
|
// another pane/monitor — we can only distinguish a ring from a regular
|
|
// message by the event type, which is stripped from the push payload
|
|
// (`format: 'event_id_only'`).
|
|
const session = await anySession();
|
|
if (session) {
|
|
try {
|
|
const details = await fetchEventDetails(session, roomId, eventId);
|
|
title = details.title;
|
|
body = details.body;
|
|
isCall = details.isCall;
|
|
isInvite = details.isInvite;
|
|
} catch {
|
|
// fall back to defaults; isCall/isInvite stay false and we show a
|
|
// generic message notification. Cold-start (no live session → no
|
|
// access token) lands here too — see techdebt 5.24.
|
|
}
|
|
}
|
|
|
|
// Foreground dedup: a visible Vojo window already surfaces sound +
|
|
// favicon in-app, so we skip the OS banner for messages and invites.
|
|
// Calls are an explicit exception — a missed ring is a much higher-cost
|
|
// failure than a duplicated banner.
|
|
if (!isCall && (await hasVisibleClient())) return;
|
|
|
|
if (isCall) {
|
|
// Distinct tag so a call notification doesn't collide with a prior
|
|
// message notification for the same room (message path uses `roomId`).
|
|
// `requireInteraction` keeps it on-screen until the user acts — a ring
|
|
// shouldn't auto-dismiss after a few seconds like a chat message.
|
|
// `renotify: true` forces re-alert (sound + re-surface) when a second
|
|
// ring arrives for the same room while the first notification is still
|
|
// in the tray — without it Chrome silently replaces the old one.
|
|
await self.registration.showNotification(title, {
|
|
body,
|
|
icon: '/res/android/android-chrome-192x192.png',
|
|
badge: '/res/android/android-chrome-96x96.png',
|
|
tag: `call_${roomId}`,
|
|
data: { roomId, eventId, isCall: true },
|
|
requireInteraction: true,
|
|
renotify: true,
|
|
} as NotificationOptions & { renotify?: boolean });
|
|
return;
|
|
}
|
|
|
|
if (isInvite) {
|
|
// Invite notifications route to /inbox/invites, so we carry `isInvite`
|
|
// in data for the click handler. Distinct tag namespace keeps invite
|
|
// banners from clobbering a message notification for the same room
|
|
// (rare, but possible if the invite event and a decrypted preview
|
|
// arrive close together).
|
|
await self.registration.showNotification(title, {
|
|
body,
|
|
icon: '/res/android/android-chrome-192x192.png',
|
|
badge: '/res/android/android-chrome-96x96.png',
|
|
tag: `invite_${roomId}`,
|
|
data: { roomId, eventId, isInvite: true },
|
|
renotify: true,
|
|
} as NotificationOptions & { renotify?: boolean });
|
|
return;
|
|
}
|
|
|
|
await self.registration.showNotification(title, {
|
|
body,
|
|
icon: '/res/android/android-chrome-192x192.png',
|
|
badge: '/res/android/android-chrome-96x96.png',
|
|
tag: roomId,
|
|
data: { roomId, eventId },
|
|
renotify: true,
|
|
} as NotificationOptions & { renotify?: boolean });
|
|
})()
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
const { roomId, isCall, isInvite } =
|
|
(event.notification.data as {
|
|
roomId?: string;
|
|
isCall?: boolean;
|
|
isInvite?: boolean;
|
|
}) ?? {};
|
|
|
|
event.waitUntil(
|
|
(async () => {
|
|
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
if (windows.length > 0) {
|
|
const target = windows[0];
|
|
await target.focus();
|
|
// `isCall` is forwarded but unused by the client today — once live
|
|
// session lands on the room, useIncomingRtcNotifications picks the
|
|
// ring up from the timeline (or backfill). The 2.5.3b bridge will
|
|
// consume this flag to auto-invoke the accept flow.
|
|
target.postMessage({ type: 'notificationClick', roomId, isCall, isInvite });
|
|
return;
|
|
}
|
|
// Cold-start path: no live window to hand off to, so pick the
|
|
// destination up-front. Invites land on the inbox list (we don't want
|
|
// to drop the user into a room they haven't joined); everything else
|
|
// opens the room directly.
|
|
let path = '/';
|
|
if (isInvite) path = '/inbox/invites';
|
|
else if (roomId) path = `/home/${encodeURIComponent(roomId)}/`;
|
|
await self.clients.openWindow(path);
|
|
})()
|
|
);
|
|
});
|
|
|
|
self.addEventListener('pushsubscriptionchange', ((event: ExtendableEvent) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
windows.forEach((c) => c.postMessage({ type: 'pushSubscriptionChange' }));
|
|
})()
|
|
);
|
|
}) as EventListener);
|