atagen

malice

public
Star0
Watch0

Commit db4e552594cb

tarball

atagen <boss@atagen.co>2026-10-26 04:43 UTC

5 files changed+488-0
web: show system notifications and an app badge while malice is open

Notifications follow the room summaries' unread counts, which already reflect the homeserver's push rules, so malice doesn't evaluate push rules itself. Only the open room is suppressed while focused, and initial sync backfill stays quiet by ignoring events older than five minutes.
Added file: web/src/lib/NotificationSettings.svelte+38 additions
@@ -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>
Modified file: web/src/lib/SettingsPanel.svelte+3 additions
@@ -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">
Added file: web/src/lib/notification-model.ts+133 additions
@@ -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.avatar
65+ };
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: false
87+ };
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: Arrival
118+): 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.avatar
132+ };
133+}
Added file: web/src/lib/notifications.ts+192 additions
@@ -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 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+}
Added file: web/test/notification-model.test.ts+122 additions
@@ -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 NotificationSource
11+} 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+ ...extra
30+ } 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+});