diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 255fc90d3db9..97ed58ecd9df 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -11,6 +11,7 @@ import {PerfProfiler} from '@/perf/react-profiler' import {ThreadRefsContext} from '../normal/context' import {useConversationCenter} from '../center-context' import { + ShownUsernameCacheContext, useConversationThreadID, useConversationThreadLoadNewerMessagesDueToScroll, useConversationThreadLoadOlderMessagesDueToScroll, @@ -20,48 +21,76 @@ 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' 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 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) + // Must be the same sticky cache the rows render with (wrapper.tsx): without it, a row that keeps + // its sticky header after a scroll-back load would be typed headerless here, mixing tall headered + // rows into the headerless pool and poisoning that pool's height average. + const shownCache = React.useContext(ShownUsernameCacheContext) 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, + shownCache, + you, + }) + return showUsername ? `${base}:hdr` : base }, - [threadStore] + [threadStore, you, shownCache] ) } @@ -75,6 +104,7 @@ const useThreadListData = () => containsLatestMessage: !s.moreToLoadForward, loaded: s.loaded, messageOrdinals: s.messageOrdinals ?? noOrdinals, + moreToLoadBack: s.moreToLoadBack, })) ) @@ -506,7 +536,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { ref={wrapperRef} > } data={(layoutReady ? messageOrdinals : noOrdinals) as unknown as T.Chat.Ordinal[]} renderItem={renderItem} @@ -521,7 +551,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}} } // Stays on while centered: the full thread response lands after the cached one and // re-measures rows above the target, which slides it out of view unless anchored. @@ -569,37 +601,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(() => { @@ -615,124 +629,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: [ { @@ -743,119 +721,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 + } + 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 @@ -877,102 +837,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} @@ -996,42 +1001,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/row-metadata.test.ts b/shared/chat/conversation/messages/row-metadata.test.ts index 475c62dac9aa..21b2af7fbacd 100644 --- a/shared/chat/conversation/messages/row-metadata.test.ts +++ b/shared/chat/conversation/messages/row-metadata.test.ts @@ -110,7 +110,9 @@ test('showUsername is derived from the previous ordinal and current message data ).toBe('bob') }) -test('row type preserves native recycle distinctions', () => { +test('row type only uses suffixes that are stable for the message lifetime', () => { + // pending flips to confirmed after every send; reactions toggle. Both would leave stale + // recycling-pool labels behind, so they must NOT affect the row type. const pending = makeTextMessage({ id: T.Chat.numberToMessageID(401), ordinal: T.Chat.numberToOrdinal(401), @@ -140,10 +142,10 @@ test('row type preserves native recycle distinctions', () => { reactions: new Map([[':+1:', makeReaction('bob', 5)]]), }) - expect(getMessageRowType(pending)).toBe('text:pending') - expect(getMessageRowType(failed)).toBe('text:pending') + expect(getMessageRowType(pending)).toBe('text') + expect(getMessageRowType(failed)).toBe('text:failed') expect(getMessageRowType(reply)).toBe('text:reply') - expect(getMessageRowType(reaction)).toBe('text:reactions') + expect(getMessageRowType(reaction)).toBe('text') }) test('showUsername recomputes from the current neighboring ordinal after inserts and deletes', () => { @@ -211,7 +213,7 @@ test('showUsername recomputes from the current neighboring ordinal after inserts ).toBe('bob') }) -test('row type combines pending, reply, and reaction suffixes after row edits', () => { +test('row type combines stable suffixes and is unchanged by send confirmation', () => { const reply = makeTextMessage({ id: T.Chat.numberToMessageID(600), ordinal: T.Chat.numberToOrdinal(600), @@ -223,6 +225,13 @@ test('row type combines pending, reply, and reaction suffixes after row edits', replyTo: reply, submitState: 'pending', }) + const failedReply = makeTextMessage({ + errorReason: 'send failed', + id: T.Chat.numberToMessageID(603), + ordinal: T.Chat.numberToOrdinal(603), + replyTo: reply, + submitState: 'failed', + }) const failedAttachment = makeAttachmentMessage({ errorReason: 'upload failed', id: T.Chat.numberToMessageID(602), @@ -230,14 +239,19 @@ test('row type combines pending, reply, and reaction suffixes after row edits', submitState: 'failed', }) - expect(getMessageRowType(pendingReplyWithReaction)).toBe('text:pending:reply:reactions') - expect(getMessageRowType(failedAttachment)).toBe('attachment:pending') + expect(getMessageRowType(pendingReplyWithReaction)).toBe('text:reply') + expect(getMessageRowType(failedReply)).toBe('text:failed:reply') + expect(getMessageRowType(failedAttachment)).toBe('attachment:failed') - const edited = makeTextMessage({ + // confirmation (pending → sent) must not change the type: the recycling pool label was recorded + // at allocation and is never updated in place + const confirmed = makeTextMessage({ id: T.Chat.numberToMessageID(601), ordinal: T.Chat.numberToOrdinal(601), + reactions: new Map([[':+1:', makeReaction('bob', 5)]]), + replyTo: reply, submitState: undefined, }) - expect(getMessageRowType(edited)).toBe('text') + expect(getMessageRowType(confirmed)).toBe('text:reply') }) diff --git a/shared/chat/conversation/messages/row-metadata.tsx b/shared/chat/conversation/messages/row-metadata.tsx index e81b9681d49b..a571c275d25c 100644 --- a/shared/chat/conversation/messages/row-metadata.tsx +++ b/shared/chat/conversation/messages/row-metadata.tsx @@ -70,11 +70,13 @@ export const getMessageRowRecycleType = ( let rowRecycleType = baseType let needsSpecificRecycleType = false - if ( - (message.type === 'text' || message.type === 'attachment') && - (message.submitState === 'pending' || message.submitState === 'failed') - ) { - rowRecycleType += ':pending' + // Only suffixes that are stable for the message's lifetime: the recycling pool label is recorded + // when a container is allocated and never updated on in-place changes, so a suffix that can flip + // (pending → confirmed after every send, reactions toggling on and off) leaves stale pool labels + // behind and recycled containers paint at the wrong pooled height. 'failed' is sticky until an + // explicit retry and 'reply' never changes. + if ((message.type === 'text' || message.type === 'attachment') && message.submitState === 'failed') { + rowRecycleType += ':failed' needsSpecificRecycleType = true } @@ -82,10 +84,6 @@ export const getMessageRowRecycleType = ( rowRecycleType += ':reply' needsSpecificRecycleType = true } - if (message.reactions?.size) { - rowRecycleType += ':reactions' - needsSpecificRecycleType = true - } return needsSpecificRecycleType ? rowRecycleType : undefined } 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 ? ( @@ -194,7 +211,6 @@ const NativeExplodingHeightRetainer = (p: Props) => { type AshTowerProps = { exploded: boolean explodedBy?: string - messageKey: string numImages: number } 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 6c536514896e..785adfd9965d 100644 --- a/shared/chat/conversation/messages/wrapper/sent.native.tsx +++ b/shared/chat/conversation/messages/wrapper/sent.native.tsx @@ -1,9 +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. +// 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 ( diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index d53766d24da6..da6403a67421 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -12,6 +12,7 @@ import ExplodingMeta from './exploding-meta' import LongPressable from './long-pressable' import {useMessagePopup} from '../message-popup' import ReactionsRow from '../reactions-rows' +import {useSyncRowLayout} from '../use-sync-row-layout' import SendIndicator from './send-indicator' import * as T from '@/constants/types' import capitalize from 'lodash/capitalize' @@ -560,6 +561,9 @@ function TextAndSiblings(p: TSProps) { const {hasReactions, popupAnchor, reactions, sendIndicatorFailed, sendIndicatorID} = p const {sendIndicatorSent, type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker, submitState} = p + // Reactions appearing and an unfurl card loading both grow the row after first paint; flush the + // measure so the list re-pins to the newest message instead of parking above it. + useSyncRowLayout(`${reactions?.size ?? 0}|${hasUnfurlList ? 1 : 0}`) const pressableProps = isMobile ? { onLongPress: decorate && shouldShowPopup ? showPopup : undefined, @@ -913,7 +917,6 @@ function RightSide(p: RProps) { export function WrapperMessage(p: WrapperMessageProps) { const {ordinal, bottomChildren, children, messageData: mdata} = p const {showPopup, showingPopup, popup, popupAnchor} = p - const [showingPicker, setShowingPicker] = React.useState(false) const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata const {canShowReactionsPopup, ecrType, exploded, explodesAt, forceExplodingRetainer, messageKey} = mdata @@ -924,9 +927,23 @@ export function WrapperMessage(p: WrapperMessageProps) { const {setEditing, setReplyTo, toggleMessageReaction} = mdata const {author, botAlias, isAdhocBot, showUsername, teamID, teamType, teamname, timestamp} = mdata - // captured at mount: only the row created by your send animates, and the tree - // shape stays stable when the message is later confirmed (youSent flips false) - const [animateSent] = React.useState(isMobile && mdata.youSent) + // Both pieces of per-row state are keyed to messageKey and reset when it changes: with + // recycleItems the component instance is REUSED for a different message, so plain mount-captured + // state would leak across rows (picker open on the wrong row, sent-animation wrapper stuck on). + // Same-message updates keep the captured value, so the tree stays stable when the message is + // later confirmed (youSent flips false). + const [rowState, setRowState] = React.useState(() => ({ + animateSent: isMobile && mdata.youSent, + key: messageKey, + showingPicker: false, + })) + if (rowState.key !== messageKey) { + setRowState({animateSent: isMobile && mdata.youSent, key: messageKey, showingPicker: false}) + } + const {animateSent, showingPicker} = rowState + const setShowingPicker = React.useCallback((s: boolean) => { + setRowState(prev => (prev.showingPicker === s ? prev : {...prev, showingPicker: s})) + }, []) const isHighlighted = showCenteredHighlight || isEditing const tsprops = { @@ -992,7 +1009,9 @@ export function WrapperMessage(p: WrapperMessageProps) { return ( - {animateSent ? {row} : row} + {/* keyed so a recycled container going straight from one of your sends to another remounts + the Animated.View — the entering animation only plays on mount */} + {animateSent ? {row} : row} {popup} ) diff --git a/shared/common-adapters/swipeable-row.native.tsx b/shared/common-adapters/swipeable-row.native.tsx index cc4eff674e92..2f692cce1cee 100644 --- a/shared/common-adapters/swipeable-row.native.tsx +++ b/shared/common-adapters/swipeable-row.native.tsx @@ -9,6 +9,7 @@ const springConfig = {friction: 20, tension: 150, useNativeDriver: false} as con const SwipeableRow = React.forwardRef(function SwipeableRow(props, ref) { 'use no memo' const {children, renderRightActions, onSwipeableOpenStartDrag, onSwipeableWillOpen, containerStyle} = props + const {enabled = true} = props const translationX = React.useRef(new Animated.Value(0)).current // Separate ref for current value since Animated.Value has no sync .value read @@ -128,7 +129,7 @@ const SwipeableRow = React.forwardRef(function Swipeabl )} - + {children} diff --git a/shared/common-adapters/swipeable-row.shared.tsx b/shared/common-adapters/swipeable-row.shared.tsx index 844e4b662fd7..d0e36b698eed 100644 --- a/shared/common-adapters/swipeable-row.shared.tsx +++ b/shared/common-adapters/swipeable-row.shared.tsx @@ -15,4 +15,7 @@ export type Props = { onSwipeableOpenStartDrag?: () => void onSwipeableWillOpen?: (direction: 'left') => void containerStyle?: ViewStyle + // When false, the swipe pan handlers are not attached (children stay mounted, so toggling this + // does NOT remount the row). Used to shed per-row touch evaluation during fast scroll. + enabled?: boolean }