/// 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);