atagen

malice

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