///
export type {};
declare const self: ServiceWorkerGlobalScope;
type SessionInfo = {
accessToken: string;
baseUrl: string;
};
/**
* Store session per client (tab)
*/
const sessions = new Map();
const clientToResolve = new Map void>();
const clientToSessionPromise = new Map>();
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 {
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 {
const client = await self.clients.get(clientId);
if (!client) return undefined;
const sessionPromise = requestSession(client);
const timeout = new Promise((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 {
return new Promise((resolve, reject) => {
const req = indexedDB.open(LANG_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(LANG_DB_STORE)) {
db.createObjectStore(LANG_DB_STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function readStoredLang(): Promise {
try {
const db = await openLangDb();
return await new Promise((resolve) => {
const tx = db.transaction(LANG_DB_STORE, 'readonly');
const req = tx.objectStore(LANG_DB_STORE).get(LANG_DB_KEY);
req.onsuccess = () => {
const val = req.result;
resolve(val === 'en' || val === 'ru' ? val : null);
};
req.onerror = () => resolve(null);
});
} catch {
return null;
}
}
async function writeStoredLang(lang: SupportedLang): Promise {
try {
const db = await openLangDb();
await new Promise((resolve) => {
const tx = db.transaction(LANG_DB_STORE, 'readwrite');
tx.objectStore(LANG_DB_STORE).put(lang, LANG_DB_KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
tx.onabort = () => resolve();
});
} catch {
/* non-fatal — `swLanguage` still holds the value in-memory */
}
}
function normalizeLang(raw: string | undefined | null): SupportedLang {
if (!raw) return 'en';
const lower = raw.toLowerCase();
if (lower.startsWith('ru')) return 'ru';
// i18n.ts sets fallbackLng: 'en' — match that so the SW stays in lock
// step with what the main app renders for unfamiliar locales. English
// is a more useful lingua franca than Russian for a user whose device
// is in some other language entirely.
return 'en';
}
/**
* Receive session + 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 {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
return clients.some((c) => c.visibilityState === 'visible');
}
async function anySession(): Promise {
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;
// Successful bundle parse per language. Failures are NOT cached: a
// transient fetch 5xx on the first push shouldn't lock us to HARDCODED
// for the whole SW lifetime, and a successful re-fetch on the next push
// is cheap. Compare to the session cache — different policy on purpose.
const bundleCache = new Map();
async function currentSwLanguage(): Promise {
if (swLanguage === 'en' || swLanguage === 'ru') return swLanguage;
if (swLanguage === undefined) {
const stored = await readStoredLang();
swLanguage = stored; // `null` marks "checked, empty" — skip IDB next call
if (stored) return stored;
}
return normalizeLang(typeof navigator !== 'undefined' ? navigator.language : undefined);
}
async function loadBundle(lang: SupportedLang): Promise {
const cached = bundleCache.get(lang);
if (cached) return cached;
try {
// Scope-relative URL so a Vite `base` change (served from `/app/`
// instead of `/`) doesn't silently break the fetch. Same physical
// file as i18next loads on the main thread.
const url = new URL(`public/locales/${lang}.json`, self.registration.scope).href;
const res = await fetch(url);
if (!res.ok) return null;
const json = (await res.json()) as { Push?: LocaleBundle };
const push = json?.Push;
if (!push) return null;
bundleCache.set(lang, push);
return push;
} catch {
return null;
}
}
function interpolate(template: string, vars: Record): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => vars[name] ?? '');
}
async function pushFallback(): Promise {
const lang = await currentSwLanguage();
const bundle = await loadBundle(lang);
const safety = HARDCODED[lang];
const lookup = (key: string, fallback: string): string => {
const value = bundle?.[key];
return typeof value === 'string' && value.length > 0 ? value : fallback;
};
return {
brand: safety.brand,
newMessage: lookup('new_message', safety.newMessage),
encrypted: lookup('encrypted_message', safety.encrypted),
incomingCall: lookup('incoming_call', safety.incomingCall),
openToAnswer: lookup('open_to_answer', safety.openToAnswer),
invitation: lookup('invitation', safety.invitation),
inviteBody: ({ inviter, roomName }) => {
const hasInviter = !!inviter;
const hasRoom = !!roomName;
let key: string;
if (hasInviter && hasRoom) key = 'invite_body';
else if (hasInviter) key = 'invite_body_no_room';
else if (hasRoom) key = 'invite_body_no_inviter';
else key = 'invite_body_generic';
const template = bundle?.[key];
if (typeof template === 'string' && template.length > 0) {
return interpolate(template, {
inviter: inviter ?? '',
roomName: roomName ?? '',
});
}
// Safety-net: bundle missing or key absent. Keep the sentence
// shape reasonable in both languages.
if (hasInviter && hasRoom) {
return lang === 'ru'
? `${inviter} приглашает вас в ${roomName}`
: `${inviter} invited you to ${roomName}`;
}
if (hasInviter) {
return lang === 'ru'
? `${inviter} приглашает вас в комнату`
: `${inviter} invited you to a room`;
}
if (hasRoom) {
return lang === 'ru' ? `Приглашение в ${roomName}` : `Invited you to ${roomName}`;
}
return safety.inviteGeneric;
},
};
}
function mxidLocalPart(mxid: string): string {
if (!mxid.startsWith('@')) return mxid;
const colon = mxid.indexOf(':');
return colon > 1 ? mxid.slice(1, colon) : mxid.slice(1);
}
// Fallback path for invitees: /rooms/{id}/event/{id} is 404 and
// /rooms/{id}/state/... is 403 for users who have never joined. The
// Matrix spec carves out /notifications for exactly this case — it
// returns events the user was (or would have been) notified about,
// accessible before the join. Synapse also ships stripped invite state
// under event.unsigned.invite_room_state, which carries m.room.name
// and the inviter's m.room.member so we can render a humane banner
// without ever joining.
//
// One retry at 400ms: Synapse indexes a just-dispatched push slightly
// behind the push itself, and the first call sometimes misses the
// event. After the retry we fall through and the caller shows the
// generic fallback. limit=30 is comfortably larger than any realistic
// burst between push dispatch and SW wake.
type InviteLookup = { isInvite: boolean; inviter?: string; roomName?: string };
type NotifEntry = {
room_id?: string;
event?: {
event_id?: string;
type?: string;
sender?: string;
content?: { membership?: string };
unsigned?: { invite_room_state?: Array> };
};
};
type StrippedEvent = {
type?: string;
sender?: string;
content?: { name?: string; displayname?: string };
};
async function tryFetchInvite(
session: SessionInfo,
roomId: string,
eventId: string,
headers: { Authorization: string }
): Promise<{ kind: 'result'; value: InviteLookup } | { kind: 'miss' } | { kind: 'error' }> {
let res: Response;
try {
res = await fetch(`${session.baseUrl}/_matrix/client/v3/notifications?limit=30`, { headers });
} catch {
return { kind: 'error' };
}
if (!res.ok) return { kind: 'error' };
let json: { notifications?: NotifEntry[] };
try {
json = (await res.json()) as { notifications?: NotifEntry[] };
} catch {
return { kind: 'error' };
}
const entries = Array.isArray(json?.notifications) ? json.notifications : [];
const hit = entries.find((n) => n?.room_id === roomId && n?.event?.event_id === eventId);
if (!hit) return { kind: 'miss' };
const ev = hit.event;
if (
ev?.type !== 'm.room.member' ||
ev?.content?.membership !== 'invite' ||
typeof ev?.sender !== 'string'
) {
return { kind: 'result', value: { isInvite: false } };
}
const rawStripped = ev?.unsigned?.invite_room_state;
const stripped: StrippedEvent[] = Array.isArray(rawStripped)
? (rawStripped as StrippedEvent[])
: [];
const inviterMember = stripped.find(
(s) =>
s?.type === 'm.room.member' &&
s?.sender === ev.sender &&
typeof s?.content?.displayname === 'string' &&
!!s.content.displayname.trim()
);
const nameState = stripped.find(
(s) =>
s?.type === 'm.room.name' && typeof s?.content?.name === 'string' && !!s.content.name.trim()
);
const inviter = inviterMember?.content?.displayname ?? mxidLocalPart(ev.sender);
const roomName = nameState?.content?.name;
return { kind: 'result', value: { isInvite: true, inviter, roomName } };
}
// Fallback path for invitees: /rooms/{id}/event/{id} is 404 and
// /rooms/{id}/state/... is 403 for users who have never joined. The
// Matrix spec carves out /notifications for exactly this case — it
// returns events the user was (or would have been) notified about,
// accessible before the join. Synapse also ships stripped invite state
// under event.unsigned.invite_room_state, which carries m.room.name
// and the inviter's m.room.member so we can render a humane banner
// without ever joining.
//
// One retry at 400ms: Synapse indexes a just-dispatched push slightly
// behind the push itself, and the first call sometimes misses the
// event. After the retry we fall through and the caller shows the
// generic fallback. limit=30 is comfortably larger than any realistic
// burst between push dispatch and SW wake.
async function fetchInviteFromNotifications(
session: SessionInfo,
roomId: string,
eventId: string,
headers: { Authorization: string }
): Promise {
const first = await tryFetchInvite(session, roomId, eventId, headers);
if (first.kind === 'error') return undefined;
if (first.kind === 'result') return first.value;
await new Promise((r) => {
setTimeout(r, 400);
});
const second = await tryFetchInvite(session, roomId, eventId, headers);
return second.kind === 'result' ? second.value : undefined;
}
async function fetchEventDetails(
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);