| 1 | import { derived, get, writable } from 'svelte/store'; |
| 2 | import { getAccountData, runCommand, setAccountData } from './api'; |
| 3 | import { pushBanner, sessionUser, 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], ([$timelines, $pinned]) => { |
| 19 | const out: Record<string, ThreadEntry[]> = {}; |
| 20 | for (const roomId of new Set([...Object.keys($timelines), ...Object.keys($pinned)])) { |
| 21 | const list = roomThreads(roomId, $timelines[roomId] ?? [], $pinned[roomId] ?? []); |
| 22 | if (list.length) out[roomId] = list; |
| 23 | } |
| 24 | return out; |
| 25 | }); |
| 26 | |
| 27 | function parsePinned(raw: unknown): Record<string, string[]> { |
| 28 | if (typeof raw !== 'object' || raw === null) return {}; |
| 29 | const rooms = (raw as Record<string, unknown>)['rooms']; |
| 30 | if (typeof rooms !== 'object' || rooms === null) return {}; |
| 31 | const out: Record<string, string[]> = {}; |
| 32 | for (const [roomId, list] of Object.entries(rooms)) { |
| 33 | if (Array.isArray(list)) out[roomId] = list.filter((id): id is string => typeof id === 'string'); |
| 34 | } |
| 35 | return out; |
| 36 | } |
| 37 | |
| 38 | export async function loadPinnedThreads(): Promise<void> { |
| 39 | try { |
| 40 | pinnedThreads.set(parsePinned(await getAccountData(PINNED_TYPE))); |
| 41 | } catch { |
| 42 | pinnedThreads.set({}); |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | export async function togglePinnedThread(roomId: string, rootEventId: string): Promise<void> { |
| 47 | const before = get(pinnedThreads); |
| 48 | const list = before[roomId] ?? []; |
| 49 | const nextList = list.includes(rootEventId) ? list.filter((id) => id !== rootEventId) : [...list, rootEventId]; |
| 50 | const next = { ...before, [roomId]: nextList }; |
| 51 | if (nextList.length === 0) delete next[roomId]; |
| 52 | pinnedThreads.set(next); |
| 53 | try { |
| 54 | await setAccountData(PINNED_TYPE, { rooms: next }); |
| 55 | } catch (e) { |
| 56 | pinnedThreads.set(before); |
| 57 | pushBanner(e instanceof Error ? e.message : 'Pinning the thread failed'); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | let opened: OpenThread | null = null; |
| 62 | |
| 63 | /** The room must already be open, since macltok builds thread timelines inside it. */ |
| 64 | export async function showThread(target: OpenThread | null): Promise<void> { |
| 65 | const previous = opened; |
| 66 | opened = target; |
| 67 | openThread.set(target); |
| 68 | if (previous && target && previous.room_id === target.room_id && previous.root_event_id === target.root_event_id) return; |
| 69 | if (previous) runCommand({ op: 'close_thread', room_id: previous.room_id, root_event_id: previous.root_event_id }).catch(() => undefined); |
| 70 | if (!target) return; |
| 71 | try { |
| 72 | await runCommand({ op: 'open_thread', room_id: target.room_id, root_event_id: target.root_event_id }); |
| 73 | } catch (e) { |
| 74 | if (opened === target) { |
| 75 | opened = null; |
| 76 | openThread.set(null); |
| 77 | } |
| 78 | pushBanner(e instanceof Error ? e.message : 'Opening the thread failed'); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | let account: string | null | undefined; |
| 83 | sessionUser.subscribe((user) => { |
| 84 | if (user === account) return; |
| 85 | account = user; |
| 86 | opened = null; |
| 87 | openThread.set(null); |
| 88 | pinnedThreads.set({}); |
| 89 | }); |