atagen

malice

public
Star0
Watch0

Commit 00ca0561eced

tarball

atagen <boss@atagen.co>2026-10-26 05:50 UTC

5 files changed+373-0
web: mute rooms, DMs and spaces

Matrix has no space-level push rule, so a muted space is kept in dev.malice.muted_spaces account data and its rooms get room-level mute rules, recording which rooms the space muted so unmuting it never unmutes rooms muted individually.
Added file: web/src/lib/MutedIcon.svelte+6 additions
@@ -0,0 +1,6 @@
1+<script lang="ts">
2+</script>
3+
4+<svg class="muted-icon" viewBox="0 0 24 24" width="13" height="13" aria-label="Muted" role="img">
5+ <path d="M6 16V11a6 6 0 0 1 9-5.2M18 10v6l2 2H4l2-2M10 21h4M4 4l16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
6+</svg>
Added file: web/src/lib/mute-model.ts+163 additions
@@ -0,0 +1,163 @@
1+import type { RoomSummary } from './types';
2+
3+export type RoomMode = 'AllMessages' | 'MentionsAndKeywordsOnly' | 'Mute';
4+
5+export interface MutedSpaces {
6+ spaces: string[];
7+ applied: Record<string, Record<string, RoomMode | null>>;
8+}
9+
10+export const NO_MUTED_SPACES: MutedSpaces = { spaces: [], applied: {} };
11+
12+function isRecord(value: unknown): value is Record<string, unknown> {
13+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14+}
15+
16+function isMode(value: unknown): value is RoomMode {
17+ return value === 'AllMessages' || value === 'MentionsAndKeywordsOnly' || value === 'Mute';
18+}
19+
20+export function parseMutedSpaces(raw: unknown): MutedSpaces {
21+ if (!isRecord(raw)) return NO_MUTED_SPACES;
22+ const spaces = Array.isArray(raw['spaces']) ? [...new Set(raw['spaces'].filter((s): s is string => typeof s === 'string'))] : [];
23+ const applied: MutedSpaces['applied'] = {};
24+ if (isRecord(raw['applied'])) {
25+ for (const [spaceId, rooms] of Object.entries(raw['applied'])) {
26+ if (!spaces.includes(spaceId) || !isRecord(rooms)) continue;
27+ const entry: Record<string, RoomMode | null> = {};
28+ for (const [roomId, mode] of Object.entries(rooms)) if (mode === null || isMode(mode)) entry[roomId] = mode;
29+ applied[spaceId] = entry;
30+ }
31+ }
32+ return { spaces, applied };
33+}
34+
35+function childMap(rooms: RoomSummary[]): Map<string, Set<string>> {
36+ const map = new Map<string, Set<string>>();
37+ const add = (space: string, child: string) => {
38+ let set = map.get(space);
39+ if (!set) map.set(space, (set = new Set()));
40+ set.add(child);
41+ };
42+ for (const room of rooms) {
43+ for (const child of room.children) add(room.room_id, child);
44+ for (const parent of room.parent_spaces) add(parent, room.room_id);
45+ }
46+ return map;
47+}
48+
49+function descendants(spaceId: string, children: Map<string, Set<string>>, spaceIds: Set<string>): Set<string> {
50+ const out = new Set<string>();
51+ const seen = new Set<string>([spaceId]);
52+ const queue = [spaceId];
53+ for (let at = queue.pop(); at !== undefined; at = queue.pop()) {
54+ for (const child of children.get(at) ?? []) {
55+ if (spaceIds.has(child)) {
56+ if (!seen.has(child)) {
57+ seen.add(child);
58+ queue.push(child);
59+ }
60+ } else out.add(child);
61+ }
62+ }
63+ return out;
64+}
65+
66+export function roomsUnderSpace(spaceId: string, rooms: RoomSummary[]): Set<string> {
67+ const spaceIds = new Set(rooms.filter((r) => r.space).map((r) => r.room_id));
68+ return descendants(spaceId, childMap(rooms), spaceIds);
69+}
70+
71+export function spaceMutedRoomIds(rooms: RoomSummary[], mutedSpaces: string[]): Set<string> {
72+ const out = new Set<string>();
73+ if (mutedSpaces.length === 0) return out;
74+ const children = childMap(rooms);
75+ const spaceIds = new Set(rooms.filter((r) => r.space).map((r) => r.room_id));
76+ for (const id of mutedSpaces) for (const room of descendants(id, children, spaceIds)) out.add(room);
77+ return out;
78+}
79+
80+export function isRoomMuted(room: RoomSummary, spaceMuted: Set<string>): boolean {
81+ return room.notification_mode === 'Mute' || spaceMuted.has(room.room_id);
82+}
83+
84+export function withSpaceMutes(rooms: RoomSummary[], spaceMuted: Set<string>): RoomSummary[] {
85+ if (spaceMuted.size === 0) return rooms;
86+ return rooms.map((room) => (spaceMuted.has(room.room_id) && room.notification_mode !== 'Mute' ? { ...room, notification_mode: 'Mute' } : room));
87+}
88+
89+export interface RoomChange {
90+ roomId: string;
91+ mode: RoomMode | null;
92+}
93+
94+export interface Plan {
95+ state: MutedSpaces;
96+ changes: RoomChange[];
97+}
98+
99+function priorMode(room: RoomSummary): RoomMode | null {
100+ const mode = room.notification_mode;
101+ return room.notification_mode_custom && (mode === 'AllMessages' || mode === 'MentionsAndKeywordsOnly') ? mode : null;
102+}
103+
104+function claimed(state: MutedSpaces): Set<string> {
105+ const out = new Set<string>();
106+ for (const entry of Object.values(state.applied)) for (const roomId of Object.keys(entry)) out.add(roomId);
107+ return out;
108+}
109+
110+function claim(state: MutedSpaces, spaceId: string, targets: RoomSummary[]): Plan {
111+ const skip = claimed(state);
112+ const fresh = targets.filter((room) => room.notification_mode !== 'Mute' && room.notification_mode !== null && !skip.has(room.room_id));
113+ const entry = { ...state.applied[spaceId] };
114+ for (const room of fresh) entry[room.room_id] = priorMode(room);
115+ return {
116+ state: fresh.length === 0 ? state : { ...state, applied: { ...state.applied, [spaceId]: entry } },
117+ changes: fresh.map((room) => ({ roomId: room.room_id, mode: 'Mute' }))
118+ };
119+}
120+
121+function joinedUnder(spaceId: string, rooms: RoomSummary[]): RoomSummary[] {
122+ const ids = roomsUnderSpace(spaceId, rooms);
123+ return rooms.filter((r) => ids.has(r.room_id) && !r.space && r.membership === 'Joined');
124+}
125+
126+export function muteSpacePlan(state: MutedSpaces, spaceId: string, rooms: RoomSummary[]): Plan {
127+ const base = state.spaces.includes(spaceId) ? state : { ...state, spaces: [...state.spaces, spaceId] };
128+ return claim(base, spaceId, joinedUnder(spaceId, rooms));
129+}
130+
131+export function reconcilePlan(state: MutedSpaces, rooms: RoomSummary[]): Plan {
132+ let current = state;
133+ const changes: RoomChange[] = [];
134+ for (const spaceId of state.spaces) {
135+ const plan = claim(current, spaceId, joinedUnder(spaceId, rooms));
136+ current = plan.state;
137+ changes.push(...plan.changes);
138+ }
139+ return { state: current, changes };
140+}
141+
142+export function unmuteSpacePlan(state: MutedSpaces, spaceId: string, rooms: RoomSummary[]): Plan {
143+ const spaces = state.spaces.filter((s) => s !== spaceId);
144+ const { [spaceId]: released = {}, ...applied } = state.applied;
145+ const next: MutedSpaces = { spaces, applied };
146+ const byId = new Map(rooms.map((r) => [r.room_id, r]));
147+ const changes: RoomChange[] = [];
148+ for (const [roomId, prior] of Object.entries(released)) {
149+ const room = byId.get(roomId);
150+ if (!room || room.notification_mode !== 'Mute') continue;
151+ const heir = spaces.find((s) => roomsUnderSpace(s, rooms).has(roomId));
152+ if (heir) {
153+ next.applied = { ...next.applied, [heir]: { ...next.applied[heir], [roomId]: prior } };
154+ continue;
155+ }
156+ changes.push({ roomId, mode: prior });
157+ }
158+ return { state: next, changes };
159+}
160+
161+export function mutingSpace(state: MutedSpaces, rooms: RoomSummary[], roomId: string): string | null {
162+ return state.spaces.find((id) => roomsUnderSpace(id, rooms).has(roomId)) ?? null;
163+}
Added file: web/src/lib/mute.ts+61 additions
@@ -0,0 +1,61 @@
1+import { derived, get } from 'svelte/store';
2+import { runCommand, setAccountData } from './api';
3+import {
4+ muteSpacePlan,
5+ reconcilePlan,
6+ spaceMutedRoomIds,
7+ unmuteSpacePlan,
8+ withSpaceMutes,
9+ type Plan,
10+ type RoomMode
11+} from './mute-model';
12+import { MUTED_SPACES_EVENT, mutedSpaces, mutedSpacesLoaded } from './muted-spaces';
13+import { rooms } from './store';
14+
15+export const spaceMutedRooms = derived([rooms, mutedSpaces], ([$rooms, $muted]) => spaceMutedRoomIds($rooms, $muted.spaces));
16+
17+export const effectiveRooms = derived([rooms, spaceMutedRooms], ([$rooms, $muted]) => withSpaceMutes($rooms, $muted));
18+
19+let queue: Promise<void> = Promise.resolve();
20+
21+export function setRoomMode(roomId: string, mode: RoomMode | null): Promise<void> {
22+ return runCommand({ op: 'set_room_notification_mode', room_id: roomId, mode }).then(() => undefined);
23+}
24+
25+async function apply(plan: Plan): Promise<void> {
26+ await setAccountData(MUTED_SPACES_EVENT, { ...plan.state });
27+ mutedSpaces.set(plan.state);
28+ const results = await Promise.allSettled(plan.changes.map((c) => setRoomMode(c.roomId, c.mode)));
29+ const failed = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
30+ if (failed) throw failed.reason;
31+}
32+
33+function enqueue(job: () => Promise<void>): Promise<void> {
34+ const run = queue.then(job);
35+ queue = run.catch(() => undefined);
36+ return run;
37+}
38+
39+export function setSpaceMuted(spaceId: string, muted: boolean): Promise<void> {
40+ return enqueue(async () => {
41+ const state = get(mutedSpaces);
42+ const all = get(rooms);
43+ await apply(muted ? muteSpacePlan(state, spaceId, all) : unmuteSpacePlan(state, spaceId, all));
44+ });
45+}
46+
47+export function startMuteSync(): () => void {
48+ const reconcile = () => {
49+ if (!mutedSpacesLoaded() || get(mutedSpaces).spaces.length === 0) return;
50+ void enqueue(async () => {
51+ const plan = reconcilePlan(get(mutedSpaces), get(rooms));
52+ if (plan.changes.length > 0) await apply(plan);
53+ }).catch(() => undefined);
54+ };
55+ const stopRooms = rooms.subscribe(reconcile);
56+ const stopMuted = mutedSpaces.subscribe(reconcile);
57+ return () => {
58+ stopRooms();
59+ stopMuted();
60+ };
61+}
Added file: web/src/lib/muted-spaces.ts+23 additions
@@ -0,0 +1,23 @@
1+import { writable } from 'svelte/store';
2+import { getAccountData } from './api';
3+import { NO_MUTED_SPACES, parseMutedSpaces, type MutedSpaces } from './mute-model';
4+
5+export const MUTED_SPACES_EVENT = 'dev.malice.muted_spaces';
6+
7+export const mutedSpaces = writable<MutedSpaces>(NO_MUTED_SPACES);
8+
9+let loaded = false;
10+
11+export const mutedSpacesLoaded = () => loaded;
12+
13+export async function loadMutedSpaces(): Promise<void> {
14+ try {
15+ mutedSpaces.set(parseMutedSpaces(await getAccountData(MUTED_SPACES_EVENT)));
16+ loaded = true;
17+ } catch {}
18+}
19+
20+export function resetMutedSpaces(): void {
21+ loaded = false;
22+ mutedSpaces.set(NO_MUTED_SPACES);
23+}
Added file: web/test/mute.test.ts+120 additions
@@ -0,0 +1,120 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import {
4+ isRoomMuted,
5+ muteSpacePlan,
6+ mutingSpace,
7+ parseMutedSpaces,
8+ reconcilePlan,
9+ spaceMutedRoomIds,
10+ unmuteSpacePlan,
11+ withSpaceMutes,
12+ type MutedSpaces
13+} from '../src/lib/mute-model.ts';
14+import { badgeTotal } from '../src/lib/notification-model.ts';
15+import type { RoomSummary } from '../src/lib/types.ts';
16+
17+function room(id: string, extra: Partial<RoomSummary> = {}): RoomSummary {
18+ return {
19+ room_id: id,
20+ name: id,
21+ membership: 'Joined',
22+ space: false,
23+ direct: false,
24+ unread_messages: 0,
25+ unread_mentions: 0,
26+ notification_mode: 'AllMessages',
27+ notification_mode_custom: false,
28+ parent_spaces: [],
29+ children: [],
30+ ...extra
31+ } as RoomSummary;
32+}
33+
34+const empty: MutedSpaces = { spaces: [], applied: {} };
35+
36+const rooms = [
37+ room('!top', { space: true, children: ['!a', '!sub'] }),
38+ room('!sub', { space: true, parent_spaces: ['!top'], children: ['!b'] }),
39+ room('!a', { parent_spaces: ['!top'], unread_messages: 3 }),
40+ room('!b', { parent_spaces: ['!sub'], unread_messages: 2, notification_mode: 'MentionsAndKeywordsOnly', notification_mode_custom: true }),
41+ room('!c', { notification_mode: 'Mute', notification_mode_custom: true, parent_spaces: ['!top'] }),
42+ room('!other', { unread_messages: 1 })
43+];
44+
45+test('space mute covers nested rooms but not rooms elsewhere', () => {
46+ assert.deepEqual([...spaceMutedRoomIds(rooms, ['!top'])].sort(), ['!a', '!b', '!c']);
47+ assert.equal(spaceMutedRoomIds(rooms, []).size, 0);
48+});
49+
50+test('space mute silences the badge total and leaves other rooms', () => {
51+ const muted = withSpaceMutes(rooms, spaceMutedRoomIds(rooms, ['!top']));
52+ assert.equal(badgeTotal(muted), 1);
53+ assert.equal(badgeTotal(rooms), 4);
54+ assert.equal(withSpaceMutes(rooms, new Set()), rooms);
55+});
56+
57+test('isRoomMuted covers explicit and inherited mute', () => {
58+ const inherited = spaceMutedRoomIds(rooms, ['!top']);
59+ assert.equal(isRoomMuted(rooms[2], inherited), true);
60+ assert.equal(isRoomMuted(rooms[5], inherited), false);
61+ assert.equal(isRoomMuted(rooms[4], new Set()), true);
62+});
63+
64+test('muting a space claims unmuted children with their prior mode', () => {
65+ const plan = muteSpacePlan(empty, '!top', rooms);
66+ assert.deepEqual(plan.state.spaces, ['!top']);
67+ assert.deepEqual(plan.state.applied['!top'], { '!a': null, '!b': 'MentionsAndKeywordsOnly' });
68+ assert.deepEqual(plan.changes.map((c) => c.roomId).sort(), ['!a', '!b']);
69+});
70+
71+test('unmuting restores prior modes and leaves individual mutes alone', () => {
72+ const muted = muteSpacePlan(empty, '!top', rooms).state;
73+ const after = rooms.map((r) => (r.room_id === '!a' || r.room_id === '!b' ? { ...r, notification_mode: 'Mute' as const } : r));
74+ const plan = unmuteSpacePlan(muted, '!top', after);
75+ assert.deepEqual(plan.state, empty);
76+ assert.deepEqual(plan.changes, [
77+ { roomId: '!a', mode: null },
78+ { roomId: '!b', mode: 'MentionsAndKeywordsOnly' }
79+ ]);
80+ assert.equal(plan.changes.some((c) => c.roomId === '!c'), false);
81+});
82+
83+test('a room a user unmuted inside a muted space is not reclaimed', () => {
84+ const muted = muteSpacePlan(empty, '!top', rooms).state;
85+ const after = rooms.map((r) => (r.room_id === '!a' ? { ...r, notification_mode: 'AllMessages' as const } : r));
86+ assert.deepEqual(reconcilePlan(muted, after).changes, []);
87+});
88+
89+test('reconcile mutes children that joined later', () => {
90+ const muted = muteSpacePlan(empty, '!top', rooms).state;
91+ const joined = [...rooms, room('!new', { parent_spaces: ['!top'] })];
92+ const plan = reconcilePlan(muted, joined);
93+ assert.deepEqual(plan.changes, [{ roomId: '!new', mode: 'Mute' }]);
94+ assert.equal(reconcilePlan(plan.state, joined).changes.length, 0);
95+});
96+
97+test('reconcile ignores rooms whose mode has not loaded', () => {
98+ const muted: MutedSpaces = { spaces: ['!top'], applied: {} };
99+ const pending = rooms.map((r) => (r.room_id === '!a' ? { ...r, notification_mode: null } : r));
100+ assert.equal(reconcilePlan(muted, pending).changes.some((c) => c.roomId === '!a'), false);
101+});
102+
103+test('overlapping muted spaces hand the room to the remaining space on unmute', () => {
104+ const both = [...rooms, room('!shared', { parent_spaces: ['!top', '!x'] }), room('!x', { space: true, children: ['!shared'] })];
105+ let state = muteSpacePlan(empty, '!top', both).state;
106+ state = muteSpacePlan(state, '!x', both).state;
107+ const after = both.map((r) => (r.notification_mode === 'AllMessages' ? { ...r, notification_mode: 'Mute' as const } : r));
108+ const plan = unmuteSpacePlan(state, '!top', after);
109+ assert.equal(plan.changes.some((c) => c.roomId === '!shared'), false);
110+ assert.ok('!shared' in (plan.state.applied['!x'] ?? {}));
111+ assert.equal(mutingSpace(plan.state, after, '!shared'), '!x');
112+});
113+
114+test('parseMutedSpaces tolerates junk', () => {
115+ assert.deepEqual(parseMutedSpaces(null), empty);
116+ assert.deepEqual(parseMutedSpaces({ spaces: ['!a', 3], applied: { '!a': { '!r': 'Mute', '!q': 'Bogus' }, '!gone': { '!r': null } } }), {
117+ spaces: ['!a'],
118+ applied: { '!a': { '!r': 'Mute' } }
119+ });
120+});