atagen

malice

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