/**
* MessageScroller — the scroll behaviour a chat transcript needs.
*
* A transcript is the one list where the interesting end is the bottom, the
* content grows while you are reading it, and history is added to the top. A
* plain scroll view gets all three wrong: it opens at the top, it stays put
* while a reply streams in below the fold, and it jumps a screen when older
* messages load. This owns those three behaviours so a screen does not have to
* rebuild them.
*
* ```tsx
*
*
*
* {messages.map((m) => (
*
* …
*
* ))}
*
*
*
*
* ```
*
* The rule behind every part of it: **the reader's position is theirs**. New
* content follows the bottom only while they are already at the bottom, and
* scrolling away hands control back to them until they ask for it again.
* Content added above them never moves what they are looking at.
*
* Needs a bounded height — from `flex-1` in a column, or an explicit one.
* Given an unbounded parent it grows to fit its content and never scrolls.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import {
FlatList,
View,
type FlatListProps,
type LayoutChangeEvent,
type ListRenderItemInfo,
type ViewProps,
type ViewToken,
} from 'react-native';
import Animated, {
runOnJS,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
type AnimatedScrollViewProps,
} from 'react-native-reanimated';
import { useCSSVariable } from 'uniwind';
import { ChevronDownIcon } from '../../icons';
import { AnimatedPressable } from '../../primitives/animated-pressable';
import { cn } from '../../utils/cn';
import { textChildren } from '../../primitives/text';
import {
distanceFromMessageScrollerTarget,
initialMessageScrollerIndex,
isMessageScrollerTargetVisible,
messageScrollerAnchorAt,
messageScrollerIndex,
MESSAGE_SCROLLER_EDGE_THRESHOLD,
} from './message-scroller-math';
/**
* How much of the previous turn is left showing above an anchored one. Scrolling
* a turn flush to the top reads as content having been cut off; a sliver of the
* message before it says "this is where you are", not "this is the beginning".
*/
const ANCHOR_PEEK = 24;
export type MessageScrollerPosition = 'start' | 'end' | 'last-anchor';
interface MessageScrollerDriver {
scrollToEnd: (animated: boolean) => void;
scrollToStart: (animated: boolean) => void;
scrollToMessage: (id: string, animated: boolean) => boolean;
}
interface MessageScrollerContextValue {
scrollRef: ReturnType>;
registerDriver: (driver: MessageScrollerDriver | null) => void;
/** 1 while the reader is at the live edge, 0 otherwise. Drives follow state. */
atEnd: ReturnType>;
/** Live distance from each edge, used by target-specific jump controls. */
distanceFromStart: ReturnType>;
distanceFromEnd: ReturnType>;
atEndJS: boolean;
setAtEndJS: (atEnd: boolean) => void;
autoScroll: boolean;
preserveScrollOnPrepend: boolean;
defaultScrollPosition: MessageScrollerPosition;
/** id → y offset inside the content, written by each Item on layout. */
itemY: React.RefObject