Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 11 additions & 34 deletions shared/chat/conversation/center-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,20 @@ import {resetAllStores} from '@/util/zustand'
const convX = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4]))
const convY = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8]))

const mockLoadMessagesCentered = jest.fn()
const mockJumpToRecentThread = jest.fn()
const mockRequestWindow = jest.fn()
const mockSetMarkReadBlocked = jest.fn()
const mockThreadLoadStatusOptions = {isThreadLoadCurrent: () => true, onThreadLoadStatus: () => {}}
let mockRouteParams: {threadSearch?: {query?: string}} | undefined

// Both providers under test pull thread/engine plumbing they don't exercise here.
jest.mock('./thread-context', () => ({
useConversationThreadJumpToRecent: () => mockJumpToRecentThread,
useConversationThreadLoadMessagesCentered: () => mockLoadMessagesCentered,
useConversationThreadSetMarkReadBlocked: () => mockSetMarkReadBlocked,
useConversationThreadStore: () => ({getState: () => ({})}),
}))
jest.mock('./send-actions', () => ({
useConversationSendActions: () => ({sendGiphyResult: jest.fn(), sendMessage: jest.fn()}),
}))
jest.mock('@/engine/action-listener', () => ({useEngineActionListener: () => {}}))
jest.mock('./thread-load-status-context', () => ({
useThreadLoadStatusOptionsGetter: () => () => mockThreadLoadStatusOptions,
}))
jest.mock('./thread-window', () => ({useRequestWindow: () => mockRequestWindow}))
jest.mock('./thread-search-route', () => ({useChatThreadRouteParams: () => mockRouteParams}))

import {ConversationCenterProvider, useConversationCenter} from './center-context'
Expand Down Expand Up @@ -80,29 +74,21 @@ test('a highlight written before mount is consumed on mount', () => {
render(<Tree id={convX} />)

expect(mockSetMarkReadBlocked).toHaveBeenCalledWith(true)
expect(mockLoadMessagesCentered).toHaveBeenCalledTimes(1)
expect(mockLoadMessagesCentered).toHaveBeenCalledWith(
T.Chat.numberToMessageID(42),
'flash',
expect.anything()
)
expect(mockRequestWindow).toHaveBeenCalledTimes(1)
expect(mockRequestWindow).toHaveBeenCalledWith({anchor: {centeredOn: T.Chat.numberToMessageID(42)}, reason: 'centered'})
expect(seenHighlightOrdinal).toBe(T.Chat.numberToOrdinal(42))
expect(useInputIntentState.getState().intents.has(convX)).toBe(false)
})

test('a highlight written after mount is delivered by the subscription', () => {
render(<Tree id={convX} />)
expect(mockLoadMessagesCentered).not.toHaveBeenCalled()
expect(mockRequestWindow).not.toHaveBeenCalled()

act(() => {
setInputIntent(convX, highlight(7))
})

expect(mockLoadMessagesCentered).toHaveBeenCalledWith(
T.Chat.numberToMessageID(7),
'flash',
expect.anything()
)
expect(mockRequestWindow).toHaveBeenCalledWith({anchor: {centeredOn: T.Chat.numberToMessageID(7)}, reason: 'centered'})
expect(seenHighlightOrdinal).toBe(T.Chat.numberToOrdinal(7))
})

Expand All @@ -118,13 +104,8 @@ test('jumping twice to the same message centers both times', () => {
setInputIntent(convX, highlight(11))
})

expect(mockLoadMessagesCentered).toHaveBeenCalledTimes(2)
expect(mockLoadMessagesCentered).toHaveBeenNthCalledWith(
2,
T.Chat.numberToMessageID(11),
'flash',
expect.anything()
)
expect(mockRequestWindow).toHaveBeenCalledTimes(2)
expect(mockRequestWindow).toHaveBeenNthCalledWith(2, {anchor: {centeredOn: T.Chat.numberToMessageID(11)}, reason: 'centered'})
})

// The two-consumer collision the store's `types` filter exists for.
Expand All @@ -133,11 +114,7 @@ test('the input provider does not consume a highlight meant for the center provi

render(<Tree id={convX} />)

expect(mockLoadMessagesCentered).toHaveBeenCalledWith(
T.Chat.numberToMessageID(5),
'flash',
expect.anything()
)
expect(mockRequestWindow).toHaveBeenCalledWith({anchor: {centeredOn: T.Chat.numberToMessageID(5)}, reason: 'centered'})
expect(seenUnsentText).toBeUndefined()
})

Expand All @@ -147,7 +124,7 @@ test('the center provider does not consume an injectText meant for the input pro
render(<Tree id={convX} />)

expect(seenUnsentText).toBe('hello')
expect(mockLoadMessagesCentered).not.toHaveBeenCalled()
expect(mockRequestWindow).not.toHaveBeenCalled()
expect(useInputIntentState.getState().intents.has(convX)).toBe(false)
})

Expand All @@ -156,6 +133,6 @@ test('a highlight for another conversation is left alone', () => {

render(<Tree id={convX} />)

expect(mockLoadMessagesCentered).not.toHaveBeenCalled()
expect(mockRequestWindow).not.toHaveBeenCalled()
expect(useInputIntentState.getState().intents.get(convY)).toEqual(highlight(3))
})
18 changes: 5 additions & 13 deletions shared/chat/conversation/center-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@ import * as T from '@/constants/types'
import {consumeInputIntent, useInputIntentState} from './input-intent-store'
import {produce} from 'immer'
import {useChatThreadRouteParams} from './thread-search-route'
import {useThreadLoadStatusOptionsGetter} from './thread-load-status-context'
import {
useConversationThreadJumpToRecent,
useConversationThreadLoadMessagesCentered,
useConversationThreadSetMarkReadBlocked,
} from './thread-context'
import {useConversationThreadSetMarkReadBlocked} from './thread-context'
import {useRequestWindow} from './thread-window'

type CenterState = {
center: T.Chat.CenterOrdinal | undefined
Expand Down Expand Up @@ -76,9 +72,7 @@ export const ConversationCenterProvider = function ConversationCenterProvider(p:
const {children, id} = p
const routeParams = useChatThreadRouteParams()
const threadSearchVisible = !!routeParams?.threadSearch
const getThreadLoadStatusOptions = useThreadLoadStatusOptionsGetter()
const loadMessagesCentered = useConversationThreadLoadMessagesCentered()
const jumpToRecentThread = useConversationThreadJumpToRecent()
const requestWindow = useRequestWindow()
const setMarkReadBlocked = useConversationThreadSetMarkReadBlocked()
const [centerState, setCenterState] = React.useState<CenterState>(() => ({
center: undefined,
Expand Down Expand Up @@ -109,14 +103,12 @@ export const ConversationCenterProvider = function ConversationCenterProvider(p:

const centerOnMessage = (messageID: T.Chat.MessageID, highlightMode: T.Chat.CenterOrdinalHighlightMode) => {
setCenterForMessage(messageID, highlightMode)
loadMessagesCentered(messageID, highlightMode, {
...getThreadLoadStatusOptions(),
})
requestWindow({anchor: {centeredOn: messageID}, reason: 'centered'})
}

const jumpToRecent = () => {
clearCenter()
jumpToRecentThread(getThreadLoadStatusOptions())
requestWindow({anchor: 'newest', reason: 'jump to recent'})
}

React.useEffect(() => {
Expand Down
82 changes: 19 additions & 63 deletions shared/chat/conversation/list-area/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,11 @@ import {useConversationCenter} from '../center-context'
import {
ShownUsernameCacheContext,
useConversationThreadID,
useConversationThreadLoadNewerMessagesDueToScroll,
useConversationThreadLoadOlderMessagesDueToScroll,
useConversationThreadMarkThreadAsRead,
useConversationThreadSelector,
useConversationThreadStore,
} from '../thread-context'
import {useJumpToRecent} from './jump-to-recent'
import {useThreadLoadStatusOptionsGetter} from '../thread-load-status-context'
import {useRequestWindow, useThreadWindow} from '../thread-window'
import {getMessageRowType, getMessageShowUsername} from '../messages/row-metadata'
import {useCurrentUserState} from '@/stores/current-user'
import * as InputState from '../input-area/input-state'
Expand Down Expand Up @@ -87,46 +84,17 @@ const useGetItemType = () => {

// ==================== SHARED ====================

// Both platforms read the same slice of thread state.
const useThreadListData = () =>
useConversationThreadSelector(
C.useShallow(s => ({
clearVersion: s.clearVersion,
containsLatestMessage: !s.moreToLoadForward,
loaded: s.loaded,
messageOrdinals: s.messageOrdinals ?? noOrdinals,
}))
)

// Pagination: load older at the top of the list, newer at the bottom (only when not already at
// the latest). Refs keep the throttled callbacks stable.
const usePagination = (p: {
containsLatestMessage: boolean
messageOrdinals: ReadonlyArray<T.Chat.Ordinal>
}) => {
const {containsLatestMessage, messageOrdinals} = p
const loadOlderMessagesDueToScroll = useConversationThreadLoadOlderMessagesDueToScroll()
const loadNewerMessagesDueToScroll = useConversationThreadLoadNewerMessagesDueToScroll()
const getThreadLoadStatusOptions = useThreadLoadStatusOptionsGetter()

const numOrdinalsRef = React.useRef(messageOrdinals.length)
React.useEffect(() => {
numOrdinalsRef.current = messageOrdinals.length
}, [messageOrdinals.length])

const containsLatestMessageRef = React.useRef(containsLatestMessage)
React.useEffect(() => {
containsLatestMessageRef.current = containsLatestMessage
}, [containsLatestMessage])
// Pagination: load older at the top of the list, newer at the bottom. Whether either edge has more
// to fetch, and how fast the same edge may re-ask, is thread-window's business.
const usePagination = () => {
const requestWindow = useRequestWindow()

const onStartReached = React.useCallback(() => {
loadOlderMessagesDueToScroll(numOrdinalsRef.current, getThreadLoadStatusOptions())
}, [loadOlderMessagesDueToScroll, getThreadLoadStatusOptions])
requestWindow({anchor: 'older', reason: 'scroll back'})
}, [requestWindow])

const onEndReached = C.useThrottledCallback(() => {
if (!containsLatestMessageRef.current) {
loadNewerMessagesDueToScroll(numOrdinalsRef.current, getThreadLoadStatusOptions())
}
requestWindow({anchor: 'newer', reason: 'scroll forward'})
}, 200)
React.useEffect(
() => () => {
Expand Down Expand Up @@ -216,20 +184,20 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {
const desktopStyles = useDesktopStyles()
const editingOrdinal = InputState.useConversationInput(s => s.editing)
const conversationIDKey = useConversationThreadID()
const data = useThreadListData()
const {generation, loaded, moreToLoadForward, ordinals: messageOrdinals} = useThreadWindow()
const {centeredOrdinal} = useConversationCenter()
const {clearVersion, containsLatestMessage, messageOrdinals, loaded} = data
const containsLatestMessage = !moreToLoadForward

// Centered loads (search hit, reply-quote jump, pinned message) clear the thread before
// refetching, so the list sees a non-empty -> empty -> non-empty transition.
const datasetKey = `${conversationIDKey}:${clearVersion}`
const datasetKey = `${conversationIDKey}:${generation}`

const listRef = React.useRef<LegendListRef | null>(null)
const wrapperRef = React.useRef<HTMLDivElement | null>(null)

const markInitiallyLoadedThreadAsRead = useConversationThreadMarkThreadAsRead()

const {onStartReached, onEndReached} = usePagination({containsLatestMessage, messageOrdinals})
const {onStartReached, onEndReached} = usePagination()

// messageOrdinalsRef feeds the imperative scroll-to-center / scroll-to-edit effects below.
const messageOrdinalsRef = React.useRef(messageOrdinals)
Expand Down Expand Up @@ -705,20 +673,15 @@ type RNFlatListRef = {
scrollToItem: (opts: {animated: boolean; item: unknown; viewPosition?: number}) => void
}

const useInvertedMessageOrdinals = (messageOrdinals?: ReadonlyArray<T.Chat.Ordinal>) => {
const source = messageOrdinals ?? noOrdinals
return React.useMemo(() => (source.length > 1 ? [...source].reverse() : source), [source])
}
const useInvertedMessageOrdinals = (source: ReadonlyArray<T.Chat.Ordinal>) =>
React.useMemo(() => (source.length > 1 ? [...source].reverse() : source), [source])

const useNativeScrolling = (p: {
centeredOrdinal: T.Chat.Ordinal
messageOrdinals: ReadonlyArray<T.Chat.Ordinal>
listRef: React.RefObject<RNFlatListRef | null>
}) => {
const {listRef, centeredOrdinal, messageOrdinals} = p
const numOrdinals = messageOrdinals.length
const loadOlderMessages = useConversationThreadLoadOlderMessagesDueToScroll()
const getThreadLoadStatusOptions = useThreadLoadStatusOptionsGetter()
const {listRef, centeredOrdinal} = p
const requestWindow = useRequestWindow()

// KeyboardChatScrollView sets contentInset.top = K - insets.bottom and
// contentOffset.y = -(K - insets.bottom) when keyboard is open. Scrolling to
Expand Down Expand Up @@ -789,7 +752,7 @@ const useNativeScrolling = (p: {
})

const onEndReached = () => {
loadOlderMessages(numOrdinals, getThreadLoadStatusOptions())
requestWindow({anchor: 'older', reason: 'scroll back'})
}

return {
Expand Down Expand Up @@ -829,19 +792,13 @@ const NativeConversationList = function NativeConversationList() {
>

const conversationIDKey = useConversationThreadID()
const listData = useConversationThreadSelector(
C.useShallow(s => ({
loaded: s.loaded,
messageOrdinals: s.messageOrdinals,
}))
)
const {loaded, ordinals} = useThreadWindow()
const {centeredHighlightOrdinal, centeredOrdinal} = useConversationCenter()
const noCenteredOrdinal = T.Chat.numberToOrdinal(-1)
const centeredOrdinalOrNone = centeredOrdinal ?? noCenteredOrdinal
const centeredHighlightOrdinalOrNone = centeredHighlightOrdinal ?? noCenteredOrdinal
const {loaded} = listData

const messageOrdinals = useInvertedMessageOrdinals(listData.messageOrdinals)
const messageOrdinals = useInvertedMessageOrdinals(ordinals)

const listRef = React.useRef<RNFlatListRef | null>(null)
const markInitiallyLoadedThreadAsRead = useConversationThreadMarkThreadAsRead()
Expand Down Expand Up @@ -886,7 +843,6 @@ const NativeConversationList = function NativeConversationList() {
const {scrollToCentered, scrollToBottom, onEndReached, onScrollToIndexFailed} = useNativeScrolling({
centeredOrdinal: centeredOrdinalOrNone,
listRef,
messageOrdinals,
})

// Closed-loop centering corrector. scrollToItem/scrollToIndex lands at the wrong
Expand Down
2 changes: 1 addition & 1 deletion shared/chat/conversation/load-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react'
import * as Kb from '@/common-adapters'
import * as T from '@/constants/types'
import logger from '@/logger'
import {useThreadLoadStatus} from './thread-load-status-context'
import {useThreadLoadStatus} from './thread-window'
import {useConversationThreadID} from './thread-context'

const ValidatedStatus = () => {
Expand Down
6 changes: 3 additions & 3 deletions shared/chat/conversation/normal/container.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function mockPassthroughProvider({children}: {children: React.ReactNode}) {
return React.createElement(React.Fragment, null, children)
}

function mockConversationThreadLoadStatusProvider(
function mockConversationThreadWindowProvider(
props: React.PropsWithChildren<{
allowMarkReadOnLoad?: boolean
id: T.Chat.ConversationIDKey
Expand Down Expand Up @@ -112,8 +112,8 @@ jest.mock('../input-area/input-state', () => {
return {ConversationInputProvider: mockPassthroughProvider}
})

jest.mock('../thread-load-status-context', () => {
return {ConversationThreadLoadStatusProvider: mockConversationThreadLoadStatusProvider}
jest.mock('../thread-window', () => {
return {ConversationThreadWindowProvider: mockConversationThreadWindowProvider}
})

jest.mock('@/common-adapters/markdown/maybe-mention/context', () => {
Expand Down
6 changes: 3 additions & 3 deletions shared/chat/conversation/normal/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
useConversationThreadSelector,
useThreadMeta,
} from '../thread-context'
import {ConversationThreadLoadStatusProvider} from '../thread-load-status-context'
import {ConversationThreadWindowProvider} from '../thread-window'
import {MaybeMentionProvider} from '@/common-adapters/markdown/maybe-mention/context'
import {peekInputIntent} from '../input-intent-store'
import {useChatThreadRouteParams} from '../thread-search-route'
Expand Down Expand Up @@ -222,13 +222,13 @@ const NormalThreadProviders = (
const {children, id, threadSearchVisible} = p
const [pendingHighlight] = React.useState(() => !!peekInputIntent(id, ['highlight']))
return (
<ConversationThreadLoadStatusProvider
<ConversationThreadWindowProvider
allowMarkReadOnLoad={!threadSearchVisible}
id={id}
skipThreadLoadOnSelection={pendingHighlight}
>
{children}
</ConversationThreadLoadStatusProvider>
</ConversationThreadWindowProvider>
)
}

Expand Down
Loading