import { ReactiveMap } from "@solid-primitives/map"; import { type mastodon } from "masto"; import { Accessor, batch, catchError, createEffect, createResource, untrack, type ResourceFetcherInfo, } from "solid-js"; import { createStore } from "solid-js/store"; type TimelineFetchTips = { direction?: "new" | "old"; }; type Timeline = { list(params: { readonly limit?: number; }): mastodon.Paginator; }; export function useTimeline( timeline: Accessor, cfg?: { /** * Use full refresh mode. This mode ignores paging, it will refetch the specified number * of toots at every refetch(). */ fullRefresh?: number; }, ) { let otl: Timeline | undefined; let npager: mastodon.Paginator | undefined; let opager: mastodon.Paginator | undefined; const [snapshot, { refetch }] = createResource< { records: mastodon.v1.Status[]; direction: "new" | "old" | "items"; tlChanged: boolean; }, [Timeline], TimelineFetchTips | undefined >( () => [timeline()] as const, async ([tl], info) => { let tlChanged = false; if (otl !== tl) { npager = opager = undefined; otl = tl; tlChanged = true; } const fullRefresh = cfg?.fullRefresh; if (typeof fullRefresh !== "undefined") { const records = await tl .list({ limit: fullRefresh, }) .next(); return { direction: "items", records: records.value ?? [], end: records.done, tlChanged, }; } const direction = typeof info.refetching !== "boolean" ? (info.refetching?.direction ?? "old") : "old"; if (direction === "old") { if (!opager) { opager = tl.list({}).setDirection("next"); } const next = await opager.next(); return { direction, records: next.value ?? [], end: next.done, tlChanged, }; } else { if (!npager) { npager = tl.list({}).setDirection("prev"); } const next = await npager.next(); const page = next.value ?? []; return { direction, records: page, end: next.done, tlChanged }; } }, ); const [store, setStore] = createStore([] as mastodon.v1.Status[]); createEffect(() => { const shot = snapshot(); if (!shot) return; const { direction, records, tlChanged } = shot; if (tlChanged) { setStore(() => []); } if (direction === "new") { setStore((x) => [...records, ...x]); } else if (direction === "old") { setStore((x) => [...x, ...records]); } else if (direction === "items") { setStore(() => records); } }); return [ store, snapshot, { refetch, mutate: setStore, }, ] as const; } export function createTimelineSnapshot( timeline: Accessor, limit: Accessor, ) { const [shot, { refetch }] = createResource( () => [timeline(), limit()] as const, async ([tl, limit]) => { const ls = await tl.list({ limit }).next(); return ls.value?.map((x) => [x]) ?? []; }, ); const [snapshot, setSnapshot] = createStore([] as mastodon.v1.Status[][]); createEffect(() => { const nls = catchError(shot, (e) => console.error(e)); if (!nls) return; const ols = Array.from(snapshot); // The algorithm below assumes the snapshot is not changing for (let i = 0; i < nls.length; i++) { if (i >= ols.length) { setSnapshot(i, nls[i]); } else { if (nls[i].length !== ols[i].length) { setSnapshot(i, nls[i]); } else { const oth = ols[i], nth = nls[i]; for (let j = 0; j < oth.length; j++) { const ost = oth[j], nst = nth[j]; for (const key of Object.keys( nst, ) as unknown as (keyof mastodon.v1.Status)[]) { if (ost[key] !== nst[key]) { setSnapshot(i, j, key, nst[key]); } } } } } } }); return [ snapshot, shot, { refetch, mutate: setSnapshot, }, ] as const; } export type TimelineFetchDirection = mastodon.Direction; export type TimelineChunk = { pager: mastodon.Paginator; chunk: readonly mastodon.v1.Status[]; done?: boolean; direction: TimelineFetchDirection; limit: number; }; function checkOrCreatePager( timeline: Timeline, limit: number, lastPager: TimelineChunk["pager"] | undefined, newDirection: TimelineFetchDirection, ) { if (!lastPager) { return timeline.list({}).setDirection(newDirection); } else { let pager = lastPager; if (pager.getDirection() !== newDirection) { pager = pager.setDirection(newDirection); } return pager; } } type TreeNode = { parent?: TreeNode; value: T; children?: TreeNode[]; }; /** Collect the path of a node for the root. * The first element is the node itself, the last element is the root. */ function collectPath(node: TreeNode) { const path = [node] as TreeNode[]; let current = node; while (current.parent) { path.push(current.parent); current = current.parent; } return path; } export function createTimeline( timeline: Accessor, limit: Accessor, ) { const lookup = new ReactiveMap>(); const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]); const [chunk, { refetch }] = createResource( () => [timeline(), limit()] as const, async ( [tl, limit], info: ResourceFetcherInfo< Readonly, TimelineFetchDirection >, ) => { const direction = typeof info.refetching === "boolean" ? "prev" : info.refetching; const rebuildTimeline = limit !== info.value?.limit; const pager = rebuildTimeline ? checkOrCreatePager(tl, limit, undefined, direction) : checkOrCreatePager(tl, limit, info.value?.pager, direction); if (rebuildTimeline) { lookup.clear(); setThreads([]); } const posts = await pager.next(); return { pager, chunk: posts.value ?? [], done: posts.done, direction, limit, }; }, ); createEffect(() => { const chk = catchError(chunk, (e) => console.error(e)); if (!chk) { return; } console.debug("fetched chunk", chk); batch(() => { for (const status of chk.chunk) { lookup.set(status.id, { value: status, }); } for (const status of chk.chunk) { const node = lookup.get(status.id)!; if (status.inReplyToId) { const parent = lookup.get(status.inReplyToId); if (parent) { const children = parent.children ?? []; if (!children.find((x) => x.value.id == status.id)) { children.push(node); } parent.children = children; node.parent = parent; } } } if (chk.direction === "next") { setThreads((threads) => [...threads, ...chk.chunk.map((x) => x.id)]); } else if (chk.direction === "prev") { setThreads((threads) => [...chk.chunk.map((x) => x.id), ...threads]); } setThreads((threads) => threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0), ); }); }); return [ { list: threads, get(id: string) { return lookup.get(id); }, getPath(id: string) { const node = lookup.get(id); if (!node) return; return collectPath(node); }, set(id: string, value: mastodon.v1.Status) { const node = untrack(() => lookup.get(id)); if (!node) return; node.value = value; lookup.set(id, node); }, }, chunk, { refetch }, ] as const; }