atagen

malice

public
Star0
Watch0
17723bb70856104761f7b2d9cba1e5f0d9100c0a
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/sw-push.tsTypescript105 lines4.4 KB
1
import { PUSH_CHANGED_MESSAGE, PUSH_CHANNEL, parsePush, parseResolved, shouldSilence, subscriptionKeys, type PushDisplay } from './lib/push-model';
2
3
const RESOLVE_TIMEOUT_MS = 3000;
4
5
declare const __MALICE_DEVICE__: boolean;
6
7
interface BadgeNavigator {
8
  setAppBadge?: (n: number) => Promise<void>;
9
  clearAppBadge?: () => Promise<void>;
10
}
11
12
let channel: BroadcastChannel | undefined;
13
let nextRequestId = 0;
14
const pending = new Map<string, (reply: { json?: unknown; error?: unknown }) => void>();
15
16
function resolveChannel(): BroadcastChannel {
17
  if (!channel) {
18
    channel = new BroadcastChannel(PUSH_CHANNEL);
19
    channel.onmessage = (ev: MessageEvent) => {
20
      const msg = ev.data as { id?: unknown; json?: unknown; error?: unknown } | null;
21
      if (typeof msg?.id !== 'string') return;
22
      const done = pending.get(msg.id);
23
      if (!done) return;
24
      pending.delete(msg.id);
25
      done(msg);
26
    };
27
  }
28
  return channel;
29
}
30
31
function requestResolve(roomId: string, eventId: string): Promise<unknown | null> {
32
  const id = `${Date.now()}-${nextRequestId++}`;
33
  return new Promise((resolve) => {
34
    const timer = setTimeout(() => {
35
      pending.delete(id);
36
      resolve(null);
37
    }, RESOLVE_TIMEOUT_MS);
38
    pending.set(id, (reply) => {
39
      clearTimeout(timer);
40
      resolve(reply.error ? null : (reply.json ?? null));
41
    });
42
    resolveChannel().postMessage({ id, kind: 'resolve', room_id: roomId, event_id: eventId });
43
  });
44
}
45
46
function show(sw: ServiceWorkerGlobalScope, display: PushDisplay, title: string, body: string, silent: boolean): Promise<void> {
47
  const options: NotificationOptions & { renotify?: boolean } = {
48
    body,
49
    tag: display.tag,
50
    silent,
51
    renotify: display.mention && !silent,
52
    data: { roomId: display.roomId, navigate: display.navigate, eventId: display.eventId }
53
  };
54
  return sw.registration.showNotification(title, options);
55
}
56
57
function setBadge(sw: ServiceWorkerGlobalScope, count: number | null): void {
58
  if (count === null) return;
59
  const nav = sw.navigator as unknown as BadgeNavigator;
60
  const call = count > 0 ? nav.setAppBadge?.(count) : nav.clearAppBadge?.();
61
  void call?.catch(() => undefined);
62
}
63
64
async function handle(sw: ServiceWorkerGlobalScope, event: PushEvent): Promise<void> {
65
  const display = parsePush(event.data?.text() ?? null, sw.location.origin);
66
  const windows = await sw.clients.matchAll({ type: 'window', includeUncontrolled: true });
67
  const focused = windows.some((client) => client.focused && client.visibilityState === 'visible');
68
  await show(sw, display, display.title, display.body, shouldSilence(display, focused));
69
  setBadge(sw, display.badge);
70
  if (!__MALICE_DEVICE__ || windows.length === 0 || !display.roomId || !display.eventId) return;
71
  const resolved = parseResolved(await requestResolve(display.roomId, display.eventId), display);
72
  if (resolved) await show(sw, display, resolved.title, resolved.body, true);
73
}
74
75
/** Browsers revoke the subscription when a push shows nothing, so every failure still shows a generic notification. */
76
export function onPush(sw: ServiceWorkerGlobalScope, event: PushEvent): void {
77
  event.waitUntil(
78
    handle(sw, event).catch(() =>
79
      sw.registration.showNotification('New message', { tag: 'malice', data: {} }).catch(() => undefined)
80
    )
81
  );
82
}
83
84
export function onSubscriptionChange(sw: ServiceWorkerGlobalScope, event: Event): void {
85
  const change = event as Event & { oldSubscription?: PushSubscription | null; newSubscription?: PushSubscription | null };
86
  (event as ExtendableEvent).waitUntil(
87
    (async () => {
88
      let sub = change.newSubscription ?? (await sw.registration.pushManager.getSubscription());
89
      const key = change.oldSubscription?.options.applicationServerKey;
90
      if (!sub && key) sub = await sw.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
91
      const keys = sub ? subscriptionKeys(sub) : null;
92
      if (keys && !__MALICE_DEVICE__) {
93
        await fetch('/api/push/subscribe', {
94
          method: 'POST',
95
          credentials: 'same-origin',
96
          headers: { 'content-type': 'application/json' },
97
          body: JSON.stringify({ endpoint: keys.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } })
98
        }).catch(() => undefined);
99
      }
100
      for (const client of await sw.clients.matchAll({ type: 'window', includeUncontrolled: true })) {
101
        client.postMessage({ type: PUSH_CHANGED_MESSAGE });
102
      }
103
    })().catch(() => undefined)
104
  );
105
}