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 Timeline = { list(params: { /** Return results older than this ID. */ readonly maxId?: string; /** Return results newer than this ID. */ readonly sinceId?: string; /** Get a list of items with ID greater than this value excluding this ID */ readonly minId?: string; /** Maximum number of results to return per page. Defaults to 40. NOTE: Pagination is done with the Link header from the response. */ readonly limit?: number; }): mastodon.Paginator; }; 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 = { tl: Timeline; chunk: readonly mastodon.v1.Status[]; done?: boolean; direction: TimelineFetchDirection; limit: number; }; 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"][]); let vpMaxId: string | undefined, vpMinId: string | undefined; const fetchExtendingPage = async ( tl: Timeline, direction: TimelineFetchDirection, limit: number, ) => { switch (direction) { case "next": { const page = await tl .list({ limit, sinceId: vpMaxId }) .setDirection(direction) .next(); if ((page.value?.length ?? 0) > 0) { vpMaxId = page.value![0].id; } return page; } case "prev": { const page = await tl .list({ limit, maxId: vpMinId }) .setDirection(direction) .next(); if ((page.value?.length ?? 0) > 0) { vpMinId = page.value![page.value!.length - 1].id; } return page; } } }; 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 = tl !== info.value?.tl; if (rebuildTimeline) { vpMaxId = undefined; vpMinId = undefined; lookup.clear(); setThreads([]); } const posts = await fetchExtendingPage(tl, direction, limit); return { tl, chunk: posts.value ?? [], done: posts.done, direction, limit, }; }, ); createEffect(() => { const chk = catchError(chunk, (e) => console.error(e)); if (!chk) { return; } const existence = [] as boolean[]; for (const [idx, status] of chk.chunk.entries()) { existence[idx] = !!untrack(() => lookup.get(status.id)); lookup.set(status.id, { value: status, }); } for (const status of chk.chunk) { const node = untrack(() => 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; } } } const nThreadIds = chk.chunk .filter((x, i) => !existence[i]) .map((x) => x.id); batch(() => { if (chk.direction === "prev") { setThreads((threads) => [...threads, ...nThreadIds]); } else if (chk.direction === "next") { setThreads((threads) => [...nThreadIds, ...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; }