| 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 | } |