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