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