atagen

malice

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

Blame: web/src/lib/threads.ts

a802a95814e6atagen2026-10-26 03:48 UTC1
import { derived, get, readable, 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';
a802a95814e6atagen2026-10-26 03:48 UTC6
import type { ThreadListEntry, ThreadUnread, TimelineItem } from './types';
7ebc2472a9e3atagen2026-10-25 09:53 UTC7
7ebc2472a9e3atagen2026-10-25 09:53 UTC8
const PINNED_TYPE = 'dev.malice.pinned_threads';
7ebc2472a9e3atagen2026-10-25 09:53 UTC9
7ebc2472a9e3atagen2026-10-25 09:53 UTC10
export interface OpenThread {
7ebc2472a9e3atagen2026-10-25 09:53 UTC11
  room_id: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC12
  root_event_id: string;
4f91f89d99faatagen2026-10-25 10:47 UTC13
  focus_event_id?: string;
7ebc2472a9e3atagen2026-10-25 09:53 UTC14
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC15
7ebc2472a9e3atagen2026-10-25 09:53 UTC16
export const openThread = writable<OpenThread | null>(null);
7ebc2472a9e3atagen2026-10-25 09:53 UTC17
export const pinnedThreads = writable<Record<string, string[]>>({});
7ebc2472a9e3atagen2026-10-25 09:53 UTC18
a802a95814e6atagen2026-10-26 03:48 UTC19
const NO_TIMELINE: TimelineItem[] = [];
a802a95814e6atagen2026-10-26 03:48 UTC20
const NO_PINS: string[] = [];
a802a95814e6atagen2026-10-26 03:48 UTC21
const NO_ENTRIES: ThreadListEntry[] = [];
a802a95814e6atagen2026-10-26 03:48 UTC22
const NO_UNREADS: Record<string, ThreadUnread> = {};
a802a95814e6atagen2026-10-26 03:48 UTC23
a802a95814e6atagen2026-10-26 03:48 UTC24
interface RoomThreadsCache {
a802a95814e6atagen2026-10-26 03:48 UTC25
  timeline: TimelineItem[];
a802a95814e6atagen2026-10-26 03:48 UTC26
  pins: string[];
a802a95814e6atagen2026-10-26 03:48 UTC27
  entries: ThreadListEntry[];
a802a95814e6atagen2026-10-26 03:48 UTC28
  unreads: Record<string, ThreadUnread>;
a802a95814e6atagen2026-10-26 03:48 UTC29
  list: ThreadEntry[];
a802a95814e6atagen2026-10-26 03:48 UTC30
}
a802a95814e6atagen2026-10-26 03:48 UTC31
a802a95814e6atagen2026-10-26 03:48 UTC32
function createThreadsByRoom() {
a802a95814e6atagen2026-10-26 03:48 UTC33
  let cache = new Map<string, RoomThreadsCache>();
a802a95814e6atagen2026-10-26 03:48 UTC34
  let last: Record<string, ThreadEntry[]> = {};
a802a95814e6atagen2026-10-26 03:48 UTC35
  const computed = derived([timelines, pinnedThreads, threadLists, rooms], ([$timelines, $pinned, $lists, $rooms]) => {
a802a95814e6atagen2026-10-26 03:48 UTC36
    const unreads = new Map($rooms.map((room) => [room.room_id, room.thread_unreads]));
a802a95814e6atagen2026-10-26 03:48 UTC37
    const next = new Map<string, RoomThreadsCache>();
a802a95814e6atagen2026-10-26 03:48 UTC38
    const out: Record<string, ThreadEntry[]> = {};
a802a95814e6atagen2026-10-26 03:48 UTC39
    const ids = [...Object.keys($timelines), ...Object.keys($pinned), ...Object.keys($lists), ...unreads.keys()];
a802a95814e6atagen2026-10-26 03:48 UTC40
    for (const roomId of new Set(ids)) {
a802a95814e6atagen2026-10-26 03:48 UTC41
      const timeline = $timelines[roomId] ?? NO_TIMELINE;
a802a95814e6atagen2026-10-26 03:48 UTC42
      const pins = $pinned[roomId] ?? NO_PINS;
a802a95814e6atagen2026-10-26 03:48 UTC43
      const entries = $lists[roomId]?.entries ?? NO_ENTRIES;
a802a95814e6atagen2026-10-26 03:48 UTC44
      const roomUnreads = unreads.get(roomId) ?? NO_UNREADS;
a802a95814e6atagen2026-10-26 03:48 UTC45
      const hit = cache.get(roomId);
a802a95814e6atagen2026-10-26 03:48 UTC46
      const reuse = hit && hit.timeline === timeline && hit.pins === pins && hit.entries === entries && hit.unreads === roomUnreads;
a802a95814e6atagen2026-10-26 03:48 UTC47
      const entry = reuse ? hit : { timeline, pins, entries, unreads: roomUnreads, list: roomThreads(roomId, timeline, pins, entries, roomUnreads) };
a802a95814e6atagen2026-10-26 03:48 UTC48
      next.set(roomId, entry);
a802a95814e6atagen2026-10-26 03:48 UTC49
      if (entry.list.length) out[roomId] = entry.list;
a802a95814e6atagen2026-10-26 03:48 UTC50
    }
a802a95814e6atagen2026-10-26 03:48 UTC51
    const keys = Object.keys(out);
a802a95814e6atagen2026-10-26 03:48 UTC52
    const unchanged = keys.length === Object.keys(last).length && keys.every((roomId) => out[roomId] === last[roomId]);
a802a95814e6atagen2026-10-26 03:48 UTC53
    cache = next;
a802a95814e6atagen2026-10-26 03:48 UTC54
    if (unchanged) return last;
a802a95814e6atagen2026-10-26 03:48 UTC55
    last = out;
a802a95814e6atagen2026-10-26 03:48 UTC56
    return out;
a802a95814e6atagen2026-10-26 03:48 UTC57
  });
a802a95814e6atagen2026-10-26 03:48 UTC58
  return readable(last, (set) => {
a802a95814e6atagen2026-10-26 03:48 UTC59
    let sent: Record<string, ThreadEntry[]> | null = null;
a802a95814e6atagen2026-10-26 03:48 UTC60
    return computed.subscribe((value) => {
a802a95814e6atagen2026-10-26 03:48 UTC61
      if (value === sent) return;
a802a95814e6atagen2026-10-26 03:48 UTC62
      sent = value;
a802a95814e6atagen2026-10-26 03:48 UTC63
      set(value);
a802a95814e6atagen2026-10-26 03:48 UTC64
    });
a802a95814e6atagen2026-10-26 03:48 UTC65
  });
a802a95814e6atagen2026-10-26 03:48 UTC66
}
a802a95814e6atagen2026-10-26 03:48 UTC67
a802a95814e6atagen2026-10-26 03:48 UTC68
export const threadsByRoom = createThreadsByRoom();
7ebc2472a9e3atagen2026-10-25 09:53 UTC69
249e439c27e5atagen2026-10-25 13:26 UTC70
const THREAD_PAGE = 20;
249e439c27e5atagen2026-10-25 13:26 UTC71
const openLists = new Set<string>();
249e439c27e5atagen2026-10-25 13:26 UTC72
249e439c27e5atagen2026-10-25 13:26 UTC73
/** 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 UTC74
export function syncThreadLists(wanted: string[]): void {
249e439c27e5atagen2026-10-25 13:26 UTC75
  const want = new Set(wanted);
249e439c27e5atagen2026-10-25 13:26 UTC76
  for (const roomId of [...openLists]) {
249e439c27e5atagen2026-10-25 13:26 UTC77
    if (want.has(roomId)) continue;
249e439c27e5atagen2026-10-25 13:26 UTC78
    openLists.delete(roomId);
249e439c27e5atagen2026-10-25 13:26 UTC79
    void closeThreadList(roomId).catch(() => undefined);
249e439c27e5atagen2026-10-25 13:26 UTC80
  }
249e439c27e5atagen2026-10-25 13:26 UTC81
  for (const roomId of want) {
249e439c27e5atagen2026-10-25 13:26 UTC82
    if (openLists.has(roomId)) continue;
249e439c27e5atagen2026-10-25 13:26 UTC83
    openLists.add(roomId);
249e439c27e5atagen2026-10-25 13:26 UTC84
    void openThreadList(roomId)
249e439c27e5atagen2026-10-25 13:26 UTC85
      .then(() => paginateThreadList(roomId, THREAD_PAGE))
249e439c27e5atagen2026-10-25 13:26 UTC86
      .catch(() => openLists.delete(roomId));
249e439c27e5atagen2026-10-25 13:26 UTC87
  }
249e439c27e5atagen2026-10-25 13:26 UTC88
}
249e439c27e5atagen2026-10-25 13:26 UTC89
249e439c27e5atagen2026-10-25 13:26 UTC90
export async function loadOlderThreads(roomId: string): Promise<void> {
249e439c27e5atagen2026-10-25 13:26 UTC91
  try {
249e439c27e5atagen2026-10-25 13:26 UTC92
    await paginateThreadList(roomId, THREAD_PAGE);
249e439c27e5atagen2026-10-25 13:26 UTC93
  } catch (e) {
249e439c27e5atagen2026-10-25 13:26 UTC94
    pushBanner(e instanceof Error ? e.message : 'Could not load older threads');
249e439c27e5atagen2026-10-25 13:26 UTC95
  }
249e439c27e5atagen2026-10-25 13:26 UTC96
}
249e439c27e5atagen2026-10-25 13:26 UTC97
7ebc2472a9e3atagen2026-10-25 09:53 UTC98
function parsePinned(raw: unknown): Record<string, string[]> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC99
  if (typeof raw !== 'object' || raw === null) return {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC100
  const rooms = (raw as Record<string, unknown>)['rooms'];
7ebc2472a9e3atagen2026-10-25 09:53 UTC101
  if (typeof rooms !== 'object' || rooms === null) return {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC102
  const out: Record<string, string[]> = {};
7ebc2472a9e3atagen2026-10-25 09:53 UTC103
  for (const [roomId, list] of Object.entries(rooms)) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC104
    if (Array.isArray(list)) out[roomId] = list.filter((id): id is string => typeof id === 'string');
7ebc2472a9e3atagen2026-10-25 09:53 UTC105
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC106
  return out;
7ebc2472a9e3atagen2026-10-25 09:53 UTC107
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC108
7ebc2472a9e3atagen2026-10-25 09:53 UTC109
export async function loadPinnedThreads(): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC110
  try {
7ebc2472a9e3atagen2026-10-25 09:53 UTC111
    pinnedThreads.set(parsePinned(await getAccountData(PINNED_TYPE)));
7ebc2472a9e3atagen2026-10-25 09:53 UTC112
  } catch {
7ebc2472a9e3atagen2026-10-25 09:53 UTC113
    pinnedThreads.set({});
7ebc2472a9e3atagen2026-10-25 09:53 UTC114
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC115
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC116
7ebc2472a9e3atagen2026-10-25 09:53 UTC117
export async function togglePinnedThread(roomId: string, rootEventId: string): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC118
  const before = get(pinnedThreads);
7ebc2472a9e3atagen2026-10-25 09:53 UTC119
  const list = before[roomId] ?? [];
7ebc2472a9e3atagen2026-10-25 09:53 UTC120
  const nextList = list.includes(rootEventId) ? list.filter((id) => id !== rootEventId) : [...list, rootEventId];
7ebc2472a9e3atagen2026-10-25 09:53 UTC121
  const next = { ...before, [roomId]: nextList };
7ebc2472a9e3atagen2026-10-25 09:53 UTC122
  if (nextList.length === 0) delete next[roomId];
7ebc2472a9e3atagen2026-10-25 09:53 UTC123
  pinnedThreads.set(next);
7ebc2472a9e3atagen2026-10-25 09:53 UTC124
  try {
7ebc2472a9e3atagen2026-10-25 09:53 UTC125
    await setAccountData(PINNED_TYPE, { rooms: next });
7ebc2472a9e3atagen2026-10-25 09:53 UTC126
  } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC127
    pinnedThreads.set(before);
7ebc2472a9e3atagen2026-10-25 09:53 UTC128
    pushBanner(e instanceof Error ? e.message : 'Pinning the thread failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC129
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC130
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC131
7ebc2472a9e3atagen2026-10-25 09:53 UTC132
let opened: OpenThread | null = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC133
7ebc2472a9e3atagen2026-10-25 09:53 UTC134
/** The room must already be open, since macltok builds thread timelines inside it. */
7ebc2472a9e3atagen2026-10-25 09:53 UTC135
export async function showThread(target: OpenThread | null): Promise<void> {
7ebc2472a9e3atagen2026-10-25 09:53 UTC136
  const previous = opened;
7ebc2472a9e3atagen2026-10-25 09:53 UTC137
  opened = target;
7ebc2472a9e3atagen2026-10-25 09:53 UTC138
  openThread.set(target);
4f91f89d99faatagen2026-10-25 10:47 UTC139
  if (previous && target && previous.room_id === target.room_id && previous.root_event_id === target.root_event_id) return;
4f91f89d99faatagen2026-10-25 10:47 UTC140
  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 UTC141
  if (!target) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC142
  try {
4f91f89d99faatagen2026-10-25 10:47 UTC143
    await runCommand({ op: 'open_thread', room_id: target.room_id, root_event_id: target.root_event_id });
7ebc2472a9e3atagen2026-10-25 09:53 UTC144
  } catch (e) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC145
    if (opened === target) {
7ebc2472a9e3atagen2026-10-25 09:53 UTC146
      opened = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC147
      openThread.set(null);
7ebc2472a9e3atagen2026-10-25 09:53 UTC148
    }
7ebc2472a9e3atagen2026-10-25 09:53 UTC149
    pushBanner(e instanceof Error ? e.message : 'Opening the thread failed');
7ebc2472a9e3atagen2026-10-25 09:53 UTC150
  }
7ebc2472a9e3atagen2026-10-25 09:53 UTC151
}
7ebc2472a9e3atagen2026-10-25 09:53 UTC152
7ebc2472a9e3atagen2026-10-25 09:53 UTC153
let account: string | null | undefined;
7ebc2472a9e3atagen2026-10-25 09:53 UTC154
sessionUser.subscribe((user) => {
7ebc2472a9e3atagen2026-10-25 09:53 UTC155
  if (user === account) return;
7ebc2472a9e3atagen2026-10-25 09:53 UTC156
  account = user;
249e439c27e5atagen2026-10-25 13:26 UTC157
  openLists.clear();
7ebc2472a9e3atagen2026-10-25 09:53 UTC158
  opened = null;
7ebc2472a9e3atagen2026-10-25 09:53 UTC159
  openThread.set(null);
7ebc2472a9e3atagen2026-10-25 09:53 UTC160
  pinnedThreads.set({});
7ebc2472a9e3atagen2026-10-25 09:53 UTC161
});