| 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 | }); |