atagen

malice

public
Star0
Watch0
85d71b94ff8e857ef07ee98c2c9948850574da61
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/threads.tsTypescript120 lines4.4 KB
1
import { derived, get, 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
7
const PINNED_TYPE = 'dev.malice.pinned_threads';
8
9
export interface OpenThread {
10
  room_id: string;
11
  root_event_id: string;
12
  focus_event_id?: string;
13
}
14
15
export const openThread = writable<OpenThread | null>(null);
16
export const pinnedThreads = writable<Record<string, string[]>>({});
17
18
export const threadsByRoom = derived([timelines, pinnedThreads, threadLists, rooms], ([$timelines, $pinned, $lists, $rooms]) => {
19
  const unreads = new Map($rooms.map((room) => [room.room_id, room.thread_unreads]));
20
  const out: Record<string, ThreadEntry[]> = {};
21
  const ids = [...Object.keys($timelines), ...Object.keys($pinned), ...Object.keys($lists), ...unreads.keys()];
22
  for (const roomId of new Set(ids)) {
23
    const list = roomThreads(roomId, $timelines[roomId] ?? [], $pinned[roomId] ?? [], $lists[roomId]?.entries ?? [], unreads.get(roomId) ?? {});
24
    if (list.length) out[roomId] = list;
25
  }
26
  return out;
27
});
28
29
const THREAD_PAGE = 20;
30
const openLists = new Set<string>();
31
32
/** Keeps a server thread list open for exactly the rooms in `wanted`, loading a first page for each new one. */
33
export function syncThreadLists(wanted: string[]): void {
34
  const want = new Set(wanted);
35
  for (const roomId of [...openLists]) {
36
    if (want.has(roomId)) continue;
37
    openLists.delete(roomId);
38
    void closeThreadList(roomId).catch(() => undefined);
39
  }
40
  for (const roomId of want) {
41
    if (openLists.has(roomId)) continue;
42
    openLists.add(roomId);
43
    void openThreadList(roomId)
44
      .then(() => paginateThreadList(roomId, THREAD_PAGE))
45
      .catch(() => openLists.delete(roomId));
46
  }
47
}
48
49
export async function loadOlderThreads(roomId: string): Promise<void> {
50
  try {
51
    await paginateThreadList(roomId, THREAD_PAGE);
52
  } catch (e) {
53
    pushBanner(e instanceof Error ? e.message : 'Could not load older threads');
54
  }
55
}
56
57
function parsePinned(raw: unknown): Record<string, string[]> {
58
  if (typeof raw !== 'object' || raw === null) return {};
59
  const rooms = (raw as Record<string, unknown>)['rooms'];
60
  if (typeof rooms !== 'object' || rooms === null) return {};
61
  const out: Record<string, string[]> = {};
62
  for (const [roomId, list] of Object.entries(rooms)) {
63
    if (Array.isArray(list)) out[roomId] = list.filter((id): id is string => typeof id === 'string');
64
  }
65
  return out;
66
}
67
68
export async function loadPinnedThreads(): Promise<void> {
69
  try {
70
    pinnedThreads.set(parsePinned(await getAccountData(PINNED_TYPE)));
71
  } catch {
72
    pinnedThreads.set({});
73
  }
74
}
75
76
export async function togglePinnedThread(roomId: string, rootEventId: string): Promise<void> {
77
  const before = get(pinnedThreads);
78
  const list = before[roomId] ?? [];
79
  const nextList = list.includes(rootEventId) ? list.filter((id) => id !== rootEventId) : [...list, rootEventId];
80
  const next = { ...before, [roomId]: nextList };
81
  if (nextList.length === 0) delete next[roomId];
82
  pinnedThreads.set(next);
83
  try {
84
    await setAccountData(PINNED_TYPE, { rooms: next });
85
  } catch (e) {
86
    pinnedThreads.set(before);
87
    pushBanner(e instanceof Error ? e.message : 'Pinning the thread failed');
88
  }
89
}
90
91
let opened: OpenThread | null = null;
92
93
/** The room must already be open, since macltok builds thread timelines inside it. */
94
export async function showThread(target: OpenThread | null): Promise<void> {
95
  const previous = opened;
96
  opened = target;
97
  openThread.set(target);
98
  if (previous && target && previous.room_id === target.room_id && previous.root_event_id === target.root_event_id) return;
99
  if (previous) runCommand({ op: 'close_thread', room_id: previous.room_id, root_event_id: previous.root_event_id }).catch(() => undefined);
100
  if (!target) return;
101
  try {
102
    await runCommand({ op: 'open_thread', room_id: target.room_id, root_event_id: target.root_event_id });
103
  } catch (e) {
104
    if (opened === target) {
105
      opened = null;
106
      openThread.set(null);
107
    }
108
    pushBanner(e instanceof Error ? e.message : 'Opening the thread failed');
109
  }
110
}
111
112
let account: string | null | undefined;
113
sessionUser.subscribe((user) => {
114
  if (user === account) return;
115
  account = user;
116
  openLists.clear();
117
  opened = null;
118
  openThread.set(null);
119
  pinnedThreads.set({});
120
});