From c1b0587f0b6131db12955bfb40cb8a473971ee39 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 22 Jun 2026 16:36:10 -0400 Subject: [PATCH 1/3] feat(chat): re-add native LegendList on 3.1.0; fix prepend jump, fling flash, late-growth parking Bump @legendapp/list 3.0.6 -> 3.1.0 and restore the native thread list to LegendList (un-revert 006c0aa966). Regenerate the web ResizeObserver-batching patch for 3.1.0. Leverage 3.1.0 APIs to fix the artifacts that caused the prior revert: - Prepend jump-to-bottom: maintainScrollAtEndThreshold 0.5 -> default 0.1. The wide window made the top of a short thread count as "near end", so loading older messages (onStartReached prepend) misfired scrollToEnd. Default threshold re-pins only true appends; prepends hold position via maintainVisibleContentPosition. - Late row-growth parking (flip result, reactions, unfurls settling after first paint left the thread above the newest message): new useSyncRowLayout hook calls 3.1.0 useSyncLayout() to flush the row measure synchronously on content change, so the list re-pins on the same frame. Wired in wrapper (reactions/unfurl card), coinflip (flip status), and unfurl image (late dimensions). Replaces the need for the wide threshold. Noops on desktop / old architecture. - Fast-fling cost: experimental_adaptiveRender emits a velocity-driven light/normal signal; rows read it via useAdaptiveRender and disable the per-row swipe pan handlers during fast scroll. SwipeableRow gains an `enabled` prop that toggles panHandlers on the same Animated.View, so the row tree stays stable across the swap (a structural swap would remount children and flash all images on fling-stop). Remove the temporary LISTDBG instrumentation. Desktop continues to use LegendList. --- shared/chat/conversation/list-area/index.tsx | 518 +++++++----------- .../messages/text/coinflip/index.tsx | 6 + .../text/unfurl/unfurl-list/image/index.tsx | 5 + .../messages/use-sync-row-layout.tsx | 20 + .../messages/wrapper/long-pressable/index.tsx | 17 +- .../conversation/messages/wrapper/wrapper.tsx | 4 + .../common-adapters/swipeable-row.native.tsx | 6 +- shared/common-adapters/swipeable-row.tsx | 1 + 8 files changed, 238 insertions(+), 339 deletions(-) create mode 100644 shared/chat/conversation/messages/use-sync-row-layout.tsx diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index c4dd3696e8aa..e5fe17d8a36e 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -20,7 +20,8 @@ import { } from '../thread-context' import {useJumpToRecent} from './jump-to-recent' import {useThreadLoadStatusOptionsGetter} from '../thread-load-status-context' -import {getMessageRowType} from '../messages/row-metadata' +import {getMessageRowType, getMessageShowUsername} from '../messages/row-metadata' +import {useCurrentUserState} from '@/stores/current-user' import * as InputState from '../input-area/input-state' import sortedIndexOf from 'lodash/sortedIndexOf' import {copyToClipboard} from '@/util/storeless-actions' @@ -28,41 +29,59 @@ import {FocusContext} from '../normal/context' 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 = {} + const keyExtractor = (ordinal: ItemType) => String(ordinal) // trim off the search-bar lift so the jump button rests ~40px above the bar const jumpAboveBarTrim = 40 -// Item type for list recycling pool separation +// Item type for list recycling pool separation. A message that leads its author group renders an +// avatar + username header (~40px taller) than a grouped follow-on of the same render type. Without +// splitting the pool, recycleItems reuses one container across both heights, so a recycled view +// paints at the wrong height for a frame before re-measure — visible as rows overlapping during +// scroll. Append ':hdr' so header and grouped rows pool separately. const useGetItemType = () => { const threadStore = useConversationThreadStore() + const you = useCurrentUserState(s => s.username) return React.useCallback( (ordinal: T.Chat.Ordinal) => { if (!ordinal) { return 'null' } - const {messageMap, messageTypeMap} = threadStore.getState() + const {messageMap, messageTypeMap, messageOrdinals} = threadStore.getState() const message = messageMap.get(ordinal) - return message - ? getMessageRowType(message, messageTypeMap.get(ordinal)) - : (messageTypeMap.get(ordinal) ?? 'text') + if (!message) { + return messageTypeMap.get(ordinal) ?? 'text' + } + const base = getMessageRowType(message, messageTypeMap.get(ordinal)) + const showUsername = getMessageShowUsername({ + message, + messageMap, + messageOrdinals: messageOrdinals ?? noOrdinals, + ordinal, + you, + }) + return showUsername ? `${base}:hdr` : base }, - [threadStore] + [threadStore, you] ) } @@ -478,37 +497,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(ScrollContext) React.useEffect(() => { @@ -524,124 +525,87 @@ 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, -} - const NativeConversationList = function NativeConversationList() { - const List = FlatList as unknown as React.ComponentType< - Record & {ref?: React.Ref} - > - const conversationIDKey = useConversationThreadID() - const listData = useConversationThreadSelector( - C.useShallow(s => ({ - loaded: s.loaded, - messageOrdinals: s.messageOrdinals, - })) - ) + const listData = useThreadListData() const {centeredHighlightOrdinal, centeredOrdinal} = useConversationCenter() const noCenteredOrdinal = T.Chat.numberToOrdinal(-1) const centeredOrdinalOrNone = centeredOrdinal ?? noCenteredOrdinal const centeredHighlightOrdinalOrNone = centeredHighlightOrdinal ?? noCenteredOrdinal - const {loaded} = listData + const {loaded, containsLatestMessage, messageOrdinals} = 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() + // Separator renders inline above each row (same as desktop) so the orange line keys off this + // row's ordinal directly. + const renderItem = React.useCallback( + ({item: ordinal}: {item: T.Chat.Ordinal}) => ( + <> + + + + ), + [centeredHighlightOrdinalOrNone] + ) + 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: [ { @@ -652,119 +616,63 @@ const NativeConversationList = function NativeConversationList() { ], })) - const {scrollToCentered, scrollToBottom, onEndReached, onScrollToIndexFailed} = useNativeScrolling({ + const {onStartReached, onEndReached} = usePagination({containsLatestMessage, messageOrdinals}) + + // 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 + } + if (!messageOrdinals.includes(centeredOrdinalOrNone)) { 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 (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 @@ -786,102 +694,82 @@ 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]) - 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} + // Keep the default re-pin threshold (0.1). Widening it makes maintainScrollAtEnd treat + // "at the top of a short thread" as near-the-end, so loading older messages (a prepend + // from onStartReached) misfires scrollToEnd and yanks the thread to the bottom. Late + // row growth (flip result, unfurls) is handled by pushing real heights via setItemSize, + // not by widening this window. + maintainVisibleContentPosition={{data: true}} + onStartReached={onStartReached} + onStartReachedThreshold={2} + onEndReached={onEndReached} + contentContainerStyle={listContentStyle} + scrollIndicatorInsets={scrollIndicatorInsets} + contentInsetEndAdjustment={contentInsetEndAdjustment} + freeze={freeze} + keyboardOffset={insets.bottom} /> + ) : null} {jumpToRecent && ( {jumpToRecent} @@ -905,42 +793,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/text/coinflip/index.tsx b/shared/chat/conversation/messages/text/coinflip/index.tsx index 70bc9760be02..0c3f328db588 100644 --- a/shared/chat/conversation/messages/text/coinflip/index.tsx +++ b/shared/chat/conversation/messages/text/coinflip/index.tsx @@ -7,6 +7,7 @@ import {useOrdinal} from '@/chat/conversation/messages/ids-context' import {pluralize} from '@/util/string' import {useConversationThreadMessage, useConversationThreadSelector} from '../../../thread-context' import {useConversationSendActions} from '../../../send-actions' +import {useSyncRowLayout} from '../../use-sync-row-layout' // The flip result arrives via a separate status notification, not with the thread, so on initial // load (an already-finished flip) the card first-paints with no result and then grows when the @@ -47,6 +48,11 @@ function CoinFlipContainer() { const showParticipants = phase === T.RPCChat.UICoinFlipPhase.complete const numParticipants = participants?.length ?? 0 + // The flip result streams in after first paint and grows the card; flush the row measure so the + // list re-pins to the newest message instead of parking above it. Keyed on the status signals + // that change the card height (loaded yet, phase, participant count, result present). + useSyncRowLayout(`${status === undefined ? 0 : 1}|${phase ?? -1}|${numParticipants}|${resultInfo ? 1 : 0}`) + const revealed = participants?.reduce((r, p) => { return r + (p.reveal ? 1 : 0) diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx index 7f4b8513b418..00cd7d397494 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx @@ -4,6 +4,7 @@ import {clampImageSize} from '@/constants/chat/helpers' import {maxWidth} from '@/chat/conversation/messages/attachment/shared' import {Video} from './video' import {openURL} from '@/util/misc' +import {useSyncRowLayout} from '@/chat/conversation/messages/use-sync-row-layout' export type Props = { autoplayVideo: boolean @@ -28,6 +29,10 @@ const UnfurlImage = (p: Props) => { const maxSize = Math.min(maxWidth, 320) - (widthPadding || 0) const {height, width} = clampImageSize(p.width, p.height, maxSize, 320) + // Usually the metadata dimensions are known at first paint, but if they arrive in a later update + // the image grows; flush the row measure so the list re-pins instead of parking above newest. + useSyncRowLayout(`${width}x${height}`) + return isVideo ? ( )} - + {children} diff --git a/shared/common-adapters/swipeable-row.tsx b/shared/common-adapters/swipeable-row.tsx index cda27dab3a88..920c1b3c5a1d 100644 --- a/shared/common-adapters/swipeable-row.tsx +++ b/shared/common-adapters/swipeable-row.tsx @@ -15,6 +15,7 @@ type Props = { onSwipeableOpenStartDrag?: () => void onSwipeableWillOpen?: (direction: 'left') => void containerStyle?: object + enabled?: boolean } const SwipeableRow = (_p: Props & {ref?: React.Ref}) => null From 23a9e6fc4505c28298c6079ab3d4bcd0522cdff1 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 1 Jul 2026 09:15:46 -0400 Subject: [PATCH 2/3] WIP --- shared/chat/conversation/list-area/index.tsx | 93 ++++++++++++++++++-- 1 file changed, 86 insertions(+), 7 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index e5fe17d8a36e..4f13170386d0 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -570,6 +570,15 @@ const NativeConversationList = function NativeConversationList() { const listRef = React.useRef(null) const markInitiallyLoadedThreadAsRead = useConversationThreadMarkThreadAsRead() + // MVCP (hold scroll position) is only needed when the user scrolls UP to load older messages + // (a prepend). At the bottom it is not just unnecessary but harmful: enabling it there — or + // letting it act during the estimate-vs-real content correction on initial load — re-anchors and + // yanks a freshly opened thread to the top. So keep it OFF until the first onStartReached (user + // reached the top, a prepend is imminent); until then maintainScrollAtEnd owns the bottom. Keyed + // to the conversation so it resets when switching threads. + const [mvcpReadyConv, setMvcpReadyConv] = React.useState(undefined) + const mvcpReady = mvcpReadyConv === conversationIDKey + const getItemType = useGetItemType() // Separator renders inline above each row (same as desktop) so the orange line keys off this @@ -616,7 +625,16 @@ const NativeConversationList = function NativeConversationList() { ], })) - const {onStartReached, onEndReached} = usePagination({containsLatestMessage, messageOrdinals}) + const {onStartReached: onStartReachedRaw, onEndReached} = usePagination({ + containsLatestMessage, + messageOrdinals, + }) + // Turn on MVCP the first time the user reaches the top (a load-older prepend is imminent), so it + // holds scroll position for the prepend without having been active at the bottom on initial load. + const onStartReached = React.useCallback(() => { + setMvcpReadyConv(conversationIDKey) + 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 @@ -705,6 +723,64 @@ const NativeConversationList = function NativeConversationList() { } }, [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 initialScrollIndex = useInitialScrollIndex(messageOrdinals, centeredOrdinal) // Reserve bottom space so the newest message clears the sticky input bar, which is pulled up @@ -754,12 +830,15 @@ const NativeConversationList = function NativeConversationList() { keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" maintainScrollAtEnd={!hasCentered} - // Keep the default re-pin threshold (0.1). Widening it makes maintainScrollAtEnd treat - // "at the top of a short thread" as near-the-end, so loading older messages (a prepend - // from onStartReached) misfires scrollToEnd and yanks the thread to the bottom. Late - // row growth (flip result, unfurls) is handled by pushing real heights via setItemSize, - // not by widening this window. - maintainVisibleContentPosition={{data: true}} + // 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 separately, not by narrowing this. + maintainScrollAtEndThreshold={0.5} + // Off until the user first reaches the top (mvcpReady, set in onStartReached) so the + // estimate-vs-real content correction on initial load can't yank the thread to the top; + // on afterward so load-older prepends hold scroll position. + maintainVisibleContentPosition={hasCentered || !mvcpReady ? undefined : {data: true}} onStartReached={onStartReached} onStartReachedThreshold={2} onEndReached={onEndReached} From 8f3433380c72d712e8d6cfbbc421c691e04bb569 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 2 Jul 2026 09:49:22 -0400 Subject: [PATCH 3/3] feat(chat): adopt legend-list 3.3.0 dataKey + footerLayout re-pin - dataKey replaces key remount on conversation switch (desktop + native); 3.3.0 resets layout readiness and re-runs the initial scroll target per dataset, so switches reuse the mounted list and recycled container pool - desktop explicit maintainScrollAtEnd.on config lost footer-resize re-pinning in 3.1.1 unless footerLayout is opted in; add it --- shared/chat/conversation/list-area/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 4f13170386d0..e2773a45507b 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -436,7 +436,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { ref={wrapperRef} > } data={(layoutReady ? messageOrdinals : noOrdinals) as unknown as T.Chat.Ordinal[]} renderItem={renderItem} @@ -451,7 +451,9 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex} maintainScrollAtEnd={ - centeredOrdinal !== undefined ? false : {on: {dataChange: true, itemLayout: true}} + centeredOrdinal !== undefined + ? false + : {on: {dataChange: true, footerLayout: true, itemLayout: true}} } maintainVisibleContentPosition={centeredOrdinal !== undefined ? undefined : {data: true}} onLoad={onLoad} @@ -804,7 +806,7 @@ const NativeConversationList = function NativeConversationList() { {listReady ? (