From 1e868f5214a96a1ef30b73577ca8ebec68ecaf3c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 9 Jul 2026 15:08:41 -0400 Subject: [PATCH 1/2] fix(chat): stop recycled message rows from leaking state and stale heights These are list-agnostic row fixes, split out of the native LegendList work. Desktop already renders the thread with a recycling LegendList, so each of these is a live bug there today; on the native FlatList they are inert guards. - per-row state (sent animation, reaction picker, exploding retained height, ash tower) is now keyed to messageKey. A recycled container reuses the component instance for a different message, so mount-captured state leaked across rows: the picker stayed open on the wrong row, the sent-animation wrapper stuck on, and a measured retain height was applied to the wrong message (and could not self-correct, since retainHeight forces the style height so onLayout only ever reports the forced value back) - recycle-pool suffixes are limited to ones that are stable for the message's lifetime (:failed, :reply). The pool label is recorded when a container is allocated and never updated in place, so :pending (flips on every send confirmation) and :reactions (toggles) left stale labels behind and recycled containers painted at the wrong pooled height - getItemType splits headered rows into their own pool (:hdr). A message that leads its author group is ~40px taller than a grouped follow-on of the same render type, so a shared pool paints recycled views at the wrong height for a frame. It reads the same sticky username cache the rows render with, else a row that keeps its header after a scroll-back load is typed headerless and poisons the headerless pool's height average - useSyncRowLayout: when a row's content settles to a new height after first paint (flip result streams in, reactions appear, an unfurl loads), flush the row measure synchronously so the list's bottom re-pin uses the final height on the same frame instead of a frame late, which otherwise parks the thread above the newest message - SwipeableRow takes an `enabled` prop that conditionally spreads its pan handlers, so a list can shed per-row touch evaluation during a fast fling without unmounting the row. Toggling the Swipeable's subtree instead would remount its children and flash images. No caller passes it yet - desktop LegendList: dataKey replaces the key remount on conversation switch, and maintainScrollAtEnd opts back into footerLayout, which 3.x stopped implying once the trigger set is given explicitly --- shared/chat/conversation/list-area/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 7c3938cd876d..aac1b2d615d7 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -539,7 +539,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { ref={wrapperRef} > } data={(layoutReady ? messageOrdinals : noOrdinals) as unknown as T.Chat.Ordinal[]} renderItem={renderItem} From e016702bdb8247879ecde68f2b06de3ed6ad3d95 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 9 Jul 2026 15:22:40 -0400 Subject: [PATCH 2/2] feat(chat): re-add native LegendList for the message thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inverted FlatList + KeyboardChatScrollView native thread with a non-inverted KeyboardAwareLegendList, on top of the list-agnostic row fixes in the parent commit. - non-inverted list: rows render in natural order, so the sent-message animation, separators, and header/footer components swap ends. Bottom clearance for the input bar is reserved statically via contentContainerStyle, with the keyboard composer inset seeded to 0 so the two don't stack. - pagination: onStartReached/onEndReached replace the viewability-window heuristic (useNativeSafeOnViewableItemsChanged is deleted). - prepend jump: maintainScrollAtEnd's dataChange trigger re-pins on ANY data change within maintainScrollAtEndThreshold of the end, so on short threads a load-older prepend yanked the view to the bottom. The threshold stays wide (0.5) because initialScrollAtEnd positions from estimatedItemSize and lands short on our tall rows; instead the re-pin is suspended while a prepend is in flight (prependActive). - initial position: the list is not mounted until the thread is loaded, so its first render always has data and initialScrollAtEnd lands at the newest message. Previously, arriving from the inbox mounted the list empty and the initial scroll ran against no data and never re-fired. - centering: scrollToItem(viewPosition: 0.5) lands accurately here, so the closed-loop offset corrector (viewable-range feedback, damped item-delta scrolls) is deleted in favor of re-asserting across the pagination settle. onScrollToIndexFailed goes with it — LegendList has no such prop. - maintainVisibleContentPosition is on from mount so prepends hold position. - fling cost: experimental_adaptiveRender lets rows shed their swipe pan handlers during fast scroll, via useAdaptiveRender in long-pressable driving the SwipeableRow `enabled` prop added in the parent commit. useAdaptiveRender reads LegendList's state context and throws outside a LegendList, so it can only be wired up here. The Swipeable stays mounted — toggling its tree would remount children and flash images. - stable renderItem: NativeRow reads the centered highlight itself, so renderItem identity never changes and a highlight change doesn't re-render every visible row. Includes a temporary [LISTDBG] dump for the initial-load settle; remove before merging. --- shared/chat/conversation/list-area/index.tsx | 600 ++++++++---------- .../messages/wrapper/long-pressable/index.tsx | 17 +- .../messages/wrapper/sent.native.tsx | 10 +- 3 files changed, 290 insertions(+), 337 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index aac1b2d615d7..695d3791b894 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -30,21 +30,29 @@ import {copyToClipboard} from '@/util/storeless-actions' import noop from 'lodash/noop' import {LegendList} from '@legendapp/list/react' import type {LegendListRef} from '@/common-adapters' -import {FlatList} from 'react-native' -import type {ScrollViewProps} from 'react-native' +import type {View} from 'react-native' import {mobileTypingContainerHeight} from '../input-area/normal/typing' import { - KeyboardChatScrollView, - useKeyboardState, - useReanimatedKeyboardAnimation, -} from 'react-native-keyboard-controller' -import Animated, {useAnimatedStyle} from 'react-native-reanimated' + KeyboardAwareLegendList, + useKeyboardChatComposerInset, + useKeyboardScrollToEnd, +} from '@legendapp/list/keyboard' +import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller' +import Animated, {useAnimatedReaction, useAnimatedStyle} from 'react-native-reanimated' +import {scheduleOnRN} from 'react-native-worklets' import {ThreadSearchOverlayContext} from '../thread-search-overlay-context' import {useSafeAreaInsets} from 'react-native-safe-area-context' type ItemType = T.Chat.Ordinal const noOrdinals: ReadonlyArray = [] +// Stable config so it doesn't churn props each render. Empty = enable adaptive render with defaults. +const adaptiveRenderConfig = {} + +// Stable MVCP config (anchor visible rows across data prepends). Referenced by native; desktop +// inlines an equivalent. +const mvcpData = {data: true} as const + const keyExtractor = (ordinal: ItemType) => String(ordinal) // trim off the search-bar lift so the jump button rests ~40px above the bar @@ -97,6 +105,7 @@ const useThreadListData = () => containsLatestMessage: !s.moreToLoadForward, loaded: s.loaded, messageOrdinals: s.messageOrdinals ?? noOrdinals, + moreToLoadBack: s.moreToLoadBack, })) ) @@ -604,37 +613,19 @@ const DesktopThreadWrapperWithProfiler = () => ( // ==================== NATIVE ==================== -type RNFlatListRef = { - scrollToOffset: (opts: {animated: boolean; offset: number}) => void - scrollToItem: (opts: {animated: boolean; item: unknown; viewPosition?: number}) => void -} - -const useInvertedMessageOrdinals = (messageOrdinals?: ReadonlyArray) => { - const source = messageOrdinals ?? noOrdinals - return React.useMemo(() => (source.length > 1 ? [...source].reverse() : source), [source]) -} - const useNativeScrolling = (p: { centeredOrdinal: T.Chat.Ordinal - messageOrdinals: ReadonlyArray - listRef: React.RefObject + listRef: React.RefObject + scrollMessageToEnd: (o: {animated: boolean; closeKeyboard: boolean}) => Promise }) => { - const {listRef, centeredOrdinal, messageOrdinals} = p - const numOrdinals = messageOrdinals.length - const loadOlderMessages = useConversationThreadLoadOlderMessagesDueToScroll() - const getThreadLoadStatusOptions = useThreadLoadStatusOptionsGetter() + const {listRef, centeredOrdinal, scrollMessageToEnd} = p - // KeyboardChatScrollView sets contentInset.top = K - insets.bottom and - // contentOffset.y = -(K - insets.bottom) when keyboard is open. Scrolling to - // offset=0 would place content K-insets.bottom pixels lower (behind the keyboard). - // We compute the correct resting offset: keyboardHeight.value (negative) + insets.bottom. - // When keyboard is closed keyboardHeight.value = 0 so the result is clamped to 0. - const {height: keyboardAnimHeight} = useReanimatedKeyboardAnimation() - const {bottom: insetsBottom} = useSafeAreaInsets() + // scrollMessageToEnd freezes the keyboard-aware scroll view, scrolls to the end, + // then unfreezes — so the newest message stays pinned above the input bar even + // while the keyboard is open. const scrollToBottom = React.useCallback(() => { - const offset = Math.min(keyboardAnimHeight.value + insetsBottom, 0) - listRef.current?.scrollToOffset({animated: false, offset}) - }, [insetsBottom, keyboardAnimHeight, listRef]) + void scrollMessageToEnd({animated: false, closeKeyboard: false}) + }, [scrollMessageToEnd]) const {setScrollRef} = React.useContext(ThreadRefsContext) React.useEffect(() => { @@ -650,124 +641,88 @@ const useNativeScrolling = (p: { }, [centeredOrdinal]) const centeredOrdinalRef = React.useRef(centeredOrdinal) - // reset per centered target so each new search hit gets a fresh batch of retries - const scrollFailRetryRef = React.useRef(0) React.useEffect(() => { centeredOrdinalRef.current = centeredOrdinal - scrollFailRetryRef.current = 0 }, [centeredOrdinal]) const [scrollToCentered] = React.useState(() => () => { - const co = centeredOrdinalRef.current - if (lastScrollToCentered.current === co) { - return - } - lastScrollToCentered.current = co - // coarse: scrollToItem lands at the wrong offset for tall variable-height rows, - // but it gets the target area rendered. The closed-loop corrector in the - // component refines from there using the real viewable index range. - const reassert = (delay: number) => - setTimeout(() => { - const list = listRef.current - const cur = centeredOrdinalRef.current - if (!list || cur !== co || T.Chat.ordinalToNumber(cur) <= 0) { - return - } - list.scrollToItem({animated: false, item: cur, viewPosition: 0.5}) - }, delay) - ;[50, 250].forEach(reassert) - }) - - // The centered hit may be outside the rendered window, so scrollToItem fails - // silently. Wait for more rows to render and retry centering (capped) until it lands. - const [onScrollToIndexFailed] = React.useState(() => () => { - if (scrollFailRetryRef.current > 5) { - return - } - scrollFailRetryRef.current += 1 setTimeout(() => { + const list = listRef.current + if (!list) { + return + } const co = centeredOrdinalRef.current - if (T.Chat.ordinalToNumber(co) > 0) { - listRef.current?.scrollToItem({animated: false, item: co, viewPosition: 0.5}) + if (lastScrollToCentered.current === co) { + return } - }, 200) - }) - const onEndReached = () => { - loadOlderMessages(numOrdinals, getThreadLoadStatusOptions()) - } + lastScrollToCentered.current = co + void list.scrollToItem({animated: false, item: co, viewPosition: 0.5}) + }, 100) + }) return { - onEndReached, - onScrollToIndexFailed, scrollToBottom, scrollToCentered, } } -// When the keyboard is open, KeyboardChatScrollView sets contentOffset.y = -(K-insets.bottom) -// (negative, inside contentInset.top). Two problems arise without special handling: -// 1. autoscrollToTopThreshold=1 fires (because -(K-I) <= 1) and scrolls to y=0, stripping the -// keyboard offset and hiding new messages behind the keyboard. -// 2. maintainVisibleContentPosition adjusts contentOffset by the new message's height when it is -// inserted, creating a visible gap between the newest message and the input area. -// Solution: disable MPV entirely when keyboard is visible. New messages appear naturally at the -// content-inset boundary (already in view), and a layout effect re-scrolls as a safety net. -const maintainVisibleContentPositionClosed = { - autoscrollToTopThreshold: 1, - minIndexForVisible: 0, -} +// Reads the centered highlight itself (like desktop's HighlightableRow) so renderItem stays +// referentially stable — a renderItem identity change re-renders every visible row at once. +const NativeRow = React.memo(function NativeRow({ordinal}: {ordinal: T.Chat.Ordinal}) { + const {centeredHighlightOrdinal} = useConversationCenter() + return ( + <> + + + + ) +}) -const NativeConversationList = function NativeConversationList() { - const List = FlatList as unknown as React.ComponentType< - Record & {ref?: React.Ref} - > +const nativeRenderItem = ({item: ordinal}: {item: T.Chat.Ordinal}) => +const NativeConversationList = function NativeConversationList() { const conversationIDKey = useConversationThreadID() - const listData = useConversationThreadSelector( - C.useShallow(s => ({ - loaded: s.loaded, - messageOrdinals: s.messageOrdinals, - })) - ) - const {centeredHighlightOrdinal, centeredOrdinal} = useConversationCenter() + const listData = useThreadListData() + const {centeredOrdinal} = useConversationCenter() const noCenteredOrdinal = T.Chat.numberToOrdinal(-1) const centeredOrdinalOrNone = centeredOrdinal ?? noCenteredOrdinal - const centeredHighlightOrdinalOrNone = centeredHighlightOrdinal ?? noCenteredOrdinal - const {loaded} = listData + const {loaded, containsLatestMessage, messageOrdinals, moreToLoadBack} = listData + const hasCentered = centeredOrdinal !== undefined - const messageOrdinals = useInvertedMessageOrdinals(listData.messageOrdinals) + // initialScrollAtEnd only positions the FIRST render that has data. Coming from the inbox the + // thread loads async after mount, so if the list mounted empty the initial scroll would run on + // an empty list and never re-fire once data streamed in (cold-start has data at mount, which is + // why only the inbox path was broken). Gate the list mount on loaded so its first render always + // has data and initialScrollAtEnd lands at the newest message on both paths. + const listReady = loaded || hasCentered - const listRef = React.useRef(null) + const listRef = React.useRef(null) const markInitiallyLoadedThreadAsRead = useConversationThreadMarkThreadAsRead() - const keyExtractor = (ordinal: ItemType) => { - return String(ordinal) - } - - const renderItem = (info?: {item?: ItemType}) => { - const ordinal = info?.item - if (!ordinal) { - return null - } - return - } - - const numOrdinals = messageOrdinals.length - const getItemType = useGetItemType() const insets = useSafeAreaInsets() - const isKeyboardVisible = useKeyboardState((s: {isVisible: boolean}) => s.isVisible) - // While the thread-search bar is open it overlays the bottom of the list. Reserve - // that height as extra content padding (so centered/newest messages clear it) and - // lift the jump-to-recent button above both the keyboard and the bar. + // While the thread-search bar is open it overlays the bottom of the list. Reserve that height + // as extra content padding (so the newest message clears it) and lift the jump-to-recent button + // above both the keyboard and the bar. searchOverlayHeight is a reanimated SharedValue set by + // the search bar's onLayout; mirror it to state for the (static) content padding. const searchOverlayHeight = React.useContext(ThreadSearchOverlayContext) + const [searchPad, setSearchPad] = React.useState(0) + useAnimatedReaction( + () => searchOverlayHeight?.value ?? 0, + (h, prev) => { + if (h !== prev) { + scheduleOnRN(setSearchPad, h) + } + }, + [searchOverlayHeight] + ) const {height: keyboardAnimHeight} = useReanimatedKeyboardAnimation() const insetsBottom = insets.bottom - // The search bar overlays the list bottom (keyboard closed) or rides the keyboard - // top (keyboard open) via KeyboardStickyView; either way it sits above the list, so - // always clear it. The keyboard term lifts past the keyboard, the bar term past the bar. + // The jump button sits in a sibling of the keyboard-aware list, so it does not move with the + // keyboard on its own. Lift it past the keyboard (keyboard term) and past the search bar (bar + // term) so it never hides behind either. const jumpLiftStyle = useAnimatedStyle(() => ({ transform: [ { @@ -778,119 +733,101 @@ const NativeConversationList = function NativeConversationList() { ], })) - const {scrollToCentered, scrollToBottom, onEndReached, onScrollToIndexFailed} = useNativeScrolling({ + const {onStartReached: onStartReachedRaw, onEndReached} = usePagination({ + containsLatestMessage, + messageOrdinals, + }) + + // Suspend the bottom re-pin while a load-older prepend is in flight. maintainScrollAtEnd's + // dataChange trigger scroll-to-ends ANY data change while within maintainScrollAtEndThreshold + // (0.5 viewport) of the end — on short threads the top is inside that window, so the prepend + // yanks the view to the bottom. The guard is set when we request older messages and cleared via + // timeout (never synchronously in render) so the prepend's data change is still processed with + // the re-pin off; 0ms once it lands (first ordinal changed) or 3s fallback if it never does. + const [prependPending, setPrependPending] = React.useState< + {conv: string; firstOrdinal: T.Chat.Ordinal | undefined} | undefined + >(undefined) + const firstOrdinal = messageOrdinals[0] + const prependActive = prependPending?.conv === conversationIDKey + React.useEffect(() => { + if (!prependPending) return undefined + const landedOrStale = + prependPending.conv !== conversationIDKey || prependPending.firstOrdinal !== firstOrdinal + const id = setTimeout(() => setPrependPending(undefined), landedOrStale ? 0 : 3000) + return () => clearTimeout(id) + }, [prependPending, conversationIDKey, firstOrdinal]) + + const firstOrdinalRef = React.useRef(firstOrdinal) + React.useEffect(() => { + firstOrdinalRef.current = firstOrdinal + }, [firstOrdinal]) + const moreToLoadBackRef = React.useRef(moreToLoadBack) + React.useEffect(() => { + moreToLoadBackRef.current = moreToLoadBack + }, [moreToLoadBack]) + + const onStartReached = React.useCallback(() => { + if (moreToLoadBackRef.current) { + setPrependPending({conv: conversationIDKey, firstOrdinal: firstOrdinalRef.current}) + } + onStartReachedRaw() + }, [conversationIDKey, onStartReachedRaw]) + + // The bottom clearance for the input bar is reserved statically via contentContainerStyle + // (listContentStyle) below, so this composer inset is seeded to 0 — otherwise the two stack + // and leave a large empty gap below the newest message on cold start. composerRef is null + // (the composer lives in a sibling subtree, not this list) so measure() is never called. + const composerRef = React.useRef(null) + const {contentInsetEndAdjustment} = useKeyboardChatComposerInset(listRef, composerRef, 0) + const {freeze, scrollMessageToEnd} = useKeyboardScrollToEnd({listRef}) + + const {scrollToCentered, scrollToBottom} = useNativeScrolling({ centeredOrdinal: centeredOrdinalOrNone, listRef, - messageOrdinals, + scrollMessageToEnd, }) - // Closed-loop centering corrector. scrollToItem/scrollToIndex lands at the wrong - // offset here (inverted list + custom keyboard scrollview + tall variable-height - // image rows), so instead we read the actual viewable index range each frame and - // scrollToOffset by the item-delta until the target sits at viewport center. - const scrollOffsetRef = React.useRef(0) - const contentHeightRef = React.useRef(0) + // Latest centered target, read inside the stable re-assert callback. const centeredRef = React.useRef(centeredOrdinalOrNone) React.useEffect(() => { centeredRef.current = centeredOrdinalOrNone }, [centeredOrdinalOrNone]) - const ordsRef = React.useRef(messageOrdinals) - React.useEffect(() => { - ordsRef.current = messageOrdinals - }, [messageOrdinals]) - // {active, iters}: correcting toward a centered hit and how many steps taken - const correctRef = React.useRef({active: false, iters: 0}) - const vFirstRef = React.useRef(undefined) - const vLastRef = React.useRef(undefined) - const [correctCenter] = React.useState( - () => (first: number | null | undefined, last: number | null | undefined) => { - const st = correctRef.current - if (!st.active) return - const co = centeredRef.current - const ords = ordsRef.current - const num = ords.length - if (co <= 0 || !num || first == null || last == null) return - const targetIdx = ords.indexOf(co) - if (targetIdx < 0) return - const centerIdx = (first + last) / 2 - const diff = targetIdx - centerIdx - if (Math.abs(diff) <= 0.5 || st.iters > 12) { - st.active = false - return - } - st.iters += 1 - const avgH = contentHeightRef.current / num - // damp by 0.9 to avoid overshoot/oscillation; higher index = older = higher offset - const newOffset = Math.max(0, scrollOffsetRef.current + diff * avgH * 0.9) - listRef.current?.scrollToOffset({animated: false, offset: newOffset}) - } - ) - const [onScrollNative] = React.useState( - () => - (e: {nativeEvent: {contentOffset: {y: number}; contentSize: {height: number}}}) => { - scrollOffsetRef.current = e.nativeEvent.contentOffset.y - contentHeightRef.current = e.nativeEvent.contentSize.height - } - ) - const [onContentSizeChangeNative] = React.useState(() => (_w: number, h: number) => { - contentHeightRef.current = h - }) - // user touched the list: stop fighting them - const [onScrollBeginDrag] = React.useState(() => () => { - correctRef.current.active = false - }) const jumpToRecent = useJumpToRecent(scrollToBottom, messageOrdinals.length) - // When keyboard is open, maintainVisibleContentPosition adjusts contentOffset by the new - // message height when a message is added, undoing the scrollToBottom from onSubmit. - // Defer the re-scroll past the native MPV adjustment (which runs on the UI thread after - // React's commit) so the newest message stays visible. - const prevNumOrdinalsRef = React.useRef(numOrdinals) - // Tracks which conversation prevNumOrdinalsRef's baseline belongs to so the - // baseline resets on a real conversation switch (value compare) rather than on - // a react-native-screens freeze/thaw, which re-mounts effects. - const numBaselineConvRef = React.useRef(conversationIDKey) - const isKeyboardVisibleRef = React.useRef(isKeyboardVisible) - React.useLayoutEffect(() => { - isKeyboardVisibleRef.current = isKeyboardVisible + // Re-assert native centering on the current target. scrollToItem(viewPosition: 0.5) lands + // accurately on its own, but the centered load streams older messages in afterward (pagination + // prepends), so we re-call it across a few frames; maintainVisibleContentPosition keeps the row + // steady between asserts. + const [reassertCentered] = React.useState(() => () => { + const co = centeredRef.current + if (co <= 0) return + void listRef.current?.scrollToItem({animated: false, item: co, viewPosition: 0.5}) }) - React.useLayoutEffect(() => { - const sameConv = numBaselineConvRef.current === conversationIDKey - numBaselineConvRef.current = conversationIDKey - const prev = prevNumOrdinalsRef.current - prevNumOrdinalsRef.current = numOrdinals - if (sameConv && numOrdinals > prev && isKeyboardVisibleRef.current) { - const id = setTimeout(() => { - if (isKeyboardVisibleRef.current) { - scrollToBottom() - } - }, 0) - return () => clearTimeout(id) - } - return undefined - }, [conversationIDKey, numOrdinals, scrollToBottom]) - - // Center on the search hit once it actually appears in the loaded list. Centering - // on the raw centeredOrdinal change is unreliable: navigating to a hit reloads the - // thread centered on it, so messageOrdinals is briefly empty (idx -1) when the - // ordinal changes. Wait for the target to load, then scroll (scrollToCentered - // guards against repeats and re-asserts across frames). + + // Center on the search hit once it actually appears in the loaded list. Centering on the raw + // centeredOrdinal change is unreliable: navigating to a hit reloads the thread centered on it, + // so messageOrdinals is briefly empty (the target not yet present) when the ordinal changes. + // Wait for the target to load, then coarse-scroll and re-assert across the pagination settle. + const lastCenteredOrdinal = React.useRef(0) React.useEffect(() => { - if (!(centeredOrdinalOrNone > 0 && messageOrdinals.includes(centeredOrdinalOrNone))) { + if (centeredOrdinalOrNone <= 0) { + lastCenteredOrdinal.current = 0 return undefined } - // coarse scroll to get the target area rendered, then run the closed-loop - // corrector which refines via the real viewable index range + if (!messageOrdinals.includes(centeredOrdinalOrNone)) { + return undefined + } + if (lastCenteredOrdinal.current === centeredOrdinalOrNone) { + return undefined + } + lastCenteredOrdinal.current = centeredOrdinalOrNone scrollToCentered() - correctRef.current = {active: true, iters: 0} - const ids = [50, 250, 500, 900].map(d => - setTimeout(() => correctCenter(vFirstRef.current, vLastRef.current), d) - ) + const ids = [50, 250, 500, 900, 1400].map(d => setTimeout(reassertCentered, d)) return () => { ids.forEach(clearTimeout) } - }, [centeredOrdinalOrNone, messageOrdinals, scrollToCentered, correctCenter]) + }, [centeredOrdinalOrNone, messageOrdinals, scrollToCentered, reassertCentered]) // These refs store the conversation they last applied to (not a boolean) so a // freeze/thaw of this screen — which re-mounts effects without a real @@ -912,102 +849,147 @@ const NativeConversationList = function NativeConversationList() { markInitiallyLoadedThreadAsRead() } + // Initial bottom position is handled declaratively by initialScrollAtEnd (the list is not + // mounted until data is loaded, so its first render has data). Centered navigation still + // needs an imperative nudge for the case where loaded flips true after centeredOrdinal set. if (centeredOrdinalOrNone > 0) { scrollToCentered() setTimeout(() => { scrollToCentered() }, 100) - } else if (numOrdinals > 0) { - scrollToBottom() - setTimeout(() => { - scrollToBottom() - }, 100) - } - }, [ - conversationIDKey, - centeredOrdinalOrNone, - loaded, - markInitiallyLoadedThreadAsRead, - numOrdinals, - scrollToBottom, - scrollToCentered, - ]) - - const onViewableItemsChanged = useNativeSafeOnViewableItemsChanged(onEndReached, messageOrdinals.length) - const [onViewableItemsChangedNative] = React.useState( - () => (info: {viewableItems: Array<{index: number | null}>}) => { - onViewableItemsChanged.current(info) - const first = info.viewableItems.at(0)?.index - const last = info.viewableItems.at(-1)?.index - vFirstRef.current = first - vLastRef.current = last - correctCenter(first, last) } + }, [conversationIDKey, centeredOrdinalOrNone, loaded, markInitiallyLoadedThreadAsRead, scrollToCentered]) + + // [LISTDBG] TEMP: diagnose initial-load not landing at bottom on tall-row threads. Dumps list + // state across the load settle: whether it lands short (gap>0) or drifts as rows measure, plus + // where the newest row actually sits (belowVp>0 = parked above) and real per-type avgs vs 120. + const dbgDump = React.useCallback( + (tag: string) => { + const s = listRef.current?.getState() as + | { + isAtEnd?: boolean + scroll?: number + scrollLength?: number + contentLength?: number + end?: number + endBuffered?: number + isWithinMaintainScrollAtEndThreshold?: boolean + getAverageItemSizes?: () => Record + positionAtIndex?: (i: number) => number + sizeAtIndex?: (i: number) => number + } + | undefined + let avgs = '' + try { + const a = s?.getAverageItemSizes?.() + if (a) { + avgs = Object.entries(a) + .map(([k, v]) => `${k}:${Math.round(v.average)}(${v.count})`) + .join(' ') + } + } catch {} + const gap = Math.round((s?.contentLength ?? 0) - (s?.scroll ?? 0) - (s?.scrollLength ?? 0)) + const lastIdx = messageOrdinals.length - 1 + let lastInfo = '' + try { + const posLast = Math.round(s?.positionAtIndex?.(lastIdx) ?? -1) + const sizeLast = Math.round(s?.sizeAtIndex?.(lastIdx) ?? -1) + const vpBottom = Math.round((s?.scroll ?? 0) + (s?.scrollLength ?? 0)) + lastInfo = `lastIdx=${lastIdx} posLast=${posLast} sizeLast=${sizeLast} lastBottom=${posLast + sizeLast} vpBottom=${vpBottom} belowVp=${posLast + sizeLast - vpBottom}` + } catch {} + console.log( + `[LISTDBG] ${tag} conv=${conversationIDKey.slice(0, 6)} num=${messageOrdinals.length} ` + + `isAtEnd=${s?.isAtEnd} withinThresh=${s?.isWithinMaintainScrollAtEndThreshold} ` + + `end=${s?.end} endBuf=${s?.endBuffered} ` + + `scroll=${Math.round(s?.scroll ?? -1)} scrollLen=${Math.round(s?.scrollLength ?? -1)} ` + + `contentLen=${Math.round(s?.contentLength ?? -1)} gap=${gap} ${lastInfo} avgs=[${avgs}]` + ) + }, + [conversationIDKey, messageOrdinals] ) + const dbgLoadedRef = React.useRef(undefined) + React.useEffect(() => { + if (!loaded) return undefined + if (dbgLoadedRef.current === conversationIDKey) return undefined + dbgLoadedRef.current = conversationIDKey + const ids = [0, 100, 300, 600, 1200, 2000, 3500].map(d => setTimeout(() => dbgDump(`t+${d}`), d)) + return () => { + ids.forEach(clearTimeout) + } + }, [loaded, conversationIDKey, dbgDump]) - const renderScrollComponent = React.useCallback( - (props: ScrollViewProps) => ( - - ), - [insets.bottom, searchOverlayHeight] - ) + const initialScrollIndex = useInitialScrollIndex(messageOrdinals, centeredOrdinal) - const nativeContentContainerStyle = React.useMemo( - () => ({ - paddingBottom: 0, - paddingTop: mobileTypingContainerHeight + insets.bottom, - }), - [insets.bottom] + // Reserve bottom space so the newest message clears the sticky input bar, which is pulled up + // over the list bottom (KeyboardStickyView offset -insets.bottom) plus the floating typing + // indicator. Without this the list scrolls to its content end but the newest row sits behind + // the input bar. + const listContentStyle = React.useMemo( + () => ({paddingBottom: mobileTypingContainerHeight + insets.bottom + searchPad}), + [insets.bottom, searchPad] ) + // The input bar (KeyboardStickyView, closed offset -insets.bottom) overlaps the bottom of the + // list by insets.bottom, so without this the scroll indicator runs down behind it. Inset the + // indicator by exactly that overlap (NOT the full content padding, which also reserves space for + // the floating typing indicator that the scrollbar doesn't need to clear). + const scrollIndicatorInsets = React.useMemo(() => ({bottom: insets.bottom}), [insets.bottom]) + return ( - 0 || !numOrdinals || isKeyboardVisible - ? undefined - : maintainVisibleContentPositionClosed - } + maintainScrollAtEnd={!hasCentered && !prependActive} + // Wide re-pin window so the first render lands at the newest message: initialScrollAtEnd + // positions from estimatedItemSize, which underestimates our tall/variable rows, so + // without this the thread opens parked above newest. The downside (re-pinning load-older + // prepends to the bottom on short threads) is handled by the prependActive guard above, + // not by narrowing this. + maintainScrollAtEndThreshold={0.5} + // On from mount so load-older prepends always hold scroll position. This used to be + // gated until the first onStartReached because MVCP acting on the initial + // estimate-vs-real correction yanked a freshly opened thread to the top; trusting the + // 3.3.0 settle fixes (visible-rows-first big jumps, batched Fabric replacement + // measurements) to have removed that. If cold-opening a tall-row thread parks or + // yanks again, re-gate (see git history for the mvcpReady gate). + maintainVisibleContentPosition={hasCentered ? undefined : mvcpData} + onStartReached={onStartReached} + onStartReachedThreshold={2} + onEndReached={onEndReached} + contentContainerStyle={listContentStyle} + scrollIndicatorInsets={scrollIndicatorInsets} + contentInsetEndAdjustment={contentInsetEndAdjustment} + freeze={freeze} + keyboardOffset={insets.bottom} /> + ) : null} {jumpToRecent && ( {jumpToRecent} @@ -1031,42 +1013,4 @@ const nativeStyles = Kb.Styles.styleSheetCreate( }) as const ) -const minTimeDelta = 1000 -const minDistanceFromEnd = 10 - -const useNativeSafeOnViewableItemsChanged = (onEndReached: () => void, numOrdinals: number) => { - const nextCallbackRef = React.useRef(new Date().getTime()) - const onEndReachedRef = React.useRef(onEndReached) - React.useEffect(() => { - onEndReachedRef.current = onEndReached - }, [onEndReached]) - const numOrdinalsRef = React.useRef(numOrdinals) - React.useEffect(() => { - numOrdinalsRef.current = numOrdinals - nextCallbackRef.current = new Date().getTime() + minTimeDelta - }, [numOrdinals]) - - // this can't change ever, so we have to use refs to keep in sync - const onViewableItemsChanged = React.useRef( - ({viewableItems}: {viewableItems: Array<{index: number | null}>}) => { - const idx = viewableItems.at(-1)?.index ?? 0 - const lastIdx = numOrdinalsRef.current - 1 - const offset = numOrdinalsRef.current > 50 ? minDistanceFromEnd : 1 - const deltaIdx = idx - lastIdx + offset - // not far enough from the end - if (deltaIdx < 0) { - return - } - const t = new Date().getTime() - const deltaT = t - nextCallbackRef.current - // enough time elapsed? - if (deltaT > 0) { - nextCallbackRef.current = t + minTimeDelta - onEndReachedRef.current() - } - } - ) - return onViewableItemsChanged -} - export default isMobile ? NativeConversationList : DesktopThreadWrapperWithProfiler diff --git a/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx b/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx index 88f059e38130..b133981f1f9a 100644 --- a/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx +++ b/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx @@ -16,6 +16,7 @@ type Props = { import {useConversationThreadToggleSearch} from '../../../thread-context' import Swipeable, {type SwipeableMethods} from '@/common-adapters/swipeable-row' import {ThreadRefsContext} from '@/chat/conversation/normal/context' +import {useAdaptiveRender} from '@legendapp/list/react-native' function ReplyIcon({progress}: {progress: Animated.Value}) { const opacity = progress.interpolate({inputRange: [-20, 0], outputRange: [1, 0], extrapolate: 'clamp'}) @@ -27,15 +28,22 @@ function ReplyIcon({progress}: {progress: Animated.Value}) { } function LongPressable(props: Props & {ref?: React.Ref}) { + if (!isMobile) { + return + } + return +} + +function LongPressableMobile(props: Props & {ref?: React.Ref}) { const toggleThreadSearch = useConversationThreadToggleSearch() const setReplyTo = InputState.useConversationInputDispatch(s => s.setReplyTo) const ordinal = useOrdinal() const {focusInput} = React.useContext(ThreadRefsContext) const swipeRef = React.useRef(null) - - if (!isMobile) { - return - } + // Velocity-driven signal from LegendList: during fast scroll it flips to "light". We keep the + // Swipeable mounted (toggling its tree would remount children and flash images) and instead just + // disable its pan handlers in light mode, shedding the per-row touch evaluation during the fling. + const adaptiveMode = useAdaptiveRender() const {children, onLongPress, style} = props @@ -66,6 +74,7 @@ function LongPressable(props: Props & {ref?: React.Ref}) { return ( diff --git a/shared/chat/conversation/messages/wrapper/sent.native.tsx b/shared/chat/conversation/messages/wrapper/sent.native.tsx index 4629e2b1f6dc..785adfd9965d 100644 --- a/shared/chat/conversation/messages/wrapper/sent.native.tsx +++ b/shared/chat/conversation/messages/wrapper/sent.native.tsx @@ -1,11 +1,11 @@ import type * as React from 'react' import Animated, {FadeInDown} from 'react-native-reanimated' -// Slide-up + fade for a message you just sent. The thread list is an inverted -// FlatList (cells are flipped with scaleY: -1), so FadeInDown renders on screen -// as sliding up from below. Runs entirely on the UI thread with no re-renders. -// The entering animation only plays when this Animated.View MOUNTS — callers must -// key it per message (recycled containers reuse instances). +// Slide-up + fade for a message you just sent. The thread list (LegendList) is +// NOT inverted, so FadeInDown (enters from 25px below, sliding up into place) +// reads as the row rising from the input bar. Runs entirely on the UI thread +// with no re-renders. The entering animation only plays when this Animated.View +// MOUNTS — callers must key it per message (recycled containers reuse instances). export function Sent(p: {children: React.ReactNode}) { return (