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/.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
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/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.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx
index d2c44a0a614b..0b8353595085 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 * 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,259 +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 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()
@@ -527,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)
@@ -540,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 ||
@@ -561,14 +363,6 @@ const ThreadWrapper = function ThreadWrapper() {
}
}
- const items = useItems({centeredOrdinal, editingOrdinal, messageOrdinals, messageTypeMap})
- const setListContents = useHandleListResize({
- centeredOrdinal,
- isLockedToBottom,
- scrollToBottom,
- setPointerWrapperRef,
- })
-
return (
-
- {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',
@@ -733,27 +423,12 @@ 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
)
-const ThreadWrapperWithProfiler = () => (
-
-
-
-)
-
-export default ThreadWrapperWithProfiler
+export default ConversationList
diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx
index d0b14b4df6a1..68dc10050b16 100644
--- a/shared/chat/conversation/list-area/index.native.tsx
+++ b/shared/chat/conversation/list-area/index.native.tsx
@@ -1,28 +1,31 @@
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'
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 {getMessageRender} from '../messages/wrapper'
+import {type LegendListRef} from '@legendapp/list/react-native'
+import {KeyboardAvoidingLegendList} from '@legendapp/list/keyboard-test'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
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 {
+ keyExtractor,
+ useConversationListData,
+ useMessageNodeRenderer,
+ useOnStartReached,
+ useRecycleType,
+ useScrollToCentered,
+ useTopOnScreen,
+} from './list-shared'
// 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
@@ -31,15 +34,15 @@ export const DEBUGDump = () => {}
const useScrolling = (p: {
centeredOrdinal: T.Chat.Ordinal
- messageOrdinals: Array
- conversationIDKey: T.Chat.ConversationIDKey
- listRef: React.RefObject*FlashList |*/ FlatList | null>
+ messageOrdinals: ReadonlyArray
+ 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 [scrollToBottom] = React.useState(() => () => {
- listRef.current?.scrollToOffset({animated: false, offset: 0})
+ void listRef.current?.scrollToEnd({animated: false})
})
const {setScrollRef} = React.useContext(ScrollContext)
@@ -47,121 +50,68 @@ 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 [scrollToCentered] = React.useState(() => () => {
- setTimeout(() => {
- const list = listRef.current
- if (!list) {
- return
- }
- const co = centeredOrdinalRef.current
- if (lastScrollToCentered.current === co) {
- return
- }
+ const scrollToCentered = useScrollToCentered(listRef, centeredOrdinal, ordinalIndexMap)
- lastScrollToCentered.current = co
- 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'}
-
- ) : null
-
- 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)
- const messageTypeMap = Chat.useChatContext(s => s.messageTypeMap)
- const _messageOrdinals = Chat.useChatContext(s => s.messageOrdinals)
+ const {conversationIDKey, centeredHighlightOrdinal, centeredOrdinal, messageOrdinals, messageTypeMap, ordinalIndexMap} =
+ useConversationListData()
+ 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 !== undefined && leadingItem !== undefined) {
+ trailingByLeading.set(leadingItem, trailingItem)
+ }
+ }
+ return trailingByLeading
+ }, [messageOrdinals])
- const messageOrdinals = [...(_messageOrdinals ?? [])].reverse()
+ const {isTopOnScreen, onViewableItemsChanged} = useTopOnScreen(messageOrdinals)
- const listRef = React.useRef*FlashList |*/ FlatList | null>(null)
+ const insets = useSafeAreaInsets()
+ const listRef = React.useRef(null)
const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey})
- const keyExtractor = (ordinal: ItemType) => {
- return String(ordinal)
- }
+ const renderMessageNode = useMessageNodeRenderer({centeredHighlightOrdinal, lastOrdinal, messageTypeMap})
- const renderItem = (info?: /*ListRenderItemInfo*/ {index?: number}) => {
- const index: number = info?.index ?? 0
- const ordinal = messageOrdinals[index]
- if (!ordinal) {
- return null
- }
- const type = messageTypeMap.get(ordinal) ?? 'text'
- const Clazz = getMessageRender(type)
- if (!Clazz) return null
- return
- }
-
- 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)
- }
+ const renderItem = React.useCallback(
+ ({item: ordinal}: {item: T.Chat.Ordinal}) => renderMessageNode(ordinal)?.node ?? null,
+ [renderMessageNode]
+ )
- const numOrdinals = messageOrdinals.length
+ const ItemSeparator = React.useCallback(
+ ({leadingItem}: {leadingItem: T.Chat.Ordinal}) => {
+ const trailingItem = separatorTrailingByLeading.get(leadingItem)
+ if (!trailingItem) return null
+ return
+ },
+ [separatorTrailingByLeading]
+ )
- 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, scrollToBottom, onEndReached} = useScrolling({
+ const {
+ scrollToCentered,
+ scrollToBottom,
+ onStartReached: onStartReachedBase,
+ } = useScrolling({
centeredOrdinal,
- conversationIDKey,
listRef,
messageOrdinals,
+ ordinalIndexMap,
})
const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length)
@@ -173,14 +123,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(() => {
@@ -190,176 +134,48 @@ 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])
-
- // 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(() => {
- // 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 onViewableItemsChanged = useSafeOnViewableItemsChanged(onEndReached, messageOrdinals.length)
- // const onLayout = useDebugLayout()
+ const onStartReached = useOnStartReached({
+ isTopOnScreen,
+ numOrdinals: messageOrdinals.length,
+ onStartReachedBase,
+ })
return (
-
-
-
-
- {jumpToRecent}
- {debugWhichList}
-
-
-
+
+ }
+ ListFooterComponent={SpecialBottomMessage}
+ overScrollMode="never"
+ contentInset={{bottom: mobileTypingContainerHeight}}
+ data={data}
+ getItemType={getItemType}
+ renderItem={renderItem}
+ ItemSeparatorComponent={ItemSeparator}
+ onStartReached={onStartReached}
+ onStartReachedThreshold={0.3}
+ keyboardDismissMode="on-drag"
+ keyboardShouldPersistTaps="handled"
+ keyExtractor={keyExtractor}
+ ref={listRef}
+ recycleItems={true}
+ alignItemsAtEnd={true}
+ initialScrollAtEnd={true}
+ maintainScrollAtEnd={{animated: false}}
+ maintainVisibleContentPosition={{data: true, size: true}}
+ waitForInitialLayout={true}
+ offset={insets.bottom}
+ />
+ {jumpToRecent}
+
)
}
-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(
- () =>
- ({
- contentContainer: {
- paddingBottom: 0,
- paddingTop: mobileTypingContainerHeight,
- },
- }) as const
-)
-
export default ConversationList
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..606e60df574a
--- /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 && mco.highlightMode !== 'none' ? mco.ordinal : T.Chat.numberToOrdinal(-1)
+ const centeredOrdinal = mco ? 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]
+ )
+}
diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx
index 03de575ab726..d4a9c512eed1 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}
@@ -245,6 +246,7 @@ const styles = Kb.Styles.styleSheetCreate(
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 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/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}
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/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/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx
index b4afd8894d8a..996451d07654 100644
--- a/shared/chat/conversation/messages/special-top-message.tsx
+++ b/shared/chat/conversation/messages/special-top-message.tsx
@@ -107,7 +107,39 @@ const ErrorMessage = () => {
)
}
-function SpecialTopMessage() {
+// Outer gate: minimal reads, manages visibility timer, mounts inner only when needed.
+function SpecialTopMessage({isOnScreen = true}: {isOnScreen?: boolean}) {
+ 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'
+ // 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
+ }
+ setVisible(false)
+ if (!isOnScreen) return
+ const timer = setTimeout(() => setVisible(true), 3000)
+ return () => clearTimeout(timer)
+ }, [hasLoadedEver, 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 +155,6 @@ function SpecialTopMessage() {
: 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,30 +180,6 @@ 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)
- 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
- }
- }
- }, [])
const openPrivateFolder = () => {
FS.navToPath(T.FS.stringToPath(`/keybase/private/${username}`))
@@ -181,7 +188,6 @@ function SpecialTopMessage() {
return (
{loadMoreType === 'noMoreToLoad' && showRetentionNotice && }
-
{hasOlderResetConversation && }
{pendingState === 'waiting' && (
@@ -203,7 +209,7 @@ function SpecialTopMessage() {
)}
- {allowDigging && loadMoreType === 'moreToLoad' && pendingState === 'done' && (
+ {loadMoreType === 'moreToLoad' && pendingState === 'done' && (
@@ -227,7 +233,6 @@ const styles = Kb.Styles.styleSheetCreate(
more: {
paddingBottom: Kb.Styles.globalMargins.medium,
},
- spacer: {height: Kb.Styles.globalMargins.small},
}) as const
)
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 15607be023b8..cf249315aac0 100644
--- a/shared/chat/conversation/messages/wrapper/wrapper.tsx
+++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx
@@ -14,8 +14,14 @@ 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 {useTrackerState} from '@/stores/tracker'
+import {navToProfile} from '@/constants/router'
+import {formatTimeForChat} from '@/util/timestamp'
export type Props = {
+ isCenteredHighlight?: boolean
+ isLastMessage?: boolean
ordinal: T.Chat.Ordinal
}
@@ -45,10 +51,141 @@ 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 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)}
+
+
+
+ >
+ )
+}
+
+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,
- ordinals: ReadonlyArray,
+ isLastMessage: boolean,
hasReactions: boolean,
message: T.Chat.Message
) => {
@@ -56,7 +193,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) => {
@@ -81,7 +218,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(
@@ -89,7 +230,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)
@@ -105,13 +245,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
@@ -134,7 +270,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal) => {
isEditing,
reactionsPopupPosition,
shouldShowPopup,
- showCenteredHighlight,
+ showCenteredHighlight: isCenteredHighlight,
showCoinsIcon,
showExplodingCountdown,
showReplyTo,
@@ -526,13 +662,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
@@ -569,7 +708,10 @@ export function WrapperMessage(p: WMProps) {
return (
-
+
+
+
+
{popup}
)
@@ -578,10 +720,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 +813,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
)
diff --git a/shared/chat/conversation/normal/index.native.tsx b/shared/chat/conversation/normal/index.native.tsx
index 154a0be8095c..af0f34bca451 100644
--- a/shared/chat/conversation/normal/index.native.tsx
+++ b/shared/chat/conversation/normal/index.native.tsx
@@ -3,7 +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 {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'
@@ -21,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) => {
@@ -39,71 +31,42 @@ 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
- 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 threadLoadedOffline = Chat.useChatContext(s => s.meta.offline)
-
- const content = (
-
-
- {threadLoadedOffline && }
- {innerComponent}
-
-
-
- )
+ const showLoader = C.Waiting.useAnyWaiting([
+ C.waitingKeyChatThreadLoad(conversationIDKey),
+ C.waitingKeyChatInboxSyncStarted,
+ ])
+ const loadingLine = showLoader ? : null
+ const offline = Chat.useChatContext(s => s.meta.offline) ? : null
return (
- {Kb.Styles.isAndroid ? (
-
- {content}
+
+ {offline}
+
+
+
+
+ {loadingLine}
+
+
+
+
+
+
+
- ) : (
-
-
- {content}
-
-
- )}
+
+
)
}
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),
},
})),
})
diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx
index 25d30901a7f9..9f5c3e40935a 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
@@ -176,6 +177,7 @@ const initialConvoStore: ConvoStore = {
moreToLoadBack: false,
moreToLoadForward: false,
mutualTeams: [],
+ ordinalIndexMap: new Map(),
participants: noParticipantInfo,
pendingJumpMessageID: undefined,
pendingOutboxToOrdinal: new Map(),