import { Fragment, type ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { FlatList, ActivityIndicator, Keyboard, View, type LayoutChangeEvent, type ListRenderItemInfo, type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; import type { StreamItem } from "@/types/stream"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy, isNearBottomForStreamRenderStrategy, resolveBottomAnchorTransportBehavior, } from "./strategy"; const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ minIndexForVisible: 0, autoscrollToTopThreshold: 0, }); const HISTORY_START_THRESHOLD_PX = 96; function keyExtractor(item: { id: string }): string { return item.id; } function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) { const { agentId, segments, boundary, renderers, listEmptyComponent, viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, onNearBottomChange, onNearHistoryStart, isLoadingOlderHistory, hasOlderHistory, scrollEnabled, listStyle, baseListContentContainerStyle, strategy, } = props; const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers; const flatListRef = useRef>(null); const streamViewportMetricsRef = useRef({ containerKey: "native-virtualized", contentHeight: 0, viewportWidth: 0, viewportHeight: 0, offsetY: 0, viewportMeasuredForKey: null as string | null, contentMeasuredForKey: null as string | null, }); const scrollOffsetYRef = useRef(0); const programmaticScrollEventBudgetRef = useRef(0); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); const historyRows = useMemo(() => { if (segments.historyVirtualized.length === 0) { return segments.historyMounted; } return [...segments.historyVirtualized, ...segments.historyMounted]; }, [segments.historyMounted, segments.historyVirtualized]); const clearNativeViewportSettling = useCallback(() => { if (nativeViewportSettlingFrameIdRef.current !== null) { cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current); nativeViewportSettlingFrameIdRef.current = null; } }, []); const markNativeViewportSettling = useCallback(() => { clearNativeViewportSettling(); setIsNativeViewportSettling(true); let remainingFrames = 4; const tick = () => { if (remainingFrames <= 0) { nativeViewportSettlingFrameIdRef.current = null; setIsNativeViewportSettling(false); return; } remainingFrames -= 1; nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick); }; nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick); }, [clearNativeViewportSettling]); const bottomAnchorTransportBehavior = useMemo( () => resolveBottomAnchorTransportBehavior({ strategy, isViewportSettling: isNativeViewportSettling, }), [isNativeViewportSettling, strategy], ); const scrollToBottom = useCallback( (animated: boolean) => { programmaticScrollEventBudgetRef.current = 3; flatListRef.current?.scrollToOffset({ offset: 0, animated, }); scrollOffsetYRef.current = 0; streamViewportMetricsRef.current = { ...streamViewportMetricsRef.current, offsetY: 0, }; onNearBottomChange(true); }, [onNearBottomChange], ); const bottomAnchorController = useBottomAnchorController({ agentId, routeRequest: routeBottomAnchorRequest, isAuthoritativeHistoryReady, renderStrategy: "inverted-stream", transportBehavior: bottomAnchorTransportBehavior, getMeasurementState: () => streamViewportMetricsRef.current, isNearBottom: () => { const metrics = streamViewportMetricsRef.current; return isNearBottomForStreamRenderStrategy({ strategy, offsetY: metrics.offsetY, threshold: 32, contentHeight: metrics.contentHeight, viewportHeight: metrics.viewportHeight, }); }, scrollToBottom, }); useEffect(() => { streamViewportMetricsRef.current = { containerKey: "native-virtualized", contentHeight: 0, viewportWidth: 0, viewportHeight: 0, offsetY: 0, viewportMeasuredForKey: null, contentMeasuredForKey: null, }; scrollOffsetYRef.current = 0; clearNativeViewportSettling(); setIsNativeViewportSettling(false); historyStartReadyRef.current = false; const frame = requestAnimationFrame(() => { historyStartReadyRef.current = true; }); return () => { cancelAnimationFrame(frame); }; }, [agentId, clearNativeViewportSettling]); useEffect(() => { const keyboardEvents = [ "keyboardWillShow", "keyboardWillHide", "keyboardDidShow", "keyboardDidHide", "keyboardWillChangeFrame", "keyboardDidChangeFrame", ] as const; const subscriptions = keyboardEvents.map((eventName) => Keyboard.addListener(eventName, () => { markNativeViewportSettling(); }), ); return () => { for (const subscription of subscriptions) { subscription.remove(); } clearNativeViewportSettling(); }; }, [clearNativeViewportSettling, markNativeViewportSettling]); useEffect(() => { bottomAnchorController.prepareForStickyContentChange(); }, [bottomAnchorController, historyRows, segments.liveHead]); useEffect(() => { const handle: StreamViewportHandle = { scrollToBottom: (reason = "jump-to-bottom") => { bottomAnchorController.requestLocalAnchor({ agentId, reason, }); }, prepareForViewportChange: () => { bottomAnchorController.prepareForStickyViewportChange(); markNativeViewportSettling(); }, }; viewportRef.current = handle; return () => { if (viewportRef.current === handle) { viewportRef.current = null; } }; }, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]); const handleScroll = useStableEvent((event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousOffsetY = scrollOffsetYRef.current; scrollOffsetYRef.current = contentOffset.y; streamViewportMetricsRef.current = { contentHeight: Math.max(0, contentSize.height), viewportWidth: Math.max(0, layoutMeasurement.width), viewportHeight: Math.max(0, layoutMeasurement.height), containerKey: "native-virtualized", offsetY: contentOffset.y, viewportMeasuredForKey: "native-virtualized", contentMeasuredForKey: "native-virtualized", }; const nearBottom = isNearBottomForStreamRenderStrategy({ strategy, offsetY: contentOffset.y, threshold: 32, contentHeight: streamViewportMetricsRef.current.contentHeight, viewportHeight: streamViewportMetricsRef.current.viewportHeight, }); onNearBottomChange(nearBottom); const distanceFromOldestEdge = streamViewportMetricsRef.current.contentHeight - streamViewportMetricsRef.current.viewportHeight - contentOffset.y; if ( historyStartReadyRef.current && hasOlderHistory && distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX ) { onNearHistoryStart(); } if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) { programmaticScrollEventBudgetRef.current -= 1; } else { programmaticScrollEventBudgetRef.current = 0; bottomAnchorController.handleScrollNearBottomChange({ nextIsNearBottom: nearBottom, scrollDelta: contentOffset.y - previousOffsetY, }); } }); const handleListLayout = useStableEvent((event: LayoutChangeEvent) => { const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth; const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight; const viewportWidth = Math.max(0, event.nativeEvent.layout.width); const viewportHeight = Math.max(0, event.nativeEvent.layout.height); const viewportChanged = (previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) || (previousViewportHeight > 0 && previousViewportHeight !== viewportHeight); streamViewportMetricsRef.current = { ...streamViewportMetricsRef.current, containerKey: "native-virtualized", viewportWidth, viewportHeight, viewportMeasuredForKey: "native-virtualized", }; if (viewportChanged) { markNativeViewportSettling(); } bottomAnchorController.handleViewportMetricsChange({ previousViewportWidth, viewportWidth, previousViewportHeight, viewportHeight, }); }); const handleContentSizeChange = useStableEvent((_width: number, height: number) => { const previousContentHeight = streamViewportMetricsRef.current.contentHeight; const nextContentHeight = Math.max(0, height); streamViewportMetricsRef.current = { ...streamViewportMetricsRef.current, containerKey: "native-virtualized", contentHeight: nextContentHeight, contentMeasuredForKey: "native-virtualized", }; bottomAnchorController.handleContentSizeChange({ previousContentHeight, contentHeight: nextContentHeight, }); }); const renderItem = useStableEvent( ({ item, index }: ListRenderItemInfo): ReactElement | null => { const rendered = renderHistoryMountedRow(item, index, historyRows); return (rendered ?? null) as ReactElement | null; }, ); const liveHeaderContent = useMemo(() => { const liveHeadRows = segments.liveHead.map((item, index) => ( {renderLiveHeadRow(item, index, segments.liveHead)} )); const liveAuxiliary = renderLiveAuxiliary(); if ( liveHeadRows.length === 0 && !liveAuxiliary && !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory ) { return (listEmptyComponent ?? null) as ReactElement | null; } return ( {liveHeadRows} {liveAuxiliary} ); }, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]); const historyFooterContent = useMemo(() => { if (!isLoadingOlderHistory) { return null; } return ( ); }, [isLoadingOlderHistory]); return ( ); } export function createNativeStreamStrategy(): StreamStrategy { const strategy = createStreamStrategy({ render: (renderInput) => , orderTailReverse: true, orderHeadReverse: true, assistantTurnTraversalStep: 1, edgeSlot: "header", historyLiveBoundaryEdge: "first", liveHeadHistoryBoundaryEdge: "last", frameChildOrder: "footer-then-content", flatListInverted: true, overlayScrollbarInverted: true, maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION, bottomAnchorTransportBehavior: { verificationDelayFrames: 2, verificationRetryMode: "recheck", }, disableParentScrollOnInlineDetailsExpansion: false, anchorBottomOnContentSizeChange: false, animateManualScrollToBottom: true, useVirtualizedList: true, isNearBottom: (input) => input.offsetY <= input.threshold, getBottomOffset: () => 0, }); return strategy; }