From df5585d700932d143df8b68ad16a199352afaf4d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 09:36:00 -0400 Subject: [PATCH 01/13] chat thread: update imports for LegendList migration --- shared/chat/conversation/list-area/index.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 80b8b6d40c09..2f876c8e901c 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -6,7 +6,6 @@ import * as T from '@/constants/types' import Separator from '../messages/separator' import SpecialBottomMessage from '../messages/special-bottom-message' import SpecialTopMessage from '../messages/special-top-message' -import type {ItemType} from './index.shared' import {MessageRow} from '../messages/wrapper' import {PerfProfiler} from '@/perf/react-profiler' import {ScrollContext} from '../normal/context' @@ -19,15 +18,14 @@ import { useConversationThreadStore, } from '../thread-context' import {useThreadLoadStatusOptionsGetter} from '../thread-load-status-context' -import {findLast} from '@/util/arrays' import {getMessageRowType} from '../messages/row-metadata' import * as InputState from '../input-area/input-state' -import chunk from 'lodash/chunk' -import useIntersectionObserver from '@/util/use-intersection-observer' -import useResizeObserver from '@/util/use-resize-observer' +import sortedIndexOf from 'lodash/sortedIndexOf' import {copyToClipboard} from '@/util/storeless-actions' 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 {usingFlashList} from './flashlist-config' From 1529cb4e21d85eaac1c225f638c9c6b086e94631 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 09:44:20 -0400 Subject: [PATCH 02/13] chat thread: delete custom desktop waypoint/scroll system --- shared/chat/conversation/list-area/index.tsx | 733 +------------------ 1 file changed, 5 insertions(+), 728 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 2f876c8e901c..8f1c4aba1050 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -32,739 +32,18 @@ import {usingFlashList} from './flashlist-config' import {mobileTypingContainerHeight} from '../input-area/normal/typing' import {KeyboardChatScrollView} from 'react-native-keyboard-controller' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import type {ItemType} from './index.shared' + +const noOrdinals: ReadonlyArray = [] // ==================== DESKTOP ==================== -// Stub types to avoid dom lib dependency in native tsconfig -type ScrollDivRef = { - scrollTop: number - scrollHeight: number - clientHeight: number - offsetHeight: number - classList: {add: (s: string) => void; remove: (s: string) => void} - getBoundingClientRect: () => DOMRect - querySelectorAll: (sel: string) => ArrayLike - addEventListener: (event: string, handler: (...args: Array) => void, opts?: {passive?: boolean}) => void - removeEventListener: (event: string, handler: (...args: Array) => void) => void - closest: (sel: string) => unknown -} +// ==================== NATIVE ==================== + type RNFlatListRef = { scrollToOffset: (opts: {animated: boolean; offset: number}) => void scrollToItem: (opts: {animated: boolean; item: unknown; viewPosition?: number}) => void } -type WaypointElement = { - getBoundingClientRect: () => DOMRect - scrollIntoView: (opts: {block: string; inline: string}) => void - dataset: Record - closest: (sel: string) => WaypointElement | null | undefined - tagName?: string -} - -// Infinite scrolling list. -// We group messages into a series of Waypoints. When the waypoint exits the screen we replace it with a single div instead -const scrollOrdinalKey = 'scroll-ordinal-key' -const noOrdinals: ReadonlyArray = [] -const ordinalsInAWaypoint = 10 - -// We load the first thread automatically so in order to mark it read -// we send an action on the first mount once -let markedInitiallyLoaded = false - -const useDesktopScrolling = (p: { - containsLatestMessage: boolean - messageOrdinals: ReadonlyArray - listRef: React.RefObject - loaded: boolean - setListRef: (r: ScrollDivRef | null) => void - centeredOrdinal: T.Chat.Ordinal | undefined -}) => { - const {listRef, setListRef: _setListRef, containsLatestMessage} = p - const containsLatestMessageRef = React.useRef(containsLatestMessage) - React.useEffect(() => { - containsLatestMessageRef.current = containsLatestMessage - }, [containsLatestMessage]) - const {messageOrdinals, centeredOrdinal, loaded} = p - const numOrdinals = messageOrdinals.length - const getThreadLoadStatusOptions = useThreadLoadStatusOptionsGetter() - const loadNewerMessagesDueToScroll = useConversationThreadLoadNewerMessagesDueToScroll() - const loadNewerMessages = C.useThrottledCallback(() => { - loadNewerMessagesDueToScroll(numOrdinals, getThreadLoadStatusOptions()) - }, 200) - // if we scroll up try and keep the position - const scrollBottomOffsetRef = React.useRef(undefined) - - const loadOlderMessagesDueToScroll = useConversationThreadLoadOlderMessagesDueToScroll() - const loadOlderMessages = React.useCallback((numOrdinals: number) => { - loadOlderMessagesDueToScroll(numOrdinals, getThreadLoadStatusOptions()) - }, [loadOlderMessagesDueToScroll, getThreadLoadStatusOptions]) - const {markInitiallyLoadedThreadAsRead} = Hooks.useActions() - // pixels away from top/bottom to load/be locked - const listEdgeSlopBottom = 10 - const listEdgeSlopTop = 1000 - const isScrollingRef = React.useRef(false) - const ignoreOnScrollRef = React.useRef(false) - const lockedToBottomRef = React.useRef(true) - // so we can turn pointer events on / off - const pointerWrapperRef = React.useRef(null) - const setPointerWrapperRef = (r: ScrollDivRef | null) => { - pointerWrapperRef.current = r - } - const numOrdinalsRef = React.useRef(numOrdinals) - const loadOlderMessagesRef = React.useRef(loadOlderMessages) - const loadNewerMessagesRef = React.useRef(loadNewerMessages) - - const [isLockedToBottom] = React.useState(() => () => { - return lockedToBottomRef.current - }) - - const adjustScrollAndIgnoreOnScroll = (fn: () => void) => { - ignoreOnScrollRef.current = true - fn() - } - - const [checkForLoadMoreThrottled] = React.useState(() => () => { - const list = listRef.current - if (list) { - if (list.scrollTop < listEdgeSlopTop) { - loadOlderMessagesRef.current(numOrdinalsRef.current) - } else if ( - !containsLatestMessageRef.current && - !lockedToBottomRef.current && - list.scrollTop > list.scrollHeight - list.clientHeight - listEdgeSlopBottom - ) { - loadNewerMessagesRef.current() - } - } - }) - - const [scrollToBottomSync] = React.useState(() => () => { - lockedToBottomRef.current = true - const list = listRef.current - if (list) { - adjustScrollAndIgnoreOnScroll(() => { - list.scrollTop = list.scrollHeight - list.clientHeight - }) - } - }) - - const [scrollToBottom] = React.useState(() => () => { - scrollToBottomSync() - setTimeout(() => { - requestAnimationFrame(scrollToBottomSync) - }, 1) - }) - - const [performScrollToCentered] = React.useState(() => () => { - const list = listRef.current - const waypoint = list?.querySelectorAll(`[data-key=${scrollOrdinalKey}]`)[0] as WaypointElement | undefined - if (!list || !waypoint) return - const listRect = list.getBoundingClientRect() - const waypointRect = waypoint.getBoundingClientRect() - const targetScrollTop = - list.scrollTop + (waypointRect.top - listRect.top) - listRect.height / 2 + waypointRect.height / 2 - const clamped = Math.max(0, Math.min(targetScrollTop, list.scrollHeight - list.clientHeight)) - adjustScrollAndIgnoreOnScroll(() => { - list.scrollTop = clamped - }) - }) - - const [scrollToCentered] = React.useState(() => () => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performScrollToCentered() - setTimeout(performScrollToCentered, 50) - }) - }) - }) - - const [scrollDown] = React.useState(() => () => { - const list = listRef.current - if (list) { - adjustScrollAndIgnoreOnScroll(() => { - list.scrollTop += list.clientHeight - }) - } - }) - - const [scrollUp] = React.useState(() => () => { - lockedToBottomRef.current = false - const list = listRef.current - if (list) { - adjustScrollAndIgnoreOnScroll(() => { - list.scrollTop -= list.clientHeight - checkForLoadMoreThrottled() - }) - } - }) - - const scrollCheckRef = React.useRef>(undefined) - React.useEffect(() => { - return () => { - clearTimeout(scrollCheckRef.current) - } - }, []) - - // While scrolling we disable mouse events to speed things up. We avoid state so we don't re-render while doing this - const onScrollThrottled = C.useThrottledCallback( - () => { - clearTimeout(scrollCheckRef.current) - scrollCheckRef.current = setTimeout(() => { - if (isScrollingRef.current) { - isScrollingRef.current = false - if (pointerWrapperRef.current) { - pointerWrapperRef.current.classList.remove('scroll-ignore-pointer') - } - - const list = listRef.current - // are we locked on the bottom? only lock if we have latest messages - if (list && !centeredOrdinal && containsLatestMessageRef.current) { - lockedToBottomRef.current = - list.scrollHeight - list.clientHeight - list.scrollTop < listEdgeSlopBottom - } - } - }, 200) - - if (!isScrollingRef.current) { - // starting a scroll - isScrollingRef.current = true - if (pointerWrapperRef.current) { - pointerWrapperRef.current.classList.add('scroll-ignore-pointer') - } - } - }, - 100, - {leading: true, trailing: true} - ) - - const onScrollThrottledRef = React.useRef(onScrollThrottled) - React.useEffect(() => { - numOrdinalsRef.current = numOrdinals - loadOlderMessagesRef.current = loadOlderMessages - loadNewerMessagesRef.current = loadNewerMessages - onScrollThrottledRef.current = onScrollThrottled - }, [numOrdinals, loadOlderMessages, loadNewerMessages, onScrollThrottled]) - - // we did it so we should ignore it - const programaticScrollRef = React.useRef(false) - - const [onScroll] = React.useState(() => () => { - if (programaticScrollRef.current) { - programaticScrollRef.current = false - return - } - if (listRef.current) { - scrollBottomOffsetRef.current = Math.max(0, listRef.current.scrollHeight - listRef.current.scrollTop) - } else { - scrollBottomOffsetRef.current = undefined - } - if (ignoreOnScrollRef.current) { - ignoreOnScrollRef.current = false - return - } - // quickly set to false to assume we're not locked. if we are the throttled one will set it to true - lockedToBottomRef.current = false - checkForLoadMoreThrottled() - onScrollThrottledRef.current() - }) - - const setListRef = (list: ScrollDivRef | null) => { - if (listRef.current) { - listRef.current.removeEventListener('scroll', onScroll) - } - if (list) { - list.addEventListener('scroll', onScroll, {passive: true}) - } - _setListRef(list) - } - - React.useEffect(() => { - return () => { - onScrollThrottled.cancel() - } - }, [onScrollThrottled]) - - const [didFirstLoad, setDidFirstLoad] = React.useState(false) - - const prevLoadedRef = React.useRef(false) - // Handle scrolling when loaded becomes true. Scroll to centered ordinal if present, else bottom - React.useLayoutEffect(() => { - const justLoaded = loaded && !prevLoadedRef.current - prevLoadedRef.current = loaded - - if (!justLoaded) return - - if (!markedInitiallyLoaded) { - markedInitiallyLoaded = true - markInitiallyLoadedThreadAsRead() - } - - setDidFirstLoad(true) - if (centeredOrdinal) { - lockedToBottomRef.current = false - scrollToCentered() - } else { - scrollToBottomSync() - requestAnimationFrame(() => { - scrollToBottomSync() - }) - } - }, [loaded, centeredOrdinal, markInitiallyLoadedThreadAsRead, scrollToBottomSync, scrollToCentered]) - - const firstOrdinal = messageOrdinals[0] - const prevFirstOrdinalRef = React.useRef(firstOrdinal) - const ordinalsLength = messageOrdinals.length - const prevOrdinalLengthRef = React.useRef(ordinalsLength) - - // called after dom update, to apply value - React.useLayoutEffect(() => { - const list = listRef.current - // no items? don't be locked - if (!ordinalsLength) { - lockedToBottomRef.current = false - return - } - - // detect if older messages were added (first ordinal changed = content added at top) - const olderMessagesAdded = prevFirstOrdinalRef.current !== firstOrdinal - prevFirstOrdinalRef.current = firstOrdinal - - // didn't scroll up - if (ordinalsLength === prevOrdinalLengthRef.current) { - return - } - prevOrdinalLengthRef.current = ordinalsLength - // maintain scroll position only when older messages added at top - // when newer messages added at bottom, browser naturally keeps position - if ( - olderMessagesAdded && - list && - !centeredOrdinal && // ignore this if we're scrolling and we're doing a search - !isLockedToBottom() && - scrollBottomOffsetRef.current !== undefined - ) { - programaticScrollRef.current = true - const newTop = list.scrollHeight - scrollBottomOffsetRef.current - list.scrollTop = newTop - } - return undefined - // we want this to fire when the ordinals change - }, [centeredOrdinal, ordinalsLength, isLockedToBottom, listRef, firstOrdinal]) - - // Also handle centered ordinal changing while already loaded (e.g. from thread search results) - const prevCenteredOrdinal = React.useRef(centeredOrdinal) - const wasLoadedRef = React.useRef(loaded) - React.useEffect(() => { - const wasLoaded = wasLoadedRef.current - const changed = prevCenteredOrdinal.current !== centeredOrdinal - prevCenteredOrdinal.current = centeredOrdinal - wasLoadedRef.current = loaded - - // Only scroll if we were already loaded and ordinal changed - // (the load effect handles scrolling when loaded transitions to true) - if (!wasLoaded || !loaded || !changed) return - - if (centeredOrdinal) { - lockedToBottomRef.current = false - scrollToCentered() - } else if (containsLatestMessage) { - lockedToBottomRef.current = true - scrollToBottom() - } - }, [centeredOrdinal, loaded, containsLatestMessage, scrollToCentered, scrollToBottom]) - - const {setScrollRef} = React.useContext(ScrollContext) - React.useEffect(() => { - setScrollRef({scrollDown, scrollToBottom, scrollUp}) - }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) - - // go to editing message - const editingOrdinal = InputState.useConversationInput(s => s.editing) - const lastEditingOrdinalRef = React.useRef(0) - React.useEffect(() => { - if (lastEditingOrdinalRef.current === editingOrdinal) return - lastEditingOrdinalRef.current = editingOrdinal - if (!editingOrdinal) return - const idx = messageOrdinals.indexOf(editingOrdinal) - if (idx !== -1) { - const waypoints = listRef.current?.querySelectorAll('[data-key]') - if (waypoints) { - // find an id that should be our parent - const toFind = Math.floor(T.Chat.ordinalToNumber(editingOrdinal) / ordinalsInAWaypoint) - const allWaypoints = Array.from(waypoints) as Array - const found = findLast(allWaypoints, w => { - const key = w.dataset['key'] - return key !== undefined && parseInt(key, 10) === toFind - }) - found?.scrollIntoView({block: 'center', inline: 'nearest'}) - } - } - }, [editingOrdinal, messageOrdinals, listRef]) - - void chunk - - return {didFirstLoad, isLockedToBottom, scrollToBottom, setListRef, setPointerWrapperRef} -} - -const useDesktopItems = (p: { - centeredHighlightOrdinal: T.Chat.Ordinal | undefined - messageOrdinals: ReadonlyArray - centeredOrdinal: T.Chat.Ordinal | undefined - editingOrdinal: T.Chat.Ordinal | undefined -}) => { - const {centeredHighlightOrdinal, centeredOrdinal, editingOrdinal, messageOrdinals} = p - const waypointData = React.useMemo(() => { - const items: Array<{key: string; ordinals: Array}> = [] - const numOrdinals = messageOrdinals.length - - let ordinals: Array = [] - let lastBucket: number | undefined - let baseIndex = 0 // this is used to de-dupe the waypoint around the centered ordinal - messageOrdinals.forEach((ordinal, idx) => { - // Centered ordinal is where we want the view to be centered on when jumping around in the thread. - const isCenteredOrdinal = ordinal === centeredOrdinal - - // We want to keep the mapping of ordinal to bucket fixed always - const bucket = Math.floor(T.Chat.ordinalToNumber(ordinal) / ordinalsInAWaypoint) - if (lastBucket === undefined) { - lastBucket = bucket - } - const needNextWaypoint = bucket !== lastBucket - const isLastItem = idx === numOrdinals - 1 - if (needNextWaypoint || isLastItem || isCenteredOrdinal) { - if (isLastItem && !isCenteredOrdinal) { - // we don't want to add the centered ordinal here, since it will go into its own waypoint - ordinals.push(ordinal) - } - if (ordinals.length) { - // don't allow buckets to be too big; sends can put more ordinals than expected in one bucket - const chunks = chunk(ordinals, ordinalsInAWaypoint) - chunks.forEach((toAdd, cidx) => { - const key = `${lastBucket || ''}:${cidx + baseIndex}` - items.push({key, ordinals: toAdd}) - }) - // we pass previous so the OrdinalWaypoint can render the top item correctly - ordinals = [] - lastBucket = bucket - } - } - // If this is the centered ordinal, it goes into its own waypoint so we can easily scroll to it - if (isCenteredOrdinal) { - items.push({key: scrollOrdinalKey, ordinals: [ordinal]}) - lastBucket = 0 - baseIndex++ // push this up if we drop the centered ordinal waypoint - } else { - ordinals.push(ordinal) - } - }) - - return items - }, [centeredOrdinal, messageOrdinals]) - - const rowRenderer = (ordinal: T.Chat.Ordinal) => { - return ( -
- - -
- ) - } - - const items = [ - , - ...waypointData.map(({key, ordinals}) => ( - - )), - , - ] - - return items -} - -const DesktopThreadWrapper = function DesktopThreadWrapper() { - const editingOrdinal = InputState.useConversationInput(s => s.editing) - const conversationIDKey = useConversationThreadID() - const data = useConversationThreadSelector( - C.useShallow(s => ({ - containsLatestMessage: !s.moreToLoadForward, - loaded: s.loaded, - messageOrdinals: s.messageOrdinals ?? noOrdinals, - })) - ) - const {centeredHighlightOrdinal, centeredOrdinal} = useConversationCenter() - const {containsLatestMessage, messageOrdinals, loaded} = data - const listRef = React.useRef(null) - const _setListRef = (r: ScrollDivRef | null) => { - listRef.current = r - } - const {isLockedToBottom, scrollToBottom, setListRef, didFirstLoad, setPointerWrapperRef} = useDesktopScrolling({ - centeredOrdinal, - containsLatestMessage, - listRef, - loaded, - messageOrdinals, - setListRef: _setListRef, - }) - - const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length) - const onCopyCapture = (e: React.BaseSyntheticEvent) => { - type DocGlobal = { - createElement: (tag: string) => { - appendChild: (n: unknown) => void - querySelectorAll: (sel: string) => ArrayLike<{parentNode?: {removeChild?: (n: unknown) => void; replaceChild?: (a: unknown, b: unknown) => void}}> - textContent: string | null - remove: () => void - } - } - type WinGlobal = { - getSelection: () => { - getRangeAt: (i: number) => {cloneContents: () => unknown} - } | null - } - e.preventDefault() - const doc = (globalThis as unknown as {document?: DocGlobal}).document - const win = (globalThis as unknown as {window?: WinGlobal}).window - const sel = win?.getSelection() - if (!sel || !doc) return - const temp = sel.getRangeAt(0).cloneContents() - const tempDiv = doc.createElement('div') - tempDiv.appendChild(temp) - const styles = tempDiv.querySelectorAll('style') - Array.from(styles).forEach(s => { - s.parentNode?.removeChild?.(s) - }) - const imgs = tempDiv.querySelectorAll('img') - Array.from(imgs).forEach(i => { - const dummy = doc.createElement('div') - dummy.textContent = '\n[IMAGE]\n' - i.parentNode?.replaceChild?.(dummy, i) - }) - const tc = tempDiv.textContent - if (tc) { - copyToClipboard(tc) - } - tempDiv.remove() - } - const {focusInput} = React.useContext(FocusContext) - const handleListClick = (ev: React.MouseEvent) => { - const target = ev.target as unknown as WaypointElement | null - const tagName = (target as {tagName?: string} | null)?.tagName?.toUpperCase() - if ( - tagName === 'INPUT' || - tagName === 'TEXTAREA' || - target?.closest('[data-search-filter="true"]') - ) { - return - } - - const sel = (globalThis as unknown as {getSelection?: () => {isCollapsed: boolean} | null}).getSelection?.() - if (sel?.isCollapsed) { - focusInput() - } - } - - const items = useDesktopItems({ - centeredHighlightOrdinal, - centeredOrdinal, - editingOrdinal, - messageOrdinals, - }) - const setListContents = useDesktopHandleListResize({ - centeredOrdinal, - isLockedToBottom, - scrollToBottom, - setPointerWrapperRef, - useResizeObserver, - }) - - - return ( - -
-
void} - > -
}> - {items} -
-
- {jumpToRecent} -
-
- ) -} - -const useDesktopHandleListResize = (p: { - centeredOrdinal: T.Chat.Ordinal | undefined - isLockedToBottom: () => boolean - scrollToBottom: () => void - setPointerWrapperRef: (r: ScrollDivRef | null) => void - useResizeObserver: (ref: React.RefObject, cb: (e: {contentRect: {height: number}}) => void) => void -}) => { - const {isLockedToBottom, scrollToBottom, setPointerWrapperRef, centeredOrdinal, useResizeObserver} = p - const lastResizeHeightRef = React.useRef(0) - const onListSizeChanged = function onListSizeChanged(contentRect: {height: number}) { - const {height} = contentRect - if (height !== lastResizeHeightRef.current) { - lastResizeHeightRef.current = height - if (isLockedToBottom() && !centeredOrdinal) { - scrollToBottom() - } - } - } - - const pointerWrapperRef = React.useRef(null) - const setListContents = (listContents: ScrollDivRef | null) => { - setPointerWrapperRef(listContents) - pointerWrapperRef.current = listContents - } - - useResizeObserver(pointerWrapperRef as React.RefObject, e => onListSizeChanged(e.contentRect)) - - return setListContents -} - -type DesktopOrdinalWaypointProps = { - id: string - rowRenderer: (ordinal: T.Chat.Ordinal) => React.ReactNode - ordinals: Array -} - -const colorWaypoints = __DEV__ && (false as boolean) -const waypointColors = new Array() -if (colorWaypoints) { - for (let i = 0; i < 10; ++i) { - console.log('COLOR WAYPOINTS ON!!!!!!!!!!!!!!!!') - waypointColors.push(`rgb(${Math.random() * 255},${Math.random() * 255},${Math.random() * 255})`) - } -} - -// Render unmeasured waypoints once so initial scroll positioning uses real heights. -// After measuring, off-screen waypoints can collapse back to placeholders. -const DesktopOrdinalWaypoint = function DesktopOrdinalWaypoint(p: DesktopOrdinalWaypointProps) { - const {ordinals, id, rowRenderer} = p - const estimatedHeight = 40 * ordinals.length - const [height, setHeight] = React.useState(-1) - const [wRef, setRef] = React.useState(null) - const [setContentRef] = React.useState(() => (ref: ScrollDivRef | null) => { - if (ref) { - const height = ref.offsetHeight - if (height) { - setHeight(oldHeight => (oldHeight === height ? oldHeight : height)) - } - } - setRef(ref) - }) - const root = wRef?.closest('.chat-scroller') as HTMLElement | undefined - const {isIntersecting} = useIntersectionObserver(wRef as unknown as React.RefObject, {root}) - const renderMessages = height < 0 || isIntersecting - let content: React.ReactElement - - if (renderMessages) { - content = - } else { - content = - } - - if (colorWaypoints) { - let cidx = parseInt(id) - if (isNaN(cidx)) cidx = 0 - cidx = cidx % waypointColors.length - return
{content}
- } else { - return content - } -} - -type DesktopContentType = { - id: string - ordinals: Array - rowRenderer: (o: T.Chat.Ordinal) => React.ReactNode - ref?: React.Ref -} -function DesktopContent(p: DesktopContentType) { - const {id, ordinals, rowRenderer, ref} = p - // Apply data-key to the dom node so we can search for editing messages - return ( - -
}> - {ordinals.map((o): React.ReactNode => rowRenderer(o))} -
-
- ) -} - -type DesktopDummyType = { - id: string - height: number - ref?: React.Ref -} -function DesktopDummy(p: DesktopDummyType) { - const {id, height, ref} = p - // Apply data-key to the dom node so we can search for editing messages - return
} /> -} - -const desktopStyles = Kb.Styles.styleSheetCreate( - () => - ({ - container: Kb.Styles.platformStyles({ - isElectron: { - ...Kb.Styles.globalStyles.flexBoxColumn, - // containment hints so we can scroll faster - contain: 'layout style', - flex: 1, - position: 'relative', - }, - }), - list: Kb.Styles.platformStyles({ - isElectron: { - ...Kb.Styles.globalStyles.fillAbsolute, - outline: 'none', - overflowX: 'hidden', - overflowY: 'auto', - overscrollBehavior: 'contain', - paddingBottom: 16, - // get our own layer so we can scroll faster - willChange: 'transform', - }, - }), - listContents: Kb.Styles.platformStyles({ - isElectron: { - contain: 'layout style', - width: '100%', - }, - }), - }) as const -) - -const DesktopThreadWrapperWithProfiler = () => ( - - - -) - -// ==================== NATIVE ==================== const useInvertedMessageOrdinals = (messageOrdinals?: ReadonlyArray) => { const source = messageOrdinals ?? noOrdinals @@ -1052,6 +331,4 @@ const useNativeSafeOnViewableItemsChanged = (onEndReached: () => void, numOrdina return onViewableItemsChanged } -export const DEBUGDump = () => {} - export default isMobile ? NativeConversationList : DesktopThreadWrapperWithProfiler From 620d71b421009bfd25da28343253f838e42f95ea Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 09:50:21 -0400 Subject: [PATCH 03/13] chat thread: migrate desktop to LegendList, remove custom virtualization --- shared/chat/conversation/list-area/index.tsx | 311 +++++++++++++++++++ 1 file changed, 311 insertions(+) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 8f1c4aba1050..51d0a75d547b 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -38,6 +38,317 @@ const noOrdinals: ReadonlyArray = [] // ==================== DESKTOP ==================== +const DesktopThreadWrapper = function DesktopThreadWrapper() { + const editingOrdinal = InputState.useConversationInput(s => s.editing) + const conversationIDKey = useConversationThreadID() + const data = useConversationThreadSelector( + C.useShallow(s => ({ + containsLatestMessage: !s.moreToLoadForward, + loaded: s.loaded, + messageOrdinals: s.messageOrdinals ?? noOrdinals, + })) + ) + const {centeredHighlightOrdinal, centeredOrdinal} = useConversationCenter() + const {containsLatestMessage, messageOrdinals, loaded} = data + + const listRef = React.useRef(null) + const wrapperRef = React.useRef(null) + const [didFirstLoad, setDidFirstLoad] = React.useState(false) + + const {markInitiallyLoadedThreadAsRead} = Hooks.useActions() + const loadNewerMessagesDueToScroll = useConversationThreadLoadNewerMessagesDueToScroll() + const loadOlderMessagesDueToScroll = useConversationThreadLoadOlderMessagesDueToScroll() + const getThreadLoadStatusOptions = useThreadLoadStatusOptionsGetter() + const threadStore = useConversationThreadStore() + + // Stable refs for values used inside stable callbacks + const containsLatestMessageRef = React.useRef(containsLatestMessage) + React.useEffect(() => { + containsLatestMessageRef.current = containsLatestMessage + }, [containsLatestMessage]) + + const numOrdinalsRef = React.useRef(messageOrdinals.length) + React.useEffect(() => { + numOrdinalsRef.current = messageOrdinals.length + }, [messageOrdinals.length]) + + const messageOrdinalsRef = React.useRef(messageOrdinals) + React.useEffect(() => { + messageOrdinalsRef.current = messageOrdinals + }, [messageOrdinals]) + + // Item type for LegendList recycling pool separation + const getItemType = React.useCallback( + (ordinal: T.Chat.Ordinal) => { + const {messageMap, messageTypeMap} = threadStore.getState() + const message = messageMap.get(ordinal) + return message ? getMessageRowType(message, messageTypeMap.get(ordinal)) : (messageTypeMap.get(ordinal) ?? 'text') + }, + [threadStore] + ) + + // Imperative scroll for ScrollContext + const scrollToBottom = React.useCallback(() => { + void listRef.current?.scrollToEnd({animated: false}) + }, []) + + const scrollUp = React.useCallback(() => { + const state = listRef.current?.getState() + if (!state) return + void listRef.current?.scrollToOffset({animated: false, offset: Math.max(0, state.scroll - state.scrollLength)}) + }, []) + + const scrollDown = React.useCallback(() => { + const state = listRef.current?.getState() + if (!state) return + void listRef.current?.scrollToOffset({animated: false, offset: state.scroll + state.scrollLength}) + }, []) + + const {setScrollRef} = React.useContext(ScrollContext) + React.useEffect(() => { + setScrollRef({scrollDown, scrollToBottom, scrollUp}) + }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) + + // Disable pointer events during scroll for performance + const isScrollingRef = React.useRef(false) + const scrollStopTimerRef = React.useRef>(undefined) + + const onScroll = C.useThrottledCallback( + (_event: unknown) => { + clearTimeout(scrollStopTimerRef.current) + scrollStopTimerRef.current = setTimeout(() => { + isScrollingRef.current = false + ;(wrapperRef.current as unknown as {classList: {remove: (c: string) => void}} | null)?.classList.remove('scroll-ignore-pointer') + }, 200) + if (!isScrollingRef.current) { + isScrollingRef.current = true + ;(wrapperRef.current as unknown as {classList: {add: (c: string) => void}} | null)?.classList.add('scroll-ignore-pointer') + } + }, + 100, + {leading: true, trailing: true} + ) + + React.useEffect(() => () => { + onScroll.cancel() + }, [onScroll]) + + // Load older messages when scrolled near the top (first 3 items visible) + const onViewableItemsChanged = C.useDebouncedCallback( + ({viewableItems}: {viewableItems: Array<{index: number; item: T.Chat.Ordinal}>}) => { + if ((viewableItems[0]?.index ?? Infinity) < 3) { + loadOlderMessagesDueToScroll(numOrdinalsRef.current, getThreadLoadStatusOptions()) + } + }, + 200 + ) + + // Load newer messages when scrolled to the end (only when not at latest) + const onEndReached = C.useThrottledCallback(() => { + if (!containsLatestMessageRef.current) { + loadNewerMessagesDueToScroll(numOrdinalsRef.current, getThreadLoadStatusOptions()) + } + }, 200) + + React.useEffect(() => () => { + onEndReached.cancel() + }, [onEndReached]) + + // Scroll to centered ordinal when it changes (search / thread navigation) + const prevCenteredOrdinalRef = React.useRef(centeredOrdinal) + React.useEffect(() => { + const changed = prevCenteredOrdinalRef.current !== centeredOrdinal + prevCenteredOrdinalRef.current = centeredOrdinal + if (!changed || !loaded) return + if (centeredOrdinal) { + const idx = sortedIndexOf(messageOrdinalsRef.current as unknown as number[], centeredOrdinal as unknown as number) + if (idx >= 0) { + void listRef.current?.scrollToIndex({animated: true, index: idx, viewPosition: 0.5}) + } + } else if (containsLatestMessage) { + void listRef.current?.scrollToEnd({animated: false}) + } + }, [centeredOrdinal, loaded, containsLatestMessage]) + + // Scroll to the message being edited + const lastEditingOrdinalRef = React.useRef(undefined) + React.useEffect(() => { + if (lastEditingOrdinalRef.current === editingOrdinal) return + lastEditingOrdinalRef.current = editingOrdinal + if (!editingOrdinal) return + const idx = sortedIndexOf(messageOrdinalsRef.current as unknown as number[], editingOrdinal as unknown as number) + if (idx >= 0) { + void listRef.current?.scrollToIndex({animated: true, index: idx, viewPosition: 0.5}) + } + }, [editingOrdinal]) + + // Mark thread as read after initial load (once per conversation) + const markedReadRef = React.useRef(false) + React.useLayoutEffect(() => { + markedReadRef.current = false + }, [conversationIDKey]) + + const onLoad = React.useCallback(() => { + setDidFirstLoad(true) + if (!markedReadRef.current) { + markedReadRef.current = true + markInitiallyLoadedThreadAsRead() + } + }, [markInitiallyLoadedThreadAsRead]) + + // Extra data drives re-renders for highlight/edit state changes + const extraData = React.useMemo( + () => ({centeredHighlightOrdinal, editingOrdinal}), + [centeredHighlightOrdinal, editingOrdinal] + ) + + const renderItem = React.useCallback( + ({item: ordinal}: {item: T.Chat.Ordinal}) => ( +
+ + +
+ ), + [centeredHighlightOrdinal, editingOrdinal] + ) + + const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length) + + const {focusInput} = React.useContext(FocusContext) + const handleListClick = (ev: React.MouseEvent) => { + const target = ev.target as {closest?: (s: string) => unknown; tagName?: string} | null + const tagName = target?.tagName?.toUpperCase() + if (tagName === 'INPUT' || tagName === 'TEXTAREA' || target?.closest?.('[data-search-filter="true"]')) return + const sel = (globalThis as unknown as {getSelection?: () => {isCollapsed: boolean} | null}).getSelection?.() + if (sel?.isCollapsed) focusInput() + } + + const onCopyCapture = (e: React.BaseSyntheticEvent) => { + type DocGlobal = { + createElement: (tag: string) => { + appendChild: (n: unknown) => void + querySelectorAll: (sel: string) => ArrayLike<{parentNode?: {removeChild?: (n: unknown) => void; replaceChild?: (a: unknown, b: unknown) => void}}> + textContent: string | null + remove: () => void + } + } + type WinGlobal = { + getSelection: () => { + getRangeAt: (i: number) => {cloneContents: () => unknown} + } | null + } + e.preventDefault() + const doc = (globalThis as unknown as {document?: DocGlobal}).document + const win = (globalThis as unknown as {window?: WinGlobal}).window + const sel = win?.getSelection() + if (!sel || !doc) return + const temp = sel.getRangeAt(0).cloneContents() + const tempDiv = doc.createElement('div') + tempDiv.appendChild(temp) + const styles = tempDiv.querySelectorAll('style') + Array.from(styles).forEach(s => { + s.parentNode?.removeChild?.(s) + }) + const imgs = tempDiv.querySelectorAll('img') + Array.from(imgs).forEach(i => { + const dummy = doc.createElement('div') + dummy.textContent = '\n[IMAGE]\n' + i.parentNode?.replaceChild?.(dummy, i) + }) + const tc = tempDiv.textContent + if (tc) { + copyToClipboard(tc) + } + tempDiv.remove() + } + + // When a centeredOrdinal is set at mount, start there; otherwise start at the end + const initialScrollIndex = centeredOrdinal !== undefined + ? { + index: Math.max(0, sortedIndexOf(messageOrdinals as unknown as number[], centeredOrdinal as unknown as number)), + viewPosition: 0.5 as const, + } + : undefined + + return ( + +
+ } + data={messageOrdinals as unknown as T.Chat.Ordinal[]} + renderItem={renderItem} + keyExtractor={(ordinal: T.Chat.Ordinal) => String(ordinal)} + getItemType={getItemType} + ListHeaderComponent={SpecialTopMessage} + ListFooterComponent={SpecialBottomMessage} + recycleItems={true} + drawDistance={250} + estimatedItemSize={72} + extraData={extraData} + style={{...Kb.Styles.castStyleDesktop(desktopStyles.list), opacity: didFirstLoad ? 1 : 0}} + initialScrollAtEnd={initialScrollIndex === undefined} + initialScrollIndex={initialScrollIndex} + maintainScrollAtEnd={centeredOrdinal ? false : {on: {dataChange: true, itemLayout: true}}} + maintainVisibleContentPosition={{data: true}} + onLoad={onLoad} + onScroll={onScroll as unknown as (e: unknown) => void} + onEndReached={onEndReached} + onViewableItemsChanged={onViewableItemsChanged as unknown as (info: unknown) => void} + /> + {jumpToRecent} +
+
+ ) +} + +const desktopStyles = Kb.Styles.styleSheetCreate( + () => + ({ + container: Kb.Styles.platformStyles({ + isElectron: { + ...Kb.Styles.globalStyles.flexBoxColumn, + contain: 'layout style', + flex: 1, + position: 'relative', + }, + }), + list: Kb.Styles.platformStyles({ + isElectron: { + height: '100%', + outline: 'none', + overflowY: 'auto', + overscrollBehavior: 'contain', + paddingBottom: 16, + scrollbarGutter: 'stable', + width: '100%', + willChange: 'transform', + }, + }), + }) as const +) + +const DesktopThreadWrapperWithProfiler = () => ( + + + +) + // ==================== NATIVE ==================== type RNFlatListRef = { From b8e11a28194909e2b17fffaf304529c584677342 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 10:13:50 -0400 Subject: [PATCH 04/13] chat thread: remove itemLayout from maintainScrollAtEnd to fix ResizeObserver loop --- 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 51d0a75d547b..aac51cd75c5a 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -304,7 +304,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { style={{...Kb.Styles.castStyleDesktop(desktopStyles.list), opacity: didFirstLoad ? 1 : 0}} initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex} - maintainScrollAtEnd={centeredOrdinal ? false : {on: {dataChange: true, itemLayout: true}}} + maintainScrollAtEnd={centeredOrdinal ? false : {on: {dataChange: true}}} maintainVisibleContentPosition={{data: true}} onLoad={onLoad} onScroll={onScroll as unknown as (e: unknown) => void} From abd9e27c7eaee7cfddb082190327adf13f136028 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 10:52:37 -0400 Subject: [PATCH 05/13] remove scroll pointer changes --- shared/chat/conversation/list-area/index.tsx | 36 ++++---------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index aac51cd75c5a..204d209e07a1 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -52,7 +52,6 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const {containsLatestMessage, messageOrdinals, loaded} = data const listRef = React.useRef(null) - const wrapperRef = React.useRef(null) const [didFirstLoad, setDidFirstLoad] = React.useState(false) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions() @@ -109,29 +108,6 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { setScrollRef({scrollDown, scrollToBottom, scrollUp}) }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) - // Disable pointer events during scroll for performance - const isScrollingRef = React.useRef(false) - const scrollStopTimerRef = React.useRef>(undefined) - - const onScroll = C.useThrottledCallback( - (_event: unknown) => { - clearTimeout(scrollStopTimerRef.current) - scrollStopTimerRef.current = setTimeout(() => { - isScrollingRef.current = false - ;(wrapperRef.current as unknown as {classList: {remove: (c: string) => void}} | null)?.classList.remove('scroll-ignore-pointer') - }, 200) - if (!isScrollingRef.current) { - isScrollingRef.current = true - ;(wrapperRef.current as unknown as {classList: {add: (c: string) => void}} | null)?.classList.add('scroll-ignore-pointer') - } - }, - 100, - {leading: true, trailing: true} - ) - - React.useEffect(() => () => { - onScroll.cancel() - }, [onScroll]) // Load older messages when scrolled near the top (first 3 items visible) const onViewableItemsChanged = C.useDebouncedCallback( @@ -286,7 +262,6 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { style={Kb.Styles.castStyleDesktop(desktopStyles.container)} onClick={handleListClick} onCopyCapture={onCopyCapture} - ref={wrapperRef} > void} onEndReached={onEndReached} onViewableItemsChanged={onViewableItemsChanged as unknown as (info: unknown) => void} /> @@ -322,10 +296,12 @@ const desktopStyles = Kb.Styles.styleSheetCreate( ({ container: Kb.Styles.platformStyles({ isElectron: { - ...Kb.Styles.globalStyles.flexBoxColumn, - contain: 'layout style', - flex: 1, - position: 'relative', + bottom: 0, + left: 0, + overflow: 'hidden', + position: 'absolute', + right: 0, + top: 0, }, }), list: Kb.Styles.platformStyles({ From 490ae4f763c9113d731c352daf418aa03eff84f1 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 10:52:44 -0400 Subject: [PATCH 06/13] WIP --- plans/todo.md | 1 - 1 file changed, 1 deletion(-) diff --git a/plans/todo.md b/plans/todo.md index f50c97262620..b4c10e4ed722 100644 --- a/plans/todo.md +++ b/plans/todo.md @@ -1,6 +1,5 @@ go screen by screen and find cleanup any leftover zustand store -ios push to convo broken legend list for chat thread desktop legend list for chat thread native update deps From 7037e7db6d3ac36fd3ade792fc8c29454ed13df9 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 11:11:30 -0400 Subject: [PATCH 07/13] fix scroll hover stop. fix overlay being cut off --- shared/chat/chat.css | 2 +- shared/chat/conversation/conversation.css | 5 +++++ shared/chat/conversation/list-area/index.tsx | 23 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/shared/chat/chat.css b/shared/chat/chat.css index 9b3923272963..c1139ebbf7cb 100644 --- a/shared/chat/chat.css +++ b/shared/chat/chat.css @@ -28,6 +28,6 @@ } } -.scroll-ignore-pointer { +.scroll-ignore-pointer .WrapperMessage-hoverBox { pointer-events: none; } diff --git a/shared/chat/conversation/conversation.css b/shared/chat/conversation/conversation.css index 7acabe7b7e7c..7927a893e233 100644 --- a/shared/chat/conversation/conversation.css +++ b/shared/chat/conversation/conversation.css @@ -41,6 +41,11 @@ } } +[data-testid='message-list'] [data-index]:has(.WrapperMessage-hoverBox:hover) { + contain: layout style !important; + overflow: visible !important; +} + .WrapperMessage-hoverBox { padding: 3px 16px 3px 0; display: flex; diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 204d209e07a1..158273d2a120 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -52,6 +52,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const {containsLatestMessage, messageOrdinals, loaded} = data const listRef = React.useRef(null) + const wrapperRef = React.useRef(null) const [didFirstLoad, setDidFirstLoad] = React.useState(false) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions() @@ -108,6 +109,26 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { setScrollRef({scrollDown, scrollToBottom, scrollUp}) }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) + const isScrollingRef = React.useRef(false) + const scrollStopTimerRef = React.useRef>(undefined) + const onScroll = C.useThrottledCallback( + (_event: unknown) => { + clearTimeout(scrollStopTimerRef.current) + scrollStopTimerRef.current = setTimeout(() => { + isScrollingRef.current = false + ;(wrapperRef.current as unknown as {classList: {remove: (c: string) => void}} | null)?.classList.remove('scroll-ignore-pointer') + }, 200) + if (!isScrollingRef.current) { + isScrollingRef.current = true + ;(wrapperRef.current as unknown as {classList: {add: (c: string) => void}} | null)?.classList.add('scroll-ignore-pointer') + } + }, + 100, + {leading: true, trailing: true} + ) + React.useEffect(() => () => { + onScroll.cancel() + }, [onScroll]) // Load older messages when scrolled near the top (first 3 items visible) const onViewableItemsChanged = C.useDebouncedCallback( @@ -262,6 +283,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { style={Kb.Styles.castStyleDesktop(desktopStyles.container)} onClick={handleListClick} onCopyCapture={onCopyCapture} + ref={wrapperRef} > void} onEndReached={onEndReached} onViewableItemsChanged={onViewableItemsChanged as unknown as (info: unknown) => void} /> From 4c9a3aabe541fd3be9f78e8e6c1c3d684d0279b0 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 11:14:40 -0400 Subject: [PATCH 08/13] handle unknown duration better --- shared/chat/audio/audio-video.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/chat/audio/audio-video.tsx b/shared/chat/audio/audio-video.tsx index 14dca8d7b226..1c2f5de40633 100644 --- a/shared/chat/audio/audio-video.tsx +++ b/shared/chat/audio/audio-video.tsx @@ -52,8 +52,8 @@ const DesktopAudioVideo = (props: Props) => { const onTimeUpdate = () => { const ct = vidRef.current?.currentTime ?? 0 - const dur = vidRef.current?.duration ?? 0 - if (dur === 0) return + const dur = vidRef.current?.duration + if (!dur) return onPositionUpdated(ct / dur) } From 1b02cad7e4af13739c0062d273b104a559bf9919 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 15:12:22 -0400 Subject: [PATCH 09/13] chat thread: fix search highlight and scroll-to-center for LegendList LegendList's scrollToIndex uses estimated item positions for unrendered items, causing it to land far from the target when estimated sizes are smaller than actual. Fix by adding data-ordinal to each row and using a retry loop: attempt scrollIntoView on the element directly, falling back to scrollToIndex to get closer each time until the element is rendered. Also extract HighlightableRow so it subscribes to the center context directly, ensuring re-renders propagate through LegendList's recycled containers. Disable maintainVisibleContentPosition during centering to prevent it from fighting the scroll. --- shared/chat/conversation/list-area/index.tsx | 92 ++++++++++++-------- 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 158273d2a120..ea95b6c3bb83 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -38,6 +38,29 @@ const noOrdinals: ReadonlyArray = [] // ==================== DESKTOP ==================== +const HighlightableRow = React.memo(({ordinal}: {ordinal: T.Chat.Ordinal}) => { + const {centeredHighlightOrdinal} = useConversationCenter() + const editingOrdinal = InputState.useConversationInput(s => s.editing) + const isHighlighted = centeredHighlightOrdinal === ordinal || editingOrdinal === ordinal + return ( +
+ + +
+ ) +}) +HighlightableRow.displayName = 'HighlightableRow' + const DesktopThreadWrapper = function DesktopThreadWrapper() { const editingOrdinal = InputState.useConversationInput(s => s.editing) const conversationIDKey = useConversationThreadID() @@ -48,7 +71,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { messageOrdinals: s.messageOrdinals ?? noOrdinals, })) ) - const {centeredHighlightOrdinal, centeredOrdinal} = useConversationCenter() + const {centeredOrdinal} = useConversationCenter() const {containsLatestMessage, messageOrdinals, loaded} = data const listRef = React.useRef(null) @@ -151,21 +174,41 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { onEndReached.cancel() }, [onEndReached]) - // Scroll to centered ordinal when it changes (search / thread navigation) - const prevCenteredOrdinalRef = React.useRef(centeredOrdinal) + // Scroll to centered ordinal when it changes (search / thread navigation). + // Use a "last scrolled to" ref rather than a "did it change" ref so we still + // scroll when loaded becomes true after centeredOrdinal was already set. + const lastScrolledCenteredRef = React.useRef(undefined) + React.useLayoutEffect(() => { + lastScrolledCenteredRef.current = undefined + }, [conversationIDKey]) + React.useEffect(() => { - const changed = prevCenteredOrdinalRef.current !== centeredOrdinal - prevCenteredOrdinalRef.current = centeredOrdinal - if (!changed || !loaded) return + if (!loaded) return if (centeredOrdinal) { + if (lastScrolledCenteredRef.current === centeredOrdinal) return const idx = sortedIndexOf(messageOrdinalsRef.current as unknown as number[], centeredOrdinal as unknown as number) - if (idx >= 0) { - void listRef.current?.scrollToIndex({animated: true, index: idx, viewPosition: 0.5}) + if (idx < 0) return + lastScrolledCenteredRef.current = centeredOrdinal + const target = centeredOrdinal + const doScrollToCenter = async () => { + for (let attempt = 0; attempt < 4; attempt++) { + const el = wrapperRef.current?.querySelector(`[data-ordinal="${target}"]`) + if (el) { + el.scrollIntoView({behavior: 'instant', block: 'center'}) + return + } + void listRef.current?.scrollToIndex({animated: false, index: idx, viewPosition: 0.5}) + await new Promise(r => setTimeout(r, 100)) + } + } + void doScrollToCenter() + } else if (lastScrolledCenteredRef.current !== undefined) { + lastScrolledCenteredRef.current = undefined + if (containsLatestMessage) { + void listRef.current?.scrollToEnd({animated: false}) } - } else if (containsLatestMessage) { - void listRef.current?.scrollToEnd({animated: false}) } - }, [centeredOrdinal, loaded, containsLatestMessage]) + }, [centeredOrdinal, loaded, containsLatestMessage, messageOrdinals]) // Scroll to the message being edited const lastEditingOrdinalRef = React.useRef(undefined) @@ -193,29 +236,9 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { } }, [markInitiallyLoadedThreadAsRead]) - // Extra data drives re-renders for highlight/edit state changes - const extraData = React.useMemo( - () => ({centeredHighlightOrdinal, editingOrdinal}), - [centeredHighlightOrdinal, editingOrdinal] - ) - const renderItem = React.useCallback( - ({item: ordinal}: {item: T.Chat.Ordinal}) => ( -
- - -
- ), - [centeredHighlightOrdinal, editingOrdinal] + ({item: ordinal}: {item: T.Chat.Ordinal}) => , + [] ) const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length) @@ -297,12 +320,11 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { recycleItems={true} drawDistance={250} estimatedItemSize={72} - extraData={extraData} style={{...Kb.Styles.castStyleDesktop(desktopStyles.list), opacity: didFirstLoad ? 1 : 0}} initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex} maintainScrollAtEnd={centeredOrdinal ? false : {on: {dataChange: true}}} - maintainVisibleContentPosition={{data: true}} + maintainVisibleContentPosition={centeredOrdinal ? undefined : {data: true}} onLoad={onLoad} onScroll={onScroll as unknown as (e: unknown) => void} onEndReached={onEndReached} From 6a6fbd54472246d25bd12c5db48c5b59b56c9f51 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 15:18:21 -0400 Subject: [PATCH 10/13] feedback --- shared/chat/conversation/list-area/index.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index ea95b6c3bb83..c5d35782ffa3 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -151,6 +151,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { ) React.useEffect(() => () => { onScroll.cancel() + clearTimeout(scrollStopTimerRef.current) }, [onScroll]) // Load older messages when scrolled near the top (first 3 items visible) @@ -292,11 +293,11 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { } // When a centeredOrdinal is set at mount, start there; otherwise start at the end - const initialScrollIndex = centeredOrdinal !== undefined - ? { - index: Math.max(0, sortedIndexOf(messageOrdinals as unknown as number[], centeredOrdinal as unknown as number)), - viewPosition: 0.5 as const, - } + const _centeredIdx = centeredOrdinal !== undefined + ? sortedIndexOf(messageOrdinals as unknown as number[], centeredOrdinal as unknown as number) + : -1 + const initialScrollIndex = _centeredIdx >= 0 + ? {index: _centeredIdx, viewPosition: 0.5 as const} : undefined return ( @@ -323,8 +324,8 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { style={{...Kb.Styles.castStyleDesktop(desktopStyles.list), opacity: didFirstLoad ? 1 : 0}} initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex} - maintainScrollAtEnd={centeredOrdinal ? false : {on: {dataChange: true}}} - maintainVisibleContentPosition={centeredOrdinal ? undefined : {data: true}} + maintainScrollAtEnd={centeredOrdinal !== undefined ? false : {on: {dataChange: true}}} + maintainVisibleContentPosition={centeredOrdinal !== undefined ? undefined : {data: true}} onLoad={onLoad} onScroll={onScroll as unknown as (e: unknown) => void} onEndReached={onEndReached} From 40dd1bc65a5363cd49d81b4283e92ffb754a0a34 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 15:26:32 -0400 Subject: [PATCH 11/13] update plans --- plans/todo.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plans/todo.md b/plans/todo.md index b4c10e4ed722..ad9aaa2b6ac7 100644 --- a/plans/todo.md +++ b/plans/todo.md @@ -1,6 +1,5 @@ go screen by screen and find cleanup any leftover zustand store -legend list for chat thread desktop legend list for chat thread native +yarn upgrade update deps -remove zoom toolkit From 3f1bfe62522843a68fab52a2527265e5728c1cfe Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 15:29:08 -0400 Subject: [PATCH 12/13] WIP --- shared/chat/conversation/list-area/index.tsx | 8 ++++++-- shared/native-only-modules.js | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index c5d35782ffa3..6ed1bc3dcafd 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -193,13 +193,17 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const target = centeredOrdinal const doScrollToCenter = async () => { for (let attempt = 0; attempt < 4; attempt++) { - const el = wrapperRef.current?.querySelector(`[data-ordinal="${target}"]`) + const el = ( + wrapperRef.current as unknown as + | {querySelector: (s: string) => {scrollIntoView: (o: object) => void} | null} + | null + )?.querySelector(`[data-ordinal="${target}"]`) if (el) { el.scrollIntoView({behavior: 'instant', block: 'center'}) return } void listRef.current?.scrollToIndex({animated: false, index: idx, viewPosition: 0.5}) - await new Promise(r => setTimeout(r, 100)) + await new Promise(resolve => setTimeout(resolve, 100)) } } void doScrollToCenter() diff --git a/shared/native-only-modules.js b/shared/native-only-modules.js index 17146525e630..86613a69c89a 100644 --- a/shared/native-only-modules.js +++ b/shared/native-only-modules.js @@ -32,4 +32,5 @@ module.exports = [ '@callstack/liquid-glass', 'react-native-screens/experimental', '@react-navigation/bottom-tabs', + 'react-native-gesture-handler', ] From 403fb65b7cb465ec5555094cb5180d2b57ca2900 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 20 May 2026 15:40:27 -0400 Subject: [PATCH 13/13] WIP --- shared/chat/conversation/list-area/index.tsx | 2 +- shared/perf/run-desktop-perf.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 6ed1bc3dcafd..a47dccecc3f9 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -185,7 +185,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { React.useEffect(() => { if (!loaded) return - if (centeredOrdinal) { + if (centeredOrdinal !== undefined) { if (lastScrolledCenteredRef.current === centeredOrdinal) return const idx = sortedIndexOf(messageOrdinalsRef.current as unknown as number[], centeredOrdinal as unknown as number) if (idx < 0) return diff --git a/shared/perf/run-desktop-perf.js b/shared/perf/run-desktop-perf.js index d4b64bb32753..21b3fd802304 100644 --- a/shared/perf/run-desktop-perf.js +++ b/shared/perf/run-desktop-perf.js @@ -61,7 +61,8 @@ const flows = { await page.click('[data-testid="inbox-list"] > :first-child') await page.waitForSelector('[data-testid="message-list"]', {timeout: 10000}) }, - scrollSelector: '[data-testid="message-list"]', + // LegendList renders an inner scroll container as first child of the testid div + scrollSelector: '[data-testid="message-list"] > :first-child', }, }