diff --git a/bun.lockb b/bun.lockb index 25e9097..3dc8a47 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 19f3a48..e5a7f53 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@solid-primitives/event-listener": "^2.3.3", "@solid-primitives/i18n": "^2.1.1", "@solid-primitives/intersection-observer": "^2.1.6", + "@solid-primitives/map": "^0.4.13", "@solid-primitives/resize-observer": "^2.0.26", "@solidjs/router": "^0.14.5", "@suid/icons-material": "^0.8.0", diff --git a/src/masto/timelines.ts b/src/masto/timelines.ts index e147cbc..06471df 100644 --- a/src/masto/timelines.ts +++ b/src/masto/timelines.ts @@ -1,5 +1,14 @@ +import { ReactiveMap } from "@solid-primitives/map"; import { type mastodon } from "masto"; -import { Accessor, catchError, createEffect, createResource } from "solid-js"; +import { + Accessor, + batch, + catchError, + createEffect, + createResource, + untrack, + type ResourceFetcherInfo, +} from "solid-js"; import { createStore } from "solid-js/store"; type TimelineFetchTips = { @@ -164,6 +173,148 @@ export function createTimelineSnapshot( ] as const; } -export function createTimeline(timeline: Accessor) { - // TODO +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; } diff --git a/src/timelines/TimelinePanel.tsx b/src/timelines/TimelinePanel.tsx index 04b8f46..ddafa87 100644 --- a/src/timelines/TimelinePanel.tsx +++ b/src/timelines/TimelinePanel.tsx @@ -10,19 +10,16 @@ import { ErrorBoundary, } from "solid-js"; import { type mastodon } from "masto"; -import { - Button, - LinearProgress, -} from "@suid/material"; -import TootThread from "./TootThread.js"; -import { useTimeline } from "../masto/timelines"; +import { Button, LinearProgress } from "@suid/material"; +import { createTimeline } from "../masto/timelines"; import { vibrate } from "../platform/hardware"; import PullDownToRefresh from "./PullDownToRefresh"; import TootComposer from "./TootComposer"; +import Thread from "./Thread.jsx"; const TimelinePanel: Component<{ client: mastodon.rest.Client; - name: "home" | "public" | "trends"; + name: "home" | "public"; prefetch?: boolean; fullRefetch?: number; @@ -33,70 +30,56 @@ const TimelinePanel: Component<{ ) => void; }> = (props) => { const [scrollLinked, setScrollLinked] = createSignal(); - const [ - timeline, - snapshot, - { refetch: refetchTimeline, mutate: mutateTimeline }, - ] = useTimeline( - () => - props.name !== "trends" - ? props.client.v1.timelines[props.name] - : props.client.v1.trends.statuses, - { fullRefresh: props.fullRefetch }, + + const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline( + () => props.client.v1.timelines[props.name], + () => 20, ); const [expandedThreadId, setExpandedThreadId] = createSignal(); const [typing, setTyping] = createSignal(false); const tlEndObserver = new IntersectionObserver(() => { if (untrack(() => props.prefetch) && !snapshot.loading) - refetchTimeline({ direction: "old" }); + refetchTimeline("next"); }); onCleanup(() => tlEndObserver.disconnect()); const onBookmark = async ( - index: number, client: mastodon.rest.Client, status: mastodon.v1.Status, ) => { const result = await (status.bookmarked ? client.v1.statuses.$select(status.id).unbookmark() : client.v1.statuses.$select(status.id).bookmark()); - mutateTimeline((o) => { - o[index] = result; - return o; - }); + timeline.set(result.id, result); }; const onBoost = async ( - index: number, client: mastodon.rest.Client, status: mastodon.v1.Status, ) => { - const reblogged = status.reblog - ? status.reblog.reblogged - : status.reblogged; vibrate(50); - mutateTimeline(index, (x) => { - if (x.reblog) { - x.reblog = { ...x.reblog, reblogged: !reblogged }; - return Object.assign({}, x); - } else { - return Object.assign({}, x, { + const rootStatus = status.reblog ? status.reblog : status; + const reblogged = rootStatus.reblogged; + if (status.reblog) { + status.reblog = { ...status.reblog, reblogged: !reblogged }; + timeline.set(status.id, status); + } else { + timeline.set( + status.id, + Object.assign(status, { reblogged: !reblogged, - }); - } - }); + }), + ); + } const result = reblogged ? await client.v1.statuses.$select(status.id).unreblog() : (await client.v1.statuses.$select(status.id).reblog()).reblog!; - mutateTimeline((o) => { - Object.assign(o[index].reblog ?? o[index], { - reblogged: result.reblogged, - reblogsCount: result.reblogsCount, - }); - return o; - }); + timeline.set( + status.id, + Object.assign(status.reblog ?? status, result.reblog), + ); }; return ( @@ -108,7 +91,7 @@ const TimelinePanel: Component<{ refetchTimeline({ direction: "new" })} + onRefresh={() => refetchTimeline("prev")} />
@@ -125,29 +108,32 @@ const TimelinePanel: Component<{ isTyping={typing()} onTypingChange={setTyping} client={props.client} - onSent={() => refetchTimeline({ direction: "new" })} + onSent={() => refetchTimeline("prev")} /> - - {(item, index) => { + + {(itemId, index) => { let element: HTMLElement | undefined; + const path = timeline.getPath(itemId)!; + const toots = path.reverse().map((x) => x.value); + return ( - onBoost(index(), ...args)} - onBookmark={(...args) => onBookmark(index(), ...args)} + toots={toots} + onBoost={onBoost} + onBookmark={onBookmark} onReply={(client, status) => props.openFullScreenToot(status, element, true) } client={props.client} - expanded={item.id === expandedThreadId() ? 1 : 0} - onExpandChange={(x) => { - setTyping(false) - if (item.id !== expandedThreadId()) { - setExpandedThreadId((x) => (x ? undefined : item.id)); - } else if (x === 2) { - props.openFullScreenToot(item, element); + isExpended={(status) => status.id === expandedThreadId()} + onItemClick={(status) => { + setTyping(false); + if (status.id !== expandedThreadId()) { + setExpandedThreadId((x) => (x ? undefined : status.id)); + } else { + props.openFullScreenToot(status, element); } }} /> @@ -179,7 +165,7 @@ const TimelinePanel: Component<{