atagen

malice

public
Star0
Watch0
18fbdbb2c103f15cd7574bd66358060d2ebee3fd
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/threads.ts

7ebc2472a9e3atagen2026-10-25 09:53 UTC1
import { derived, get, writable } from 'svelte/store';
249e439c27e5atagen2026-10-25 13:26 UTC2
import { closeThreadList, getAccountData, openThreadList, paginateThreadList, runCommand, setAccountData } from './api';
249e439c27e5atagen2026-10-25 13:26 UTC3
import { pushBanner, rooms, sessionUser, threadLists, timelines } from './store';
7ebc2472a9e3atagen2026-10-25 09:53 UTC4
import { roomThreads } from './thread-index';
7ebc2472a9e3atagen2026-10-25 09:53 UTC5
import type { ThreadEntry } from './thread-index';
7ebc2472a9e3atagen2026-10-25 09:53 UTC6
7ebc2472a9e3atagen2026-10-25 09:53 UTC7
const PINNED_TYPE = 'dev.malice.pinned_threads';
7ebc2472a9e3atagen2026-10-25 09:53 UTC8
7ebc2472a9e3atagen2026-10-25 09:53 UTC9
export interface OpenThread {
7ebc2472a9e3atagen2026-10-25 09:53 UTC10
  room_id: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC11
  root_event_id: string;
4f91f89d99faatagen2026-10-25 10:47 UTC12
  focus_event_id?: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC13
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC14
7ebc2472a9e3atagen2026-10-25 09:53 UTC15
export const openThread = writable<OpenThread | null>(null);
7ebc2472a9e3atagen2026-10-25 09:53 UTC16
export const pinnedThreads = writable<Record<string, string[]>>({});
7ebc2472a9e3atagen2026-10-25 09:53 UTC17
249e439c27e5atagen2026-10-25 13:26 UTC18
export const threadsByRoom = derived([timelines, pinnedThreads, threadLists, rooms], ([$timelines, $pinned, $lists, $rooms]) => {
249e439c27e5atagen2026-10-25 13:26 UTC19
  const unreads = new Map($rooms.map((room) => [room.room_id, room.thread_unreads]));
7ebc2472a9e3atagen2026-10-25 09:53 UTC20
  const out: Record<string, ThreadEntry[]> = {};
249e439c27e5atagen2026-10-25 13:26 UTC21
  const ids = [...Object.keys($timelines), ...Object.keys($pinned), ...Object.keys($lists), ...unreads.keys()];
249e439c27e5atagen2026-10-25 13:26 UTC22
  for (const roomId of new Set(ids)) {
249e439c27e5atagen2026-10-25 13:26 UTC23
    const list = roomThreads(roomId, $timelines[roomId] ?? [], $pinned[roomId] ?? [], $lists[roomId]?.entries ?? [], unreads.get(roomId) ?? {});
7ebc2472a9e3atagen2026-10-25 09:53 UTC24
    if (list.length) out[roomId] = list;
7ebc2472a9e3atagen2026-10-25 09:53 UTC25
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC26
  return out;
7ebc2472a9e3atagen2026-10-25 09:53 UTC27
});
7ebc2472a9e3atagen2026-10-25 09:53 UTC28
249e439c27e5atagen2026-10-25 13:26 UTC29
const THREAD_PAGE = 20;
249e439c27e5atagen2026-10-25 13:26 UTC30
const openLists = new Set<string>();
249e439c27e5atagen2026-10-25 13:26 UTC31
249e439c27e5atagen2026-10-25 13:26 UTC32
/** Keeps a server thread list open for exactly the rooms in `wanted`, loading a first page for each new one. */
249e439c27e5atagen2026-10-25 13:26 UTC33
export function syncThreadLists(wanted: string[]): void {
249e439c27e5atagen2026-10-25 13:26 UTC34
  const want = new Set(wanted);
249e439c27e5atagen2026-10-25 13:26 UTC35
  for (const roomId of [...openLists]) {
249e439c27e5atagen2026-10-25 13:26 UTC36
    if (want.has(roomId)) continue;
249e439c27e5atagen2026-10-25 13:26 UTC37
    openLists.delete(roomId);
249e439c27e5atagen2026-10-25 13:26 UTC38
    void closeThreadList(roomId).catch(() => undefined);
249e439c27e5atagen2026-10-25 13:26 UTC39
  }
249e439c27e5atagen2026-10-25 13:26 UTC40
  for (const roomId of want) {
249e439c27e5atagen2026-10-25 13:26 UTC41
    if (openLists.has(roomId)) continue;
249e439c27e5atagen2026-10-25 13:26 UTC42
    openLists.add(roomId);
249e439c27e5atagen2026-10-25 13:26 UTC43
    void openThreadList(roomId)
249e439c27e5atagen2026-10-25 13:26 UTC44
      .then(() => paginateThreadList(roomId, THREAD_PAGE))
249e439c27e5atagen2026-10-25 13:26 UTC45
      .catch(() => openLists.delete(roomId));
249e439c27e5atagen2026-10-25 13:26 UTC46
  }
249e439c27e5atagen2026-10-25 13:26 UTC47
}
249e439c27e5atagen2026-10-25 13:26 UTC48
249e439c27e5atagen2026-10-25 13:26 UTC49
export async function loadOlderThreads(roomId: string): Promise<void> {
249e439c27e5atagen2026-10-25 13:26 UTC50
  try {
249e439c27e5atagen2026-10-25 13:26 UTC51
    await paginateThreadList(roomId, THREAD_PAGE);
249e439c27e5atagen2026-10-25 13:26 UTC52
  } catch (e) {
249e439c27e5atagen2026-10-25 13:26 UTC53
    pushBanner(e instanceof Error ? e.message : 'Could not load older threads');
249e439c27e5atagen2026-10-25 13:26 UTC54
  }
249e439c27e5atagen2026-10-25 13:26 UTC55
}
249e439c27e5atagen2026-10-25 13:26 UTC56
7ebc2472a9e3atagen2026-10-25 09:53 UTC57
function parsePinned(raw: unknown): Record<string, string[]> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC58
  if (typeof raw !== 'object' || raw === null) return {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC59
  const rooms = (raw as Record<string, unknown>)['rooms'];
7ebc2472a9e3atagen2026-10-25 09:53 UTC60
  if (typeof rooms !== 'object' || rooms === null) return {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC61
  const out: Record<string, string[]> = {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC62
  for (const [roomId, list] of Object.entries(rooms)) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC63
    if (Array.isArray(list)) out[roomId] = list.filter((id): id is string => typeof id === 'string');
7ebc2472a9e3atagen2026-10-25 09:53 UTC64
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC65
  return out;
7ebc2472a9e3atagen2026-10-25 09:53 UTC66
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC67
7ebc2472a9e3atagen2026-10-25 09:53 UTC68
export async function loadPinnedThreads(): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC69
  try {
7ebc2472a9e3atagen2026-10-25 09:53 UTC70
    pinnedThreads.set(parsePinned(await getAccountData(PINNED_TYPE)));
7ebc2472a9e3atagen2026-10-25 09:53 UTC71
  } catch {
7ebc2472a9e3atagen2026-10-25 09:53 UTC72
    pinnedThreads.set({});
7ebc2472a9e3atagen2026-10-25 09:53 UTC73
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC74
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC75
7ebc2472a9e3atagen2026-10-25 09:53 UTC76
export async function togglePinnedThread(roomId: string, rootEventId: string): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC77
  const before = get(pinnedThreads);
7ebc2472a9e3atagen2026-10-25 09:53 UTC78
  const list = before[roomId] ?? [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC79
  const nextList = list.includes(rootEventId) ? list.filter((id) => id !== rootEventId) : [...list, rootEventId];
7ebc2472a9e3atagen2026-10-25 09:53 UTC80
  const next = { ...before, [roomId]: nextList };
7ebc2472a9e3atagen2026-10-25 09:53 UTC81
  if (nextList.length === 0) delete next[roomId];
7ebc2472a9e3atagen2026-10-25 09:53 UTC82
  pinnedThreads.set(next);
7ebc2472a9e3atagen2026-10-25 09:53 UTC83
  try {
7ebc2472a9e3atagen2026-10-25 09:53 UTC84
    await setAccountData(PINNED_TYPE, { rooms: next });
7ebc2472a9e3atagen2026-10-25 09:53 UTC85
  } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC86
    pinnedThreads.set(before);
7ebc2472a9e3atagen2026-10-25 09:53 UTC87
    pushBanner(e instanceof Error ? e.message : 'Pinning the thread failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC88
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC89
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC90
7ebc2472a9e3atagen2026-10-25 09:53 UTC91
let opened: OpenThread | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC92
7ebc2472a9e3atagen2026-10-25 09:53 UTC93
/** The room must already be open, since macltok builds thread timelines inside it. */
7ebc2472a9e3atagen2026-10-25 09:53 UTC94
export async function showThread(target: OpenThread | null): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC95
  const previous = opened;
7ebc2472a9e3atagen2026-10-25 09:53 UTC96
  opened = target;
7ebc2472a9e3atagen2026-10-25 09:53 UTC97
  openThread.set(target);
4f91f89d99faatagen2026-10-25 10:47 UTC98
  if (previous && target && previous.room_id === target.room_id && previous.root_event_id === target.root_event_id) return;
4f91f89d99faatagen2026-10-25 10:47 UTC99
  if (previous) runCommand({ op: 'close_thread', room_id: previous.room_id, root_event_id: previous.root_event_id }).catch(() => undefined);
7ebc2472a9e3atagen2026-10-25 09:53 UTC100
  if (!target) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC101
  try {
4f91f89d99faatagen2026-10-25 10:47 UTC102
    await runCommand({ op: 'open_thread', room_id: target.room_id, root_event_id: target.root_event_id });
7ebc2472a9e3atagen2026-10-25 09:53 UTC103
  } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC104
    if (opened === target) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC105
      opened = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC106
      openThread.set(null);
7ebc2472a9e3atagen2026-10-25 09:53 UTC107
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC108
    pushBanner(e instanceof Error ? e.message : 'Opening the thread failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC109
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC110
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC111
7ebc2472a9e3atagen2026-10-25 09:53 UTC112
let account: string | null | undefined;
7ebc2472a9e3atagen2026-10-25 09:53 UTC113
sessionUser.subscribe((user) => {
7ebc2472a9e3atagen2026-10-25 09:53 UTC114
  if (user === account) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC115
  account = user;
249e439c27e5atagen2026-10-25 13:26 UTC116
  openLists.clear();
7ebc2472a9e3atagen2026-10-25 09:53 UTC117
  opened = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC118
  openThread.set(null);
7ebc2472a9e3atagen2026-10-25 09:53 UTC119
  pinnedThreads.set({});
7ebc2472a9e3atagen2026-10-25 09:53 UTC120
});