atagen

malice

public
Star0
Watch0
afba1feb41cebde51e0dd74fa483474fbf4d06ac
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/notifications.ts

db4e552594cbatagen2026-10-26 04:43 UTC1
import { get, writable } from 'svelte/store';
db4e552594cbatagen2026-10-26 04:43 UTC2
import { mediaUrl } from './api';
db4e552594cbatagen2026-10-26 04:43 UTC3
import {
db4e552594cbatagen2026-10-26 04:43 UTC4
  arrivalIn,
db4e552594cbatagen2026-10-26 04:43 UTC5
  type Arrival,
db4e552594cbatagen2026-10-26 04:43 UTC6
  badgeTotal,
db4e552594cbatagen2026-10-26 04:43 UTC7
  isRoomWatched,
db4e552594cbatagen2026-10-26 04:43 UTC8
  notifyingCount,
db4e552594cbatagen2026-10-26 04:43 UTC9
  planNotification,
db4e552594cbatagen2026-10-26 04:43 UTC10
  roomTag,
db4e552594cbatagen2026-10-26 04:43 UTC11
  sourceFromSummary,
db4e552594cbatagen2026-10-26 04:43 UTC12
  sourceFromTimeline,
db4e552594cbatagen2026-10-26 04:43 UTC13
  type NotificationPlan
db4e552594cbatagen2026-10-26 04:43 UTC14
} from './notification-model';
20fe182f3204atagen2026-10-26 05:50 UTC15
import { effectiveRooms } from './mute';
4ac384dcce6catagen2026-10-27 04:31 UTC16
import { messageSound, playMessageSound } from './message-sound';
4ac384dcce6catagen2026-10-27 04:31 UTC17
import { shouldPlaySound } from './message-sound-model';
235ce394f90datagen2026-10-26 06:54 UTC18
import { PUSH_CHANGED_MESSAGE } from './push-model';
235ce394f90datagen2026-10-26 06:54 UTC19
import { disablePush, enablePush, pushActive, syncPush } from './push';
20fe182f3204atagen2026-10-26 05:50 UTC20
import { selectedRoomId, sessionUser, timelines } from './store';
db4e552594cbatagen2026-10-26 04:43 UTC21
import type { RoomSummary } from './types';
db4e552594cbatagen2026-10-26 04:43 UTC22
db4e552594cbatagen2026-10-26 04:43 UTC23
const SETTLE_MS = 300;
db4e552594cbatagen2026-10-26 04:43 UTC24
db4e552594cbatagen2026-10-26 04:43 UTC25
export const OPEN_ROOM_MESSAGE = 'malice-open-room';
db4e552594cbatagen2026-10-26 04:43 UTC26
db4e552594cbatagen2026-10-26 04:43 UTC27
export type NotificationPermissionState = NotificationPermission | 'unsupported';
db4e552594cbatagen2026-10-26 04:43 UTC28
db4e552594cbatagen2026-10-26 04:43 UTC29
const supported = typeof Notification !== 'undefined';
db4e552594cbatagen2026-10-26 04:43 UTC30
const preferenceKey = (account: string) => `malice.notifications.${account}`;
db4e552594cbatagen2026-10-26 04:43 UTC31
4ac384dcce6catagen2026-10-27 04:31 UTC32
function startMessageSound(): () => void {
4ac384dcce6catagen2026-10-27 04:31 UTC33
  let previous = new Map<string, RoomSummary>();
4ac384dcce6catagen2026-10-27 04:31 UTC34
  let lastPlayedAt = 0;
4ac384dcce6catagen2026-10-27 04:31 UTC35
  const unsubscribe = effectiveRooms.subscribe((list) => {
4ac384dcce6catagen2026-10-27 04:31 UTC36
    const prior = previous;
4ac384dcce6catagen2026-10-27 04:31 UTC37
    previous = new Map(list.map((room) => [room.room_id, room]));
4ac384dcce6catagen2026-10-27 04:31 UTC38
    const now = Date.now();
4ac384dcce6catagen2026-10-27 04:31 UTC39
    const mode = get(messageSound).mode;
4ac384dcce6catagen2026-10-27 04:31 UTC40
    for (const room of list) {
4ac384dcce6catagen2026-10-27 04:31 UTC41
      const arrival = arrivalIn(prior.get(room.room_id), room, now);
4ac384dcce6catagen2026-10-27 04:31 UTC42
      if (!arrival) continue;
4ac384dcce6catagen2026-10-27 04:31 UTC43
      if (isRoomWatched(room.room_id, get(selectedRoomId), document.visibilityState === 'visible', document.hasFocus())) continue;
4ac384dcce6catagen2026-10-27 04:31 UTC44
      if (!shouldPlaySound(mode, room, arrival, lastPlayedAt, now)) continue;
4ac384dcce6catagen2026-10-27 04:31 UTC45
      lastPlayedAt = now;
4ac384dcce6catagen2026-10-27 04:31 UTC46
      void playMessageSound();
4ac384dcce6catagen2026-10-27 04:31 UTC47
    }
4ac384dcce6catagen2026-10-27 04:31 UTC48
  });
4ac384dcce6catagen2026-10-27 04:31 UTC49
  return unsubscribe;
4ac384dcce6catagen2026-10-27 04:31 UTC50
}
4ac384dcce6catagen2026-10-27 04:31 UTC51
db4e552594cbatagen2026-10-26 04:43 UTC52
export const notificationsEnabled = writable(false);
db4e552594cbatagen2026-10-26 04:43 UTC53
export const notificationPermission = writable<NotificationPermissionState>(supported ? Notification.permission : 'unsupported');
db4e552594cbatagen2026-10-26 04:43 UTC54
db4e552594cbatagen2026-10-26 04:43 UTC55
function readPreference(account: string | null | undefined): boolean {
db4e552594cbatagen2026-10-26 04:43 UTC56
  if (!account) return false;
db4e552594cbatagen2026-10-26 04:43 UTC57
  try {
db4e552594cbatagen2026-10-26 04:43 UTC58
    return localStorage.getItem(preferenceKey(account)) === '1';
db4e552594cbatagen2026-10-26 04:43 UTC59
  } catch {
db4e552594cbatagen2026-10-26 04:43 UTC60
    return false;
db4e552594cbatagen2026-10-26 04:43 UTC61
  }
db4e552594cbatagen2026-10-26 04:43 UTC62
}
db4e552594cbatagen2026-10-26 04:43 UTC63
db4e552594cbatagen2026-10-26 04:43 UTC64
function writePreference(account: string, enabled: boolean): void {
db4e552594cbatagen2026-10-26 04:43 UTC65
  try {
db4e552594cbatagen2026-10-26 04:43 UTC66
    localStorage.setItem(preferenceKey(account), enabled ? '1' : '0');
db4e552594cbatagen2026-10-26 04:43 UTC67
  } catch {}
db4e552594cbatagen2026-10-26 04:43 UTC68
}
db4e552594cbatagen2026-10-26 04:43 UTC69
db4e552594cbatagen2026-10-26 04:43 UTC70
/** Must run inside a user gesture, since browsers refuse the permission prompt otherwise. */
db4e552594cbatagen2026-10-26 04:43 UTC71
export async function enableNotifications(): Promise<void> {
db4e552594cbatagen2026-10-26 04:43 UTC72
  const account = get(sessionUser);
db4e552594cbatagen2026-10-26 04:43 UTC73
  if (!supported || !account) return;
db4e552594cbatagen2026-10-26 04:43 UTC74
  const result = Notification.permission === 'default' ? await Notification.requestPermission() : Notification.permission;
db4e552594cbatagen2026-10-26 04:43 UTC75
  notificationPermission.set(result);
db4e552594cbatagen2026-10-26 04:43 UTC76
  const on = result === 'granted';
db4e552594cbatagen2026-10-26 04:43 UTC77
  writePreference(account, on);
db4e552594cbatagen2026-10-26 04:43 UTC78
  notificationsEnabled.set(on);
235ce394f90datagen2026-10-26 06:54 UTC79
  if (on) await enablePush(account);
db4e552594cbatagen2026-10-26 04:43 UTC80
}
db4e552594cbatagen2026-10-26 04:43 UTC81
db4e552594cbatagen2026-10-26 04:43 UTC82
export function disableNotifications(): void {
db4e552594cbatagen2026-10-26 04:43 UTC83
  const account = get(sessionUser);
235ce394f90datagen2026-10-26 06:54 UTC84
  if (account) {
235ce394f90datagen2026-10-26 06:54 UTC85
    writePreference(account, false);
235ce394f90datagen2026-10-26 06:54 UTC86
    void disablePush(account);
235ce394f90datagen2026-10-26 06:54 UTC87
  }
db4e552594cbatagen2026-10-26 04:43 UTC88
  notificationsEnabled.set(false);
db4e552594cbatagen2026-10-26 04:43 UTC89
}
db4e552594cbatagen2026-10-26 04:43 UTC90
db4e552594cbatagen2026-10-26 04:43 UTC91
function active(): boolean {
db4e552594cbatagen2026-10-26 04:43 UTC92
  return supported && get(notificationsEnabled) && Notification.permission === 'granted';
db4e552594cbatagen2026-10-26 04:43 UTC93
}
db4e552594cbatagen2026-10-26 04:43 UTC94
db4e552594cbatagen2026-10-26 04:43 UTC95
async function registration(): Promise<ServiceWorkerRegistration | null> {
db4e552594cbatagen2026-10-26 04:43 UTC96
  if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return null;
db4e552594cbatagen2026-10-26 04:43 UTC97
  try {
db4e552594cbatagen2026-10-26 04:43 UTC98
    return await navigator.serviceWorker.ready;
db4e552594cbatagen2026-10-26 04:43 UTC99
  } catch {
db4e552594cbatagen2026-10-26 04:43 UTC100
    return null;
db4e552594cbatagen2026-10-26 04:43 UTC101
  }
db4e552594cbatagen2026-10-26 04:43 UTC102
}
db4e552594cbatagen2026-10-26 04:43 UTC103
db4e552594cbatagen2026-10-26 04:43 UTC104
async function show(plan: NotificationPlan, openRoom: (roomId: string) => void): Promise<void> {
db4e552594cbatagen2026-10-26 04:43 UTC105
  const icon = mediaUrl(plan.iconMxc, 128) ?? undefined;
db4e552594cbatagen2026-10-26 04:43 UTC106
  const options: NotificationOptions & { renotify?: boolean } = {
db4e552594cbatagen2026-10-26 04:43 UTC107
    body: plan.body,
db4e552594cbatagen2026-10-26 04:43 UTC108
    tag: plan.tag,
db4e552594cbatagen2026-10-26 04:43 UTC109
    icon,
db4e552594cbatagen2026-10-26 04:43 UTC110
    renotify: plan.renotify,
db4e552594cbatagen2026-10-26 04:43 UTC111
    data: { roomId: plan.roomId }
db4e552594cbatagen2026-10-26 04:43 UTC112
  };
db4e552594cbatagen2026-10-26 04:43 UTC113
  const reg = await registration();
db4e552594cbatagen2026-10-26 04:43 UTC114
  if (reg) {
db4e552594cbatagen2026-10-26 04:43 UTC115
    await reg.showNotification(plan.title, options);
db4e552594cbatagen2026-10-26 04:43 UTC116
    return;
db4e552594cbatagen2026-10-26 04:43 UTC117
  }
db4e552594cbatagen2026-10-26 04:43 UTC118
  const note = new Notification(plan.title, options);
db4e552594cbatagen2026-10-26 04:43 UTC119
  note.onclick = () => {
db4e552594cbatagen2026-10-26 04:43 UTC120
    window.focus();
db4e552594cbatagen2026-10-26 04:43 UTC121
    openRoom(plan.roomId);
db4e552594cbatagen2026-10-26 04:43 UTC122
    note.close();
db4e552594cbatagen2026-10-26 04:43 UTC123
  };
db4e552594cbatagen2026-10-26 04:43 UTC124
}
db4e552594cbatagen2026-10-26 04:43 UTC125
db4e552594cbatagen2026-10-26 04:43 UTC126
async function closeRoom(roomId: string): Promise<void> {
db4e552594cbatagen2026-10-26 04:43 UTC127
  const reg = await registration();
db4e552594cbatagen2026-10-26 04:43 UTC128
  if (!reg) return;
db4e552594cbatagen2026-10-26 04:43 UTC129
  for (const note of await reg.getNotifications({ tag: roomTag(roomId) })) note.close();
db4e552594cbatagen2026-10-26 04:43 UTC130
}
db4e552594cbatagen2026-10-26 04:43 UTC131
db4e552594cbatagen2026-10-26 04:43 UTC132
function setBadge(total: number): void {
db4e552594cbatagen2026-10-26 04:43 UTC133
  const nav = navigator as Navigator & { setAppBadge?: (n: number) => Promise<void>; clearAppBadge?: () => Promise<void> };
db4e552594cbatagen2026-10-26 04:43 UTC134
  if (total > 0) void nav.setAppBadge?.(total).catch(() => undefined);
db4e552594cbatagen2026-10-26 04:43 UTC135
  else void nav.clearAppBadge?.().catch(() => undefined);
db4e552594cbatagen2026-10-26 04:43 UTC136
}
db4e552594cbatagen2026-10-26 04:43 UTC137
db4e552594cbatagen2026-10-26 04:43 UTC138
/** Watches room summaries for notifying unread increases, shows and clears notifications, and keeps the app badge current. */
db4e552594cbatagen2026-10-26 04:43 UTC139
export function startNotifications(openRoom: (roomId: string) => void): () => void {
db4e552594cbatagen2026-10-26 04:43 UTC140
  let previous = new Map<string, RoomSummary>();
db4e552594cbatagen2026-10-26 04:43 UTC141
  let notified = new Set<string>();
db4e552594cbatagen2026-10-26 04:43 UTC142
  let badge = -1;
db4e552594cbatagen2026-10-26 04:43 UTC143
235ce394f90datagen2026-10-26 06:54 UTC144
  const unsubscribeSession = sessionUser.subscribe((account) => {
235ce394f90datagen2026-10-26 06:54 UTC145
    const enabled = readPreference(account);
235ce394f90datagen2026-10-26 06:54 UTC146
    notificationsEnabled.set(enabled);
235ce394f90datagen2026-10-26 06:54 UTC147
    if (account) void syncPush(account, enabled);
235ce394f90datagen2026-10-26 06:54 UTC148
  });
db4e552594cbatagen2026-10-26 04:43 UTC149
db4e552594cbatagen2026-10-26 04:43 UTC150
  void registration().then(async (reg) => {
db4e552594cbatagen2026-10-26 04:43 UTC151
    for (const note of (await reg?.getNotifications()) ?? []) {
db4e552594cbatagen2026-10-26 04:43 UTC152
      const roomId = (note.data as { roomId?: unknown } | null)?.roomId;
db4e552594cbatagen2026-10-26 04:43 UTC153
      if (typeof roomId === 'string') notified.add(roomId);
db4e552594cbatagen2026-10-26 04:43 UTC154
    }
db4e552594cbatagen2026-10-26 04:43 UTC155
  });
db4e552594cbatagen2026-10-26 04:43 UTC156
db4e552594cbatagen2026-10-26 04:43 UTC157
  const watched = (roomId: string) =>
db4e552594cbatagen2026-10-26 04:43 UTC158
    isRoomWatched(roomId, get(selectedRoomId), document.visibilityState === 'visible', document.hasFocus());
db4e552594cbatagen2026-10-26 04:43 UTC159
db4e552594cbatagen2026-10-26 04:43 UTC160
  const clearRead = (list: RoomSummary[]) => {
db4e552594cbatagen2026-10-26 04:43 UTC161
    for (const room of list) {
db4e552594cbatagen2026-10-26 04:43 UTC162
      if (notified.has(room.room_id) && (notifyingCount(room) === 0 || watched(room.room_id))) {
db4e552594cbatagen2026-10-26 04:43 UTC163
        notified.delete(room.room_id);
db4e552594cbatagen2026-10-26 04:43 UTC164
        void closeRoom(room.room_id);
db4e552594cbatagen2026-10-26 04:43 UTC165
      }
db4e552594cbatagen2026-10-26 04:43 UTC166
    }
db4e552594cbatagen2026-10-26 04:43 UTC167
  };
db4e552594cbatagen2026-10-26 04:43 UTC168
db4e552594cbatagen2026-10-26 04:43 UTC169
  const waiting = new Map<string, Arrival & { timer: ReturnType<typeof setTimeout> }>();
db4e552594cbatagen2026-10-26 04:43 UTC170
db4e552594cbatagen2026-10-26 04:43 UTC171
  const queue = (roomId: string, mention: boolean) => {
db4e552594cbatagen2026-10-26 04:43 UTC172
    clearTimeout(waiting.get(roomId)?.timer);
db4e552594cbatagen2026-10-26 04:43 UTC173
    const timer = setTimeout(() => {
db4e552594cbatagen2026-10-26 04:43 UTC174
      waiting.delete(roomId);
20fe182f3204atagen2026-10-26 05:50 UTC175
      const room = get(effectiveRooms).find((r) => r.room_id === roomId);
db4e552594cbatagen2026-10-26 04:43 UTC176
      if (!room || !active() || watched(roomId) || notifyingCount(room) === 0) return;
235ce394f90datagen2026-10-26 06:54 UTC177
      if (get(pushActive)) return;
db4e552594cbatagen2026-10-26 04:43 UTC178
      const source = sourceFromTimeline(get(timelines)[roomId]) ?? sourceFromSummary(room);
db4e552594cbatagen2026-10-26 04:43 UTC179
      notified.add(roomId);
db4e552594cbatagen2026-10-26 04:43 UTC180
      void show(planNotification(room, source, { mention }), openRoom);
db4e552594cbatagen2026-10-26 04:43 UTC181
    }, SETTLE_MS);
db4e552594cbatagen2026-10-26 04:43 UTC182
    waiting.set(roomId, { mention, timer });
db4e552594cbatagen2026-10-26 04:43 UTC183
  };
db4e552594cbatagen2026-10-26 04:43 UTC184
db4e552594cbatagen2026-10-26 04:43 UTC185
  const onRooms = (list: RoomSummary[]) => {
db4e552594cbatagen2026-10-26 04:43 UTC186
    const next = new Map(list.map((room) => [room.room_id, room]));
db4e552594cbatagen2026-10-26 04:43 UTC187
    const prior = previous;
db4e552594cbatagen2026-10-26 04:43 UTC188
    previous = next;
db4e552594cbatagen2026-10-26 04:43 UTC189
    if (!active()) {
db4e552594cbatagen2026-10-26 04:43 UTC190
      if (badge !== 0) setBadge((badge = 0));
db4e552594cbatagen2026-10-26 04:43 UTC191
      return;
db4e552594cbatagen2026-10-26 04:43 UTC192
    }
db4e552594cbatagen2026-10-26 04:43 UTC193
    const total = badgeTotal(list);
db4e552594cbatagen2026-10-26 04:43 UTC194
    if (total !== badge) setBadge((badge = total));
db4e552594cbatagen2026-10-26 04:43 UTC195
    const now = Date.now();
db4e552594cbatagen2026-10-26 04:43 UTC196
    for (const room of list) {
db4e552594cbatagen2026-10-26 04:43 UTC197
      const arrival = arrivalIn(prior.get(room.room_id), room, now);
db4e552594cbatagen2026-10-26 04:43 UTC198
      if (!arrival || watched(room.room_id)) continue;
db4e552594cbatagen2026-10-26 04:43 UTC199
      queue(room.room_id, arrival.mention || (waiting.get(room.room_id)?.mention ?? false));
db4e552594cbatagen2026-10-26 04:43 UTC200
    }
db4e552594cbatagen2026-10-26 04:43 UTC201
    clearRead(list);
db4e552594cbatagen2026-10-26 04:43 UTC202
  };
db4e552594cbatagen2026-10-26 04:43 UTC203
c8d898e6eb59atagen2026-10-27 03:55 UTC204
  const unsubscribeSound = startMessageSound();
20fe182f3204atagen2026-10-26 05:50 UTC205
  const unsubscribeRooms = effectiveRooms.subscribe(onRooms);
20fe182f3204atagen2026-10-26 05:50 UTC206
  const unsubscribeEnabled = notificationsEnabled.subscribe(() => onRooms(get(effectiveRooms)));
235ce394f90datagen2026-10-26 06:54 UTC207
  const onAttention = () => {
235ce394f90datagen2026-10-26 06:54 UTC208
    notificationPermission.set(supported ? Notification.permission : 'unsupported');
235ce394f90datagen2026-10-26 06:54 UTC209
    if (supported && Notification.permission !== 'granted') pushActive.set(false);
235ce394f90datagen2026-10-26 06:54 UTC210
    clearRead(get(effectiveRooms));
235ce394f90datagen2026-10-26 06:54 UTC211
    const open = get(selectedRoomId);
235ce394f90datagen2026-10-26 06:54 UTC212
    if (open && get(pushActive) && watched(open)) void closeRoom(open);
235ce394f90datagen2026-10-26 06:54 UTC213
  };
db4e552594cbatagen2026-10-26 04:43 UTC214
  const onMessage = (ev: MessageEvent) => {
db4e552594cbatagen2026-10-26 04:43 UTC215
    const data = ev.data as { type?: unknown; roomId?: unknown } | null;
235ce394f90datagen2026-10-26 06:54 UTC216
    const account = get(sessionUser);
235ce394f90datagen2026-10-26 06:54 UTC217
    if (data?.type === PUSH_CHANGED_MESSAGE && account) void syncPush(account, get(notificationsEnabled));
db4e552594cbatagen2026-10-26 04:43 UTC218
    if (data?.type === OPEN_ROOM_MESSAGE && typeof data.roomId === 'string') openRoom(data.roomId);
db4e552594cbatagen2026-10-26 04:43 UTC219
  };
db4e552594cbatagen2026-10-26 04:43 UTC220
  window.addEventListener('focus', onAttention);
db4e552594cbatagen2026-10-26 04:43 UTC221
  document.addEventListener('visibilitychange', onAttention);
db4e552594cbatagen2026-10-26 04:43 UTC222
  const unsubscribeSelected = selectedRoomId.subscribe(onAttention);
db4e552594cbatagen2026-10-26 04:43 UTC223
  if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('message', onMessage);
db4e552594cbatagen2026-10-26 04:43 UTC224
db4e552594cbatagen2026-10-26 04:43 UTC225
  return () => {
db4e552594cbatagen2026-10-26 04:43 UTC226
    unsubscribeSession();
db4e552594cbatagen2026-10-26 04:43 UTC227
    unsubscribeRooms();
c8d898e6eb59atagen2026-10-27 03:55 UTC228
    unsubscribeSound();
db4e552594cbatagen2026-10-26 04:43 UTC229
    unsubscribeEnabled();
db4e552594cbatagen2026-10-26 04:43 UTC230
    unsubscribeSelected();
db4e552594cbatagen2026-10-26 04:43 UTC231
    for (const { timer } of waiting.values()) clearTimeout(timer);
db4e552594cbatagen2026-10-26 04:43 UTC232
    window.removeEventListener('focus', onAttention);
db4e552594cbatagen2026-10-26 04:43 UTC233
    document.removeEventListener('visibilitychange', onAttention);
db4e552594cbatagen2026-10-26 04:43 UTC234
    if ('serviceWorker' in navigator) navigator.serviceWorker.removeEventListener('message', onMessage);
db4e552594cbatagen2026-10-26 04:43 UTC235
  };
db4e552594cbatagen2026-10-26 04:43 UTC236
}