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 (
-
-
- {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', @@ -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 |*/ 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 |*/ 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(),