Commit 00ca0561eced
tarball5 files changed+373-0
@@ -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>@@ -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
+}@@ -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 RoomMode11
+} 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
+}@@ -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
+}@@ -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 MutedSpaces13
+} 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
+ ...extra31
+ } 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
+});