1export type ThreadFeedLiveFollowEvent =
2 | { readonly type: "reset" }
3 | { readonly type: "user-scroll-begin" }
4 | {
5 readonly type: "user-scroll-end";
6 readonly isAtEnd: boolean;
7 readonly userScrollSessionActive: boolean;
8 }
9 | {
10 readonly type: "scroll" | "disclosure-settled";
11 readonly isAtEnd: boolean;
12 readonly userScrollSessionActive: boolean;
13 };
14
15export interface ThreadWorkGroupScrollPosition {
16 readonly rowId: string;
17 readonly offsetWithinRow: number;
18 readonly scrollOffset: number;
19 readonly contentHeight: number;
20}
21
22export function resolveThreadWorkGroupInitialScroll(
23 rows: ReadonlyArray<{ readonly id: string }>,
24 position: ThreadWorkGroupScrollPosition | undefined,
25) {
26 const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1;
27 return index >= 0 && position ? { index, viewOffset: -position.offsetWithinRow } : undefined;
28}
29
30export function shouldFollowThreadWorkGroupAppend(input: {
31 readonly previousRows: ReadonlyArray<{ readonly id: string }>;
32 readonly rows: ReadonlyArray<{ readonly id: string }>;
33 readonly previousContentHeight: number;
34 readonly contentHeight: number;
35 readonly viewportHeight: number;
36 readonly scrollOffset: number;
37 readonly detailsChanged: boolean;
38 readonly userScrolling: boolean;
39}) {
40 return (
41 !input.detailsChanged &&
42 !input.userScrolling &&
43 input.contentHeight > input.previousContentHeight &&
44 input.rows.length > input.previousRows.length &&
45 input.previousRows.every((row, index) => row.id === input.rows[index]?.id) &&
46 input.previousContentHeight - input.viewportHeight - input.scrollOffset <= 1
47 );
48}
49
50export function resolveThreadFeedSubmissionAnchor<AnchorId>(input: {
51 readonly currentAnchorMessageId: AnchorId | null;
52 readonly submittedMessageId: AnchorId;
53 readonly hasStartedTurn: boolean;
54 readonly hasUserMessage: boolean;
55 readonly queuedMessageCount: number;
56}): AnchorId | null {
57 if (input.hasStartedTurn || input.hasUserMessage) {
58 return null;
59 }
60
61 if (input.currentAnchorMessageId !== null) {
62 return input.currentAnchorMessageId;
63 }
64
65 return input.queuedMessageCount > 0 ? null : input.submittedMessageId;
66}
67
68export function resolveThreadFeedLiveFollow(
69 current: boolean,
70 event: ThreadFeedLiveFollowEvent,
71): boolean {
72 switch (event.type) {
73 case "reset":
74 return true;
75 case "user-scroll-begin":
76 return false;
77 case "user-scroll-end":
78 return event.userScrollSessionActive ? event.isAtEnd : current;
79 case "disclosure-settled":
80 return !event.userScrollSessionActive && event.isAtEnd;
81 case "scroll":
82 if (event.userScrollSessionActive) {
83 return false;
84 }
85 if (event.isAtEnd) {
86 return true;
87 }
88 return current;
89 }
90}