1import type { LegendListRef } from "@legendapp/list/react";
2import type { MessagesTimelineRow } from "./MessagesTimeline.logic";
3import type { RememberedTimelinePosition } from "./timelineScrollAnchoring";
4import type { TimelineViewport } from "./timelineViewport";
5
6// A streaming reply moves the end every frame, so the target may never hold
7// still. Past this many frames the list's own end maintenance takes over.
8const SETTLE_MAX_FRAMES = 60;
9
10/**
11 * Index and end scrolling start from estimated row sizes, so they can stop
12 * short of a tall row. Corrects against the DOM until the viewport holds the
13 * target for two layout frames, the frame budget runs out, the caller cancels,
14 * or the target disappears.
15 */
16export function settleListScroll(
17 list: Pick<LegendListRef, "getScrollableNode" | "scrollToOffset">,
18 target: (element: HTMLElement) => number | undefined,
19 isCancelled: () => boolean,
20): Promise<void> {
21 return new Promise((resolve) => {
22 let stableFrames = 0;
23 let frames = 0;
24 const reconcile = () => {
25 if (isCancelled()) return resolve();
26 const element = list.getScrollableNode();
27 const offset = element ? target(element) : undefined;
28 frames += 1;
29 if (!element || offset === undefined || frames > SETTLE_MAX_FRAMES) return resolve();
30 if (Math.abs(element.scrollTop - offset) > 1) {
31 stableFrames = 0;
32 void list.scrollToOffset({ offset, animated: false }).then(() => {
33 requestAnimationFrame(reconcile);
34 });
35 return;
36 }
37 stableFrames += 1;
38 if (stableFrames >= 2) resolve();
39 else requestAnimationFrame(reconcile);
40 };
41 requestAnimationFrame(reconcile);
42 });
43}
44
45export const measuredEndOffset = (element: HTMLElement) =>
46 element.scrollHeight - element.clientHeight;
47
48/**
49 * Scrolls a newly shown thread back to where the user left it, or to its end,
50 * and reports the thread positioned once the saved row settles. Any navigation
51 * on the viewport cancels it where it is. Returns the cancel for the
52 * operation's owner.
53 */
54export function startTimelinePositionRestore({
55 list,
56 viewport,
57 listIdentityKey,
58 listOpenedOn,
59 rememberedPosition: position,
60 citationPending,
61 releaseForFind,
62 onPositioned,
63}: {
64 readonly list: LegendListRef;
65 readonly viewport: TimelineViewport;
66 readonly listIdentityKey: string;
67 /** The identity the mounted list opened on, until it shows another; see nextListOpening. */
68 readonly listOpenedOn: string | null;
69 readonly rememberedPosition: RememberedTimelinePosition | undefined;
70 readonly citationPending: boolean;
71 /**
72 * Find was open when the list started showing the thread. Opening find is
73 * navigation, so follow lets go; the thread still opens where it was left,
74 * and MessagesTimeline holds find's reveal until this reports it positioned.
75 */
76 readonly releaseForFind: boolean;
77 readonly onPositioned: (identity: string) => void;
78}): () => void {
79 if (citationPending) {
80 // The citation scroll owns positioning once the list has mounted.
81 onPositioned(listIdentityKey);
82 return () => {};
83 }
84 let cancelled = false;
85 const rows = list.getState().data as ReadonlyArray<MessagesTimelineRow>;
86 const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1;
87 // Reading history or finding: follow lets go before the restore claims the viewport.
88 if (position?.atEnd === false || releaseForFind) viewport.navigate({ releasesFollow: true });
89 const releaseScrollHold = viewport.holdProgrammaticScroll();
90 const stop = () => {
91 cancelled = true;
92 releaseScrollHold();
93 unsubscribe();
94 };
95 const unsubscribe = viewport.subscribe(() => {
96 if (cancelled) return;
97 stop();
98 void list.scrollToOffset({ offset: viewport.scrollNode.scrollTop, animated: false });
99 onPositioned(listIdentityKey);
100 });
101 const finish = () => {
102 stop();
103 onPositioned(listIdentityKey);
104 };
105 const scrolling =
106 position?.atEnd === false
107 ? index >= 0
108 ? list.scrollToIndex({
109 index,
110 animated: false,
111 viewPosition: 0,
112 viewOffset: -position.offsetWithinRow,
113 })
114 : list.scrollToOffset({ offset: position.scrollOffset, animated: false })
115 : // initialScrollAtEnd measured this target; scrollToEnd would replace it with an estimate.
116 listOpenedOn === listIdentityKey
117 ? undefined
118 : list.scrollToEnd({ animated: false });
119 const settlesAtEnd = position?.atEnd !== false && scrolling !== undefined;
120 void Promise.resolve(scrolling).then(async () => {
121 if (cancelled) return;
122 if (settlesAtEnd) {
123 await settleListScroll(list, measuredEndOffset, () => cancelled);
124 } else if (position?.atEnd === false && index >= 0) {
125 // Keep the saved row mounted until its measured position and the DOM agree.
126 await settleListScroll(
127 list,
128 (element) => {
129 const state = list.getState();
130 const rowIndex = state.indexByKey(position.rowId);
131 const row = rowIndex === undefined ? undefined : state.elementAtIndex(rowIndex);
132 if (!row) return undefined;
133 return Math.max(
134 0,
135 Math.min(
136 element.scrollTop +
137 row.getBoundingClientRect().top -
138 element.getBoundingClientRect().top +
139 position.offsetWithinRow,
140 measuredEndOffset(element),
141 ),
142 );
143 },
144 () => cancelled,
145 );
146 }
147 if (!cancelled) finish();
148 });
149 return stop;
150}