atagen

malice

public
Star0
Watch0
e99ada4450ecaa8f2bbfb50cf78be269cefa8752
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/mute-model.tsTypescript163 lines6.2 KB
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
}