atagen

malice

public
Star0
Watch0
0b88effaec6d8c5b10a7d8def9b9a3c999d2442f
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/notifications.tsTypescript215 lines7.7 KB
1
import { get, writable } from 'svelte/store';
2
import { mediaUrl } from './api';
3
import {
4
  arrivalIn,
5
  type Arrival,
6
  badgeTotal,
7
  isRoomWatched,
8
  notifyingCount,
9
  planNotification,
10
  roomTag,
11
  sourceFromSummary,
12
  sourceFromTimeline,
13
  type NotificationPlan
14
} from './notification-model';
15
import { effectiveRooms } from './mute';
16
import { startMessageSound } from './message-sound';
17
import { PUSH_CHANGED_MESSAGE } from './push-model';
18
import { disablePush, enablePush, pushActive, syncPush } from './push';
19
import { selectedRoomId, sessionUser, timelines } from './store';
20
import type { RoomSummary } from './types';
21
22
const SETTLE_MS = 300;
23
24
export const OPEN_ROOM_MESSAGE = 'malice-open-room';
25
26
export type NotificationPermissionState = NotificationPermission | 'unsupported';
27
28
const supported = typeof Notification !== 'undefined';
29
const preferenceKey = (account: string) => `malice.notifications.${account}`;
30
31
export const notificationsEnabled = writable(false);
32
export const notificationPermission = writable<NotificationPermissionState>(supported ? Notification.permission : 'unsupported');
33
34
function readPreference(account: string | null | undefined): boolean {
35
  if (!account) return false;
36
  try {
37
    return localStorage.getItem(preferenceKey(account)) === '1';
38
  } catch {
39
    return false;
40
  }
41
}
42
43
function writePreference(account: string, enabled: boolean): void {
44
  try {
45
    localStorage.setItem(preferenceKey(account), enabled ? '1' : '0');
46
  } catch {}
47
}
48
49
/** Must run inside a user gesture, since browsers refuse the permission prompt otherwise. */
50
export async function enableNotifications(): Promise<void> {
51
  const account = get(sessionUser);
52
  if (!supported || !account) return;
53
  const result = Notification.permission === 'default' ? await Notification.requestPermission() : Notification.permission;
54
  notificationPermission.set(result);
55
  const on = result === 'granted';
56
  writePreference(account, on);
57
  notificationsEnabled.set(on);
58
  if (on) await enablePush(account);
59
}
60
61
export function disableNotifications(): void {
62
  const account = get(sessionUser);
63
  if (account) {
64
    writePreference(account, false);
65
    void disablePush(account);
66
  }
67
  notificationsEnabled.set(false);
68
}
69
70
function active(): boolean {
71
  return supported && get(notificationsEnabled) && Notification.permission === 'granted';
72
}
73
74
async function registration(): Promise<ServiceWorkerRegistration | null> {
75
  if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return null;
76
  try {
77
    return await navigator.serviceWorker.ready;
78
  } catch {
79
    return null;
80
  }
81
}
82
83
async function show(plan: NotificationPlan, openRoom: (roomId: string) => void): Promise<void> {
84
  const icon = mediaUrl(plan.iconMxc, 128) ?? undefined;
85
  const options: NotificationOptions & { renotify?: boolean } = {
86
    body: plan.body,
87
    tag: plan.tag,
88
    icon,
89
    renotify: plan.renotify,
90
    data: { roomId: plan.roomId }
91
  };
92
  const reg = await registration();
93
  if (reg) {
94
    await reg.showNotification(plan.title, options);
95
    return;
96
  }
97
  const note = new Notification(plan.title, options);
98
  note.onclick = () => {
99
    window.focus();
100
    openRoom(plan.roomId);
101
    note.close();
102
  };
103
}
104
105
async function closeRoom(roomId: string): Promise<void> {
106
  const reg = await registration();
107
  if (!reg) return;
108
  for (const note of await reg.getNotifications({ tag: roomTag(roomId) })) note.close();
109
}
110
111
function setBadge(total: number): void {
112
  const nav = navigator as Navigator & { setAppBadge?: (n: number) => Promise<void>; clearAppBadge?: () => Promise<void> };
113
  if (total > 0) void nav.setAppBadge?.(total).catch(() => undefined);
114
  else void nav.clearAppBadge?.().catch(() => undefined);
115
}
116
117
/** Watches room summaries for notifying unread increases, shows and clears notifications, and keeps the app badge current. */
118
export function startNotifications(openRoom: (roomId: string) => void): () => void {
119
  let previous = new Map<string, RoomSummary>();
120
  let notified = new Set<string>();
121
  let badge = -1;
122
123
  const unsubscribeSession = sessionUser.subscribe((account) => {
124
    const enabled = readPreference(account);
125
    notificationsEnabled.set(enabled);
126
    if (account) void syncPush(account, enabled);
127
  });
128
129
  void registration().then(async (reg) => {
130
    for (const note of (await reg?.getNotifications()) ?? []) {
131
      const roomId = (note.data as { roomId?: unknown } | null)?.roomId;
132
      if (typeof roomId === 'string') notified.add(roomId);
133
    }
134
  });
135
136
  const watched = (roomId: string) =>
137
    isRoomWatched(roomId, get(selectedRoomId), document.visibilityState === 'visible', document.hasFocus());
138
139
  const clearRead = (list: RoomSummary[]) => {
140
    for (const room of list) {
141
      if (notified.has(room.room_id) && (notifyingCount(room) === 0 || watched(room.room_id))) {
142
        notified.delete(room.room_id);
143
        void closeRoom(room.room_id);
144
      }
145
    }
146
  };
147
148
  const waiting = new Map<string, Arrival & { timer: ReturnType<typeof setTimeout> }>();
149
150
  const queue = (roomId: string, mention: boolean) => {
151
    clearTimeout(waiting.get(roomId)?.timer);
152
    const timer = setTimeout(() => {
153
      waiting.delete(roomId);
154
      const room = get(effectiveRooms).find((r) => r.room_id === roomId);
155
      if (!room || !active() || watched(roomId) || notifyingCount(room) === 0) return;
156
      if (get(pushActive)) return;
157
      const source = sourceFromTimeline(get(timelines)[roomId]) ?? sourceFromSummary(room);
158
      notified.add(roomId);
159
      void show(planNotification(room, source, { mention }), openRoom);
160
    }, SETTLE_MS);
161
    waiting.set(roomId, { mention, timer });
162
  };
163
164
  const onRooms = (list: RoomSummary[]) => {
165
    const next = new Map(list.map((room) => [room.room_id, room]));
166
    const prior = previous;
167
    previous = next;
168
    if (!active()) {
169
      if (badge !== 0) setBadge((badge = 0));
170
      return;
171
    }
172
    const total = badgeTotal(list);
173
    if (total !== badge) setBadge((badge = total));
174
    const now = Date.now();
175
    for (const room of list) {
176
      const arrival = arrivalIn(prior.get(room.room_id), room, now);
177
      if (!arrival || watched(room.room_id)) continue;
178
      queue(room.room_id, arrival.mention || (waiting.get(room.room_id)?.mention ?? false));
179
    }
180
    clearRead(list);
181
  };
182
183
  const unsubscribeSound = startMessageSound();
184
  const unsubscribeRooms = effectiveRooms.subscribe(onRooms);
185
  const unsubscribeEnabled = notificationsEnabled.subscribe(() => onRooms(get(effectiveRooms)));
186
  const onAttention = () => {
187
    notificationPermission.set(supported ? Notification.permission : 'unsupported');
188
    if (supported && Notification.permission !== 'granted') pushActive.set(false);
189
    clearRead(get(effectiveRooms));
190
    const open = get(selectedRoomId);
191
    if (open && get(pushActive) && watched(open)) void closeRoom(open);
192
  };
193
  const onMessage = (ev: MessageEvent) => {
194
    const data = ev.data as { type?: unknown; roomId?: unknown } | null;
195
    const account = get(sessionUser);
196
    if (data?.type === PUSH_CHANGED_MESSAGE && account) void syncPush(account, get(notificationsEnabled));
197
    if (data?.type === OPEN_ROOM_MESSAGE && typeof data.roomId === 'string') openRoom(data.roomId);
198
  };
199
  window.addEventListener('focus', onAttention);
200
  document.addEventListener('visibilitychange', onAttention);
201
  const unsubscribeSelected = selectedRoomId.subscribe(onAttention);
202
  if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('message', onMessage);
203
204
  return () => {
205
    unsubscribeSession();
206
    unsubscribeRooms();
207
    unsubscribeSound();
208
    unsubscribeEnabled();
209
    unsubscribeSelected();
210
    for (const { timer } of waiting.values()) clearTimeout(timer);
211
    window.removeEventListener('focus', onAttention);
212
    document.removeEventListener('visibilitychange', onAttention);
213
    if ('serviceWorker' in navigator) navigator.serviceWorker.removeEventListener('message', onMessage);
214
  };
215
}