/// 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(); })() ); }); /** * Receive session updates from clients */ self.addEventListener('message', (event: ExtendableMessageEvent) => { const client = event.source as Client | null; if (!client) return; const { type, accessToken, baseUrl } = event.data || {}; if (type === 'setSession') { setSession(client.id, accessToken, baseUrl); cleanupDeadClients(); } }); 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 --- type PushPayload = { 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'; }; }; async function hasVisibleClient(): Promise { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); return clients.some((c) => c.visibilityState === 'visible' && c.focused); } 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). 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. type PushFallback = { brand: string; newMessage: string; encrypted: string }; const PUSH_FALLBACKS: Record = { en: { brand: 'Vojo', newMessage: 'New message', encrypted: 'New encrypted message' }, ru: { brand: 'Vojo', newMessage: 'Новое сообщение', encrypted: 'Новое зашифрованное сообщение' }, }; function pushFallback(): PushFallback { const lang = (typeof navigator !== 'undefined' ? navigator.language : 'en') .slice(0, 2) .toLowerCase(); return PUSH_FALLBACKS[lang] ?? PUSH_FALLBACKS.en; } async function fetchEventDetails( session: SessionInfo, roomId: string, eventId: string ): Promise<{ title: string; body: string }> { const headers = { Authorization: `Bearer ${session.accessToken}` }; 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 = pushFallback(); let title = fb.brand; let body = fb.newMessage; if (evRes.ok) { const event = await evRes.json(); if (event?.type === 'm.room.encrypted') { body = fb.encrypted; } else if (typeof event?.content?.body === 'string') { body = event.content.body.slice(0, 200); } } if (nameRes?.ok) { const json = await nameRes.json(); if (typeof json?.name === 'string') title = json.name; } return { title, body }; } self.addEventListener('push', (event: PushEvent) => { if (!event.data) return; event.waitUntil( (async () => { // Foreground dedup: in-app MessageNotifications handles visible clients if (await hasVisibleClient()) return; let payload: PushPayload = {}; try { if (event.data) payload = event.data.json(); } catch { payload = {}; } const notif = payload.notification; const roomId = notif?.room_id; const eventId = notif?.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 = pushFallback(); let title = notif?.room_name ?? fb.brand; let body = notif?.content?.body ?? fb.newMessage; const session = await anySession(); if (session) { try { const details = await fetchEventDetails(session, roomId, eventId); title = details.title; body = details.body; } catch { // fall back to defaults } } 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 } = (event.notification.data as { roomId?: string }) ?? {}; event.waitUntil( (async () => { const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); if (windows.length > 0) { const target = windows[0]; await target.focus(); target.postMessage({ type: 'notificationClick', roomId }); return; } const path = roomId ? `/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);