Commit db4e552594cb
tarball5 files changed+488-0
@@ -0,0 +1,38 @@
1
+<script lang="ts">2
+ import { disableNotifications, enableNotifications, notificationPermission, notificationsEnabled } from './notifications';3
+4
+ let busy = false;5
+6
+ $: denied = $notificationPermission === 'denied';7
+ $: unsupported = $notificationPermission === 'unsupported';8
+ $: on = $notificationsEnabled && $notificationPermission === 'granted';9
+10
+ async function toggle(): Promise<void> {11
+ if (busy) return;12
+ busy = true;13
+ try {14
+ if (on) disableNotifications();15
+ else await enableNotifications();16
+ } finally {17
+ busy = false;18
+ }19
+ }20
+</script>21
+22
+<section aria-label="Notifications">23
+ <div class="sec-head">24
+ <h3 class="sec-h">Notifications</h3>25
+ <span class="state" class:ok={on}>{unsupported ? 'Unavailable' : denied ? 'Blocked' : on ? 'On' : 'Off'}</span>26
+ </div>27
+ <button type="button" class="ghost-btn" disabled={busy || denied || unsupported} aria-pressed={on} on:click={() => void toggle()}>28
+ {on ? 'Turn off' : 'Turn on'}29
+ </button>30
+</section>31
+32
+<style>33
+ section { margin-top: 18px; }34
+ .sec-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }35
+ .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0; }36
+ .state { font-size: 12px; color: var(--muted); }37
+ .state.ok { color: var(--accent); }38
+</style>@@ -14,6 +14,7 @@
1414
import ReactionPicker from './ReactionPicker.svelte';1515
import ThemeSettings from './ThemeSettings.svelte';1616
import KeywordSettings from './KeywordSettings.svelte';17
+ import NotificationSettings from './NotificationSettings.svelte';1718
import type { ImagePack, QuickReaction } from './types';1819
1920
export let quick: QuickReaction[];@@ -80,6 +81,8 @@
8081
</div>8182
</section>8283
84
+ <NotificationSettings />85
+8386
<KeywordSettings />8487
8588
<section aria-label="Quick reactions">@@ -0,0 +1,133 @@
1
+import type { MessageKind, RoomSummary, TimelineItem } from './types';2
+3
+const RECENT_MS = 5 * 60 * 1000;4
+5
+export interface NotificationSource {6
+ senderId: string | null;7
+ senderName: string | null;8
+ senderAvatar: string | null;9
+ kind: MessageKind | null;10
+ body: string | null;11
+ undecryptable: boolean;12
+ highlight: boolean;13
+}14
+15
+export interface NotificationPlan {16
+ title: string;17
+ body: string;18
+ tag: string;19
+ renotify: boolean;20
+ roomId: string;21
+ iconMxc: string | null;22
+}23
+24
+export function notifyingCount(room: RoomSummary): number {25
+ if (room.space || room.membership !== 'Joined' || room.notification_mode === 'Mute') return 0;26
+ if (room.notification_mode === 'MentionsAndKeywordsOnly') return room.unread_mentions;27
+ return room.unread_messages + room.unread_mentions;28
+}29
+30
+export function badgeTotal(rooms: RoomSummary[]): number {31
+ return rooms.reduce((sum, room) => sum + notifyingCount(room), 0);32
+}33
+34
+export function roomTag(roomId: string): string {35
+ return `room:${roomId}`;36
+}37
+38
+export interface Arrival {39
+ mention: boolean;40
+}41
+42
+export function arrivalIn(previous: RoomSummary | undefined, next: RoomSummary, now: number): Arrival | null {43
+ if (!previous || notifyingCount(next) <= notifyingCount(previous)) return null;44
+ const at = next.latest_event_timestamp_ms;45
+ if (at !== null && now - at > RECENT_MS) return null;46
+ return { mention: next.unread_mentions > previous.unread_mentions };47
+}48
+49
+export function isRoomWatched(roomId: string, openRoomId: string | null, visible: boolean, focused: boolean): boolean {50
+ return visible && focused && roomId === openRoomId;51
+}52
+53
+export function sourceFromTimeline(items: TimelineItem[] | undefined): NotificationSource | null {54
+ if (!items) return null;55
+ for (let i = items.length - 1; i >= 0; i--) {56
+ const item = items[i];57
+ if (item.kind !== 'event') continue;58
+ const { event } = item;59
+ if (event.own) return null;60
+ const content = event.content;61
+ const common = {62
+ senderId: event.sender.user_id,63
+ senderName: event.sender.display_name,64
+ senderAvatar: event.sender.avatar65
+ };66
+ if (content.kind === 'Message') {67
+ return { ...common, kind: content.message.kind, body: content.message.body, undecryptable: false, highlight: event.highlight === true };68
+ }69
+ if (content.kind === 'Sticker') return { ...common, kind: null, body: content.body, undecryptable: false, highlight: event.highlight === true };70
+ if (content.kind === 'UnableToDecrypt') return { ...common, kind: null, body: null, undecryptable: true, highlight: false };71
+ }72
+ return null;73
+}74
+75
+export function sourceFromSummary(room: RoomSummary): NotificationSource | null {76
+ const latest = room.latest_event;77
+ if (!latest || latest.send_state.kind !== 'Sent') return null;78
+ const hero = room.heroes.find((h) => h.user_id === latest.sender);79
+ return {80
+ senderId: latest.sender,81
+ senderName: hero?.display_name ?? null,82
+ senderAvatar: hero?.avatar ?? null,83
+ kind: latest.kind,84
+ body: latest.body,85
+ undecryptable: false,86
+ highlight: false87
+ };88
+}89
+90
+const MEDIA_LABEL: Partial<Record<string, string>> = {91
+ Image: 'Sent an image',92
+ Video: 'Sent a video',93
+ Audio: 'Sent an audio message',94
+ File: 'Sent a file',95
+ Location: 'Shared a location'96
+};97
+98
+function previewText(source: NotificationSource, sender: string): string | null {99
+ if (source.undecryptable) return null;100
+ const kind = typeof source.kind === 'string' ? source.kind : null;101
+ const label = kind ? MEDIA_LABEL[kind] : undefined;102
+ const text = source.body?.trim();103
+ if (label) return label;104
+ if (!text) return null;105
+ return kind === 'Emote' ? `${sender} ${text}` : text;106
+}107
+108
+function localPart(userId: string): string {109
+ const name = userId.startsWith('@') ? userId.slice(1) : userId;110
+ const colon = name.indexOf(':');111
+ return colon > 0 ? name.slice(0, colon) : name;112
+}113
+114
+export function planNotification(115
+ room: RoomSummary,116
+ source: NotificationSource | null,117
+ arrival: Arrival118
+): NotificationPlan {119
+ const sender = source?.senderName ?? (source?.senderId ? localPart(source.senderId) : null);120
+ const text = source && sender ? previewText(source, sender) : null;121
+ const generic = room.direct ? 'New message' : 'New messages';122
+ let body: string;123
+ if (!text) body = sender && !room.direct ? `${sender}: ${generic}` : generic;124
+ else body = room.direct || !sender || source?.kind === 'Emote' ? text : `${sender}: ${text}`;125
+ return {126
+ title: room.direct && sender ? sender : room.name,127
+ body,128
+ tag: roomTag(room.room_id),129
+ renotify: arrival.mention || source?.highlight === true,130
+ roomId: room.room_id,131
+ iconMxc: (room.direct ? source?.senderAvatar : null) ?? room.avatar132
+ };133
+}@@ -0,0 +1,192 @@
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 NotificationPlan14
+} 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
+}@@ -0,0 +1,122 @@
1
+import assert from 'node:assert/strict';2
+import { test } from 'node:test';3
+import {4
+ arrivalIn,5
+ badgeTotal,6
+ isRoomWatched,7
+ notifyingCount,8
+ planNotification,9
+ sourceFromTimeline,10
+ type NotificationSource11
+} from '../src/lib/notification-model.ts';12
+import type { RoomSummary, TimelineItem } from '../src/lib/types.ts';13
+14
+const NOW = 1_000_000_000;15
+16
+function room(extra: Partial<RoomSummary> = {}): RoomSummary {17
+ return {18
+ room_id: '!r:x',19
+ name: 'General',20
+ membership: 'Joined',21
+ unread_messages: 0,22
+ unread_mentions: 0,23
+ unread_notifications: 0,24
+ space: false,25
+ direct: false,26
+ notification_mode: null,27
+ latest_event_timestamp_ms: NOW - 1000,28
+ avatar: 'mxc://x/room',29
+ ...extra30
+ } as RoomSummary;31
+}32
+33
+function source(extra: Partial<NotificationSource> = {}): NotificationSource {34
+ return { senderId: '@bob:x', senderName: 'Bob', senderAvatar: 'mxc://x/bob', kind: 'Text', body: 'hi', undecryptable: false, highlight: false, ...extra };35
+}36
+37
+test('counts only notifying unread per mode', () => {38
+ assert.equal(notifyingCount(room({ unread_notifications: 0, unread_messages: 9 })), 9);39
+ assert.equal(notifyingCount(room({ direct: true, unread_messages: 2, unread_mentions: 1 })), 3);40
+ assert.equal(notifyingCount(room({ notification_mode: 'Mute', unread_mentions: 4 })), 0);41
+ assert.equal(notifyingCount(room({ notification_mode: 'MentionsAndKeywordsOnly', unread_notifications: 5, unread_mentions: 1 })), 1);42
+ assert.equal(notifyingCount(room({ space: true, unread_mentions: 1 })), 0);43
+ assert.equal(notifyingCount(room({ membership: 'Invited', unread_mentions: 1 })), 0);44
+});45
+46
+test('badge sums notifying counts across rooms', () => {47
+ const rooms = [room({ unread_messages: 2 }), room({ direct: true, unread_messages: 1 }), room({ notification_mode: 'Mute', unread_messages: 9 })];48
+ assert.equal(badgeTotal(rooms), 3);49
+ assert.equal(badgeTotal([]), 0);50
+});51
+52
+test('arrival needs a known previous room and a count increase', () => {53
+ const before = room({ unread_messages: 1 });54
+ assert.equal(arrivalIn(undefined, before, NOW), null);55
+ assert.equal(arrivalIn(before, before, NOW), null);56
+ assert.equal(arrivalIn(before, room({ unread_messages: 0 }), NOW), null);57
+ assert.deepEqual(arrivalIn(before, room({ unread_messages: 2 }), NOW), { mention: false });58
+ assert.deepEqual(arrivalIn(before, room({ unread_messages: 2, unread_mentions: 1 }), NOW), { mention: true });59
+});60
+61
+test('arrival ignores stale backfill', () => {62
+ const next = room({ unread_messages: 1, latest_event_timestamp_ms: NOW - 10 * 60 * 1000 });63
+ assert.equal(arrivalIn(room(), next, NOW), null);64
+});65
+66
+test('only the focused open room is watched', () => {67
+ assert.equal(isRoomWatched('!a', '!a', true, true), true);68
+ assert.equal(isRoomWatched('!a', '!a', false, true), false);69
+ assert.equal(isRoomWatched('!a', '!a', true, false), false);70
+ assert.equal(isRoomWatched('!a', '!b', true, true), false);71
+});72
+73
+test('group notification prefixes the sender and tags per room', () => {74
+ const plan = planNotification(room(), source(), { mention: false });75
+ assert.equal(plan.title, 'General');76
+ assert.equal(plan.body, 'Bob: hi');77
+ assert.equal(plan.tag, 'room:!r:x');78
+ assert.equal(plan.renotify, false);79
+ assert.equal(plan.iconMxc, 'mxc://x/room');80
+});81
+82
+test('direct notification titles the sender and uses their avatar', () => {83
+ const plan = planNotification(room({ direct: true, name: 'Bob' }), source(), { mention: false });84
+ assert.equal(plan.title, 'Bob');85
+ assert.equal(plan.body, 'hi');86
+ assert.equal(plan.iconMxc, 'mxc://x/bob');87
+});88
+89
+test('mentions and keyword highlights renotify', () => {90
+ assert.equal(planNotification(room(), source(), { mention: true }).renotify, true);91
+ assert.equal(planNotification(room(), source({ highlight: true }), { mention: false }).renotify, true);92
+});93
+94
+test('undecryptable and missing content get a generic body', () => {95
+ assert.equal(planNotification(room(), source({ undecryptable: true, body: null }), { mention: false }).body, 'Bob: New messages');96
+ assert.equal(planNotification(room({ direct: true }), null, { mention: false }).body, 'New message');97
+});98
+99
+test('media and emotes get readable previews', () => {100
+ assert.equal(planNotification(room({ direct: true }), source({ kind: 'Image', body: 'cat.png' }), { mention: false }).body, 'Sent an image');101
+ assert.equal(planNotification(room(), source({ kind: 'Emote', body: 'waves' }), { mention: false }).body, 'Bob waves');102
+});103
+104
+test('falls back to the user id local part when no display name is known', () => {105
+ assert.equal(planNotification(room(), source({ senderName: null }), { mention: false }).body, 'bob: hi');106
+});107
+108
+test('timeline source skips own newest and non-message items', () => {109
+ const message = (own: boolean, body: string): TimelineItem =>110
+ ({111
+ kind: 'event',112
+ event: {113
+ own,114
+ sender: { user_id: '@bob:x', display_name: 'Bob', avatar: null, name_ambiguous: false },115
+ content: { kind: 'Message', message: { kind: 'Text', body } }116
+ }117
+ }) as TimelineItem;118
+ const membership = { kind: 'event', event: { own: false, sender: { user_id: '@c:x' }, content: { kind: 'RoomCreated' } } } as TimelineItem;119
+ assert.equal(sourceFromTimeline([message(false, 'a'), membership])?.body, 'a');120
+ assert.equal(sourceFromTimeline([message(false, 'a'), message(true, 'b')]), null);121
+ assert.equal(sourceFromTimeline(undefined), null);122
+});