From 4a15df8322e18dc1dd9967fedc8ea3583e4c9123 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 09:56:54 -0400 Subject: [PATCH 01/31] convert mobile chat thread from FlatList (inverted) to LegendList (alignItemsAtEnd) Switches index.native.tsx to use LegendList directly with chronological data order and alignItemsAtEnd/initialScrollAtEnd instead of inverted+reversed array. Replaces onViewableItemsChanged scroll-load trigger with onStartReached. --- .../conversation/list-area/index.native.tsx | 110 ++++++------------ 1 file changed, 33 insertions(+), 77 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index d0b14b4df6a1..aab91f32b0a4 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -7,22 +7,17 @@ import Separator from '../messages/separator' import SpecialBottomMessage from '../messages/special-bottom-message' import SpecialTopMessage from '../messages/special-top-message' import type {ItemType} from '.' -import {FlatList} from 'react-native' -// import {FlashList, type ListRenderItemInfo} from '@shopify/flash-list' +import {LegendList, type LegendListRef} from '@legendapp/list/react-native' import {getMessageRender} from '../messages/wrapper' import {mobileTypingContainerHeight} from '../input-area/normal/typing' import {SetRecycleTypeContext} from '../recycle-type-context' import {ForceListRedrawContext} from '../force-list-redraw-context' // import {useChatDebugDump} from '@/constants/chat/debug' -import {usingFlashList} from './flashlist-config' import {PerfProfiler} from '@/perf/react-profiler' import {ScrollContext} from '../normal/context' import noop from 'lodash/noop' // import {useDebugLayout} from '@/util/debug-react' -// TODO if we bring flashlist back bring back the patch -const List = /*usingFlashList ? FlashList :*/ FlatList - // 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 @@ -33,13 +28,13 @@ const useScrolling = (p: { centeredOrdinal: T.Chat.Ordinal messageOrdinals: Array conversationIDKey: T.Chat.ConversationIDKey - listRef: React.RefObject |*/ FlatList | null> + listRef: React.RefObject }) => { const {listRef, centeredOrdinal, messageOrdinals} = p const numOrdinals = messageOrdinals.length const loadOlderMessages = Chat.useChatContext(s => s.dispatch.loadOlderMessagesDueToScroll) const [scrollToBottom] = React.useState(() => () => { - listRef.current?.scrollToOffset({animated: false, offset: 0}) + void listRef.current?.scrollToEnd({animated: false}) }) const {setScrollRef} = React.useContext(ScrollContext) @@ -71,29 +66,25 @@ const useScrolling = (p: { } lastScrollToCentered.current = co - list.scrollToItem({animated: false, item: co, viewPosition: 0.5}) + void list.scrollToItem({animated: false, item: co, viewPosition: 0.5}) }, 100) }) - const onEndReached = () => { + const onStartReached = () => { loadOlderMessages(numOrdinals) } return { - onEndReached, + onStartReached, scrollToBottom, scrollToCentered, } } -// This keeps the list stable when data changes. If we don't do this it will jump around -// when new messages come in and its very easy to get this to cause an unstoppable loop of -// quick janking up and down -const maintainVisibleContentPosition = {autoscrollToTopThreshold: 1, minIndexForVisible: 0} const ConversationList = function ConversationList() { const debugWhichList = __DEV__ ? ( - {usingFlashList ? 'FLASH' : 'old'} + legend ) : null @@ -109,24 +100,24 @@ const ConversationList = function ConversationList() { const messageTypeMap = Chat.useChatContext(s => s.messageTypeMap) const _messageOrdinals = Chat.useChatContext(s => s.messageOrdinals) - const messageOrdinals = [...(_messageOrdinals ?? [])].reverse() + const messageOrdinals: Array = _messageOrdinals ? [..._messageOrdinals] : [] - const listRef = React.useRef |*/ FlatList | null>(null) + const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const keyExtractor = (ordinal: ItemType) => { return String(ordinal) } - const renderItem = (info?: /*ListRenderItemInfo*/ {index?: number}) => { - const index: number = info?.index ?? 0 - const ordinal = messageOrdinals[index] - if (!ordinal) { - return null - } + const renderItem = ({item: ordinal}: {item: T.Chat.Ordinal}) => { const type = messageTypeMap.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) if (!Clazz) return null - return + return ( + <> + + + + ) } const recycleTypeRef = React.useRef(new Map()) @@ -157,7 +148,7 @@ const ConversationList = function ConversationList() { return baseType } - const {scrollToCentered, scrollToBottom, onEndReached} = useScrolling({ + const {scrollToCentered, scrollToBottom, onStartReached: onStartReachedBase} = useScrolling({ centeredOrdinal, conversationIDKey, listRef, @@ -270,7 +261,13 @@ const ConversationList = function ConversationList() { // }) // ) - const onViewableItemsChanged = useSafeOnViewableItemsChanged(onEndReached, messageOrdinals.length) + const lastStartReachedRef = React.useRef(0) + const onStartReached = () => { + const t = Date.now() + if (t - lastStartReachedRef.current < 1000) return + lastStartReachedRef.current = t + onStartReachedBase() + } // const onLayout = useDebugLayout() return ( @@ -279,30 +276,27 @@ const ConversationList = function ConversationList() { - {jumpToRecent} {debugWhichList} @@ -314,44 +308,6 @@ const ConversationList = function ConversationList() { ) } -const minTimeDelta = 1000 -const minDistanceFromEnd = 10 - -const useSafeOnViewableItemsChanged = (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 -} - const styles = Kb.Styles.styleSheetCreate( () => ({ From 72d1d6fd072e847c0949a8f284e2bd2089117ae7 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 09:58:36 -0400 Subject: [PATCH 02/31] fix bottom padding between thread list and input area --- shared/chat/conversation/list-area/index.native.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index aab91f32b0a4..1ca439917bcc 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -312,8 +312,8 @@ const styles = Kb.Styles.styleSheetCreate( () => ({ contentContainer: { - paddingBottom: 0, - paddingTop: mobileTypingContainerHeight, + paddingBottom: mobileTypingContainerHeight, + paddingTop: 0, }, }) as const ) From 620614ecadc31dc874b4eadf9959260dfa0a9732 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 10:06:44 -0400 Subject: [PATCH 03/31] remove forceListRedraw; enable recycleItems Use messageTypeMap as extraData so LegendList re-renders naturally when placeholder messages resolve, eliminating the manual redraw counter. Enable recycleItems for better list performance. --- .../conversation/list-area/index.native.tsx | 29 ++----------------- .../messages/placeholder/wrapper.tsx | 18 +----------- 2 files changed, 3 insertions(+), 44 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 1ca439917bcc..3780f599509f 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -11,7 +11,6 @@ import {LegendList, type LegendListRef} from '@legendapp/list/react-native' import {getMessageRender} from '../messages/wrapper' import {mobileTypingContainerHeight} from '../input-area/normal/typing' import {SetRecycleTypeContext} from '../recycle-type-context' -import {ForceListRedrawContext} from '../force-list-redraw-context' // import {useChatDebugDump} from '@/constants/chat/debug' import {PerfProfiler} from '@/perf/react-profiler' import {ScrollContext} from '../normal/context' @@ -90,10 +89,6 @@ const ConversationList = function ConversationList() { const conversationIDKey = Chat.useChatContext(s => s.id) - // used to force a rerender when a type changes, aka placeholder resolves - const [extraData, setExtraData] = React.useState(0) - const [lastED, setLastED] = React.useState(extraData) - const loaded = Chat.useChatContext(s => s.loaded) const centeredOrdinal = Chat.useChatContext(s => s.messageCenterOrdinal)?.ordinal ?? T.Chat.numberToOrdinal(-1) @@ -121,12 +116,6 @@ const ConversationList = function ConversationList() { } const recycleTypeRef = React.useRef(new Map()) - React.useEffect(() => { - if (lastED !== extraData) { - recycleTypeRef.current = new Map() - setLastED(extraData) - } - }, [extraData, lastED]) const setRecycleType = (ordinal: T.Chat.Ordinal, type: string) => { recycleTypeRef.current.set(ordinal, type) } @@ -201,19 +190,6 @@ const ConversationList = function ConversationList() { } }, [loaded, centeredOrdinal, scrollToBottom, scrollToCentered, numOrdinals]) - // We use context to inject a way for items to force the list to rerender when they notice something about their - // internals have changed (aka a placeholder isn't a placeholder anymore). This can be racy as if you detect this - // and call you can get effectively memoized. In order to allow the item to re-render if they're still in this state - // we make this callback mutate, so they have a chance to rerender and recall it - // A repro is a placeholder resolving as a placeholder multiple times before resolving for real - const forceListRedraw = () => { - extraData // just to silence eslint - // wrap in timeout so we don't get max update depths sometimes - setTimeout(() => { - setExtraData(d => d + 1) - }, 100) - } - // useChatDebugDump( // 'listArea', // C.useEvent(() => { @@ -273,12 +249,11 @@ const ConversationList = function ConversationList() { return ( - - ) diff --git a/shared/chat/conversation/messages/placeholder/wrapper.tsx b/shared/chat/conversation/messages/placeholder/wrapper.tsx index 6a8032355001..e65c332d88bf 100644 --- a/shared/chat/conversation/messages/placeholder/wrapper.tsx +++ b/shared/chat/conversation/messages/placeholder/wrapper.tsx @@ -1,11 +1,7 @@ -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' import {WrapperMessage, type Props} from '../wrapper/wrapper' -import {ForceListRedrawContext} from '../../force-list-redraw-context' - -const noop = () => {} const baseWidth = Kb.Styles.isMobile ? 100 : 150 const mult = Kb.Styles.isMobile ? 5 : 10 @@ -17,23 +13,11 @@ function WrapperPlaceholder(p: Props) { const width = baseWidth + (code % 20) * mult // pseudo randomize the length const noAnchor = React.useRef(null) - const forceListRedraw = React.useContext(ForceListRedrawContext) - - const type = Chat.useChatContext(s => s.messageMap.get(ordinal)?.type) - const [lastType, setLastType] = React.useState(type) - - if (lastType !== type) { - setLastType(type) - if (type !== 'placeholder') { - forceListRedraw() - } - } - return ( {}} showingPopup={false} popup={null} popupAnchor={noAnchor} From 726a33b16c3432a983412d1f87a9ccd5137741d1 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 10:08:43 -0400 Subject: [PATCH 04/31] =?UTF-8?q?lower=20estimatedItemSize=2072=E2=86=9244?= =?UTF-8?q?=20to=20fix=20container=20pool=20exhaustion=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/chat/conversation/list-area/index.native.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 3780f599509f..b2cbafb70862 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -254,7 +254,7 @@ const ConversationList = function ConversationList() { Date: Tue, 17 Mar 2026 10:11:07 -0400 Subject: [PATCH 05/31] add DEV logging for item size changes and suggested estimatedItemSize --- shared/chat/conversation/list-area/index.native.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index b2cbafb70862..5bf91f3ce61a 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -269,6 +269,8 @@ const ConversationList = function ConversationList() { keyExtractor={keyExtractor} ref={listRef} recycleItems={true} + suggestEstimatedItemSize={__DEV__} + onItemSizeChanged={__DEV__ ? info => { console.log('[ll] size changed', info) } : undefined} alignItemsAtEnd={true} initialScrollAtEnd={true} maintainScrollAtEnd={{animated: false}} From 457f6e7b95c78de51fdce2f789f796469db7eda8 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 10:19:25 -0400 Subject: [PATCH 06/31] track async item size changes in DEV; remove estimatedItemSize --- shared/chat/conversation/list-area/index.native.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 5bf91f3ce61a..2bc23cc39b58 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -98,6 +98,7 @@ const ConversationList = function ConversationList() { const messageOrdinals: Array = _messageOrdinals ? [..._messageOrdinals] : [] const listRef = React.useRef(null) + const itemSizeMapRef = React.useRef(new Map()) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const keyExtractor = (ordinal: ItemType) => { return String(ordinal) @@ -254,7 +255,7 @@ const ConversationList = function ConversationList() { { console.log('[ll] size changed', info) } : undefined} + onItemSizeChanged={__DEV__ ? info => { + console.log('[ll] size changed', info) + const prev = itemSizeMapRef.current.get(info.itemData) + if (prev !== undefined && prev !== info.size) { + console.warn('[ll] ASYNC SIZE CHANGE', {from: prev, itemData: info.itemData, to: info.size}) + } + itemSizeMapRef.current.set(info.itemData, info.size) + } : undefined} alignItemsAtEnd={true} initialScrollAtEnd={true} maintainScrollAtEnd={{animated: false}} From 8c9dbfd942b030a6e360e3e9cbaa9c452a6aa804 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 15:04:30 -0400 Subject: [PATCH 07/31] initial legends list --- .../conversation/list-area/index.native.tsx | 28 ++++--------------- .../conversation/messages/attachment/file.tsx | 4 ++- .../messages/attachment/wrapper.tsx | 1 + .../conversation/messages/reactions-rows.tsx | 10 ++++++- shared/common-adapters/avatar/avatar-line.tsx | 16 +++++------ 5 files changed, 27 insertions(+), 32 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 2bc23cc39b58..87b6d1da18b2 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -81,12 +81,6 @@ const useScrolling = (p: { } const ConversationList = function ConversationList() { - const debugWhichList = __DEV__ ? ( - - legend - - ) : null - const conversationIDKey = Chat.useChatContext(s => s.id) const loaded = Chat.useChatContext(s => s.loaded) @@ -98,7 +92,6 @@ const ConversationList = function ConversationList() { const messageOrdinals: Array = _messageOrdinals ? [..._messageOrdinals] : [] const listRef = React.useRef(null) - const itemSizeMapRef = React.useRef(new Map()) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const keyExtractor = (ordinal: ItemType) => { return String(ordinal) @@ -108,14 +101,13 @@ const ConversationList = function ConversationList() { const type = messageTypeMap.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) if (!Clazz) return null - return ( - <> - - - - ) + return } + const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => ( + + ) + const recycleTypeRef = React.useRef(new Map()) const setRecycleType = (ordinal: T.Chat.Ordinal, type: string) => { recycleTypeRef.current.set(ordinal, type) @@ -263,6 +255,7 @@ const ConversationList = function ConversationList() { data={messageOrdinals} getItemType={getItemType} renderItem={renderItem} + ItemSeparatorComponent={ItemSeparator} onStartReached={onStartReached} onStartReachedThreshold={0.3} keyboardDismissMode="on-drag" @@ -270,21 +263,12 @@ const ConversationList = function ConversationList() { keyExtractor={keyExtractor} ref={listRef} recycleItems={true} - onItemSizeChanged={__DEV__ ? info => { - console.log('[ll] size changed', info) - const prev = itemSizeMapRef.current.get(info.itemData) - if (prev !== undefined && prev !== info.size) { - console.warn('[ll] ASYNC SIZE CHANGE', {from: prev, itemData: info.itemData, to: info.size}) - } - itemSizeMapRef.current.set(info.itemData, info.size) - } : undefined} alignItemsAtEnd={true} initialScrollAtEnd={true} maintainScrollAtEnd={{animated: false}} maintainVisibleContentPosition={{data: true}} /> {jumpToRecent} - {debugWhichList} diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx index 03de575ab726..6c464026750d 100644 --- a/shared/chat/conversation/messages/attachment/file.tsx +++ b/shared/chat/conversation/messages/attachment/file.tsx @@ -123,6 +123,7 @@ function FileContainer(p: OwnProps) { @@ -182,7 +183,7 @@ function FileContainer(p: OwnProps) { )} {!!progressLabel && ( - + {progressLabel} @@ -241,6 +242,7 @@ const styles = Kb.Styles.styleSheetCreate( }, }), linkStyle: {color: Kb.Styles.globalColors.black_50}, + progressOverlay: {bottom: 0, left: 0, position: 'absolute', right: 0}, progressLabelStyle: { color: Kb.Styles.globalColors.black_50, marginRight: Kb.Styles.globalMargins.tiny, diff --git a/shared/chat/conversation/messages/attachment/wrapper.tsx b/shared/chat/conversation/messages/attachment/wrapper.tsx index 142185e41154..91effeda0eda 100644 --- a/shared/chat/conversation/messages/attachment/wrapper.tsx +++ b/shared/chat/conversation/messages/attachment/wrapper.tsx @@ -1,3 +1,4 @@ +import * as Kb from '@/common-adapters' import type AudioAttachmentType from './audio' import type FileAttachmentType from './file' import type ImageAttachmentType from './image' diff --git a/shared/chat/conversation/messages/reactions-rows.tsx b/shared/chat/conversation/messages/reactions-rows.tsx index 86eb38bec95d..d5174106c19d 100644 --- a/shared/chat/conversation/messages/reactions-rows.tsx +++ b/shared/chat/conversation/messages/reactions-rows.tsx @@ -13,7 +13,15 @@ const emptyEmojis: ReadonlyArray = [] function ReactionsRowContainer() { const ordinal = useOrdinal() - const emojis = Chat.useChatContext(C.useShallow(s => s.reactionOrderMap.get(ordinal) ?? emptyEmojis)) + const emojis = Chat.useChatContext( + C.useShallow(s => { + const fromMap = s.reactionOrderMap.get(ordinal) + if (fromMap?.length) return fromMap + // reactionOrderMap may be temporarily out of sync with m.reactions — fall back to keys + const reactions = s.messageMap.get(ordinal)?.reactions + return reactions?.size ? [...reactions.keys()] : emptyEmojis + }) + ) return emojis.length === 0 ? null : ( diff --git a/shared/common-adapters/avatar/avatar-line.tsx b/shared/common-adapters/avatar/avatar-line.tsx index 046e34dd2c34..6820fafcd440 100644 --- a/shared/common-adapters/avatar/avatar-line.tsx +++ b/shared/common-adapters/avatar/avatar-line.tsx @@ -57,42 +57,42 @@ const getTextSize = (size: AvatarSize) => (size >= 48 ? 'BodySmallBold' : 'BodyT const getSizeStyle = (size: AvatarSize) => ({ horizontal: Kb.Styles.styleSheetCreate(() => ({ avatar: { - marginRight: -size / 3, + marginRight: -Math.round(size / 3), }, container: { marginLeft: 2, - marginRight: size / 3 + 2, + marginRight: Math.round(size / 3) + 2, }, overflowBox: { backgroundColor: Kb.Styles.globalColors.grey, borderBottomRightRadius: size, borderTopRightRadius: size, height: size, - paddingLeft: size / 2, + paddingLeft: Math.round(size / 2), }, text: { color: Kb.Styles.globalColors.black_50, - paddingRight: size / 5, + paddingRight: Math.round(size / 5), }, })), vertical: Kb.Styles.styleSheetCreate(() => ({ avatar: { - marginBottom: -size / 3, + marginBottom: -Math.round(size / 3), }, container: { - marginBottom: size / 3 + 2, + marginBottom: Math.round(size / 3) + 2, marginTop: 2, }, overflowBox: { backgroundColor: Kb.Styles.globalColors.grey, borderBottomLeftRadius: size, borderBottomRightRadius: size, - paddingTop: size / 2, + paddingTop: Math.round(size / 2), width: size, }, text: { color: Kb.Styles.globalColors.black_50, - paddingBottom: size / 5, + paddingBottom: Math.round(size / 5), }, })), }) From 759126d6cb775a6b1e4049fd547387b729b300d3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 15:14:58 -0400 Subject: [PATCH 08/31] fix sep --- CLAUDE.md | 2 +- .../conversation/list-area/index.native.tsx | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 25405c987feb..36a37f0a7b99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ - No DOM elements (`
`, ``, etc.) in plain `.tsx` files — use `Kb.*`. Guard desktop-only DOM with `Styles.isMobile`. - Temp files go in `/tmp/`. - Remove unused code when editing: styles, imports, vars, params, dead helpers. -- Comments: no refactoring notes; only add when context isn't obvious from code. +- Comments: no refactoring notes, no "we changed X" history; only add when context isn't obvious from code. - Exact versions in `package.json` (no `^`/`~`). - Keep `react`, `react-dom`, `react-native`, `@react-native/*` in sync with Expo SDK. - When updating deps: edit `package.json` → `yarn` → `yarn pod-install`. diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 87b6d1da18b2..947a73346791 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -91,6 +91,13 @@ const ConversationList = function ConversationList() { const messageOrdinals: Array = _messageOrdinals ? [..._messageOrdinals] : [] + // Map ordinal → index for O(1) trailing-item lookup in ItemSeparator + const ordinalIndexMap = React.useMemo(() => { + const m = new Map() + ;(_messageOrdinals ?? []).forEach((o, i) => m.set(o, i)) + return m + }, [_messageOrdinals]) + const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const keyExtractor = (ordinal: ItemType) => { @@ -104,9 +111,14 @@ const ConversationList = function ConversationList() { return } - const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => ( - - ) + // LegendList passes leadingItem=older message, but Separator.tsx on mobile uses leadingItem + // as the ordinal for showUsernameMap, which is keyed by the newer (upper) message. + const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { + const idx = ordinalIndexMap.get(leadingItem) ?? -1 + const trailingItem = messageOrdinals[idx + 1] + if (!trailingItem) return null + return + } const recycleTypeRef = React.useRef(new Map()) const setRecycleType = (ordinal: T.Chat.Ordinal, type: string) => { From 5d76a5ff2a0d2f969ed43e934c1ba4d4f728fbe2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 15:34:33 -0400 Subject: [PATCH 09/31] cleaner scrolling --- .../conversation/list-area/index.native.tsx | 69 ++++++++----------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 947a73346791..08d0ee775233 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -28,8 +28,9 @@ const useScrolling = (p: { messageOrdinals: Array conversationIDKey: T.Chat.ConversationIDKey listRef: React.RefObject + ordinalIndexMap: Map }) => { - const {listRef, centeredOrdinal, messageOrdinals} = p + const {listRef, centeredOrdinal, messageOrdinals, ordinalIndexMap} = p const numOrdinals = messageOrdinals.length const loadOlderMessages = Chat.useChatContext(s => s.dispatch.loadOlderMessagesDueToScroll) const [scrollToBottom] = React.useState(() => () => { @@ -53,20 +54,32 @@ const useScrolling = (p: { React.useEffect(() => { centeredOrdinalRef.current = centeredOrdinal }, [centeredOrdinal]) + + const ordinalIndexMapRef = React.useRef(ordinalIndexMap) + React.useEffect(() => { + ordinalIndexMapRef.current = ordinalIndexMap + }, [ordinalIndexMap]) + const [scrollToCentered] = React.useState(() => () => { - setTimeout(() => { - const list = listRef.current - if (!list) { - return - } - const co = centeredOrdinalRef.current - if (lastScrollToCentered.current === co) { - return - } + const list = listRef.current + if (!list) { + return + } + const co = centeredOrdinalRef.current + if (lastScrollToCentered.current === co) { + return + } + lastScrollToCentered.current = co - lastScrollToCentered.current = co - void list.scrollToItem({animated: false, item: co, viewPosition: 0.5}) - }, 100) + const idx = ordinalIndexMapRef.current.get(co) ?? -1 + if (idx < 0) { + return + } + // Scroll once, then re-scroll after the promise resolves so that size + // stabilization has settled and the item lands in view correctly. + void list.scrollToIndex({animated: false, index: idx, viewPosition: 0.5}).then(() => { + void list.scrollToIndex({animated: false, index: idx, viewPosition: 0.5}) + }) }) const onStartReached = () => { @@ -83,7 +96,6 @@ const useScrolling = (p: { const ConversationList = function ConversationList() { const conversationIDKey = Chat.useChatContext(s => s.id) - const loaded = Chat.useChatContext(s => s.loaded) const centeredOrdinal = Chat.useChatContext(s => s.messageCenterOrdinal)?.ordinal ?? T.Chat.numberToOrdinal(-1) const messageTypeMap = Chat.useChatContext(s => s.messageTypeMap) @@ -147,6 +159,7 @@ const ConversationList = function ConversationList() { conversationIDKey, listRef, messageOrdinals, + ordinalIndexMap, }) const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length) @@ -158,14 +171,8 @@ const ConversationList = function ConversationList() { } lastCenteredOrdinal.current = centeredOrdinal if (centeredOrdinal > 0) { - const id = setTimeout(() => { - scrollToCentered() - }, 200) - return () => { - clearTimeout(id) - } + scrollToCentered() } - return undefined }, [centeredOrdinal, scrollToCentered]) React.useEffect(() => { @@ -175,26 +182,6 @@ const ConversationList = function ConversationList() { } }, [markInitiallyLoadedThreadAsRead]) - const prevLoadedRef = React.useRef(loaded) - React.useLayoutEffect(() => { - const justLoaded = loaded && !prevLoadedRef.current - prevLoadedRef.current = loaded - - if (!justLoaded) return - - if (centeredOrdinal > 0) { - scrollToCentered() - setTimeout(() => { - scrollToCentered() - }, 100) - } else if (numOrdinals > 0) { - scrollToBottom() - setTimeout(() => { - scrollToBottom() - }, 100) - } - }, [loaded, centeredOrdinal, scrollToBottom, scrollToCentered, numOrdinals]) - // useChatDebugDump( // 'listArea', // C.useEvent(() => { From 8171b6907b3bca8781d2a6f4499c438bcc7c5b45 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 15:50:48 -0400 Subject: [PATCH 10/31] WIP --- shared/chat/conversation/messages/attachment/file.tsx | 2 +- shared/chat/conversation/messages/attachment/wrapper.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx index 6c464026750d..d4a9c512eed1 100644 --- a/shared/chat/conversation/messages/attachment/file.tsx +++ b/shared/chat/conversation/messages/attachment/file.tsx @@ -242,11 +242,11 @@ const styles = Kb.Styles.styleSheetCreate( }, }), linkStyle: {color: Kb.Styles.globalColors.black_50}, - progressOverlay: {bottom: 0, left: 0, position: 'absolute', right: 0}, progressLabelStyle: { color: Kb.Styles.globalColors.black_50, marginRight: Kb.Styles.globalMargins.tiny, }, + progressOverlay: {bottom: 0, left: 0, position: 'absolute', right: 0}, retry: { color: Kb.Styles.globalColors.redDark, textDecorationLine: 'underline', diff --git a/shared/chat/conversation/messages/attachment/wrapper.tsx b/shared/chat/conversation/messages/attachment/wrapper.tsx index 91effeda0eda..142185e41154 100644 --- a/shared/chat/conversation/messages/attachment/wrapper.tsx +++ b/shared/chat/conversation/messages/attachment/wrapper.tsx @@ -1,4 +1,3 @@ -import * as Kb from '@/common-adapters' import type AudioAttachmentType from './audio' import type FileAttachmentType from './file' import type ImageAttachmentType from './image' From 67dae00449249d043d2fb781a3f095ebb0cfe6ee Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Tue, 17 Mar 2026 17:12:38 -0400 Subject: [PATCH 11/31] move avatar/header from separator into message wrapper --- .../chat/conversation/messages/separator.tsx | 225 +----------------- .../conversation/messages/wrapper/wrapper.tsx | 180 +++++++++++++- 2 files changed, 191 insertions(+), 214 deletions(-) diff --git a/shared/chat/conversation/messages/separator.tsx b/shared/chat/conversation/messages/separator.tsx index c41ee092e677..ba7704df6e40 100644 --- a/shared/chat/conversation/messages/separator.tsx +++ b/shared/chat/conversation/messages/separator.tsx @@ -1,13 +1,10 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' -import {useTeamsState} from '@/stores/teams' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import {formatTimeForConversationList, formatTimeForChat} from '@/util/timestamp' +import {formatTimeForConversationList} from '@/util/timestamp' import {OrangeLineContext} from '../orange-line-context' -import {useTrackerState} from '@/stores/tracker' -import {navToProfile} from '@/constants/router' const missingMessage = Chat.makeMessageDeleted({}) @@ -34,147 +31,11 @@ const useSeparatorData = (trailingItem: T.Chat.Ordinal, leadingItem: T.Chat.Ordi ? formatTimeForConversationList(m.timestamp) : '' - if (!showUsername) { - return { - author: '', - botAlias: '', - isAdhocBot: false, - orangeLineAbove, - orangeTime, - ordinal, - showUsername, - teamID: '' as T.Teams.TeamID, - teamType: 'adhoc' as T.Chat.TeamType, - teamname: '', - timestamp: 0, - } - } - - const {author, timestamp} = m - const {teamID, botAliases, teamType, teamname} = s.meta - const participantInfoNames = s.participants.name - const isAdhocBot = - teamType === 'adhoc' && participantInfoNames.length > 0 - ? !participantInfoNames.includes(author) - : false - - return { - author, - botAlias: botAliases[author] ?? '', - isAdhocBot, - orangeLineAbove, - orangeTime, - ordinal, - showUsername, - teamID, - teamType, - teamname, - timestamp, - } + return {orangeLineAbove, orangeTime, ordinal} }) ) } -type AuthorProps = { - author: string - botAlias: string - isAdhocBot: boolean - teamID: T.Teams.TeamID - teamType: T.Chat.TeamType - teamname: string - timestamp: number - showUsername: string -} - -// Separate component so useTeamsState/useTrackerState only -// subscribe when there's actually an author to show. -function AuthorSection(p: AuthorProps) { - const {author, botAlias, isAdhocBot, teamID, teamType, teamname, timestamp, showUsername} = p - - const authorRoleInTeam = useTeamsState(s => s.teamIDToMembers.get(teamID)?.get(author)?.type) - const showUser = useTrackerState(s => s.dispatch.showUser) - - const onAuthorClick = () => { - if (C.isMobile) { - navToProfile(showUsername) - } else { - showUser(showUsername, true) - } - } - - const authorIsOwner = authorRoleInTeam === 'owner' - const authorIsAdmin = authorRoleInTeam === 'admin' - const authorIsBot = teamname - ? authorRoleInTeam === 'restrictedbot' || authorRoleInTeam === 'bot' - : isAdhocBot - const allowCrown = teamType !== 'adhoc' && (authorIsOwner || authorIsAdmin) - - const usernameNode = ( - - ) - - const ownerAdminTooltipIcon = allowCrown ? ( - - - - ) : null - - const botIcon = authorIsBot ? ( - - - - ) : null - - const botAliasOrUsername = botAlias ? ( - - {botAlias} {' [' + showUsername + ']'} - - ) : ( - usernameNode - ) - - return ( - <> - - - - {botAliasOrUsername} - {ownerAdminTooltipIcon} - {botIcon} - - {formatTimeForChat(timestamp)} - - - - - ) -} - type Props = { leadingItem?: T.Chat.Ordinal trailingItem: T.Chat.Ordinal @@ -183,39 +44,25 @@ type Props = { function SeparatorConnector(p: Props) { const {leadingItem, trailingItem} = p const data = useSeparatorData(trailingItem, leadingItem ?? T.Chat.numberToOrdinal(0)) - const {ordinal, showUsername, orangeLineAbove, orangeTime} = data + const {ordinal, orangeLineAbove, orangeTime} = data - if (!ordinal || (!showUsername && !orangeLineAbove)) return null + if (!ordinal || !orangeLineAbove) return null return ( - {showUsername ? ( - - ) : null} - {orangeLineAbove ? ( - - {orangeTime ? ( - - {orangeTime} - - ) : null} - - ) : null} + + {orangeTime ? ( + + {orangeTime} + + ) : null} + ) } @@ -223,46 +70,7 @@ function SeparatorConnector(p: Props) { const styles = Kb.Styles.styleSheetCreate( () => ({ - authorContainer: Kb.Styles.platformStyles({ - common: { - alignItems: 'flex-start', - alignSelf: 'flex-start', - marginLeft: Kb.Styles.isMobile ? 48 : 56, - }, - isElectron: { - marginBottom: 0, - marginTop: 0, - }, - isMobile: {marginTop: 8}, - }), - avatar: Kb.Styles.platformStyles({ - common: {position: 'absolute', top: 4}, - isElectron: { - left: Kb.Styles.globalMargins.small, - top: 4, - zIndex: 2, - }, - isMobile: {left: Kb.Styles.globalMargins.tiny}, - }), - botAlias: Kb.Styles.platformStyles({ - common: {color: Kb.Styles.globalColors.black}, - isElectron: { - maxWidth: 240, - wordBreak: 'break-all', - }, - isMobile: {maxWidth: 120}, - }), container: Kb.Styles.platformStyles({ - common: { - position: 'relative', - }, - isElectron: { - height: 21, - marginBottom: 0, - paddingTop: 5, - }, - }), - containerNoName: Kb.Styles.platformStyles({ common: { position: 'relative', }, @@ -297,15 +105,6 @@ const styles = Kb.Styles.styleSheetCreate( right: -16, }, }), - usernameCrown: Kb.Styles.platformStyles({ - isElectron: { - alignItems: 'baseline', - marginRight: 48, - position: 'relative', - top: -2, - }, - isMobile: {alignItems: 'center'}, - }), }) as const ) diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 15607be023b8..4507f6b6a4fa 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -14,6 +14,10 @@ import * as T from '@/constants/types' import capitalize from 'lodash/capitalize' import {useEdited} from './edited' import {useCurrentUserState} from '@/stores/current-user' +import {useTeamsState} from '@/stores/teams' +import {useProfileState} from '@/stores/profile' +import {useTrackerState} from '@/stores/tracker' +import {formatTimeForChat} from '@/util/timestamp' export type Props = { ordinal: T.Chat.Ordinal @@ -45,6 +49,139 @@ const messageShowsPopup = (type?: T.Chat.Message['type']) => // If there is no matching message treat it like a deleted const missingMessage = Chat.makeMessageDeleted({}) +type AuthorProps = { + author: string + botAlias: string + isAdhocBot: boolean + teamID: T.Teams.TeamID + teamType: T.Chat.TeamType + teamname: string + timestamp: number + showUsername: string +} + +function AuthorSection(p: AuthorProps) { + const {author, botAlias, isAdhocBot, teamID, teamType, teamname, timestamp, showUsername} = p + + const authorRoleInTeam = useTeamsState(s => s.teamIDToMembers.get(teamID)?.get(author)?.type) + const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) + const showUser = useTrackerState(s => s.dispatch.showUser) + + const onAuthorClick = () => { + if (C.isMobile) { + showUserProfile(showUsername) + } else { + showUser(showUsername, true) + } + } + + const authorIsOwner = authorRoleInTeam === 'owner' + const authorIsAdmin = authorRoleInTeam === 'admin' + const authorIsBot = teamname + ? authorRoleInTeam === 'restrictedbot' || authorRoleInTeam === 'bot' + : isAdhocBot + const allowCrown = teamType !== 'adhoc' && (authorIsOwner || authorIsAdmin) + + const usernameNode = ( + + ) + + const ownerAdminTooltipIcon = allowCrown ? ( + + + + ) : null + + const botIcon = authorIsBot ? ( + + + + ) : null + + const botAliasOrUsername = botAlias ? ( + + {botAlias} {' [' + showUsername + ']'} + + ) : ( + usernameNode + ) + + return ( + <> + + + + {botAliasOrUsername} + {ownerAdminTooltipIcon} + {botIcon} + + {formatTimeForChat(timestamp)} + + + + + ) +} + +const useAuthorData = (ordinal: T.Chat.Ordinal) => + Chat.useChatContext( + C.useShallow(s => { + const showUsername = s.showUsernameMap.get(ordinal) ?? '' + if (!showUsername) { + return { + author: '', + botAlias: '', + isAdhocBot: false, + showUsername, + teamID: '' as T.Teams.TeamID, + teamType: 'adhoc' as T.Chat.TeamType, + teamname: '', + timestamp: 0, + } + } + const m = s.messageMap.get(ordinal) ?? missingMessage + const {author, timestamp} = m + const {teamID, botAliases, teamType, teamname} = s.meta + const participantInfoNames = s.participants.name + const isAdhocBot = + teamType === 'adhoc' && participantInfoNames.length > 0 + ? !participantInfoNames.includes(author) + : false + return {author, botAlias: botAliases[author] ?? '', isAdhocBot, showUsername, teamID, teamType, teamname, timestamp} + }) + ) + +function AuthorHeader({ordinal}: {ordinal: T.Chat.Ordinal}) { + const data = useAuthorData(ordinal) + if (!data.showUsername) return null + return +} + // Pure helper functions - moved outside hooks to avoid recreating them per message const getReactionsPopupPosition = ( ordinal: T.Chat.Ordinal, @@ -569,7 +706,10 @@ export function WrapperMessage(p: WMProps) { return ( - + + + + {popup} ) @@ -578,10 +718,39 @@ export function WrapperMessage(p: WMProps) { const styles = Kb.Styles.styleSheetCreate( () => ({ + authorContainer: Kb.Styles.platformStyles({ + common: { + alignItems: 'flex-start', + alignSelf: 'flex-start', + marginLeft: Kb.Styles.isMobile ? 48 : 56, + }, + isElectron: { + marginBottom: 0, + marginTop: 0, + }, + isMobile: {marginTop: 8}, + }), + avatar: Kb.Styles.platformStyles({ + common: {position: 'absolute', top: 4}, + isElectron: { + left: Kb.Styles.globalMargins.small, + top: 4, + zIndex: 2, + }, + isMobile: {left: Kb.Styles.globalMargins.tiny}, + }), background: { alignSelf: 'stretch', flexShrink: 1, }, + botAlias: Kb.Styles.platformStyles({ + common: {color: Kb.Styles.globalColors.black}, + isElectron: { + maxWidth: 240, + wordBreak: 'break-all', + }, + isMobile: {maxWidth: 120}, + }), ellipsis: Kb.Styles.platformStyles({ isElectron: {paddingTop: 2}, isMobile: {paddingTop: 4}, @@ -642,5 +811,14 @@ const styles = Kb.Styles.styleSheetCreate( }, isElectron: {minHeight: 14}, }), + usernameCrown: Kb.Styles.platformStyles({ + isElectron: { + alignItems: 'baseline', + marginRight: 48, + position: 'relative', + top: -2, + }, + isMobile: {alignItems: 'center'}, + }), }) as const ) From 1cf2682e990f0dfc7f41250af06576a39dc0414d Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Tue, 17 Mar 2026 17:23:58 -0400 Subject: [PATCH 12/31] fix: fullWidth on wrapper Box2 so mobile messages fill list width --- shared/chat/conversation/messages/wrapper/wrapper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 4507f6b6a4fa..f0b0fee7603e 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -706,7 +706,7 @@ export function WrapperMessage(p: WMProps) { return ( - + From e3e87107cc776d2f47f9dabaaec4489233a73ce0 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 19:52:18 -0400 Subject: [PATCH 13/31] WIP --- .../conversation/list-area/index.native.tsx | 133 ++++++------------ 1 file changed, 41 insertions(+), 92 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 08d0ee775233..461a210c3864 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -93,6 +93,10 @@ const useScrolling = (p: { } } +const keyExtractor = (ordinal: ItemType) => { + return String(ordinal) +} + const ConversationList = function ConversationList() { const conversationIDKey = Chat.useChatContext(s => s.id) @@ -112,15 +116,16 @@ const ConversationList = function ConversationList() { const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) - const keyExtractor = (ordinal: ItemType) => { - return String(ordinal) - } const renderItem = ({item: ordinal}: {item: T.Chat.Ordinal}) => { const type = messageTypeMap.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) if (!Clazz) return null - return + return ( + + + + ) } // LegendList passes leadingItem=older message, but Separator.tsx on mobile uses leadingItem @@ -154,7 +159,11 @@ const ConversationList = function ConversationList() { return baseType } - const {scrollToCentered, scrollToBottom, onStartReached: onStartReachedBase} = useScrolling({ + const { + scrollToCentered, + scrollToBottom, + onStartReached: onStartReachedBase, + } = useScrolling({ centeredOrdinal, conversationIDKey, listRef, @@ -182,53 +191,6 @@ const ConversationList = function ConversationList() { } }, [markInitiallyLoadedThreadAsRead]) - // useChatDebugDump( - // 'listArea', - // C.useEvent(() => { - // if (!listRef.current) return '' - // const {props, state} = listRef.current as { - // props: {extraData?: {}; data?: [number]} - // state?: object - // } - // const {extraData, data} = props - // - // // const layoutManager = (state?.layoutProvider?._lastLayoutManager ?? ({} as unknown)) as { - // // _layouts?: [unknown] - // // _renderWindowSize: unknown - // // _totalHeight: unknown - // // _totalWidth: unknown - // // } - // // const {_layouts, _renderWindowSize, _totalHeight, _totalWidth} = layoutManager - // // const mm = window.DEBUGStore.store.getState().chat.messageMap.get(conversationIDKey) - // // const stateItems = messageOrdinals.map(o => ({o, type: mm.get(o)?.type})) - // - // console.log(listRef.current) - // - // const items = data?.map((ordinal: number, idx: number) => { - // const layout = _layouts?.[idx] - // // const m = mm.get(ordinal) ?? ({} as any) - // return { - // idx, - // layout, - // ordinal, - // // rid: m.id, - // // rtype: m.type, - // } - // }) - // - // const details = { - // // children, - // _renderWindowSize, - // _totalHeight, - // _totalWidth, - // data, - // extraData, - // items, - // } - // return JSON.stringify(details) - // }) - // ) - const lastStartReachedRef = React.useRef(0) const onStartReached = () => { const t = Date.now() @@ -236,53 +198,40 @@ const ConversationList = function ConversationList() { lastStartReachedRef.current = t onStartReachedBase() } - // const onLayout = useDebugLayout() return ( - - - - {jumpToRecent} - - + + + {jumpToRecent} + ) } -const styles = Kb.Styles.styleSheetCreate( - () => - ({ - contentContainer: { - paddingBottom: mobileTypingContainerHeight, - paddingTop: 0, - }, - }) as const -) - export default ConversationList From e1f33820889a88aad5877b8d1351756f8bd042e5 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Tue, 17 Mar 2026 21:48:58 -0400 Subject: [PATCH 14/31] WIP --- .../conversation/list-area/index.native.tsx | 59 ++++++++++--------- shared/stores/convostate.tsx | 2 + 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 461a210c3864..d375c2bdd899 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -1,3 +1,4 @@ +import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as T from '@/constants/types' import * as Hooks from './hooks' @@ -23,16 +24,32 @@ let markedInitiallyLoaded = false export const DEBUGDump = () => {} +// Stable empty array so we never create a new reference when ordinals are absent +const emptyOrdinals: Array = [] + +// LegendList passes leadingItem=older message, but Separator.tsx on mobile uses leadingItem +// as the ordinal for showUsernameMap, which is keyed by the newer (upper) message. +// Defined outside ConversationList so React sees a stable component type across renders. +const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { + const {ordinalIndexMap, messageOrdinals} = Chat.useChatContext( + C.useShallow(s => ({ordinalIndexMap: s.ordinalIndexMap, messageOrdinals: s.messageOrdinals})) + ) + const idx = ordinalIndexMap.get(leadingItem) ?? -1 + const trailingItem = messageOrdinals?.[idx + 1] + if (!trailingItem) return null + return +} + const useScrolling = (p: { centeredOrdinal: T.Chat.Ordinal - messageOrdinals: Array + messageOrdinals: ReadonlyArray conversationIDKey: T.Chat.ConversationIDKey listRef: React.RefObject - ordinalIndexMap: Map }) => { - const {listRef, centeredOrdinal, messageOrdinals, ordinalIndexMap} = p + const {listRef, centeredOrdinal, messageOrdinals} = p const numOrdinals = messageOrdinals.length const loadOlderMessages = Chat.useChatContext(s => s.dispatch.loadOlderMessagesDueToScroll) + const ordinalIndexMap = Chat.useChatContext(s => s.ordinalIndexMap) const [scrollToBottom] = React.useState(() => () => { void listRef.current?.scrollToEnd({animated: false}) }) @@ -98,21 +115,17 @@ const keyExtractor = (ordinal: ItemType) => { } const ConversationList = function ConversationList() { - const conversationIDKey = Chat.useChatContext(s => s.id) - - const centeredOrdinal = - Chat.useChatContext(s => s.messageCenterOrdinal)?.ordinal ?? T.Chat.numberToOrdinal(-1) - const messageTypeMap = Chat.useChatContext(s => s.messageTypeMap) - const _messageOrdinals = Chat.useChatContext(s => s.messageOrdinals) - - const messageOrdinals: Array = _messageOrdinals ? [..._messageOrdinals] : [] + const {conversationIDKey, centeredOrdinal, messageTypeMap, _messageOrdinals} = + Chat.useChatContext( + C.useShallow(s => ({ + _messageOrdinals: s.messageOrdinals, + centeredOrdinal: s.messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1), + conversationIDKey: s.id, + messageTypeMap: s.messageTypeMap, + })) + ) - // Map ordinal → index for O(1) trailing-item lookup in ItemSeparator - const ordinalIndexMap = React.useMemo(() => { - const m = new Map() - ;(_messageOrdinals ?? []).forEach((o, i) => m.set(o, i)) - return m - }, [_messageOrdinals]) + const messageOrdinals = _messageOrdinals ?? emptyOrdinals const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) @@ -128,15 +141,6 @@ const ConversationList = function ConversationList() { ) } - // LegendList passes leadingItem=older message, but Separator.tsx on mobile uses leadingItem - // as the ordinal for showUsernameMap, which is keyed by the newer (upper) message. - const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { - const idx = ordinalIndexMap.get(leadingItem) ?? -1 - const trailingItem = messageOrdinals[idx + 1] - if (!trailingItem) return null - return - } - const recycleTypeRef = React.useRef(new Map()) const setRecycleType = (ordinal: T.Chat.Ordinal, type: string) => { recycleTypeRef.current.set(ordinal, type) @@ -168,7 +172,6 @@ const ConversationList = function ConversationList() { conversationIDKey, listRef, messageOrdinals, - ordinalIndexMap, }) const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length) @@ -211,7 +214,7 @@ const ConversationList = function ConversationList() { ListFooterComponent={SpecialBottomMessage} overScrollMode="never" contentInset={{bottom: mobileTypingContainerHeight}} - data={messageOrdinals} + data={messageOrdinals as Array} getItemType={getItemType} renderItem={renderItem} ItemSeparatorComponent={ItemSeparator} diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 25d30901a7f9..b558018bf412 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -128,6 +128,7 @@ type ConvoStore = T.Immutable<{ messageIDToOrdinal: Map messageTypeMap: Map // messages T.Chat to help the thread, text is never used messageOrdinals?: ReadonlyArray // ordered ordinals in a thread, + ordinalIndexMap: Map // ordinal → index for O(1) lookup, messageMap: Map // messages in a thread, meta: T.Chat.ConversationMeta // metadata about a thread, There is a special node for the pending conversation, moreToLoadBack: boolean @@ -172,6 +173,7 @@ const initialConvoStore: ConvoStore = { messageMap: new Map(), messageOrdinals: undefined, messageTypeMap: new Map(), + ordinalIndexMap: new Map(), meta: Meta.makeConversationMeta(), moreToLoadBack: false, moreToLoadForward: false, From 6c3a25dc99d9cba9e3bbc51ba52461668eaaa4a9 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 21:50:11 -0400 Subject: [PATCH 15/31] WIP --- .../conversation/list-area/index.native.tsx | 19 +++++++++---------- shared/stores/convostate.tsx | 2 +- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index d375c2bdd899..befc87868dcc 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -32,7 +32,7 @@ const emptyOrdinals: Array = [] // Defined outside ConversationList so React sees a stable component type across renders. const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { const {ordinalIndexMap, messageOrdinals} = Chat.useChatContext( - C.useShallow(s => ({ordinalIndexMap: s.ordinalIndexMap, messageOrdinals: s.messageOrdinals})) + C.useShallow(s => ({messageOrdinals: s.messageOrdinals, ordinalIndexMap: s.ordinalIndexMap})) ) const idx = ordinalIndexMap.get(leadingItem) ?? -1 const trailingItem = messageOrdinals?.[idx + 1] @@ -115,15 +115,14 @@ const keyExtractor = (ordinal: ItemType) => { } const ConversationList = function ConversationList() { - const {conversationIDKey, centeredOrdinal, messageTypeMap, _messageOrdinals} = - Chat.useChatContext( - C.useShallow(s => ({ - _messageOrdinals: s.messageOrdinals, - centeredOrdinal: s.messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1), - conversationIDKey: s.id, - messageTypeMap: s.messageTypeMap, - })) - ) + const {conversationIDKey, centeredOrdinal, messageTypeMap, _messageOrdinals} = Chat.useChatContext( + C.useShallow(s => ({ + _messageOrdinals: s.messageOrdinals, + centeredOrdinal: s.messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1), + conversationIDKey: s.id, + messageTypeMap: s.messageTypeMap, + })) + ) const messageOrdinals = _messageOrdinals ?? emptyOrdinals diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index b558018bf412..9f5c3e40935a 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -173,11 +173,11 @@ const initialConvoStore: ConvoStore = { messageMap: new Map(), messageOrdinals: undefined, messageTypeMap: new Map(), - ordinalIndexMap: new Map(), meta: Meta.makeConversationMeta(), moreToLoadBack: false, moreToLoadForward: false, mutualTeams: [], + ordinalIndexMap: new Map(), participants: noParticipantInfo, pendingJumpMessageID: undefined, pendingOutboxToOrdinal: new Map(), From d891b4a303efebd28f1bbfd79b481a6eff5acc41 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 17 Mar 2026 22:08:51 -0400 Subject: [PATCH 16/31] WIP --- shared/chat/conversation/list-area/index.native.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index befc87868dcc..22f5edc7bba3 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -208,9 +208,9 @@ const ConversationList = function ConversationList() { } @@ -228,6 +228,7 @@ const ConversationList = function ConversationList() { initialScrollAtEnd={true} maintainScrollAtEnd={{animated: false}} maintainVisibleContentPosition={true} + waitForInitialLayout={true} /> {jumpToRecent} From 0bcb93fe17da707da9648d2a1ccdf5a0c6f84b40 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 18 Mar 2026 09:30:40 -0400 Subject: [PATCH 17/31] WIP --- .../conversation/list-area/index.native.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 22f5edc7bba3..0e7f43daf254 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -27,6 +27,10 @@ export const DEBUGDump = () => {} // Stable empty array so we never create a new reference when ordinals are absent const emptyOrdinals: Array = [] +// Sentinel ordinal for SpecialTopMessage rendered as a regular list item so +// LegendList can track its size changes and maintain scroll position correctly. +const SPECIAL_TOP_ORDINAL = T.Chat.numberToOrdinal(-2) + // LegendList passes leadingItem=older message, but Separator.tsx on mobile uses leadingItem // as the ordinal for showUsernameMap, which is keyed by the newer (upper) message. // Defined outside ConversationList so React sees a stable component type across renders. @@ -34,6 +38,8 @@ const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { const {ordinalIndexMap, messageOrdinals} = Chat.useChatContext( C.useShallow(s => ({messageOrdinals: s.messageOrdinals, ordinalIndexMap: s.ordinalIndexMap})) ) + // SpecialTopMessage renders its own separator at its bottom edge. + if (leadingItem === SPECIAL_TOP_ORDINAL) return null const idx = ordinalIndexMap.get(leadingItem) ?? -1 const trailingItem = messageOrdinals?.[idx + 1] if (!trailingItem) return null @@ -125,11 +131,18 @@ const ConversationList = function ConversationList() { ) const messageOrdinals = _messageOrdinals ?? emptyOrdinals + const data = React.useMemo( + () => [SPECIAL_TOP_ORDINAL, ...(messageOrdinals as Array)], + [messageOrdinals] + ) const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const renderItem = ({item: ordinal}: {item: T.Chat.Ordinal}) => { + if (ordinal === SPECIAL_TOP_ORDINAL) { + return + } const type = messageTypeMap.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) if (!Clazz) return null @@ -148,6 +161,9 @@ const ConversationList = function ConversationList() { const numOrdinals = messageOrdinals.length const getItemType = (ordinal: T.Chat.Ordinal, idx: number) => { + if (ordinal === SPECIAL_TOP_ORDINAL) { + return 'special-top' + } if (!ordinal) { return 'null' } @@ -156,7 +172,8 @@ const ConversationList = function ConversationList() { if (recycled) return recycled const baseType = messageTypeMap.get(ordinal) ?? 'text' // Last item is most-recently sent; isolate it to avoid recycling with settled messages - if (numOrdinals - 1 === idx && (baseType === 'text' || baseType === 'attachment')) { + // +1 because SPECIAL_TOP_ORDINAL is at index 0, shifting all message indices by 1. + if (numOrdinals === idx && (baseType === 'text' || baseType === 'attachment')) { return `${baseType}:pending` } return baseType @@ -209,11 +226,10 @@ const ConversationList = function ConversationList() { testID="messageList" extraData={messageTypeMap} estimatedItemSize={undefined} - // ListHeaderComponent={SpecialTopMessage} - // ListFooterComponent={SpecialBottomMessage} + ListFooterComponent={SpecialBottomMessage} overScrollMode="never" contentInset={{bottom: mobileTypingContainerHeight}} - data={messageOrdinals as Array} + data={data} getItemType={getItemType} renderItem={renderItem} ItemSeparatorComponent={ItemSeparator} From 33931ddefc4c2ab29da37768f5ca8bd6a9874320 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 18 Mar 2026 11:39:22 -0400 Subject: [PATCH 18/31] use legend keyboard wrapper --- .../input-area/normal/input.native.tsx | 15 +++-- .../conversation/list-area/index.native.tsx | 9 ++- .../chat/conversation/normal/index.native.tsx | 60 +++++++++---------- 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/input.native.tsx b/shared/chat/conversation/input-area/normal/input.native.tsx index 39d3bac7e964..9bb2790953ff 100644 --- a/shared/chat/conversation/input-area/normal/input.native.tsx +++ b/shared/chat/conversation/input-area/normal/input.native.tsx @@ -14,6 +14,7 @@ import type {Props as InputLowLevelProps, PlatformInputProps as Props, TextInfo, import {AudioSendWrapper} from '@/chat/audio/audio-send.native' import {Keyboard, TextInput, type NativeSyntheticEvent, type TextInputSelectionChangeEventData, useColorScheme} from 'react-native' import {MaxInputAreaContext} from './max-input-area-context' +import {useAnimatedKeyboard} from 'react-native-keyboard-controller' import { default as Animated, skipAnimations, @@ -431,11 +432,15 @@ const AnimatedInput = (() => { const {expanded, inputRef, ...rest} = p const lastExpandedRef = React.useRef(expanded) const offset = useSharedValue(expanded ? 1 : 0) - const maxHeight = maxInputArea - inputAreaHeight - 15 - const as = useAnimatedStyle(() => ({ - maxHeight: withTiming(offset.value ? maxHeight : threeLineHeight), - minHeight: withTiming(offset.value ? maxHeight : singleLineHeight), - })) + const keyboard = useAnimatedKeyboard() + const maxHeightBase = maxInputArea - inputAreaHeight - 15 + const as = useAnimatedStyle(() => { + const maxHeight = maxHeightBase - keyboard.height.value + return { + maxHeight: withTiming(offset.value ? maxHeight : threeLineHeight), + minHeight: withTiming(offset.value ? maxHeight : singleLineHeight), + } + }) React.useEffect(() => { if (expanded !== lastExpandedRef.current) { lastExpandedRef.current = expanded diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 0e7f43daf254..363359bef708 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -8,7 +8,9 @@ import Separator from '../messages/separator' import SpecialBottomMessage from '../messages/special-bottom-message' import SpecialTopMessage from '../messages/special-top-message' import type {ItemType} from '.' -import {LegendList, type LegendListRef} from '@legendapp/list/react-native' +import {type LegendListRef} from '@legendapp/list/react-native' +import {KeyboardAvoidingLegendList} from '@legendapp/list/keyboard-test' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {getMessageRender} from '../messages/wrapper' import {mobileTypingContainerHeight} from '../input-area/normal/typing' import {SetRecycleTypeContext} from '../recycle-type-context' @@ -136,6 +138,7 @@ const ConversationList = function ConversationList() { [messageOrdinals] ) + const insets = useSafeAreaInsets() const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) @@ -222,7 +225,7 @@ const ConversationList = function ConversationList() { - {jumpToRecent} diff --git a/shared/chat/conversation/normal/index.native.tsx b/shared/chat/conversation/normal/index.native.tsx index 154a0be8095c..67056de4d2f9 100644 --- a/shared/chat/conversation/normal/index.native.tsx +++ b/shared/chat/conversation/normal/index.native.tsx @@ -4,6 +4,7 @@ import {PortalHost} from '@/common-adapters/portal.native' import * as Kb from '@/common-adapters' import * as React from 'react' import {useSafeAreaInsets, useSafeAreaFrame} from 'react-native-safe-area-context' +import {KeyboardStickyView} from 'react-native-keyboard-controller' import Banner from '../bottom-banner' import InputArea from '../input-area/container' import InvitationToBlock from '@/chat/blocking/invitation-to-block' @@ -39,22 +40,6 @@ const Conversation = function Conversation() { const conversationIDKey = Chat.useChatContext(s => s.id) logger.info(`Conversation: rendering convID: ${conversationIDKey}`) - const innerComponent = ( - - - - - - - - - - - - - - ) - const insets = useSafeAreaInsets() const headerHeight = Kb.Styles.isTablet ? 115 : 44 const windowHeight = useSafeAreaFrame().height @@ -69,6 +54,32 @@ const Conversation = function Conversation() { paddingBottom: Kb.Styles.isTablet ? 0 : insets.bottom, } + const bottomContent = ( + <> + + + + + + + ) + + const innerComponent = ( + + + + + + + + {Kb.Styles.isIOS ? ( + {bottomContent} + ) : ( + bottomContent + )} + + ) + const threadLoadedOffline = Chat.useChatContext(s => s.meta.offline) const content = ( @@ -90,20 +101,9 @@ const Conversation = function Conversation() { return ( - {Kb.Styles.isAndroid ? ( - - {content} - - ) : ( - - - {content} - - - )} + + {content} + ) } From 05e7d4f8e5b37560f17d38b6fa50b8fae30db354 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 18 Mar 2026 11:50:57 -0400 Subject: [PATCH 19/31] WIP --- shared/chat/conversation/list-area/index.native.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 363359bef708..3174908d202c 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -246,7 +246,7 @@ const ConversationList = function ConversationList() { alignItemsAtEnd={true} initialScrollAtEnd={true} maintainScrollAtEnd={{animated: false}} - maintainVisibleContentPosition={true} + maintainVisibleContentPosition={{data: true, scroll: true}} waitForInitialLayout={true} keyboardLiftBehavior="whenAtEnd" offset={insets.bottom} From 599a08be48091fd3d5de4f41441f074478850eef Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 18 Mar 2026 13:26:18 -0400 Subject: [PATCH 20/31] WIP --- .../conversation/input-area/normal/index.tsx | 2 +- .../chat/conversation/list-area/index.native.tsx | 4 ++-- shared/chat/conversation/normal/index.native.tsx | 16 +++++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 8571056cd57e..d3783e2d8189 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -277,7 +277,7 @@ const styles = Kb.Styles.styleSheetCreate(() => { const suggestDesktop = {marginLeft: 15, marginRight: 15, marginTop: 'auto'} return { container: Kb.Styles.platformStyles({ - isMobile: {justifyContent: 'flex-end'}, + isMobile: {backgroundColor: Kb.Styles.globalColors.white, justifyContent: 'flex-end'}, }), suggestionOverlay: Kb.Styles.platformStyles({ isElectron: suggestDesktop, diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 3174908d202c..ef3d49d8c0d6 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -238,7 +238,7 @@ const ConversationList = function ConversationList() { ItemSeparatorComponent={ItemSeparator} onStartReached={onStartReached} onStartReachedThreshold={0.3} - keyboardDismissMode="on-drag" + keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" keyExtractor={keyExtractor} ref={listRef} @@ -246,7 +246,7 @@ const ConversationList = function ConversationList() { alignItemsAtEnd={true} initialScrollAtEnd={true} maintainScrollAtEnd={{animated: false}} - maintainVisibleContentPosition={{data: true, scroll: true}} + maintainVisibleContentPosition={{data: true, size: true}} waitForInitialLayout={true} keyboardLiftBehavior="whenAtEnd" offset={insets.bottom} diff --git a/shared/chat/conversation/normal/index.native.tsx b/shared/chat/conversation/normal/index.native.tsx index 67056de4d2f9..e91c3d5889e7 100644 --- a/shared/chat/conversation/normal/index.native.tsx +++ b/shared/chat/conversation/normal/index.native.tsx @@ -4,7 +4,7 @@ import {PortalHost} from '@/common-adapters/portal.native' import * as Kb from '@/common-adapters' import * as React from 'react' import {useSafeAreaInsets, useSafeAreaFrame} from 'react-native-safe-area-context' -import {KeyboardStickyView} from 'react-native-keyboard-controller' +import {KeyboardGestureArea, KeyboardStickyView} from 'react-native-keyboard-controller' import Banner from '../bottom-banner' import InputArea from '../input-area/container' import InvitationToBlock from '@/chat/blocking/invitation-to-block' @@ -66,12 +66,14 @@ const Conversation = function Conversation() { const innerComponent = ( - - - - - - + + + + + + + + {Kb.Styles.isIOS ? ( {bottomContent} ) : ( From 6ce3c43e19678e8dbd5cc6c148d66cd7d4c66821 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 18 Mar 2026 13:38:00 -0400 Subject: [PATCH 21/31] WIP --- .../conversation/list-area/index.native.tsx | 13 ++++++- .../messages/special-top-message.tsx | 37 +++++-------------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index ef3d49d8c0d6..5b6d772ca6d2 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -138,13 +138,21 @@ const ConversationList = function ConversationList() { [messageOrdinals] ) + const [isTopOnScreen, setIsTopOnScreen] = React.useState(false) + const onViewableItemsChanged = React.useCallback( + ({viewableItems}: {viewableItems: Array<{item: T.Chat.Ordinal}>}) => { + setIsTopOnScreen(viewableItems.some(vt => vt.item === SPECIAL_TOP_ORDINAL)) + }, + [] + ) + const insets = useSafeAreaInsets() const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const renderItem = ({item: ordinal}: {item: T.Chat.Ordinal}) => { if (ordinal === SPECIAL_TOP_ORDINAL) { - return + return } const type = messageTypeMap.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) @@ -227,7 +235,8 @@ const ConversationList = function ConversationList() { ({isTopOnScreen, messageTypeMap}), [isTopOnScreen, messageTypeMap])} + onViewableItemsChanged={onViewableItemsChanged} estimatedItemSize={undefined} ListFooterComponent={SpecialBottomMessage} overScrollMode="never" diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index b4afd8894d8a..ea6168db66da 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -107,7 +107,7 @@ const ErrorMessage = () => { ) } -function SpecialTopMessage() { +function SpecialTopMessage({isOnScreen = true}: {isOnScreen?: boolean}) { const username = useCurrentUserState(s => s.username) const data = Chat.useChatContext( C.useShallow(s => { @@ -149,39 +149,23 @@ function SpecialTopMessage() { ) const {ordinal, pendingState, isHelloBotConversation, hasOlderResetConversation} = data const {loadMoreType, isSelfConversation, showTeamOffer, showRetentionNotice} = data - // we defer showing this so it doesn't flash so much - const [allowDigging, setAllowDigging] = React.useState(false) - const lastOrdinalRef = React.useRef(ordinal) - - const digTimerRef = React.useRef | undefined>(undefined) + const [visible, setVisible] = React.useState(false) React.useEffect(() => { - if (ordinal !== lastOrdinalRef.current) { - setAllowDigging(false) - lastOrdinalRef.current = ordinal - digTimerRef.current && clearTimeout(digTimerRef.current) - digTimerRef.current = setTimeout(() => { - setAllowDigging(true) - }, 3000) - } - }, [ordinal]) - - React.useEffect(() => { - return () => { - if (digTimerRef.current) { - clearTimeout(digTimerRef.current) - digTimerRef.current = undefined - } - } - }, []) + setVisible(false) + if (!isOnScreen) return + const timer = setTimeout(() => setVisible(true), 3000) + return () => clearTimeout(timer) + }, [isOnScreen, ordinal]) const openPrivateFolder = () => { FS.navToPath(T.FS.stringToPath(`/keybase/private/${username}`)) } + if (!visible) return null + return ( {loadMoreType === 'noMoreToLoad' && showRetentionNotice && } - {hasOlderResetConversation && } {pendingState === 'waiting' && ( @@ -203,7 +187,7 @@ function SpecialTopMessage() { )} - {allowDigging && loadMoreType === 'moreToLoad' && pendingState === 'done' && ( + {loadMoreType === 'moreToLoad' && pendingState === 'done' && ( @@ -227,7 +211,6 @@ const styles = Kb.Styles.styleSheetCreate( more: { paddingBottom: Kb.Styles.globalMargins.medium, }, - spacer: {height: Kb.Styles.globalMargins.small}, }) as const ) From b7c133e9d3fbd6ec7212fce8ed8fde05a08e0a57 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 18 Mar 2026 16:12:33 -0400 Subject: [PATCH 22/31] WIP --- .../conversation/list-area/index.native.tsx | 53 ++++++++-- .../messages/special-top-message.tsx | 8 +- .../chat/conversation/normal/index.native.tsx | 99 ++++++------------- 3 files changed, 80 insertions(+), 80 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 5b6d772ca6d2..9d40b4968e58 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -221,13 +221,44 @@ const ConversationList = function ConversationList() { } }, [markInitiallyLoadedThreadAsRead]) - const lastStartReachedRef = React.useRef(0) - const onStartReached = () => { - const t = Date.now() - if (t - lastStartReachedRef.current < 1000) return - lastStartReachedRef.current = t - onStartReachedBase() - } + const isLoadingOlderRef = React.useRef(false) + const prevNumOrdinalsRef = React.useRef(numOrdinals) + const loadResetTimerRef = React.useRef | undefined>(undefined) + const onStartReachedBaseRef = React.useRef(onStartReachedBase) + + React.useEffect(() => { + onStartReachedBaseRef.current = onStartReachedBase + }, [onStartReachedBase]) + + React.useEffect(() => { + if (numOrdinals !== prevNumOrdinalsRef.current) { + prevNumOrdinalsRef.current = numOrdinals + isLoadingOlderRef.current = false + clearTimeout(loadResetTimerRef.current) + } + }, [numOrdinals]) + + // Cleanup on unmount + React.useEffect(() => () => clearTimeout(loadResetTimerRef.current), []) + + const [onStartReached] = React.useState(() => () => { + if (isLoadingOlderRef.current) return + isLoadingOlderRef.current = true + clearTimeout(loadResetTimerRef.current) + // Safety reset in case no messages arrive (already at top of history) + loadResetTimerRef.current = setTimeout(() => { + isLoadingOlderRef.current = false + }, 3000) + onStartReachedBaseRef.current() + }) + + // When the top item is actually visible, ensure we load — catches bad-scroll-position cases + // where maintainVisibleContentPosition leaves us at the top without onStartReached firing. + React.useEffect(() => { + if (isTopOnScreen) { + onStartReached() + } + }, [isTopOnScreen, onStartReached]) return ( @@ -235,7 +266,10 @@ const ConversationList = function ConversationList() { ({isTopOnScreen, messageTypeMap}), [isTopOnScreen, messageTypeMap])} + extraData={React.useMemo( + () => ({isTopOnScreen, messageTypeMap}), + [isTopOnScreen, messageTypeMap] + )} onViewableItemsChanged={onViewableItemsChanged} estimatedItemSize={undefined} ListFooterComponent={SpecialBottomMessage} @@ -247,7 +281,7 @@ const ConversationList = function ConversationList() { ItemSeparatorComponent={ItemSeparator} onStartReached={onStartReached} onStartReachedThreshold={0.3} - keyboardDismissMode="interactive" + keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" keyExtractor={keyExtractor} ref={listRef} @@ -257,7 +291,6 @@ const ConversationList = function ConversationList() { maintainScrollAtEnd={{animated: false}} maintainVisibleContentPosition={{data: true, size: true}} waitForInitialLayout={true} - keyboardLiftBehavior="whenAtEnd" offset={insets.bottom} /> {jumpToRecent} diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index ea6168db66da..af9553eac291 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -149,13 +149,19 @@ function SpecialTopMessage({isOnScreen = true}: {isOnScreen?: boolean}) { ) const {ordinal, pendingState, isHelloBotConversation, hasOlderResetConversation} = data const {loadMoreType, isSelfConversation, showTeamOffer, showRetentionNotice} = data + // When there's nothing more to load, we're at the true top — show immediately. + // Otherwise delay to avoid flashing "Digging ancient messages..." before content settles. const [visible, setVisible] = React.useState(false) React.useEffect(() => { + if (loadMoreType === 'noMoreToLoad') { + setVisible(true) + return + } setVisible(false) if (!isOnScreen) return const timer = setTimeout(() => setVisible(true), 3000) return () => clearTimeout(timer) - }, [isOnScreen, ordinal]) + }, [isOnScreen, loadMoreType, ordinal]) const openPrivateFolder = () => { FS.navToPath(T.FS.stringToPath(`/keybase/private/${username}`)) diff --git a/shared/chat/conversation/normal/index.native.tsx b/shared/chat/conversation/normal/index.native.tsx index e91c3d5889e7..af0f34bca451 100644 --- a/shared/chat/conversation/normal/index.native.tsx +++ b/shared/chat/conversation/normal/index.native.tsx @@ -3,8 +3,8 @@ import * as Chat from '@/stores/chat' import {PortalHost} from '@/common-adapters/portal.native' import * as Kb from '@/common-adapters' import * as React from 'react' -import {useSafeAreaInsets, useSafeAreaFrame} from 'react-native-safe-area-context' -import {KeyboardGestureArea, KeyboardStickyView} from 'react-native-keyboard-controller' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {KeyboardStickyView} from 'react-native-keyboard-controller' import Banner from '../bottom-banner' import InputArea from '../input-area/container' import InvitationToBlock from '@/chat/blocking/invitation-to-block' @@ -22,15 +22,6 @@ const Offline = () => ( ) -const LoadingLine = () => { - const conversationIDKey = Chat.useChatContext(s => s.id) - const showLoader = C.Waiting.useAnyWaiting([ - C.waitingKeyChatThreadLoad(conversationIDKey), - C.waitingKeyChatInboxSyncStarted, - ]) - return showLoader ? : null -} - const Conversation = function Conversation() { const [maxInputArea, setMaxInputArea] = React.useState(0) const onLayout = (e: LayoutEvent) => { @@ -41,70 +32,40 @@ const Conversation = function Conversation() { logger.info(`Conversation: rendering convID: ${conversationIDKey}`) const insets = useSafeAreaInsets() - const headerHeight = Kb.Styles.isTablet ? 115 : 44 - const windowHeight = useSafeAreaFrame().height - const height = windowHeight - insets.top - headerHeight - - const safeStyle = Kb.Styles.isAndroid - ? {paddingBottom: insets.bottom} - : { - height, - maxHeight: height, - minHeight: height, - paddingBottom: Kb.Styles.isTablet ? 0 : insets.bottom, - } - const bottomContent = ( - <> - - - - - - - ) + const showLoader = C.Waiting.useAnyWaiting([ + C.waitingKeyChatThreadLoad(conversationIDKey), + C.waitingKeyChatInboxSyncStarted, + ]) + const loadingLine = showLoader ? : null + const offline = Chat.useChatContext(s => s.meta.offline) ? : null - const innerComponent = ( - - - + return ( + + + {offline} + - + {loadingLine} + + + + + + + - - {Kb.Styles.isIOS ? ( - {bottomContent} - ) : ( - bottomContent - )} - - ) - - const threadLoadedOffline = Chat.useChatContext(s => s.meta.offline) - - const content = ( - - - {threadLoadedOffline && } - {innerComponent} - - - - ) - - return ( - - - {content} + ) From 054246d3b423505df35c947da3073b5797406518 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 18 Mar 2026 18:11:17 -0400 Subject: [PATCH 23/31] fix scroll jump when loading older messages via stable anchor sentinel Add STABLE_ANCHOR_ORDINAL (-3) at list index 0 so maintainVisibleContentPosition anchors to a fixed 1px item instead of SPECIAL_TOP_ORDINAL, whose dynamic height changes were corrupting LegendList's offset compensation. Also set estimatedItemSize=80 for better prepend scroll-offset baseline. --- .../conversation/list-area/index.native.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 9d40b4968e58..dfac31fd2faa 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -33,6 +33,11 @@ const emptyOrdinals: Array = [] // LegendList can track its size changes and maintain scroll position correctly. const SPECIAL_TOP_ORDINAL = T.Chat.numberToOrdinal(-2) +// 1px stable anchor at list index 0 so maintainVisibleContentPosition +// anchors to a fixed item. SPECIAL_TOP changes height dynamically; if it were +// the anchor, its own height changes would corrupt the offset compensation. +const STABLE_ANCHOR_ORDINAL = T.Chat.numberToOrdinal(-3) + // LegendList passes leadingItem=older message, but Separator.tsx on mobile uses leadingItem // as the ordinal for showUsernameMap, which is keyed by the newer (upper) message. // Defined outside ConversationList so React sees a stable component type across renders. @@ -41,7 +46,7 @@ const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { C.useShallow(s => ({messageOrdinals: s.messageOrdinals, ordinalIndexMap: s.ordinalIndexMap})) ) // SpecialTopMessage renders its own separator at its bottom edge. - if (leadingItem === SPECIAL_TOP_ORDINAL) return null + if (leadingItem === SPECIAL_TOP_ORDINAL || leadingItem === STABLE_ANCHOR_ORDINAL) return null const idx = ordinalIndexMap.get(leadingItem) ?? -1 const trailingItem = messageOrdinals?.[idx + 1] if (!trailingItem) return null @@ -134,7 +139,7 @@ const ConversationList = function ConversationList() { const messageOrdinals = _messageOrdinals ?? emptyOrdinals const data = React.useMemo( - () => [SPECIAL_TOP_ORDINAL, ...(messageOrdinals as Array)], + () => [STABLE_ANCHOR_ORDINAL, SPECIAL_TOP_ORDINAL, ...(messageOrdinals as Array)], [messageOrdinals] ) @@ -151,6 +156,9 @@ const ConversationList = function ConversationList() { const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const renderItem = ({item: ordinal}: {item: T.Chat.Ordinal}) => { + if (ordinal === STABLE_ANCHOR_ORDINAL) { + return + } if (ordinal === SPECIAL_TOP_ORDINAL) { return } @@ -172,6 +180,9 @@ const ConversationList = function ConversationList() { const numOrdinals = messageOrdinals.length const getItemType = (ordinal: T.Chat.Ordinal, idx: number) => { + if (ordinal === STABLE_ANCHOR_ORDINAL) { + return 'stable-anchor' + } if (ordinal === SPECIAL_TOP_ORDINAL) { return 'special-top' } @@ -183,8 +194,8 @@ const ConversationList = function ConversationList() { if (recycled) return recycled const baseType = messageTypeMap.get(ordinal) ?? 'text' // Last item is most-recently sent; isolate it to avoid recycling with settled messages - // +1 because SPECIAL_TOP_ORDINAL is at index 0, shifting all message indices by 1. - if (numOrdinals === idx && (baseType === 'text' || baseType === 'attachment')) { + // +2 because STABLE_ANCHOR_ORDINAL and SPECIAL_TOP_ORDINAL are at indices 0 and 1. + if (numOrdinals + 1 === idx && (baseType === 'text' || baseType === 'attachment')) { return `${baseType}:pending` } return baseType @@ -271,7 +282,7 @@ const ConversationList = function ConversationList() { [isTopOnScreen, messageTypeMap] )} onViewableItemsChanged={onViewableItemsChanged} - estimatedItemSize={undefined} + estimatedItemSize={80} ListFooterComponent={SpecialBottomMessage} overScrollMode="never" contentInset={{bottom: mobileTypingContainerHeight}} From f86a0f6df202b39aa9c8a8a2516a7810f37c5854 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 19 Mar 2026 10:42:42 -0400 Subject: [PATCH 24/31] lighter weight special. use top instead for maintaining position better --- .../conversation/list-area/index.native.tsx | 43 ++++--------------- .../messages/special-top-message.tsx | 43 ++++++++++++------- 2 files changed, 36 insertions(+), 50 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index dfac31fd2faa..0a2c00ce6f1c 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -29,15 +29,6 @@ export const DEBUGDump = () => {} // Stable empty array so we never create a new reference when ordinals are absent const emptyOrdinals: Array = [] -// Sentinel ordinal for SpecialTopMessage rendered as a regular list item so -// LegendList can track its size changes and maintain scroll position correctly. -const SPECIAL_TOP_ORDINAL = T.Chat.numberToOrdinal(-2) - -// 1px stable anchor at list index 0 so maintainVisibleContentPosition -// anchors to a fixed item. SPECIAL_TOP changes height dynamically; if it were -// the anchor, its own height changes would corrupt the offset compensation. -const STABLE_ANCHOR_ORDINAL = T.Chat.numberToOrdinal(-3) - // LegendList passes leadingItem=older message, but Separator.tsx on mobile uses leadingItem // as the ordinal for showUsernameMap, which is keyed by the newer (upper) message. // Defined outside ConversationList so React sees a stable component type across renders. @@ -45,8 +36,6 @@ const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { const {ordinalIndexMap, messageOrdinals} = Chat.useChatContext( C.useShallow(s => ({messageOrdinals: s.messageOrdinals, ordinalIndexMap: s.ordinalIndexMap})) ) - // SpecialTopMessage renders its own separator at its bottom edge. - if (leadingItem === SPECIAL_TOP_ORDINAL || leadingItem === STABLE_ANCHOR_ORDINAL) return null const idx = ordinalIndexMap.get(leadingItem) ?? -1 const trailingItem = messageOrdinals?.[idx + 1] if (!trailingItem) return null @@ -138,15 +127,16 @@ const ConversationList = function ConversationList() { ) const messageOrdinals = _messageOrdinals ?? emptyOrdinals - const data = React.useMemo( - () => [STABLE_ANCHOR_ORDINAL, SPECIAL_TOP_ORDINAL, ...(messageOrdinals as Array)], - [messageOrdinals] - ) + const data = messageOrdinals as Array + + // Always keep a ref to the latest messageOrdinals for use in stable callbacks. + const messageOrdinalsRef = React.useRef(messageOrdinals) + messageOrdinalsRef.current = messageOrdinals const [isTopOnScreen, setIsTopOnScreen] = React.useState(false) const onViewableItemsChanged = React.useCallback( ({viewableItems}: {viewableItems: Array<{item: T.Chat.Ordinal}>}) => { - setIsTopOnScreen(viewableItems.some(vt => vt.item === SPECIAL_TOP_ORDINAL)) + setIsTopOnScreen(viewableItems[0]?.item === messageOrdinalsRef.current[0]) }, [] ) @@ -156,12 +146,6 @@ const ConversationList = function ConversationList() { const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const renderItem = ({item: ordinal}: {item: T.Chat.Ordinal}) => { - if (ordinal === STABLE_ANCHOR_ORDINAL) { - return - } - if (ordinal === SPECIAL_TOP_ORDINAL) { - return - } const type = messageTypeMap.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) if (!Clazz) return null @@ -180,12 +164,6 @@ const ConversationList = function ConversationList() { const numOrdinals = messageOrdinals.length const getItemType = (ordinal: T.Chat.Ordinal, idx: number) => { - if (ordinal === STABLE_ANCHOR_ORDINAL) { - return 'stable-anchor' - } - if (ordinal === SPECIAL_TOP_ORDINAL) { - return 'special-top' - } if (!ordinal) { return 'null' } @@ -194,8 +172,7 @@ const ConversationList = function ConversationList() { if (recycled) return recycled const baseType = messageTypeMap.get(ordinal) ?? 'text' // Last item is most-recently sent; isolate it to avoid recycling with settled messages - // +2 because STABLE_ANCHOR_ORDINAL and SPECIAL_TOP_ORDINAL are at indices 0 and 1. - if (numOrdinals + 1 === idx && (baseType === 'text' || baseType === 'attachment')) { + if (numOrdinals - 1 === idx && (baseType === 'text' || baseType === 'attachment')) { return `${baseType}:pending` } return baseType @@ -277,12 +254,10 @@ const ConversationList = function ConversationList() { ({isTopOnScreen, messageTypeMap}), - [isTopOnScreen, messageTypeMap] - )} + extraData={messageTypeMap} onViewableItemsChanged={onViewableItemsChanged} estimatedItemSize={80} + ListHeaderComponent={} ListFooterComponent={SpecialBottomMessage} overScrollMode="never" contentInset={{bottom: mobileTypingContainerHeight}} diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index af9553eac291..d3b4e0b37877 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -107,7 +107,34 @@ const ErrorMessage = () => { ) } +// Outer gate: minimal reads, manages visibility timer, mounts inner only when needed. function SpecialTopMessage({isOnScreen = true}: {isOnScreen?: boolean}) { + const {moreToLoadBack, ordinal} = Chat.useChatContext( + C.useShallow(s => ({ + moreToLoadBack: s.moreToLoadBack, + ordinal: s.messageOrdinals?.[0] ?? T.Chat.numberToOrdinal(0), + })) + ) + const loadMoreType = moreToLoadBack ? 'moreToLoad' : 'noMoreToLoad' + // When there's nothing more to load, we're at the true top — show immediately. + // Otherwise delay to avoid flashing "Digging ancient messages..." before content settles. + const [visible, setVisible] = React.useState(false) + React.useEffect(() => { + if (loadMoreType === 'noMoreToLoad') { + setVisible(true) + return + } + setVisible(false) + if (!isOnScreen) return + const timer = setTimeout(() => setVisible(true), 3000) + return () => clearTimeout(timer) + }, [isOnScreen, loadMoreType, ordinal]) + + if (!visible) return null + return +} + +function SpecialTopMessageInner() { const username = useCurrentUserState(s => s.username) const data = Chat.useChatContext( C.useShallow(s => { @@ -123,7 +150,6 @@ function SpecialTopMessage({isOnScreen = true}: {isOnScreen?: boolean}) { : s.id === Chat.pendingErrorConversationIDKey ? 'error' : 'done' - const partAll = s.participants.all const partNum = partAll.length const isHelloBotConversation = teamType === 'adhoc' && partNum === 2 && partAll.includes('hellobot') @@ -149,26 +175,11 @@ function SpecialTopMessage({isOnScreen = true}: {isOnScreen?: boolean}) { ) const {ordinal, pendingState, isHelloBotConversation, hasOlderResetConversation} = data const {loadMoreType, isSelfConversation, showTeamOffer, showRetentionNotice} = data - // When there's nothing more to load, we're at the true top — show immediately. - // Otherwise delay to avoid flashing "Digging ancient messages..." before content settles. - const [visible, setVisible] = React.useState(false) - React.useEffect(() => { - if (loadMoreType === 'noMoreToLoad') { - setVisible(true) - return - } - setVisible(false) - if (!isOnScreen) return - const timer = setTimeout(() => setVisible(true), 3000) - return () => clearTimeout(timer) - }, [isOnScreen, loadMoreType, ordinal]) const openPrivateFolder = () => { FS.navToPath(T.FS.stringToPath(`/keybase/private/${username}`)) } - if (!visible) return null - return ( {loadMoreType === 'noMoreToLoad' && showRetentionNotice && } From 721870b00dbb59d37e15232663f3db64f5dcdb15 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 19 Mar 2026 11:01:31 -0400 Subject: [PATCH 25/31] dont show top in initial load --- .../conversation/messages/special-top-message.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index d3b4e0b37877..996451d07654 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -109,17 +109,22 @@ const ErrorMessage = () => { // Outer gate: minimal reads, manages visibility timer, mounts inner only when needed. function SpecialTopMessage({isOnScreen = true}: {isOnScreen?: boolean}) { - const {moreToLoadBack, ordinal} = Chat.useChatContext( + const {hasLoadedEver, moreToLoadBack, ordinal} = Chat.useChatContext( C.useShallow(s => ({ + hasLoadedEver: s.messageOrdinals !== undefined, moreToLoadBack: s.moreToLoadBack, ordinal: s.messageOrdinals?.[0] ?? T.Chat.numberToOrdinal(0), })) ) const loadMoreType = moreToLoadBack ? 'moreToLoad' : 'noMoreToLoad' - // When there's nothing more to load, we're at the true top — show immediately. - // Otherwise delay to avoid flashing "Digging ancient messages..." before content settles. + // Hold off until the thread has actually loaded — avoids flashing cards while initial + // data is in flight. Once loaded: show immediately at the true top, delay otherwise. const [visible, setVisible] = React.useState(false) React.useEffect(() => { + if (!hasLoadedEver) { + setVisible(false) + return + } if (loadMoreType === 'noMoreToLoad') { setVisible(true) return @@ -128,7 +133,7 @@ function SpecialTopMessage({isOnScreen = true}: {isOnScreen?: boolean}) { if (!isOnScreen) return const timer = setTimeout(() => setVisible(true), 3000) return () => clearTimeout(timer) - }, [isOnScreen, loadMoreType, ordinal]) + }, [hasLoadedEver, isOnScreen, loadMoreType, ordinal]) if (!visible) return null return From 2d14df606b3936b3455aa2cb1845af32170d6c05 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 19 Mar 2026 15:17:20 -0400 Subject: [PATCH 26/31] less subs --- .../conversation/list-area/index.desktop.tsx | 7 ++- .../conversation/list-area/index.native.tsx | 54 ++++++++++++------- .../messages/attachment/wrapper.tsx | 34 ++++++------ .../conversation/messages/text/wrapper.tsx | 10 ++-- .../conversation/messages/wrapper/wrapper.tsx | 33 ++++++------ 5 files changed, 81 insertions(+), 57 deletions(-) diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index d2c44a0a614b..0a50bcff68d9 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -373,6 +373,7 @@ const useItems = (p: { }) => { const {messageTypeMap, messageOrdinals, centeredOrdinal, editingOrdinal} = p const ordinalsInAWaypoint = 10 + const lastOrdinal = messageOrdinals.at(-1) const rowRenderer = (ordinal: T.Chat.Ordinal) => { const type = messageTypeMap?.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) @@ -397,7 +398,11 @@ const useItems = (p: { )} > - +
) } diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 0a2c00ce6f1c..df84511d9848 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -29,19 +29,6 @@ export const DEBUGDump = () => {} // Stable empty array so we never create a new reference when ordinals are absent const emptyOrdinals: Array = [] -// LegendList passes leadingItem=older message, but Separator.tsx on mobile uses leadingItem -// as the ordinal for showUsernameMap, which is keyed by the newer (upper) message. -// Defined outside ConversationList so React sees a stable component type across renders. -const ItemSeparator = ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { - const {ordinalIndexMap, messageOrdinals} = Chat.useChatContext( - C.useShallow(s => ({messageOrdinals: s.messageOrdinals, ordinalIndexMap: s.ordinalIndexMap})) - ) - const idx = ordinalIndexMap.get(leadingItem) ?? -1 - const trailingItem = messageOrdinals?.[idx + 1] - if (!trailingItem) return null - return -} - const useScrolling = (p: { centeredOrdinal: T.Chat.Ordinal messageOrdinals: ReadonlyArray @@ -117,9 +104,13 @@ const keyExtractor = (ordinal: ItemType) => { } const ConversationList = function ConversationList() { - const {conversationIDKey, centeredOrdinal, messageTypeMap, _messageOrdinals} = Chat.useChatContext( + const {conversationIDKey, centeredHighlightOrdinal, centeredOrdinal, messageTypeMap, _messageOrdinals} = Chat.useChatContext( C.useShallow(s => ({ _messageOrdinals: s.messageOrdinals, + centeredHighlightOrdinal: + s.messageCenterOrdinal?.highlightMode !== 'none' + ? (s.messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1)) + : T.Chat.numberToOrdinal(-1), centeredOrdinal: s.messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1), conversationIDKey: s.id, messageTypeMap: s.messageTypeMap, @@ -128,6 +119,18 @@ const ConversationList = function ConversationList() { const messageOrdinals = _messageOrdinals ?? emptyOrdinals const data = messageOrdinals as Array + const lastOrdinal = messageOrdinals.at(-1) + const separatorTrailingByLeading = React.useMemo(() => { + const trailingByLeading = new Map() + for (let idx = 0; idx < messageOrdinals.length - 1; idx++) { + const trailingItem = messageOrdinals[idx + 1] + const leadingItem = messageOrdinals[idx] + if (trailingItem) { + trailingByLeading.set(leadingItem, trailingItem) + } + } + return trailingByLeading + }, [messageOrdinals]) // Always keep a ref to the latest messageOrdinals for use in stable callbacks. const messageOrdinalsRef = React.useRef(messageOrdinals) @@ -145,21 +148,34 @@ const ConversationList = function ConversationList() { const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) - const renderItem = ({item: ordinal}: {item: T.Chat.Ordinal}) => { + const renderItem = React.useCallback(({item: ordinal}: {item: T.Chat.Ordinal}) => { const type = messageTypeMap.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) if (!Clazz) return null return ( - + ) - } + }, [centeredHighlightOrdinal, lastOrdinal, messageTypeMap]) + + const ItemSeparator = React.useCallback( + ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { + const trailingItem = separatorTrailingByLeading.get(leadingItem) + if (!trailingItem) return null + return + }, + [separatorTrailingByLeading] + ) const recycleTypeRef = React.useRef(new Map()) - const setRecycleType = (ordinal: T.Chat.Ordinal, type: string) => { + const [setRecycleType] = React.useState(() => (ordinal: T.Chat.Ordinal, type: string) => { recycleTypeRef.current.set(ordinal, type) - } + }) const numOrdinals = messageOrdinals.length diff --git a/shared/chat/conversation/messages/attachment/wrapper.tsx b/shared/chat/conversation/messages/attachment/wrapper.tsx index 142185e41154..7e884892444d 100644 --- a/shared/chat/conversation/messages/attachment/wrapper.tsx +++ b/shared/chat/conversation/messages/attachment/wrapper.tsx @@ -2,56 +2,56 @@ import type AudioAttachmentType from './audio' import type FileAttachmentType from './file' import type ImageAttachmentType from './image' import type VideoAttachmentType from './video' -import {WrapperMessage, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' +import {WrapperMessageView, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' export function WrapperAttachmentAudio(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {default: AudioAttachment} = require('./audio') as {default: typeof AudioAttachmentType} return ( - + - + ) } export function WrapperAttachmentFile(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {showPopup} = common const {default: FileAttachment} = require('./file') as {default: typeof FileAttachmentType} return ( - + - + ) } export function WrapperAttachmentVideo(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {showPopup} = common const {default: VideoAttachment} = require('./video') as {default: typeof VideoAttachmentType} return ( - + - + ) } export function WrapperAttachmentImage(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {showPopup} = common const {default: ImageAttachment} = require('./image') as {default: typeof ImageAttachmentType} return ( - + - + ) } diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index 4c99c716becd..cc1586dc7eba 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -4,7 +4,7 @@ import {useReply} from './reply' import {useBottom} from './bottom' import {useOrdinal} from '../ids-context' import {SetRecycleTypeContext} from '../../recycle-type-context' -import {WrapperMessage, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' +import {WrapperMessageView, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' import type {StyleOverride} from '@/common-adapters/markdown' import {sharedStyles} from '../shared-styles' @@ -46,8 +46,8 @@ function MessageMarkdown({style, text}: {style: Kb.Styles.StylesCrossPlatform; t } function WrapperText(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {type, showCenteredHighlight} = common const {isEditing, hasReactions} = messageData @@ -87,9 +87,9 @@ function WrapperText(p: Props) { } return ( - + {children} - + ) } diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index f0b0fee7603e..bdc12c400d55 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -20,6 +20,8 @@ import {useTrackerState} from '@/stores/tracker' import {formatTimeForChat} from '@/util/timestamp' export type Props = { + isCenteredHighlight?: boolean + isLastMessage?: boolean ordinal: T.Chat.Ordinal } @@ -184,8 +186,7 @@ function AuthorHeader({ordinal}: {ordinal: T.Chat.Ordinal}) { // Pure helper functions - moved outside hooks to avoid recreating them per message const getReactionsPopupPosition = ( - ordinal: T.Chat.Ordinal, - ordinals: ReadonlyArray, + isLastMessage: boolean, hasReactions: boolean, message: T.Chat.Message ) => { @@ -193,7 +194,7 @@ const getReactionsPopupPosition = ( if (hasReactions) return 'none' as const const validMessage = Chat.isMessageWithReactions(message) if (!validMessage) return 'none' as const - return ordinals.at(-1) === ordinal ? ('last' as const) : ('middle' as const) + return isLastMessage ? ('last' as const) : ('middle' as const) } const getEcrType = (message: T.Chat.Message, you: string) => { @@ -218,7 +219,11 @@ const getEcrType = (message: T.Chat.Message, you: string) => { } // Combined selector hook that fetches all message data in a single subscription -export const useMessageData = (ordinal: T.Chat.Ordinal) => { +export const useMessageData = ( + ordinal: T.Chat.Ordinal, + isLastMessage = false, + isCenteredHighlight = false +) => { const you = useCurrentUserState(s => s.username) return Chat.useChatContext( @@ -226,7 +231,6 @@ export const useMessageData = (ordinal: T.Chat.Ordinal) => { const accountsInfoMap = s.accountsInfoMap const m = s.messageMap.get(ordinal) ?? missingMessage const isEditing = s.editing === ordinal - const ordinals = s.messageOrdinals const {exploded, submitState, author, id, botUsername} = m const type = m.type const idMatchesOrdinal = T.Chat.ordinalToNumber(m.ordinal) === T.Chat.messageIDToNumber(id) @@ -242,13 +246,9 @@ export const useMessageData = (ordinal: T.Chat.Ordinal) => { const showCoinsIcon = hasSuccessfulInlinePayments(paymentStatusMap, m) const hasReactions = (m.reactions?.size ?? 0) > 0 const botname = botUsername === author ? '' : (botUsername ?? '') - const reactionsPopupPosition = getReactionsPopupPosition(ordinal, ordinals ?? [], hasReactions, m) + const reactionsPopupPosition = getReactionsPopupPosition(isLastMessage, hasReactions, m) const ecrType = getEcrType(m, you) const shouldShowPopup = Chat.shouldShowPopup(accountsInfoMap, m) - // Inline highlight mode check to avoid separate selector - const centeredOrdinalType = s.messageCenterOrdinal - const showCenteredHighlight = - centeredOrdinalType?.ordinal === ordinal && centeredOrdinalType.highlightMode !== 'none' // Fields lifted from child components to consolidate subscriptions const hasBeenEdited = m.hasBeenEdited ?? false const hasCoinFlip = m.type === 'text' && !!m.flipGameID @@ -271,7 +271,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal) => { isEditing, reactionsPopupPosition, shouldShowPopup, - showCenteredHighlight, + showCenteredHighlight: isCenteredHighlight, showCoinsIcon, showExplodingCountdown, showReplyTo, @@ -663,13 +663,16 @@ function RightSide(p: RProps) { } export function WrapperMessage(p: WMProps) { + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) + return +} + +export function WrapperMessageView(p: WMProps & {messageData: ReturnType}) { const {ordinal, bottomChildren, children, messageData: mdataProp} = p const {showCenteredHighlight, showPopup, showingPopup, popup, popupAnchor} = p const [showingPicker, setShowingPicker] = React.useState(false) - - // Use provided messageData if available, otherwise fetch it - const mdataFetched = useMessageData(ordinal) - const mdata = mdataProp ?? mdataFetched + const mdata = mdataProp const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata const {ecrType, showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata From 0445dfb999af2e3f84b66c53fae37d1504834f62 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 19 Mar 2026 15:19:40 -0400 Subject: [PATCH 27/31] wip --- shared/chat/conversation/list-area/index.native.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index df84511d9848..83493054a3a3 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -125,23 +125,20 @@ const ConversationList = function ConversationList() { for (let idx = 0; idx < messageOrdinals.length - 1; idx++) { const trailingItem = messageOrdinals[idx + 1] const leadingItem = messageOrdinals[idx] - if (trailingItem) { + if (trailingItem !== undefined && leadingItem !== undefined) { trailingByLeading.set(leadingItem, trailingItem) } } return trailingByLeading }, [messageOrdinals]) - // Always keep a ref to the latest messageOrdinals for use in stable callbacks. - const messageOrdinalsRef = React.useRef(messageOrdinals) - messageOrdinalsRef.current = messageOrdinals - + const firstOrdinal = messageOrdinals[0] const [isTopOnScreen, setIsTopOnScreen] = React.useState(false) const onViewableItemsChanged = React.useCallback( ({viewableItems}: {viewableItems: Array<{item: T.Chat.Ordinal}>}) => { - setIsTopOnScreen(viewableItems[0]?.item === messageOrdinalsRef.current[0]) + setIsTopOnScreen(viewableItems[0]?.item === firstOrdinal) }, - [] + [firstOrdinal] ) const insets = useSafeAreaInsets() From f7ac2e1ff00f2bd2bf239b0ec66daa0979b79b62 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 19 Mar 2026 15:40:20 -0400 Subject: [PATCH 28/31] try desktop also --- .../conversation/list-area/index.desktop.tsx | 796 ++++++------------ .../conversation/list-area/index.native.tsx | 173 +--- .../conversation/list-area/list-shared.tsx | 205 +++++ 3 files changed, 467 insertions(+), 707 deletions(-) create mode 100644 shared/chat/conversation/list-area/list-shared.tsx diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 0a50bcff68d9..ff5923d9b7f1 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -3,246 +3,122 @@ import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as Hooks from './hooks' import * as React from 'react' -import {PerfProfiler} from '@/perf/react-profiler' import * as T from '@/constants/types' +import type {LegendListRef} from '@/common-adapters' +import {LegendList} from '@legendapp/list/react' import Separator from '../messages/separator' import SpecialBottomMessage from '../messages/special-bottom-message' import SpecialTopMessage from '../messages/special-top-message' -import chunk from 'lodash/chunk' -import {findLast} from '@/util/arrays' -import {getMessageRender} from '../messages/wrapper' +import {SetRecycleTypeContext} from '../recycle-type-context' import {globalMargins} from '@/styles/shared' import {FocusContext, ScrollContext} from '../normal/context' -import {chatDebugEnabled} from '@/constants/chat/debug' -import logger from '@/logger' -import shallowEqual from 'shallowequal' -import useResizeObserver from '@/util/use-resize-observer.desktop' -import useIntersectionObserver from '@/util/use-intersection-observer' import {useConfigState} from '@/stores/config' - -// 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' +import {PerfProfiler} from '@/perf/react-profiler' +import { + hasOrdinal, + keyExtractor, + useConversationListData, + useMessageNodeRenderer, + useOnStartReached, + useRecycleType, + useScrollToCentered, + useTopOnScreen, +} from './list-shared' // 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 -// scrolling related things +const LegendListAny: any = LegendList + const useScrolling = (p: { + centeredOrdinal: T.Chat.Ordinal containsLatestMessage: boolean - messageOrdinals: ReadonlyArray - listRef: React.RefObject + isTopOnScreen: boolean + listRef: React.RefObject loaded: boolean - setListRef: (r: HTMLDivElement | null) => void - centeredOrdinal: T.Chat.Ordinal | undefined + markInitiallyLoadedThreadAsRead: () => void + messageOrdinals: ReadonlyArray + ordinalIndexMap: ReadonlyMap }) => { - const conversationIDKey = Chat.useChatContext(s => s.id) - const {listRef, setListRef: _setListRef, containsLatestMessage} = p + const {centeredOrdinal, containsLatestMessage, isTopOnScreen, listRef, loaded, markInitiallyLoadedThreadAsRead} = p + const {messageOrdinals, ordinalIndexMap} = p + const numOrdinals = messageOrdinals.length + const loadOlderMessagesDueToScroll = Chat.useChatContext(s => s.dispatch.loadOlderMessagesDueToScroll) + const loadNewerMessagesDueToScroll = Chat.useChatContext(s => s.dispatch.loadNewerMessagesDueToScroll) + const {setScrollRef} = React.useContext(ScrollContext) + const numOrdinalsRef = React.useRef(numOrdinals) + const loadOlderMessagesDueToScrollRef = React.useRef(loadOlderMessagesDueToScroll) + const loadNewerMessagesDueToScrollRef = React.useRef(loadNewerMessagesDueToScroll) + + React.useEffect(() => { + numOrdinalsRef.current = numOrdinals + loadOlderMessagesDueToScrollRef.current = loadOlderMessagesDueToScroll + loadNewerMessagesDueToScrollRef.current = loadNewerMessagesDueToScroll + }, [loadNewerMessagesDueToScroll, loadOlderMessagesDueToScroll, numOrdinals]) + const containsLatestMessageRef = React.useRef(containsLatestMessage) React.useEffect(() => { containsLatestMessageRef.current = containsLatestMessage }, [containsLatestMessage]) - const {messageOrdinals, centeredOrdinal, loaded} = p - const numOrdinals = messageOrdinals.length - const loadNewerMessagesDueToScroll = Chat.useChatContext(s => s.dispatch.loadNewerMessagesDueToScroll) - const loadNewerMessages = C.useThrottledCallback( - () => { - loadNewerMessagesDueToScroll(numOrdinals) - }, - 200 - ) - // if we scroll up try and keep the position - const scrollBottomOffsetRef = React.useRef(undefined) - const loadOlderMessages = Chat.useChatContext(s => s.dispatch.loadOlderMessagesDueToScroll) - const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) - // 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: HTMLDivElement | null) => { - pointerWrapperRef.current = r - } - const numOrdinalsRef = React.useRef(numOrdinals) - const loadOlderMessagesRef = React.useRef(loadOlderMessages) - const loadNewerMessagesRef = React.useRef(loadNewerMessages) + const centeredOrdinalRef = React.useRef(centeredOrdinal) + React.useEffect(() => { + centeredOrdinalRef.current = centeredOrdinal + }, [centeredOrdinal]) - const [isLockedToBottom] = React.useState(() => () => { - return lockedToBottomRef.current - }) + const topVisibleOrdinalRef = React.useRef(undefined) + const lockedToBottomRef = React.useRef(true) - const adjustScrollAndIgnoreOnScroll = (fn: () => void) => { - ignoreOnScrollRef.current = true - fn() - } + const [isLockedToBottom] = React.useState(() => () => lockedToBottomRef.current) + const [didFirstLoad, setDidFirstLoad] = React.useState(false) - 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 loadNewerMessagesThrottled = C.useThrottledCallback(() => { + loadNewerMessagesDueToScrollRef.current(numOrdinalsRef.current) + }, 200) - const [scrollToBottomSync] = React.useState(() => () => { - lockedToBottomRef.current = true - const list = listRef.current - if (list) { - adjustScrollAndIgnoreOnScroll(() => { - list.scrollTop = list.scrollHeight - list.clientHeight - }) - } - }) + React.useEffect( + () => () => { + loadNewerMessagesThrottled.cancel() + }, + [loadNewerMessagesThrottled] + ) const [scrollToBottom] = React.useState(() => () => { - scrollToBottomSync() + lockedToBottomRef.current = true + void listRef.current?.scrollToEnd({animated: false}) setTimeout(() => { - requestAnimationFrame(scrollToBottomSync) - }, 1) - }) - - const [performScrollToCentered] = React.useState(() => () => { - const list = listRef.current - const waypoint = list?.querySelectorAll(`[data-key=${scrollOrdinalKey}]`)[0] as HTMLElement | 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) + void listRef.current?.scrollToEnd({animated: false}) }) - }) + }, 1) }) + const scrollToCentered = useScrollToCentered(listRef, centeredOrdinal, ordinalIndexMap) + const [scrollDown] = React.useState(() => () => { const list = listRef.current - list && - adjustScrollAndIgnoreOnScroll(() => { - list.scrollTop += list.clientHeight - }) + if (!list) return + const {end, start} = list.getState() + const pageSize = Math.max(1, end - start) + const nextIndex = Math.min(Math.max(0, numOrdinalsRef.current - 1), Math.max(end, start, 0) + pageSize) + void list.scrollToIndex({animated: false, index: nextIndex, viewPosition: 0}) }) const [scrollUp] = React.useState(() => () => { lockedToBottomRef.current = false const list = listRef.current - list && - adjustScrollAndIgnoreOnScroll(() => { - list.scrollTop -= list.clientHeight - checkForLoadMoreThrottled() - }) + if (!list) return + const {end, start} = list.getState() + const pageSize = Math.max(1, end - start) + const nextIndex = Math.max(0, Math.max(0, start) - pageSize) + void list.scrollToIndex({animated: false, index: nextIndex, viewPosition: 0}) }) - 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: HTMLDivElement | 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) + setScrollRef({scrollDown, scrollToBottom, scrollUp}) + }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) - // Ensure didFirstLoad is true whenever we're loaded (even if we skipped reload) React.useEffect(() => { if (loaded && !didFirstLoad) { requestAnimationFrame(() => { @@ -251,12 +127,10 @@ const useScrolling = (p: { } }, [loaded, didFirstLoad]) - // Handle scrolling when loaded becomes true. Scroll to centered ordinal if present, else bottom const prevLoadedRef = React.useRef(loaded) React.useLayoutEffect(() => { const justLoaded = loaded && !prevLoadedRef.current prevLoadedRef.current = loaded - if (!justLoaded) return if (!markedInitiallyLoaded) { @@ -264,264 +138,186 @@ const useScrolling = (p: { markInitiallyLoadedThreadAsRead() } - if (centeredOrdinal) { + if (hasOrdinal(centeredOrdinal)) { lockedToBottomRef.current = false scrollToCentered() } else { scrollToBottom() } - }, [loaded, centeredOrdinal, markInitiallyLoadedThreadAsRead, scrollToBottom, scrollToCentered]) + }, [centeredOrdinal, loaded, markInitiallyLoadedThreadAsRead, scrollToBottom, 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 + const prevNumOrdinalsRef = React.useRef(numOrdinals) React.useLayoutEffect(() => { - const list = listRef.current - // no items? don't be locked - if (!ordinalsLength) { + if (!numOrdinals) { 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) { + if (numOrdinals === prevNumOrdinalsRef.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 + prevNumOrdinalsRef.current = numOrdinals + if ( olderMessagesAdded && - list && - !centeredOrdinal && // ignore this if we're scrolling and we're doing a search + !hasOrdinal(centeredOrdinal) && !isLockedToBottom() && - scrollBottomOffsetRef.current !== undefined + topVisibleOrdinalRef.current !== undefined ) { - programaticScrollRef.current = true - const newTop = list.scrollHeight - scrollBottomOffsetRef.current - list.scrollTop = newTop + const idx = ordinalIndexMap.get(topVisibleOrdinalRef.current) ?? -1 + if (idx >= 0) { + void listRef.current?.scrollToIndex({animated: false, index: idx, viewPosition: 0}) + return + } + } + + if (isLockedToBottom() && !hasOrdinal(centeredOrdinal)) { + scrollToBottom() } - return undefined - // we want this to fire when the ordinals change - }, [centeredOrdinal, ordinalsLength, isLockedToBottom, listRef, firstOrdinal]) + }, [centeredOrdinal, firstOrdinal, isLockedToBottom, listRef, numOrdinals, ordinalIndexMap, scrollToBottom]) - // Also handle centered ordinal changing while already loaded (e.g. from thread search results) - const prevCenteredOrdinal = React.useRef(centeredOrdinal) + const prevCenteredOrdinalRef = React.useRef(centeredOrdinal) const wasLoadedRef = React.useRef(loaded) React.useEffect(() => { const wasLoaded = wasLoadedRef.current - const changed = prevCenteredOrdinal.current !== centeredOrdinal - prevCenteredOrdinal.current = centeredOrdinal + const changed = prevCenteredOrdinalRef.current !== centeredOrdinal + prevCenteredOrdinalRef.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) { + if (hasOrdinal(centeredOrdinal)) { lockedToBottomRef.current = false scrollToCentered() } else if (containsLatestMessage) { lockedToBottomRef.current = true scrollToBottom() } - }, [centeredOrdinal, loaded, containsLatestMessage, scrollToCentered, scrollToBottom]) + }, [centeredOrdinal, containsLatestMessage, loaded, scrollToBottom, scrollToCentered]) - const {setScrollRef} = React.useContext(ScrollContext) - React.useEffect(() => { - setScrollRef({scrollDown, scrollToBottom, scrollUp}) - }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) + const [onViewableItemsChanged] = React.useState( + () => + ({viewableItems}: {viewableItems: ReadonlyArray<{index?: number; item: T.Chat.Ordinal}>}) => { + const end = viewableItems.at(-1)?.index ?? -1 + topVisibleOrdinalRef.current = viewableItems[0]?.item - // go to editing message - const editingOrdinal = Chat.useChatContext(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) / 10) - 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'}) + if (!hasOrdinal(centeredOrdinalRef.current) && containsLatestMessageRef.current) { + lockedToBottomRef.current = end >= numOrdinalsRef.current - 1 + } + + if ( + !containsLatestMessageRef.current && + !lockedToBottomRef.current && + end >= numOrdinalsRef.current - 1 + ) { + loadNewerMessagesThrottled() + } } - } - }, [editingOrdinal, messageOrdinals, listRef]) + ) - return {didFirstLoad, isLockedToBottom, scrollToBottom, setListRef, setPointerWrapperRef} -} + const onStartReached = useOnStartReached({ + isTopOnScreen, + numOrdinals, + onStartReachedBase: () => { + loadOlderMessagesDueToScrollRef.current(numOrdinalsRef.current) + }, + }) -const useItems = (p: { - messageOrdinals: ReadonlyArray - centeredOrdinal: T.Chat.Ordinal | undefined - editingOrdinal: T.Chat.Ordinal | undefined - messageTypeMap: ReadonlyMap | undefined -}) => { - const {messageTypeMap, messageOrdinals, centeredOrdinal, editingOrdinal} = p - const ordinalsInAWaypoint = 10 - const lastOrdinal = messageOrdinals.at(-1) - const rowRenderer = (ordinal: T.Chat.Ordinal) => { - const type = messageTypeMap?.get(ordinal) ?? 'text' - const Clazz = getMessageRender(type) - if (!Clazz) { - if (chatDebugEnabled) { - logger.error('[CHATDEBUG] no rendertype', {Clazz, ordinal, type}) - } - return null - } + return {didFirstLoad, onStartReached, onViewableItemsChanged, scrollToBottom} +} - return ( -
- - -
- ) - } +const ConversationList = function ConversationList() { + const data = useConversationListData() + const { + centeredHighlightOrdinal, + centeredOrdinal, + containsLatestMessage, + conversationIDKey, + editingOrdinal, + loaded, + messageOrdinals, + messageTypeMap, + ordinalIndexMap, + } = data - const wayOrdinalCachRef = React.useRef(new Map>()) + const copyToClipboard = useConfigState(s => s.dispatch.defer.copyToClipboard) + const listRef = React.useRef(null) + const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) + const lastOrdinal = messageOrdinals.at(-1) + const {isTopOnScreen, onViewableItemsChanged: onTopViewableItemsChanged} = useTopOnScreen(messageOrdinals) + const renderMessageNode = useMessageNodeRenderer({centeredHighlightOrdinal, lastOrdinal, messageTypeMap}) - // TODO doesn't need all messageOrdinals in there, could just find buckets and push details down - const items = (() => { - const items: Array = [] - const numOrdinals = messageOrdinals.length + const {getItemType, setRecycleType} = useRecycleType(messageOrdinals, messageTypeMap) - 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 + const renderItem = React.useCallback( + ({item: ordinal}: {item: T.Chat.Ordinal}) => { + const rendered = renderMessageNode(ordinal) + if (!rendered) return null - // 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, we have sends which can allow > 10 ordinals in a bucket so we split it further - const chunks = chunk(ordinals, 10) - chunks.forEach((toAdd, cidx) => { - const key = `${lastBucket || ''}:${cidx + baseIndex}` - let wayOrdinals = toAdd - const existing = wayOrdinalCachRef.current.get(key) - if (existing && shallowEqual(existing, wayOrdinals)) { - wayOrdinals = existing - } else { - wayOrdinalCachRef.current.set(key, wayOrdinals) + return ( +
+ + {rendered.node} +
+ ) + }, + [centeredHighlightOrdinal, editingOrdinal, renderMessageNode] + ) - items.push( - - ) - }) - // 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) { - const key = scrollOrdinalKey - let wayOrdinals = [ordinal] - const existing = wayOrdinalCachRef.current.get(key) - if (existing && shallowEqual(existing, wayOrdinals)) { - wayOrdinals = existing - } else { - wayOrdinalCachRef.current.set(key, wayOrdinals) - } - items.push( - - ) - lastBucket = 0 - baseIndex++ // push this up if we drop the centered ordinal waypoint - } else { - ordinals.push(ordinal) - } + const { + didFirstLoad, + onStartReached, + onViewableItemsChanged: onScrollViewableItemsChanged, + scrollToBottom, + } = useScrolling({ + centeredOrdinal, + containsLatestMessage, + isTopOnScreen, + listRef, + loaded, + markInitiallyLoadedThreadAsRead, + messageOrdinals, + ordinalIndexMap, }) - return [, ...items, ] - })() - - return items -} - -const noOrdinals = new Array() -const ThreadWrapper = function ThreadWrapper() { - const data = Chat.useChatContext( - C.useShallow(s => { - const {messageTypeMap, editing: editingOrdinal, id: conversationIDKey} = s - const {messageCenterOrdinal: mco, messageOrdinals = noOrdinals, loaded} = s - const centeredOrdinal = mco && mco.highlightMode !== 'none' ? mco.ordinal : undefined - const containsLatestMessage = s.isCaughtUp() - return { - centeredOrdinal, - containsLatestMessage, - conversationIDKey, - editingOrdinal, - loaded, - messageOrdinals, - messageTypeMap, - } - }) + const onViewableItemsChanged = React.useCallback( + (data: {viewableItems: ReadonlyArray<{index?: number; item: T.Chat.Ordinal}>}) => { + onTopViewableItemsChanged(data) + onScrollViewableItemsChanged(data) + }, + [onScrollViewableItemsChanged, onTopViewableItemsChanged] ) - const {conversationIDKey, editingOrdinal, centeredOrdinal} = data - const {containsLatestMessage, messageOrdinals, loaded, messageTypeMap} = data - const copyToClipboard = useConfigState(s => s.dispatch.defer.copyToClipboard) - const listRef = React.useRef(null) - const _setListRef = (r: HTMLDivElement | null) => { - listRef.current = r - } - const {isLockedToBottom, scrollToBottom, setListRef, didFirstLoad, setPointerWrapperRef} = useScrolling({ - centeredOrdinal, - containsLatestMessage, - listRef, - loaded, - messageOrdinals, - setListRef: _setListRef, - }) const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length) + + const lastEditingOrdinalRef = React.useRef(editingOrdinal) + React.useEffect(() => { + if (lastEditingOrdinalRef.current === editingOrdinal) return + lastEditingOrdinalRef.current = editingOrdinal + if (!editingOrdinal) return + const idx = ordinalIndexMap.get(editingOrdinal) ?? -1 + if (idx < 0) return + void listRef.current?.scrollToIndex({animated: false, index: idx, viewPosition: 0.5}) + }, [editingOrdinal, ordinalIndexMap]) + const onCopyCapture = (e: React.BaseSyntheticEvent) => { // Copy text only, not HTML/styling. We use virtualText on texts to make uncopyable text e.preventDefault() @@ -532,7 +328,6 @@ const ThreadWrapper = function ThreadWrapper() { // extra newlines only when you do toString() vs getting the textContents const tempDiv = document.createElement('div') tempDiv.appendChild(temp) - // filter const styles = tempDiv.querySelectorAll('style') styles.forEach(s => { s.parentNode?.removeChild(s) @@ -545,13 +340,15 @@ const ThreadWrapper = function ThreadWrapper() { }) const tc = tempDiv.textContent - tc && copyToClipboard(tc) + if (tc) { + copyToClipboard(tc) + } tempDiv.remove() } + const {focusInput} = React.useContext(FocusContext) const handleListClick = (ev: React.MouseEvent) => { const target = ev.target - // allow focusing other inner inputs such as the reacji picker filter if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || @@ -566,14 +363,6 @@ const ThreadWrapper = function ThreadWrapper() { } } - const items = useItems({centeredOrdinal, editingOrdinal, messageOrdinals, messageTypeMap}) - const setListContents = useHandleListResize({ - centeredOrdinal, - isLockedToBottom, - scrollToBottom, - setPointerWrapperRef, - }) - return (
-
-
- {items} -
-
- {jumpToRecent} + + + } + alignItemsAtEnd={true} + className="chat-scroller" + data-testid="message-list" + drawDistance={400} + getItemType={getItemType} + initialScrollAtEnd={true} + key={conversationIDKey} + keyExtractor={keyExtractor} + maintainScrollAtEnd={{animated: false}} + maintainVisibleContentPosition={{data: true, size: true}} + onStartReached={onStartReached} + onStartReachedThreshold={0.3} + onViewableItemsChanged={onViewableItemsChanged} + recycleItems={true} + ref={listRef} + renderItem={renderItem} + style={Kb.Styles.castStyleDesktop( + Kb.Styles.collapseStyles([styles.list, {opacity: didFirstLoad ? 1 : 0}]) + )} + testID="message-list" + waitForInitialLayout={true} + /> + {jumpToRecent} + +
) } -const useHandleListResize = (p: { - centeredOrdinal: T.Chat.Ordinal | undefined - isLockedToBottom: () => boolean - scrollToBottom: () => void - setPointerWrapperRef: (r: HTMLDivElement | null) => void -}) => { - const {isLockedToBottom, scrollToBottom, setPointerWrapperRef, centeredOrdinal} = 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: HTMLDivElement | null) => { - setPointerWrapperRef(listContents) - pointerWrapperRef.current = listContents - } - - useResizeObserver(pointerWrapperRef, e => onListSizeChanged(e.contentRect)) - - return setListContents -} - -type OrdinalWaypointProps = { - id: string - rowRenderer: (ordinal: T.Chat.Ordinal) => React.ReactNode - ordinals: Array -} - -const colorWaypoints = __DEV__ && (false as boolean) -const colors = new Array() -if (colorWaypoints) { - for (let i = 0; i < 10; ++i) { - console.log('COLOR WAYPOINTS ON!!!!!!!!!!!!!!!!') - colors.push(`rgb(${Math.random() * 255},${Math.random() * 255},${Math.random() * 255})`) - } -} - -// Start unmeasured waypoints as placeholders with estimated height. -// Intersection Observer fires synchronously for elements in the viewport on mount, -// so visible waypoints render immediately. Off-screen ones stay as placeholders until scrolled to. -const OrdinalWaypoint = function OrdinalWaypoint(p: OrdinalWaypointProps) { - const {ordinals, id, rowRenderer} = p - const estimatedHeight = 40 * ordinals.length - const [height, setHeight] = React.useState(-1) - const [isVisible, setVisible] = React.useState(false) - const [wRef, setRef] = React.useState(null) - const root = wRef?.closest('.chat-scroller') as HTMLElement | undefined - const {isIntersecting} = useIntersectionObserver(wRef, {root}) - const lastIsIntersecting = React.useRef(isIntersecting) - - React.useEffect(() => { - if (lastIsIntersecting.current === isIntersecting) return - lastIsIntersecting.current = isIntersecting - setVisible(isIntersecting) - }, [isIntersecting]) - - const renderMessages = isVisible - let content: React.ReactElement - - const lastRenderMessages = React.useRef(false) - React.useEffect(() => { - if (!wRef) return - if (lastRenderMessages.current === renderMessages) return - if (renderMessages) { - const h = wRef.offsetHeight - if (h) { - setHeight(h) - } - } - lastRenderMessages.current = renderMessages - }, [renderMessages, wRef]) - - if (renderMessages) { - content = - } else { - content = - } - - if (colorWaypoints) { - let cidx = parseInt(id) - if (isNaN(cidx)) cidx = 0 - cidx = cidx % colors.length - return
{content}
- } else { - return content - } -} - -type ContentType = { - id: string - ordinals: Array - rowRenderer: (o: T.Chat.Ordinal) => React.ReactNode - ref?: React.Ref -} -function Content(p: ContentType) { - 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 DummyType = { - id: string - height: number - ref?: React.Ref -} -function Dummy(p: DummyType) { - const {id, height, ref} = p - // Apply data-key to the dom node so we can search for editing messages - return
-} - const styles = 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', @@ -738,20 +423,11 @@ const styles = Kb.Styles.styleSheetCreate( isElectron: { ...Kb.Styles.globalStyles.fillAbsolute, outline: 'none', - overflowX: 'hidden', - overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: globalMargins.small, - // get our own layer so we can scroll faster willChange: 'transform', }, }), - listContents: Kb.Styles.platformStyles({ - isElectron: { - contain: 'layout style', - width: '100%', - }, - }), }) as const ) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 83493054a3a3..4c454457a944 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -1,4 +1,3 @@ -import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as T from '@/constants/types' import * as Hooks from './hooks' @@ -7,17 +6,24 @@ import * as React from 'react' import Separator from '../messages/separator' import SpecialBottomMessage from '../messages/special-bottom-message' import SpecialTopMessage from '../messages/special-top-message' -import type {ItemType} from '.' import {type LegendListRef} from '@legendapp/list/react-native' import {KeyboardAvoidingLegendList} from '@legendapp/list/keyboard-test' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {getMessageRender} from '../messages/wrapper' import {mobileTypingContainerHeight} from '../input-area/normal/typing' import {SetRecycleTypeContext} from '../recycle-type-context' // import {useChatDebugDump} from '@/constants/chat/debug' import {PerfProfiler} from '@/perf/react-profiler' import {ScrollContext} from '../normal/context' import noop from 'lodash/noop' +import { + keyExtractor, + useConversationListData, + useMessageNodeRenderer, + useOnStartReached, + useRecycleType, + useScrollToCentered, + useTopOnScreen, +} from './list-shared' // import {useDebugLayout} from '@/util/debug-react' // We load the first thread automatically so in order to mark it read @@ -26,19 +32,15 @@ let markedInitiallyLoaded = false export const DEBUGDump = () => {} -// Stable empty array so we never create a new reference when ordinals are absent -const emptyOrdinals: Array = [] - const useScrolling = (p: { centeredOrdinal: T.Chat.Ordinal messageOrdinals: ReadonlyArray - conversationIDKey: T.Chat.ConversationIDKey listRef: React.RefObject + ordinalIndexMap: ReadonlyMap }) => { - const {listRef, centeredOrdinal, messageOrdinals} = p + const {centeredOrdinal, listRef, messageOrdinals, ordinalIndexMap} = p const numOrdinals = messageOrdinals.length const loadOlderMessages = Chat.useChatContext(s => s.dispatch.loadOlderMessagesDueToScroll) - const ordinalIndexMap = Chat.useChatContext(s => s.ordinalIndexMap) const [scrollToBottom] = React.useState(() => () => { void listRef.current?.scrollToEnd({animated: false}) }) @@ -48,45 +50,7 @@ const useScrolling = (p: { setScrollRef({scrollDown: noop, scrollToBottom, scrollUp: noop}) }, [setScrollRef, scrollToBottom]) - // only scroll to center once per - const lastScrollToCentered = React.useRef(-1) - React.useEffect(() => { - if (T.Chat.ordinalToNumber(centeredOrdinal) < 0) { - lastScrollToCentered.current = -1 - } - }, [centeredOrdinal]) - - const centeredOrdinalRef = React.useRef(centeredOrdinal) - React.useEffect(() => { - centeredOrdinalRef.current = centeredOrdinal - }, [centeredOrdinal]) - - const ordinalIndexMapRef = React.useRef(ordinalIndexMap) - React.useEffect(() => { - ordinalIndexMapRef.current = ordinalIndexMap - }, [ordinalIndexMap]) - - const [scrollToCentered] = React.useState(() => () => { - const list = listRef.current - if (!list) { - return - } - const co = centeredOrdinalRef.current - if (lastScrollToCentered.current === co) { - return - } - lastScrollToCentered.current = co - - const idx = ordinalIndexMapRef.current.get(co) ?? -1 - if (idx < 0) { - return - } - // Scroll once, then re-scroll after the promise resolves so that size - // stabilization has settled and the item lands in view correctly. - void list.scrollToIndex({animated: false, index: idx, viewPosition: 0.5}).then(() => { - void list.scrollToIndex({animated: false, index: idx, viewPosition: 0.5}) - }) - }) + const scrollToCentered = useScrollToCentered(listRef, centeredOrdinal, ordinalIndexMap) const onStartReached = () => { loadOlderMessages(numOrdinals) @@ -99,25 +63,9 @@ const useScrolling = (p: { } } -const keyExtractor = (ordinal: ItemType) => { - return String(ordinal) -} - const ConversationList = function ConversationList() { - const {conversationIDKey, centeredHighlightOrdinal, centeredOrdinal, messageTypeMap, _messageOrdinals} = Chat.useChatContext( - C.useShallow(s => ({ - _messageOrdinals: s.messageOrdinals, - centeredHighlightOrdinal: - s.messageCenterOrdinal?.highlightMode !== 'none' - ? (s.messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1)) - : T.Chat.numberToOrdinal(-1), - centeredOrdinal: s.messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1), - conversationIDKey: s.id, - messageTypeMap: s.messageTypeMap, - })) - ) - - const messageOrdinals = _messageOrdinals ?? emptyOrdinals + const {conversationIDKey, centeredHighlightOrdinal, centeredOrdinal, messageOrdinals, messageTypeMap, ordinalIndexMap} = + useConversationListData() const data = messageOrdinals as Array const lastOrdinal = messageOrdinals.at(-1) const separatorTrailingByLeading = React.useMemo(() => { @@ -132,33 +80,17 @@ const ConversationList = function ConversationList() { return trailingByLeading }, [messageOrdinals]) - const firstOrdinal = messageOrdinals[0] - const [isTopOnScreen, setIsTopOnScreen] = React.useState(false) - const onViewableItemsChanged = React.useCallback( - ({viewableItems}: {viewableItems: Array<{item: T.Chat.Ordinal}>}) => { - setIsTopOnScreen(viewableItems[0]?.item === firstOrdinal) - }, - [firstOrdinal] - ) + const {isTopOnScreen, onViewableItemsChanged} = useTopOnScreen(messageOrdinals) const insets = useSafeAreaInsets() const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) + const renderMessageNode = useMessageNodeRenderer({centeredHighlightOrdinal, lastOrdinal, messageTypeMap}) - const renderItem = React.useCallback(({item: ordinal}: {item: T.Chat.Ordinal}) => { - const type = messageTypeMap.get(ordinal) ?? 'text' - const Clazz = getMessageRender(type) - if (!Clazz) return null - return ( - - - - ) - }, [centeredHighlightOrdinal, lastOrdinal, messageTypeMap]) + const renderItem = React.useCallback( + ({item: ordinal}: {item: T.Chat.Ordinal}) => renderMessageNode(ordinal)?.node ?? null, + [renderMessageNode] + ) const ItemSeparator = React.useCallback( ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => { @@ -169,27 +101,7 @@ const ConversationList = function ConversationList() { [separatorTrailingByLeading] ) - const recycleTypeRef = React.useRef(new Map()) - const [setRecycleType] = React.useState(() => (ordinal: T.Chat.Ordinal, type: string) => { - recycleTypeRef.current.set(ordinal, type) - }) - - const numOrdinals = messageOrdinals.length - - const getItemType = (ordinal: T.Chat.Ordinal, idx: number) => { - if (!ordinal) { - return 'null' - } - // Check recycleType first (set by messages after render — includes subtypes like 'text:reply') - const recycled = recycleTypeRef.current.get(ordinal) - if (recycled) return recycled - const baseType = messageTypeMap.get(ordinal) ?? 'text' - // Last item is most-recently sent; isolate it to avoid recycling with settled messages - if (numOrdinals - 1 === idx && (baseType === 'text' || baseType === 'attachment')) { - return `${baseType}:pending` - } - return baseType - } + const {getItemType, setRecycleType} = useRecycleType(messageOrdinals, messageTypeMap) const { scrollToCentered, @@ -197,9 +109,9 @@ const ConversationList = function ConversationList() { onStartReached: onStartReachedBase, } = useScrolling({ centeredOrdinal, - conversationIDKey, listRef, messageOrdinals, + ordinalIndexMap, }) const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length) @@ -222,45 +134,12 @@ const ConversationList = function ConversationList() { } }, [markInitiallyLoadedThreadAsRead]) - const isLoadingOlderRef = React.useRef(false) - const prevNumOrdinalsRef = React.useRef(numOrdinals) - const loadResetTimerRef = React.useRef | undefined>(undefined) - const onStartReachedBaseRef = React.useRef(onStartReachedBase) - - React.useEffect(() => { - onStartReachedBaseRef.current = onStartReachedBase - }, [onStartReachedBase]) - - React.useEffect(() => { - if (numOrdinals !== prevNumOrdinalsRef.current) { - prevNumOrdinalsRef.current = numOrdinals - isLoadingOlderRef.current = false - clearTimeout(loadResetTimerRef.current) - } - }, [numOrdinals]) - - // Cleanup on unmount - React.useEffect(() => () => clearTimeout(loadResetTimerRef.current), []) - - const [onStartReached] = React.useState(() => () => { - if (isLoadingOlderRef.current) return - isLoadingOlderRef.current = true - clearTimeout(loadResetTimerRef.current) - // Safety reset in case no messages arrive (already at top of history) - loadResetTimerRef.current = setTimeout(() => { - isLoadingOlderRef.current = false - }, 3000) - onStartReachedBaseRef.current() + const onStartReached = useOnStartReached({ + isTopOnScreen, + numOrdinals: messageOrdinals.length, + onStartReachedBase, }) - // When the top item is actually visible, ensure we load — catches bad-scroll-position cases - // where maintainVisibleContentPosition leaves us at the top without onStartReached firing. - React.useEffect(() => { - if (isTopOnScreen) { - onStartReached() - } - }, [isTopOnScreen, onStartReached]) - return ( diff --git a/shared/chat/conversation/list-area/list-shared.tsx b/shared/chat/conversation/list-area/list-shared.tsx new file mode 100644 index 000000000000..f4788d7c11c8 --- /dev/null +++ b/shared/chat/conversation/list-area/list-shared.tsx @@ -0,0 +1,205 @@ +import * as C from '@/constants' +import * as Chat from '@/stores/chat' +import * as React from 'react' +import * as T from '@/constants/types' +import {chatDebugEnabled} from '@/constants/chat/debug' +import logger from '@/logger' +import {PerfProfiler} from '@/perf/react-profiler' +import {getMessageRender} from '../messages/wrapper' +import type {ItemType} from '.' + +export const emptyOrdinals: Array = [] + +export const hasOrdinal = (ordinal: T.Chat.Ordinal) => T.Chat.ordinalToNumber(ordinal) > 0 +export const keyExtractor = (ordinal: ItemType) => String(ordinal) + +type ScrollableListRef = { + scrollToIndex: (params: {animated?: boolean; index: number; viewPosition?: number}) => Promise | void +} + +export const useConversationListData = () => + Chat.useChatContext( + C.useShallow(s => { + const {editing: editingOrdinal, id: conversationIDKey, messageTypeMap, ordinalIndexMap} = s + const {messageCenterOrdinal: mco, messageOrdinals = emptyOrdinals, loaded} = s + const centeredHighlightOrdinal = + mco?.highlightMode !== 'none' ? (mco.ordinal ?? T.Chat.numberToOrdinal(-1)) : T.Chat.numberToOrdinal(-1) + const centeredOrdinal = mco?.ordinal ?? T.Chat.numberToOrdinal(-1) + const containsLatestMessage = s.isCaughtUp() + return { + centeredHighlightOrdinal, + centeredOrdinal, + containsLatestMessage, + conversationIDKey, + editingOrdinal, + loaded, + messageOrdinals, + messageTypeMap, + ordinalIndexMap, + } + }) + ) + +export const useTopOnScreen = (messageOrdinals: ReadonlyArray) => { + const firstOrdinal = messageOrdinals[0] + const [isTopOnScreen, setIsTopOnScreen] = React.useState(false) + const onViewableItemsChanged = React.useCallback( + ({viewableItems}: {viewableItems: ReadonlyArray<{item: T.Chat.Ordinal}>}) => { + setIsTopOnScreen(viewableItems[0]?.item === firstOrdinal) + }, + [firstOrdinal] + ) + return {isTopOnScreen, onViewableItemsChanged} +} + +export const useScrollToCentered = ( + listRef: React.RefObject, + centeredOrdinal: T.Chat.Ordinal, + ordinalIndexMap: ReadonlyMap +) => { + const lastScrollToCentered = React.useRef(-1) + React.useEffect(() => { + if (!hasOrdinal(centeredOrdinal)) { + lastScrollToCentered.current = -1 + } + }, [centeredOrdinal]) + + const centeredOrdinalRef = React.useRef(centeredOrdinal) + React.useEffect(() => { + centeredOrdinalRef.current = centeredOrdinal + }, [centeredOrdinal]) + + const ordinalIndexMapRef = React.useRef(ordinalIndexMap) + React.useEffect(() => { + ordinalIndexMapRef.current = ordinalIndexMap + }, [ordinalIndexMap]) + + const [scrollToCentered] = React.useState(() => () => { + const list = listRef.current + if (!list) { + return + } + const ordinal = centeredOrdinalRef.current + if (!hasOrdinal(ordinal) || lastScrollToCentered.current === ordinal) { + return + } + lastScrollToCentered.current = ordinal + + const idx = ordinalIndexMapRef.current.get(ordinal) ?? -1 + if (idx < 0) { + return + } + + void Promise.resolve(list.scrollToIndex({animated: false, index: idx, viewPosition: 0.5})).then(() => { + void list.scrollToIndex({animated: false, index: idx, viewPosition: 0.5}) + }) + }) + + return scrollToCentered +} + +export const useOnStartReached = (p: { + isTopOnScreen: boolean + numOrdinals: number + onStartReachedBase: () => void +}) => { + const {isTopOnScreen, numOrdinals, onStartReachedBase} = p + const isLoadingOlderRef = React.useRef(false) + const prevNumOrdinalsRef = React.useRef(numOrdinals) + const loadResetTimerRef = React.useRef | undefined>(undefined) + const onStartReachedBaseRef = React.useRef(onStartReachedBase) + + React.useEffect(() => { + onStartReachedBaseRef.current = onStartReachedBase + }, [onStartReachedBase]) + + React.useEffect(() => { + if (numOrdinals !== prevNumOrdinalsRef.current) { + prevNumOrdinalsRef.current = numOrdinals + isLoadingOlderRef.current = false + clearTimeout(loadResetTimerRef.current) + } + }, [numOrdinals]) + + React.useEffect(() => () => clearTimeout(loadResetTimerRef.current), []) + + const [onStartReached] = React.useState(() => () => { + if (isLoadingOlderRef.current) return + isLoadingOlderRef.current = true + clearTimeout(loadResetTimerRef.current) + loadResetTimerRef.current = setTimeout(() => { + isLoadingOlderRef.current = false + }, 3000) + onStartReachedBaseRef.current() + }) + + React.useEffect(() => { + if (isTopOnScreen) { + onStartReached() + } + }, [isTopOnScreen, onStartReached]) + + return onStartReached +} + +export const useRecycleType = ( + messageOrdinals: ReadonlyArray, + messageTypeMap: ReadonlyMap +) => { + const recycleTypeRef = React.useRef(new Map()) + const [setRecycleType] = React.useState(() => (ordinal: T.Chat.Ordinal, type: string) => { + recycleTypeRef.current.set(ordinal, type) + }) + + const numOrdinals = messageOrdinals.length + const getItemType = React.useCallback( + (ordinal: T.Chat.Ordinal, idx: number) => { + if (!ordinal) { + return 'null' + } + const recycled = recycleTypeRef.current.get(ordinal) + if (recycled) return recycled + const baseType = messageTypeMap.get(ordinal) ?? 'text' + if (numOrdinals - 1 === idx && (baseType === 'text' || baseType === 'attachment')) { + return `${baseType}:pending` + } + return baseType + }, + [messageTypeMap, numOrdinals] + ) + + return {getItemType, setRecycleType} +} + +export const useMessageNodeRenderer = (p: { + centeredHighlightOrdinal: T.Chat.Ordinal + lastOrdinal: T.Chat.Ordinal | undefined + messageTypeMap: ReadonlyMap +}) => { + const {centeredHighlightOrdinal, lastOrdinal, messageTypeMap} = p + return React.useCallback( + (ordinal: T.Chat.Ordinal) => { + const type = messageTypeMap.get(ordinal) ?? 'text' + const Clazz = getMessageRender(type) + if (!Clazz) { + if (chatDebugEnabled) { + logger.error('[CHATDEBUG] no rendertype', {ordinal, type}) + } + return null + } + return { + node: ( + + + + ), + type, + } + }, + [centeredHighlightOrdinal, lastOrdinal, messageTypeMap] + ) +} From dbbc63f22031250b4e043968a768161ce63ccd82 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 19 Mar 2026 15:43:22 -0400 Subject: [PATCH 29/31] lint --- shared/chat/conversation/list-area/index.desktop.tsx | 2 +- shared/chat/conversation/list-area/index.native.tsx | 2 +- shared/chat/conversation/list-area/list-shared.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index ff5923d9b7f1..1a5cadd65d1a 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -3,7 +3,7 @@ import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as Hooks from './hooks' import * as React from 'react' -import * as T from '@/constants/types' +import type * as T from '@/constants/types' import type {LegendListRef} from '@/common-adapters' import {LegendList} from '@legendapp/list/react' import Separator from '../messages/separator' diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 4c454457a944..68dc10050b16 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -1,5 +1,5 @@ import * as Chat from '@/stores/chat' -import * as T from '@/constants/types' +import type * as T from '@/constants/types' import * as Hooks from './hooks' import * as Kb from '@/common-adapters' import * as React from 'react' diff --git a/shared/chat/conversation/list-area/list-shared.tsx b/shared/chat/conversation/list-area/list-shared.tsx index f4788d7c11c8..606e60df574a 100644 --- a/shared/chat/conversation/list-area/list-shared.tsx +++ b/shared/chat/conversation/list-area/list-shared.tsx @@ -23,8 +23,8 @@ export const useConversationListData = () => const {editing: editingOrdinal, id: conversationIDKey, messageTypeMap, ordinalIndexMap} = s const {messageCenterOrdinal: mco, messageOrdinals = emptyOrdinals, loaded} = s const centeredHighlightOrdinal = - mco?.highlightMode !== 'none' ? (mco.ordinal ?? T.Chat.numberToOrdinal(-1)) : T.Chat.numberToOrdinal(-1) - const centeredOrdinal = mco?.ordinal ?? T.Chat.numberToOrdinal(-1) + mco && mco.highlightMode !== 'none' ? mco.ordinal : T.Chat.numberToOrdinal(-1) + const centeredOrdinal = mco ? mco.ordinal : T.Chat.numberToOrdinal(-1) const containsLatestMessage = s.isCaughtUp() return { centeredHighlightOrdinal, From 2982b86a2c4eba621d33815d3ab6dad209f73048 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 19 Mar 2026 11:29:12 -0400 Subject: [PATCH 30/31] WIP --- shared/.maestro/performance/perf-thread-scroll.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/.maestro/performance/perf-thread-scroll.yaml b/shared/.maestro/performance/perf-thread-scroll.yaml index 4ea1ab0ddbbd..b75ffc132a01 100644 --- a/shared/.maestro/performance/perf-thread-scroll.yaml +++ b/shared/.maestro/performance/perf-thread-scroll.yaml @@ -13,8 +13,8 @@ appId: keybase.ios - swipe: id: 'messageList' direction: DOWN - duration: 400 - waitToSettleTimeoutMs: 50 + duration: 50 + waitToSettleTimeoutMs: 1 # Press Home to trigger app background → flushes profiler data - pressKey: Home From d83a85ed994dd0bcb98cc4d4486a29aa50005b7e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 31 Mar 2026 15:09:47 -0400 Subject: [PATCH 31/31] WIP --- shared/chat/conversation/list-area/index.desktop.tsx | 8 +------- shared/chat/conversation/messages/wrapper/wrapper.tsx | 5 ++--- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 1a5cadd65d1a..0b8353595085 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -431,10 +431,4 @@ const styles = Kb.Styles.styleSheetCreate( }) as const ) -const ThreadWrapperWithProfiler = () => ( - - - -) - -export default ThreadWrapperWithProfiler +export default ConversationList diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index bdc12c400d55..cf249315aac0 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -15,8 +15,8 @@ import capitalize from 'lodash/capitalize' import {useEdited} from './edited' import {useCurrentUserState} from '@/stores/current-user' import {useTeamsState} from '@/stores/teams' -import {useProfileState} from '@/stores/profile' import {useTrackerState} from '@/stores/tracker' +import {navToProfile} from '@/constants/router' import {formatTimeForChat} from '@/util/timestamp' export type Props = { @@ -66,12 +66,11 @@ function AuthorSection(p: AuthorProps) { const {author, botAlias, isAdhocBot, teamID, teamType, teamname, timestamp, showUsername} = p const authorRoleInTeam = useTeamsState(s => s.teamIDToMembers.get(teamID)?.get(author)?.type) - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const showUser = useTrackerState(s => s.dispatch.showUser) const onAuthorClick = () => { if (C.isMobile) { - showUserProfile(showUsername) + navToProfile(showUsername) } else { showUser(showUsername, true) }