atagen

malice

public
Star0
Watch0
a6320fb35dc65d5edb74bcf9692c022cf00b436d
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/threads.tsTypescript161 lines5.9 KB
1
import { derived, get, readable, writable } from 'svelte/store';
2
import { closeThreadList, getAccountData, openThreadList, paginateThreadList, runCommand, setAccountData } from './api';
3
import { pushBanner, rooms, sessionUser, threadLists, timelines } from './store';
4
import { roomThreads } from './thread-index';
5
import type { ThreadEntry } from './thread-index';
6
import type { ThreadListEntry, ThreadUnread, TimelineItem } from './types';
7
8
const PINNED_TYPE = 'dev.malice.pinned_threads';
9
10
export interface OpenThread {
11
  room_id: string;
12
  root_event_id: string;
13
  focus_event_id?: string;
14
}
15
16
export const openThread = writable<OpenThread | null>(null);
17
export const pinnedThreads = writable<Record<string, string[]>>({});
18
19
const NO_TIMELINE: TimelineItem[] = [];
20
const NO_PINS: string[] = [];
21
const NO_ENTRIES: ThreadListEntry[] = [];
22
const NO_UNREADS: Record<string, ThreadUnread> = {};
23
24
interface RoomThreadsCache {
25
  timeline: TimelineItem[];
26
  pins: string[];
27
  entries: ThreadListEntry[];
28
  unreads: Record<string, ThreadUnread>;
29
  list: ThreadEntry[];
30
}
31
32
function createThreadsByRoom() {
33
  let cache = new Map<string, RoomThreadsCache>();
34
  let last: Record<string, ThreadEntry[]> = {};
35
  const computed = derived([timelines, pinnedThreads, threadLists, rooms], ([$timelines, $pinned, $lists, $rooms]) => {
36
    const unreads = new Map($rooms.map((room) => [room.room_id, room.thread_unreads]));
37
    const next = new Map<string, RoomThreadsCache>();
38
    const out: Record<string, ThreadEntry[]> = {};
39
    const ids = [...Object.keys($timelines), ...Object.keys($pinned), ...Object.keys($lists), ...unreads.keys()];
40
    for (const roomId of new Set(ids)) {
41
      const timeline = $timelines[roomId] ?? NO_TIMELINE;
42
      const pins = $pinned[roomId] ?? NO_PINS;
43
      const entries = $lists[roomId]?.entries ?? NO_ENTRIES;
44
      const roomUnreads = unreads.get(roomId) ?? NO_UNREADS;
45
      const hit = cache.get(roomId);
46
      const reuse = hit && hit.timeline === timeline && hit.pins === pins && hit.entries === entries && hit.unreads === roomUnreads;
47
      const entry = reuse ? hit : { timeline, pins, entries, unreads: roomUnreads, list: roomThreads(roomId, timeline, pins, entries, roomUnreads) };
48
      next.set(roomId, entry);
49
      if (entry.list.length) out[roomId] = entry.list;
50
    }
51
    const keys = Object.keys(out);
52
    const unchanged = keys.length === Object.keys(last).length && keys.every((roomId) => out[roomId] === last[roomId]);
53
    cache = next;
54
    if (unchanged) return last;
55
    last = out;
56
    return out;
57
  });
58
  return readable(last, (set) => {
59
    let sent: Record<string, ThreadEntry[]> | null = null;
60
    return computed.subscribe((value) => {
61
      if (value === sent) return;
62
      sent = value;
63
      set(value);
64
    });
65
  });
66
}
67
68
export const threadsByRoom = createThreadsByRoom();
69
70
const THREAD_PAGE = 20;
71
const openLists = new Set<string>();
72
73
/** Keeps a server thread list open for exactly the rooms in `wanted`, loading a first page for each new one. */
74
export function syncThreadLists(wanted: string[]): void {
75
  const want = new Set(wanted);
76
  for (const roomId of [...openLists]) {
77
    if (want.has(roomId)) continue;
78
    openLists.delete(roomId);
79
    void closeThreadList(roomId).catch(() => undefined);
80
  }
81
  for (const roomId of want) {
82
    if (openLists.has(roomId)) continue;
83
    openLists.add(roomId);
84
    void openThreadList(roomId)
85
      .then(() => paginateThreadList(roomId, THREAD_PAGE))
86
      .catch(() => openLists.delete(roomId));
87
  }
88
}
89
90
export async function loadOlderThreads(roomId: string): Promise<void> {
91
  try {
92
    await paginateThreadList(roomId, THREAD_PAGE);
93
  } catch (e) {
94
    pushBanner(e instanceof Error ? e.message : 'Could not load older threads');
95
  }
96
}
97
98
function parsePinned(raw: unknown): Record<string, string[]> {
99
  if (typeof raw !== 'object' || raw === null) return {};
100
  const rooms = (raw as Record<string, unknown>)['rooms'];
101
  if (typeof rooms !== 'object' || rooms === null) return {};
102
  const out: Record<string, string[]> = {};
103
  for (const [roomId, list] of Object.entries(rooms)) {
104
    if (Array.isArray(list)) out[roomId] = list.filter((id): id is string => typeof id === 'string');
105
  }
106
  return out;
107
}
108
109
export async function loadPinnedThreads(): Promise<void> {
110
  try {
111
    pinnedThreads.set(parsePinned(await getAccountData(PINNED_TYPE)));
112
  } catch {
113
    pinnedThreads.set({});
114
  }
115
}
116
117
export async function togglePinnedThread(roomId: string, rootEventId: string): Promise<void> {
118
  const before = get(pinnedThreads);
119
  const list = before[roomId] ?? [];
120
  const nextList = list.includes(rootEventId) ? list.filter((id) => id !== rootEventId) : [...list, rootEventId];
121
  const next = { ...before, [roomId]: nextList };
122
  if (nextList.length === 0) delete next[roomId];
123
  pinnedThreads.set(next);
124
  try {
125
    await setAccountData(PINNED_TYPE, { rooms: next });
126
  } catch (e) {
127
    pinnedThreads.set(before);
128
    pushBanner(e instanceof Error ? e.message : 'Pinning the thread failed');
129
  }
130
}
131
132
let opened: OpenThread | null = null;
133
134
/** The room must already be open, since macltok builds thread timelines inside it. */
135
export async function showThread(target: OpenThread | null): Promise<void> {
136
  const previous = opened;
137
  opened = target;
138
  openThread.set(target);
139
  if (previous && target && previous.room_id === target.room_id && previous.root_event_id === target.root_event_id) return;
140
  if (previous) runCommand({ op: 'close_thread', room_id: previous.room_id, root_event_id: previous.root_event_id }).catch(() => undefined);
141
  if (!target) return;
142
  try {
143
    await runCommand({ op: 'open_thread', room_id: target.room_id, root_event_id: target.root_event_id });
144
  } catch (e) {
145
    if (opened === target) {
146
      opened = null;
147
      openThread.set(null);
148
    }
149
    pushBanner(e instanceof Error ? e.message : 'Opening the thread failed');
150
  }
151
}
152
153
let account: string | null | undefined;
154
sessionUser.subscribe((user) => {
155
  if (user === account) return;
156
  account = user;
157
  openLists.clear();
158
  opened = null;
159
  openThread.set(null);
160
  pinnedThreads.set({});
161
});