323 lines
9.2 KiB
TypeScript
323 lines
9.2 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();
|
|
})()
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
return clients.some((c) => c.visibilityState === 'visible' && c.focused);
|
|
}
|
|
|
|
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). 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<string, PushFallback> = {
|
|
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);
|