diff --git a/shared/chat/conversation/center-context.test.tsx b/shared/chat/conversation/center-context.test.tsx
index 9df8cffcf0f8..0829a64ffcff 100644
--- a/shared/chat/conversation/center-context.test.tsx
+++ b/shared/chat/conversation/center-context.test.tsx
@@ -8,16 +8,12 @@ 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: () => ({})}),
}))
@@ -25,9 +21,7 @@ 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'
@@ -80,29 +74,21 @@ test('a highlight written before mount is consumed on mount', () => {
render()
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()
- 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))
})
@@ -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.
@@ -133,11 +114,7 @@ test('the input provider does not consume a highlight meant for the center provi
render()
- expect(mockLoadMessagesCentered).toHaveBeenCalledWith(
- T.Chat.numberToMessageID(5),
- 'flash',
- expect.anything()
- )
+ expect(mockRequestWindow).toHaveBeenCalledWith({anchor: {centeredOn: T.Chat.numberToMessageID(5)}, reason: 'centered'})
expect(seenUnsentText).toBeUndefined()
})
@@ -147,7 +124,7 @@ test('the center provider does not consume an injectText meant for the input pro
render()
expect(seenUnsentText).toBe('hello')
- expect(mockLoadMessagesCentered).not.toHaveBeenCalled()
+ expect(mockRequestWindow).not.toHaveBeenCalled()
expect(useInputIntentState.getState().intents.has(convX)).toBe(false)
})
@@ -156,6 +133,6 @@ test('a highlight for another conversation is left alone', () => {
render()
- expect(mockLoadMessagesCentered).not.toHaveBeenCalled()
+ expect(mockRequestWindow).not.toHaveBeenCalled()
expect(useInputIntentState.getState().intents.get(convY)).toEqual(highlight(3))
})
diff --git a/shared/chat/conversation/center-context.tsx b/shared/chat/conversation/center-context.tsx
index 269a6a8116d7..17dc2ae2945d 100644
--- a/shared/chat/conversation/center-context.tsx
+++ b/shared/chat/conversation/center-context.tsx
@@ -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
@@ -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(() => ({
center: undefined,
@@ -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(() => {
diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx
index 7800f4eb543d..baaeeb785f49 100644
--- a/shared/chat/conversation/list-area/index.tsx
+++ b/shared/chat/conversation/list-area/index.tsx
@@ -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'
@@ -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
-}) => {
- 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(
() => () => {
@@ -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(null)
const wrapperRef = React.useRef(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)
@@ -705,20 +673,15 @@ type RNFlatListRef = {
scrollToItem: (opts: {animated: boolean; item: unknown; viewPosition?: number}) => void
}
-const useInvertedMessageOrdinals = (messageOrdinals?: ReadonlyArray) => {
- const source = messageOrdinals ?? noOrdinals
- return React.useMemo(() => (source.length > 1 ? [...source].reverse() : source), [source])
-}
+const useInvertedMessageOrdinals = (source: ReadonlyArray) =>
+ React.useMemo(() => (source.length > 1 ? [...source].reverse() : source), [source])
const useNativeScrolling = (p: {
centeredOrdinal: T.Chat.Ordinal
- messageOrdinals: ReadonlyArray
listRef: React.RefObject
}) => {
- 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
@@ -789,7 +752,7 @@ const useNativeScrolling = (p: {
})
const onEndReached = () => {
- loadOlderMessages(numOrdinals, getThreadLoadStatusOptions())
+ requestWindow({anchor: 'older', reason: 'scroll back'})
}
return {
@@ -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(null)
const markInitiallyLoadedThreadAsRead = useConversationThreadMarkThreadAsRead()
@@ -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
diff --git a/shared/chat/conversation/load-status.tsx b/shared/chat/conversation/load-status.tsx
index 1e2282964322..29f607a306c0 100644
--- a/shared/chat/conversation/load-status.tsx
+++ b/shared/chat/conversation/load-status.tsx
@@ -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 = () => {
diff --git a/shared/chat/conversation/normal/container.test.tsx b/shared/chat/conversation/normal/container.test.tsx
index 7a2622a60607..3cbf83018a9b 100644
--- a/shared/chat/conversation/normal/container.test.tsx
+++ b/shared/chat/conversation/normal/container.test.tsx
@@ -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
@@ -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', () => {
diff --git a/shared/chat/conversation/normal/container.tsx b/shared/chat/conversation/normal/container.tsx
index c1d97028da76..2c4a5aa22d53 100644
--- a/shared/chat/conversation/normal/container.tsx
+++ b/shared/chat/conversation/normal/container.tsx
@@ -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'
@@ -222,13 +222,13 @@ const NormalThreadProviders = (
const {children, id, threadSearchVisible} = p
const [pendingHighlight] = React.useState(() => !!peekInputIntent(id, ['highlight']))
return (
-
{children}
-
+
)
}
diff --git a/shared/chat/conversation/thread-context.test.tsx b/shared/chat/conversation/thread-context.test.tsx
index abcf188d74e0..d4af1bebbcfe 100644
--- a/shared/chat/conversation/thread-context.test.tsx
+++ b/shared/chat/conversation/thread-context.test.tsx
@@ -17,18 +17,13 @@ import {
ConversationThreadProvider,
LiveConversationThreadProvider,
useConversationThreadActions,
- useConversationThreadJumpToRecent,
- useConversationThreadLoadMoreMessages,
- useConversationThreadLoadMessagesCentered,
- useConversationThreadLoadOlderMessagesDueToScroll,
- useConversationThreadMarkThreadAsRead,
useConversationThreadMessage,
useConversationThreadMessageActions,
useConversationThreadSelector,
useConversationThreadStore,
} from './thread-context'
-import {ConversationThreadLoadStatusProvider} from './thread-load-status-context'
import {useConversationParticipants} from './data-hooks'
+import {ConversationThreadWindowProvider, useRequestWindow} from './thread-window'
const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4]))
const emptyStringSet = new Set()
@@ -245,7 +240,18 @@ const separatePlainThreadWrapper = ({children}: {children: React.ReactNode}) =>
{children}
)
+// For the mark-read tests, which need a real thread load to arm mark-read the way the app does.
+// The mount-time selection load is skipped so each test issues exactly the load it is about.
+const loadingWrapper = ({children}: {children: React.ReactNode}) => (
+
+
+ {children}
+
+
+)
+
beforeEach(() => {
+ jest.spyOn(T.RPCChat, 'localRequestInboxUnboxRpcPromise').mockResolvedValue(undefined)
useCurrentUserState.getState().dispatch.setBootstrap({
deviceID: 'device-id',
deviceName: 'test-device',
@@ -325,183 +331,6 @@ test('mounted thread syncs participant updates received outside its provider', (
expect(result.current.name).toEqual(['alice'])
})
-test('centered load clears stale thread state and requests a centered load', async () => {
- const loadThread = jest
- .spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener')
- .mockResolvedValue({offline: false})
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- loadMessagesCentered: useConversationThreadLoadMessagesCentered(),
- messageOrdinals: useConversationThreadSelector(s => s.messageOrdinals),
- staleMessage: useConversationThreadMessage(T.Chat.numberToOrdinal(301)),
- }),
- {wrapper}
- )
-
- act(() => {
- result.current.actions.addMessages([makeTextMessage()])
- })
- expect(result.current.staleMessage?.id).toBe(T.Chat.numberToMessageID(301))
-
- act(() => {
- result.current.loadMessagesCentered(T.Chat.numberToMessageID(999), 'flash')
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(result.current.staleMessage).toBeUndefined()
- expect(result.current.messageOrdinals).toBeUndefined()
- expect(loadThread).toHaveBeenCalledWith(
- expect.objectContaining({
- params: expect.objectContaining({
- query: expect.objectContaining({
- messageIDControl: expect.objectContaining({
- mode: T.RPCChat.MessageIDControlMode.centered,
- pivot: T.Chat.numberToMessageID(999),
- }),
- }),
- }),
- })
- )
-})
-
-test('jumpToRecent reloads recent messages through the mounted thread action', async () => {
- useConfigState.setState({loggedIn: true})
- jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
- const markAsRead = jest
- .spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise')
- .mockResolvedValue({offline: false})
- const onThreadLoadStatus = jest.fn()
- const msgID = T.Chat.numberToMessageID(202)
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadStatus']?.({
- status: {typ: T.RPCChat.UIChatThreadStatusTyp.server},
- })
- p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
- thread: JSON.stringify({
- messages: [makeValidTextUIMessage(msgID, 'recent')],
- pagination: {last: true, next: '', num: 100, previous: ''},
- }),
- })
- await Promise.resolve()
- return {offline: false}
- })
- const {result} = renderHook(() => useConversationThreadJumpToRecent(), {wrapper})
-
- act(() => {
- result.current({onThreadLoadStatus})
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(onThreadLoadStatus).toHaveBeenCalledWith(convID, T.RPCChat.UIChatThreadStatusTyp.server)
- expect(markAsRead).toHaveBeenCalledWith({
- conversationID: T.Chat.keyToConversationID(convID),
- forceUnread: false,
- msgID,
- })
-})
-
-test('mark-read disabled latest load does not arm active or explicit mark read', async () => {
- useConfigState.setState({loggedIn: true})
- useShellState.getState().dispatch.setActive(false)
- jest
- .spyOn(Common, 'isUserActivelyLookingAtThisThread')
- .mockImplementation(() => useShellState.getState().active)
- const markAsRead = jest
- .spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise')
- .mockResolvedValue({offline: false})
- const msgID = T.Chat.numberToMessageID(203)
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
- thread: JSON.stringify({
- messages: [makeValidTextUIMessage(msgID, 'search latest')],
- pagination: {last: true, next: '', num: 100, previous: ''},
- }),
- })
- await Promise.resolve()
- return {offline: false}
- })
- const {result} = renderHook(
- () => ({
- loadMoreMessages: useConversationThreadLoadMoreMessages(),
- markThreadAsRead: useConversationThreadMarkThreadAsRead(),
- }),
- {wrapper}
- )
-
- act(() => {
- result.current.loadMoreMessages({allowMarkAsRead: false, reason: 'focused'})
- })
- await act(async () => {
- await flushPromises()
- })
- expect(markAsRead).not.toHaveBeenCalled()
-
- act(() => {
- useShellState.getState().dispatch.setActive(true)
- })
- await act(async () => {
- await flushPromises()
- })
- expect(markAsRead).not.toHaveBeenCalled()
-
- act(() => {
- result.current.markThreadAsRead()
- })
- await act(async () => {
- await flushPromises()
- })
- expect(markAsRead).not.toHaveBeenCalled()
-})
-
-test('scrollback loads older messages without marking the thread read', async () => {
- useConfigState.setState({loggedIn: true})
- jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
- thread: JSON.stringify({
- messages: [makeValidTextUIMessage(T.Chat.numberToMessageID(201), 'older')],
- pagination: {last: false, next: '', num: 100, previous: ''},
- }),
- })
- await Promise.resolve()
- return {offline: false}
- })
- const markAsRead = jest
- .spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise')
- .mockResolvedValue({offline: false})
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- loadOlderMessagesDueToScroll: useConversationThreadLoadOlderMessagesDueToScroll(),
- }),
- {wrapper}
- )
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [makeTextMessage()],
- moreToLoad: true,
- scrollDirection: 'back',
- })
- })
-
- act(() => {
- result.current.loadOlderMessagesDueToScroll(1)
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(markAsRead).not.toHaveBeenCalled()
-})
-
test('mounted thread listener applies messagesUpdated for the active conversation', () => {
jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
const firstMsgID = T.Chat.numberToMessageID(401)
@@ -534,100 +363,7 @@ test('mounted thread listener applies messagesUpdated for the active conversatio
expect(result.current.message?.id).toBe(firstMsgID)
})
-// The full jump -> scroll-to-bottom -> stale-reload chain behind normal/container.tsx's
-// allowMarkReadOnLoad. Jumping to a highlighted message mounts the thread with
-// skipThreadLoadOnSelection (the centered load replaces the select-on-mount load) and blocks
-// mark-read. The block is NOT permanent: applyThreadLoad releases it as soon as the user scrolls
-// to the latest message ('forward' with no moreToLoad). The stale reload that follows -
-// ChatThreadsStale fires on every mobile background -> foreground - must then be free to mark the
-// thread read. reloadStaleThread reads allowMarkReadOnLoad through useEffectEvent, i.e. the latest
-// render's value, so a caller that derived it from the one-shot highlight and froze it at `false`
-// would leave the conversation badged unread for as long as the thread stayed mounted.
-const staleThreadUpdate = {
- payload: {
- params: {
- uid: '',
- updates: [
- {convID: T.Chat.keyToConversationID(convID), updateType: T.RPCChat.StaleUpdateType.newactivity},
- ],
- },
- },
- type: 'chat.1.NotifyChat.ChatThreadsStale',
-} as never
-
-const renderJumpedThenScrolledToBottom = (allowMarkReadOnLoad: boolean) => {
- useConfigState.setState({loggedIn: true})
- jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
- const markAsRead = jest
- .spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise')
- .mockResolvedValue({offline: false})
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
- thread: JSON.stringify({
- messages: [makeValidTextUIMessage(T.Chat.numberToMessageID(203), 'latest')],
- pagination: {last: true, next: '', num: 100, previous: ''},
- }),
- })
- await Promise.resolve()
- return {offline: false}
- })
- const {result} = renderHook(() => useConversationThreadActions(), {
- wrapper: ({children}: {children: React.ReactNode}) => (
-
-
- {children}
-
-
- ),
- })
- // jumping to a highlighted message blocks mark-read
- act(() => {
- result.current.setMarkReadBlocked(true)
- })
- // ...then the user scrolls all the way forward to the latest message, releasing the block
- act(() => {
- result.current.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [makeTextMessage()],
- moreToLoad: false,
- scrollDirection: 'forward',
- })
- })
- return markAsRead
-}
-
-test('a stale reload after a jump and a scroll to the bottom marks the thread read', async () => {
- const markAsRead = renderJumpedThenScrolledToBottom(true)
-
- act(() => {
- notifyEngineActionListeners(staleThreadUpdate)
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(markAsRead).toHaveBeenCalledTimes(1)
-})
-
// The counterfactual: exactly what deriving allowMarkReadOnLoad from the one-shot highlight did.
-test('a stale reload that disallows mark read leaves the thread unread even once the block is gone', async () => {
- const markAsRead = renderJumpedThenScrolledToBottom(false)
-
- act(() => {
- notifyEngineActionListeners(staleThreadUpdate)
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(markAsRead).not.toHaveBeenCalled()
-})
-
test('mounted thread listener applies incoming messages for the active conversation', async () => {
jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
useConfigState.setState({loggedIn: true})
@@ -648,15 +384,15 @@ test('mounted thread listener applies incoming messages for the active conversat
const firstMsgID = T.Chat.numberToMessageID(601)
const {result} = renderHook(
() => ({
- loadMoreMessages: useConversationThreadLoadMoreMessages(),
message: useConversationThreadMessage(T.Chat.numberToOrdinal(601)),
ordinals: useConversationThreadSelector(s => s.messageOrdinals),
+ requestWindow: useRequestWindow(),
}),
- {wrapper}
+ {wrapper: loadingWrapper}
)
act(() => {
- result.current.loadMoreMessages({reason: 'focused'})
+ result.current.requestWindow({anchor: 'newest', reason: 'focused'})
})
await act(async () => {
await flushPromises()
@@ -767,10 +503,10 @@ test('an unlocalized conversation defers mark read until localization lands', as
await Promise.resolve()
return {offline: false}
})
- const {result} = renderHook(() => useConversationThreadLoadMoreMessages(), {wrapper})
+ const {result} = renderHook(useRequestWindow, {wrapper: loadingWrapper})
act(() => {
- result.current({reason: 'tab selected'})
+ result.current({anchor: 'newest', reason: 'tab selected'})
})
await act(async () => {
await flushPromises()
@@ -821,10 +557,10 @@ test('active change marks read after an eligible mounted thread load', async ()
await Promise.resolve()
return {offline: false}
})
- const {result} = renderHook(() => useConversationThreadLoadMoreMessages(), {wrapper})
+ const {result} = renderHook(useRequestWindow, {wrapper: loadingWrapper})
act(() => {
- result.current({reason: 'tab selected'})
+ result.current({anchor: 'newest', reason: 'tab selected'})
})
await act(async () => {
await flushPromises()
@@ -864,10 +600,10 @@ test('active change does not mark read after a centered thread load', async () =
await Promise.resolve()
return {offline: false}
})
- const {result} = renderHook(() => useConversationThreadLoadMessagesCentered(), {wrapper})
+ const {result} = renderHook(useRequestWindow, {wrapper: loadingWrapper})
act(() => {
- result.current(msgID, 'flash')
+ result.current({anchor: {centeredOn: msgID}, reason: 'centered'})
})
await act(async () => {
await flushPromises()
@@ -1006,14 +742,14 @@ test('mounted thread listener applies reaction updates for the active conversati
})
const {result} = renderHook(
() => ({
- loadMoreMessages: useConversationThreadLoadMoreMessages(),
message: useConversationThreadMessage(T.Chat.numberToOrdinal(301)),
+ requestWindow: useRequestWindow(),
}),
- {wrapper}
+ {wrapper: loadingWrapper}
)
act(() => {
- result.current.loadMoreMessages({reason: 'focused'})
+ result.current.requestWindow({anchor: 'newest', reason: 'focused'})
})
await act(async () => {
await flushPromises()
@@ -1066,22 +802,25 @@ test('mounted thread listener applies reaction updates for the active conversati
})
})
-test('loaded focus refresh does not overwrite newer streamed reaction updates', async () => {
+test('toggleMessageReaction overlays locally without mutating server reactions', async () => {
const targetMsgID = T.Chat.numberToMessageID(301)
const targetOrdinal = T.Chat.numberToOrdinal(301)
- let incomingCallMap:
- | Parameters[0]['incomingCallMap']
- | undefined
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- incomingCallMap = p.incomingCallMap
- await Promise.resolve()
- return {offline: false}
- })
+ const postReaction = jest
+ .spyOn(T.RPCChat, 'localPostReactionNonblockRpcPromise')
+ .mockImplementation(async p => {
+ const outboxID = await Promise.resolve(p.outboxID ?? new Uint8Array())
+ return {
+ identifyFailures: null,
+ outboxID,
+ rateLimits: null,
+ }
+ })
const {result} = renderHook(
() => ({
actions: useConversationThreadActions(),
- loadMoreMessages: useConversationThreadLoadMoreMessages(),
message: useConversationThreadMessage(targetOrdinal),
+ messageActions: useConversationThreadMessageActions(),
+ store: useConversationThreadStore(),
}),
{wrapper}
)
@@ -1089,7 +828,7 @@ test('loaded focus refresh does not overwrite newer streamed reaction updates',
act(() => {
result.current.actions.applyThreadLoad({
centered: false,
- enableActiveMarkRead: true,
+ enableActiveMarkRead: false,
messages: [makeTextMessage()],
moreToLoad: false,
scrollDirection: 'none',
@@ -1097,13 +836,41 @@ test('loaded focus refresh does not overwrite newer streamed reaction updates',
})
act(() => {
- result.current.loadMoreMessages({reason: 'tab selected'})
+ result.current.messageActions.toggleMessageReaction(targetOrdinal, ':+1:')
})
+
+ expect(result.current.message?.reactions?.get(':+1:')?.users.map(u => u.username)).toEqual(['alice'])
+ expect(result.current.store.getState().messageMap.get(targetOrdinal)?.reactions).toBeUndefined()
await act(async () => {
await flushPromises()
})
- expect(incomingCallMap).toBeDefined()
+ const outboxID = postReaction.mock.calls[0]?.[0].outboxID
+ expect(outboxID).toBeDefined()
+
+ act(() => {
+ notifyEngineActionListeners({
+ payload: {
+ params: {
+ activity: {
+ activityType: T.RPCChat.ChatActivityType.incomingMessage,
+ incomingMessage: makeIncomingOutboxReaction(
+ convID,
+ outboxID!,
+ targetMsgID,
+ ':+1:',
+ 'decorated-plus-one'
+ ),
+ },
+ },
+ },
+ type: 'chat.1.NotifyChat.NewChatActivity',
+ } as never)
+ })
+
+ expect(result.current.message?.reactions?.get(':+1:')?.users.map(u => u.username)).toEqual(['alice'])
+ expect(result.current.message?.reactions?.get(':+1:')?.decorated).toBe('decorated-plus-one')
+ expect(result.current.store.getState().messageMap.get(targetOrdinal)?.reactions).toBeUndefined()
act(() => {
notifyEngineActionListeners({
@@ -1118,7 +885,7 @@ test('loaded focus refresh does not overwrite newer streamed reaction updates',
reactions: {
reactions: {
':+1:': {
- decorated: ':+1:',
+ decorated: 'server-plus-one',
users: {
alice: {
ctime: 300,
@@ -1140,129 +907,10 @@ test('loaded focus refresh does not overwrite newer streamed reaction updates',
} as never)
})
- expect(result.current.message?.reactions?.get(':+1:')?.users.map(u => u.username)).toEqual(['alice'])
-
- act(() => {
- incomingCallMap?.['chat.1.chatUi.chatThreadFull']?.({
- thread: JSON.stringify({
- messages: [makeValidTextUIMessage(targetMsgID, 'stale server copy')],
- pagination: {last: true, next: '', num: 20, previous: ''},
- }),
- })
- })
-
- expect(result.current.message?.reactions?.get(':+1:')?.users.map(u => u.username)).toEqual(['alice'])
-})
-
-test('toggleMessageReaction overlays locally without mutating server reactions', async () => {
- const targetMsgID = T.Chat.numberToMessageID(301)
- const targetOrdinal = T.Chat.numberToOrdinal(301)
- const postReaction = jest
- .spyOn(T.RPCChat, 'localPostReactionNonblockRpcPromise')
- .mockImplementation(async p => {
- const outboxID = await Promise.resolve(p.outboxID ?? new Uint8Array())
- return {
- identifyFailures: null,
- outboxID,
- rateLimits: null,
- }
- })
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- message: useConversationThreadMessage(targetOrdinal),
- messageActions: useConversationThreadMessageActions(),
- store: useConversationThreadStore(),
- }),
- {wrapper}
- )
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [makeTextMessage()],
- moreToLoad: false,
- scrollDirection: 'none',
- })
- })
-
- act(() => {
- result.current.messageActions.toggleMessageReaction(targetOrdinal, ':+1:')
- })
-
- expect(result.current.message?.reactions?.get(':+1:')?.users.map(u => u.username)).toEqual(['alice'])
- expect(result.current.store.getState().messageMap.get(targetOrdinal)?.reactions).toBeUndefined()
- await act(async () => {
- await flushPromises()
- })
-
- const outboxID = postReaction.mock.calls[0]?.[0].outboxID
- expect(outboxID).toBeDefined()
-
- act(() => {
- notifyEngineActionListeners({
- payload: {
- params: {
- activity: {
- activityType: T.RPCChat.ChatActivityType.incomingMessage,
- incomingMessage: makeIncomingOutboxReaction(
- convID,
- outboxID!,
- targetMsgID,
- ':+1:',
- 'decorated-plus-one'
- ),
- },
- },
- },
- type: 'chat.1.NotifyChat.NewChatActivity',
- } as never)
- })
-
- expect(result.current.message?.reactions?.get(':+1:')?.users.map(u => u.username)).toEqual(['alice'])
- expect(result.current.message?.reactions?.get(':+1:')?.decorated).toBe('decorated-plus-one')
- expect(result.current.store.getState().messageMap.get(targetOrdinal)?.reactions).toBeUndefined()
-
- act(() => {
- notifyEngineActionListeners({
- payload: {
- params: {
- activity: {
- activityType: T.RPCChat.ChatActivityType.reactionUpdate,
- reactionUpdate: {
- convID: T.Chat.keyToConversationID(convID),
- reactionUpdates: [
- {
- reactions: {
- reactions: {
- ':+1:': {
- decorated: 'server-plus-one',
- users: {
- alice: {
- ctime: 300,
- reactionMsgID: T.Chat.messageIDToNumber(T.Chat.numberToMessageID(99)),
- },
- },
- },
- },
- },
- targetMsgID: T.Chat.messageIDToNumber(targetMsgID),
- },
- ],
- userReacjis: {skinTone: T.RPCGen.ReacjiSkinTone.none, topReacjis: null},
- },
- },
- },
- },
- type: 'chat.1.NotifyChat.NewChatActivity',
- } as never)
- })
-
- expect(result.current.store.getState().optimisticReactionMap.size).toBe(0)
- expect(result.current.store.getState().messageMap.get(targetOrdinal)?.reactions?.get(':+1:')).toEqual({
- decorated: 'server-plus-one',
- users: [{timestamp: 300, username: 'alice'}],
+ expect(result.current.store.getState().optimisticReactionMap.size).toBe(0)
+ expect(result.current.store.getState().messageMap.get(targetOrdinal)?.reactions?.get(':+1:')).toEqual({
+ decorated: 'server-plus-one',
+ users: [{timestamp: 300, username: 'alice'}],
})
})
@@ -1466,292 +1114,6 @@ test('mounted thread listener applies attachment download and upload progress',
).toBeUndefined()
})
-test('a warm-cache load prunes against both passes, not either one alone', async () => {
- // Regression: once the service has sent a cached thread it switches the full response to
- // INCREMENTAL, so the full pass only carries what changed. Treating either pass on its own as
- // authoritative deleted real messages that were still in the thread. The two together are the
- // window - INCREMENTAL walks it and omits only what the cached pass already carried - so the
- // range spans both, and everything inside it that either pass carried survives.
- useConfigState.setState({loggedIn: true})
- jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
- jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
- const ids = [301, 302, 303, 304].map(T.Chat.numberToMessageID)
- const threadJSON = (msgIDs: ReadonlyArray) =>
- JSON.stringify({
- messages: msgIDs.map(id => makeValidTextUIMessage(id, `m${id}`)),
- pagination: {last: true, next: '', num: 100, previous: ''},
- })
-
- // The cache holds the older three; only 304 changed, so that is all the full pass carries. The
- // span is what makes this the dangerous shape: a range of [301..304] computed from the full pass
- // alone covers 302 and 303, which are absent from it and would be pruned.
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadCached']?.({thread: threadJSON(ids.slice(0, 3))})
- await Promise.resolve()
- p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({thread: threadJSON([ids[3]!])})
- await Promise.resolve()
- return {offline: false}
- })
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- loadMoreMessages: useConversationThreadLoadMoreMessages(),
- ordinals: useConversationThreadSelector(s => s.messageOrdinals),
- }),
- {wrapper}
- )
-
- // Seed a settled four-message window the way a whole-window full pass would.
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: ids.map(id =>
- Message.makeMessageText({
- author: 'alice',
- conversationIDKey: convID,
- id,
- ordinal: T.Chat.numberToOrdinal(T.Chat.messageIDToNumber(id)),
- outboxID: undefined,
- text: new HiddenString(`m${id}`),
- timestamp: 100,
- })
- ),
- moreToLoad: false,
- scrollDirection: 'none',
- })
- })
- expect(result.current.ordinals).toEqual([301, 302, 303, 304])
-
- act(() => {
- result.current.loadMoreMessages({reason: 'test'})
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(result.current.ordinals).toEqual([301, 302, 303, 304])
-})
-
-test('a warm-cache load does not prune a message sitting on its outbox ordinal', async () => {
- // A message you sent keeps the fractional ordinal it had in the outbox, so the ordinal it parses
- // with - its server one - is not the ordinal it occupies. The prune walks the window, so what
- // the passes delivered has to be recorded in the window's terms too; recording the parsed
- // ordinal deletes the row it was meant to protect.
- useConfigState.setState({loggedIn: true})
- jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
- jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
- const outboxID = T.Chat.stringToOutboxID('sent-1')
- const sentOrdinal = T.Chat.numberToOrdinal(302.001)
-
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadCached']?.({
- thread: JSON.stringify({
- messages: [
- makeValidTextUIMessage(T.Chat.numberToMessageID(301), 'm301'),
- makeValidTextUIMessage(T.Chat.numberToMessageID(302), 'm302'),
- makeValidTextUIMessage(T.Chat.numberToMessageID(303), 'mine', 'sent-1'),
- ],
- pagination: {last: true, next: '', num: 100, previous: ''},
- }),
- })
- await Promise.resolve()
- p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
- thread: JSON.stringify({
- messages: [makeValidTextUIMessage(T.Chat.numberToMessageID(301), 'm301 edited')],
- pagination: {last: true, next: '', num: 100, previous: ''},
- }),
- })
- await Promise.resolve()
- return {offline: false}
- })
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- loadMoreMessages: useConversationThreadLoadMoreMessages(),
- ordinals: useConversationThreadSelector(s => s.messageOrdinals),
- }),
- {wrapper}
- )
-
- // The window as it stands after the send settled: the message is at its outbox ordinal, indexed
- // under the server ID the service will send it back as.
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [
- Message.makeMessageText({
- author: 'alice',
- conversationIDKey: convID,
- id: T.Chat.numberToMessageID(301),
- ordinal: T.Chat.numberToOrdinal(301),
- outboxID: undefined,
- text: new HiddenString('m301'),
- timestamp: 100,
- }),
- Message.makeMessageText({
- author: 'alice',
- conversationIDKey: convID,
- id: T.Chat.numberToMessageID(302),
- ordinal: T.Chat.numberToOrdinal(302),
- outboxID: undefined,
- text: new HiddenString('m302'),
- timestamp: 100,
- }),
- Message.makeMessageText({
- author: 'testuser',
- conversationIDKey: convID,
- id: T.Chat.numberToMessageID(303),
- ordinal: sentOrdinal,
- outboxID,
- text: new HiddenString('mine'),
- timestamp: 100,
- }),
- ],
- moreToLoad: false,
- scrollDirection: 'none',
- })
- })
- expect(result.current.ordinals).toEqual([301, 302, sentOrdinal])
-
- act(() => {
- result.current.loadMoreMessages({reason: 'test'})
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(result.current.ordinals).toEqual([301, 302, sentOrdinal])
-})
-
-test('a full pass that changed nothing still reconciles the window', async () => {
- // The ordinary warm reload: the cached pass is the window and the INCREMENTAL full pass behind it
- // carries nothing at all, because nothing changed. That is still an authoritative answer about
- // the span, so a row the service no longer has is still a ghost - skipping the prune for want of
- // messages to add leaves it on screen until the conversation is reopened.
- useConfigState.setState({loggedIn: true})
- jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
- jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
- const ids = [301, 302, 303].map(T.Chat.numberToMessageID)
-
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadCached']?.({
- thread: JSON.stringify({
- messages: [ids[0]!, ids[2]!].map(id => makeValidTextUIMessage(id, `m${id}`)),
- pagination: {last: true, next: '', num: 100, previous: ''},
- }),
- })
- await Promise.resolve()
- p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
- thread: JSON.stringify({messages: null, pagination: {last: true, next: '', num: 100, previous: ''}}),
- })
- await Promise.resolve()
- return {offline: false}
- })
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- loadMoreMessages: useConversationThreadLoadMoreMessages(),
- ordinals: useConversationThreadSelector(s => s.messageOrdinals),
- }),
- {wrapper}
- )
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: ids.map(id =>
- Message.makeMessageText({
- author: 'alice',
- conversationIDKey: convID,
- id,
- ordinal: T.Chat.numberToOrdinal(T.Chat.messageIDToNumber(id)),
- outboxID: undefined,
- text: new HiddenString(`m${id}`),
- timestamp: 100,
- })
- ),
- moreToLoad: false,
- scrollDirection: 'none',
- })
- })
- expect(result.current.ordinals).toEqual([301, 302, 303])
-
- act(() => {
- result.current.loadMoreMessages({reason: 'test'})
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(result.current.ordinals).toEqual([301, 303])
-})
-
-test('a warm-cache load still prunes a row neither pass carries', async () => {
- // The other half of the same rule: a row inside the range that neither pass returned is a ghost -
- // a cache repair left it behind, or it was deleted while we were away - and reconciling it away
- // is what the range is for. Gating on a full pass with no cached one before it would have given
- // this up for every conversation the cache is warm for.
- useConfigState.setState({loggedIn: true})
- jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
- jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
- const ids = [301, 302, 303, 304].map(T.Chat.numberToMessageID)
- const threadJSON = (msgIDs: ReadonlyArray) =>
- JSON.stringify({
- messages: msgIDs.map(id => makeValidTextUIMessage(id, `m${id}`)),
- pagination: {last: true, next: '', num: 100, previous: ''},
- })
-
- // 303 is in neither pass, and it sits inside the span the two of them cover.
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadCached']?.({thread: threadJSON([ids[0]!, ids[1]!])})
- await Promise.resolve()
- p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({thread: threadJSON([ids[3]!])})
- await Promise.resolve()
- return {offline: false}
- })
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- loadMoreMessages: useConversationThreadLoadMoreMessages(),
- ordinals: useConversationThreadSelector(s => s.messageOrdinals),
- }),
- {wrapper}
- )
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: ids.map(id =>
- Message.makeMessageText({
- author: 'alice',
- conversationIDKey: convID,
- id,
- ordinal: T.Chat.numberToOrdinal(T.Chat.messageIDToNumber(id)),
- outboxID: undefined,
- text: new HiddenString(`m${id}`),
- timestamp: 100,
- })
- ),
- moreToLoad: false,
- scrollDirection: 'none',
- })
- })
- expect(result.current.ordinals).toEqual([301, 302, 303, 304])
-
- act(() => {
- result.current.loadMoreMessages({reason: 'test'})
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(result.current.ordinals).toEqual([301, 302, 304])
-})
-
// The window invariant, at the callsite that enforces it. The four unit tests in
// thread-message-state.test.tsx pass `dropNewBelowWindow` themselves; only this proves addMessages
// actually sets it, and that thread loads are still allowed to extend the window downward.
@@ -1803,189 +1165,6 @@ test('a notification may not strand a new ordinal below the loaded window', () =
)
})
-test('jumpToRecent drops the old window instead of merging a disjoint one into it', async () => {
- // The newest window has nothing to do with wherever the reader had scrolled back to, so merging
- // the two leaves a hole in messageOrdinals between them - which is the same stranded-index-0
- // shape that kills scrollback. A centered jump already clears first; this must too.
- useConfigState.setState({loggedIn: true})
- jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
- jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
- const recent = T.Chat.numberToMessageID(9001)
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
- thread: JSON.stringify({
- messages: [makeValidTextUIMessage(recent, 'newest')],
- pagination: {last: true, next: '', num: 100, previous: ''},
- }),
- })
- await Promise.resolve()
- return {offline: false}
- })
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- jumpToRecent: useConversationThreadJumpToRecent(),
- ordinals: useConversationThreadSelector(s => s.messageOrdinals),
- }),
- {wrapper}
- )
-
- // The reader is deep in old history.
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [textAt(101), textAt(102)],
- moreToLoad: true,
- scrollDirection: 'none',
- })
- })
-
- act(() => {
- result.current.jumpToRecent()
- })
- await act(async () => {
- await flushPromises()
- })
-
- // Only the newest window survives. If the old one were merged in, ordinals would read
- // [101, 102, 9001] with a 8899-wide hole.
- expect(result.current.ordinals).toEqual([T.Chat.numberToOrdinal(9001)])
-})
-
-test('only the load that claimed the window gate may drop it', () => {
- // clearVersion cannot separate two loads of the same conversation - the load generation only
- // moves on a conversation change or unmount, so both call themselves current. The reader taps a
- // search result, messagesClear issues the centered reload, and a ChatThreadsStale notification
- // then fires a second load at the same generation. If that one settles first - no thread, an
- // error - it would take the gate down while the reload is still in flight, and a push landing in
- // what is left of the gap strands exactly as it did before the gate existed.
- const {result} = renderHook(() => ({actions: useConversationThreadActions()}), {wrapper})
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [textAt(7152), textAt(7153)],
- moreToLoad: true,
- scrollDirection: 'none',
- })
- })
- act(() => {
- result.current.actions.messagesClear()
- })
-
- // The reload the clear issued claims the gate; the stale-thread load that follows loses the race.
- act(() => {
- result.current.actions.claimWindowGate(1)
- result.current.actions.claimWindowGate(2)
- })
- act(() => {
- result.current.actions.clearWindowGate(2)
- })
- expect(result.current.actions.getSnapshot().windowCleared).toBe(true)
-
- act(() => {
- result.current.actions.clearWindowGate(1)
- })
- expect(result.current.actions.getSnapshot().windowCleared).toBe(false)
-})
-
-test('a stale reload does not merge the newest page into a centered window', () => {
- // The reader taps a search result and sits on the window around it, with more to load forward.
- // A ChatThreadsStale reload fetches the newest page, which is nowhere near that window: merging
- // the two leaves ordinals with a hole through the middle and then calls the result the latest
- // message, which is the gap this invariant is about.
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- ordinals: useConversationThreadSelector(s => s.messageOrdinals),
- }),
- {wrapper}
- )
-
- const textAt = (ord: number) =>
- Message.makeMessageText({
- author: 'alice',
- conversationIDKey: convID,
- id: T.Chat.numberToMessageID(ord),
- ordinal: T.Chat.numberToOrdinal(ord),
- outboxID: undefined,
- text: new HiddenString(`m${ord}`),
- timestamp: 100,
- })
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: true,
- enableActiveMarkRead: false,
- messages: [textAt(7000), textAt(7001)],
- moreToLoad: true,
- scrollDirection: 'none',
- })
- })
- expect(result.current.actions.getSnapshot().moreToLoadForward).toBe(true)
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [textAt(9900), textAt(9901)],
- moreToLoad: true,
- scrollDirection: 'none',
- })
- })
-
- expect(result.current.ordinals).toEqual([7000, 7001])
- // ...and the window still knows it has not reached the latest message.
- expect(result.current.actions.getSnapshot().moreToLoadForward).toBe(true)
-})
-
-test('a newest page that reaches the window is still merged', () => {
- // The other side of the rule. A reader near the bottom gets a page that overlaps what they hold,
- // so there is no hole to open and the refresh must land.
- const {result} = renderHook(
- () => ({
- actions: useConversationThreadActions(),
- ordinals: useConversationThreadSelector(s => s.messageOrdinals),
- }),
- {wrapper}
- )
-
- const textAt = (ord: number) =>
- Message.makeMessageText({
- author: 'alice',
- conversationIDKey: convID,
- id: T.Chat.numberToMessageID(ord),
- ordinal: T.Chat.numberToOrdinal(ord),
- outboxID: undefined,
- text: new HiddenString(`m${ord}`),
- timestamp: 100,
- })
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: true,
- enableActiveMarkRead: false,
- messages: [textAt(9900), textAt(9901)],
- moreToLoad: true,
- scrollDirection: 'none',
- })
- })
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [textAt(9901), textAt(9902)],
- moreToLoad: true,
- scrollDirection: 'none',
- })
- })
-
- expect(result.current.ordinals).toEqual([9900, 9901, 9902])
-})
-
test('an empty pass leaves the thread unloaded rather than loaded and empty', () => {
// addMessagesToThreadState always leaves a messageOrdinals array behind, and the top-of-thread
// block reads `messageOrdinals !== undefined` as "this conversation has loaded at least once".
@@ -2006,50 +1185,6 @@ test('an empty pass leaves the thread unloaded rather than loaded and empty', ()
expect(result.current.actions.getSnapshot().messageOrdinals).toBeUndefined()
})
-test('an empty pass during a jump-to-recent gap leaves the gate up', () => {
- // A cold cache sends a cached pass carrying no messages ahead of the full response, and it
- // reaches applyThreadLoad like any other. Dropping the gate on it reopens the gap: a
- // notification landing before the real page becomes the sole ordinal, and the page that follows
- // is disjoint from it.
- const {result} = renderHook(() => ({actions: useConversationThreadActions()}), {wrapper})
-
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [textAt(7152), textAt(7153)],
- moreToLoad: true,
- scrollDirection: 'none',
- })
- })
- act(() => {
- result.current.actions.messagesClear()
- })
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [],
- moreToLoad: true,
- scrollDirection: 'none',
- })
- })
- act(() => {
- result.current.actions.addMessages([textAt(7155)], {liveUpdate: true})
- })
- act(() => {
- result.current.actions.applyThreadLoad({
- centered: false,
- enableActiveMarkRead: false,
- messages: [textAt(9001)],
- moreToLoad: true,
- scrollDirection: 'none',
- })
- })
-
- expect(result.current.actions.getSnapshot().messageOrdinals).toEqual([T.Chat.numberToOrdinal(9001)])
-})
-
test('a notification during a jump-to-recent gap cannot become the new window', () => {
// jumpToRecent and a centered jump both clear before reloading, so for one RPC round trip the
// window is empty. The notification most likely to land in that gap is the post-send
diff --git a/shared/chat/conversation/thread-context.tsx b/shared/chat/conversation/thread-context.tsx
index 0a7abbe52b08..259c11638072 100644
--- a/shared/chat/conversation/thread-context.tsx
+++ b/shared/chat/conversation/thread-context.tsx
@@ -6,13 +6,10 @@ import * as T from '@/constants/types'
import {getVisibleScreen, navigateAppend, navigateToThread, navigateUp, setChatRootParams} from '@/constants/router'
import {isPhone} from '@/constants/platform'
import logger from '@/logger'
-import throttle from 'lodash/throttle'
-import {clearChatTimeCache} from '@/util/timestamp'
import {findLast} from '@/util/arrays'
import {ignorePromise} from '@/constants/utils'
import {RPCError} from '@/util/errors'
import {useCurrentUserState} from '@/stores/current-user'
-import {useUsersState} from '@/stores/users'
import {useConfigState} from '@/stores/config'
import {useShellState} from '@/stores/shell'
import {produce, type Draft} from 'immer'
@@ -40,13 +37,7 @@ import {
updateAttachmentUploadProgressInThreadState,
updateReactionsInThreadState,
} from './thread-message-state'
-import {
- getInboxConversationMeta,
- getInboxConversationParticipants,
- metasReceived,
- unboxRows,
- useInboxMetadataState,
-} from '@/chat/inbox/metadata'
+import {getInboxConversationMeta, metasReceived, useInboxMetadataState} from '@/chat/inbox/metadata'
import {loadThreadMessageIDAtIndex, markConversationRead} from './thread-rpc'
import {
cancelConversationPost,
@@ -61,9 +52,6 @@ import {
getClientPrevFromSnapshot,
getExplodingModeFromConfig,
getMeta,
- loadConversationThreadMessages,
- numMessagesOnInitialLoad,
- numMessagesOnScrollback,
persistExplodingMode,
} from './thread-load'
import {useThreadEngineListeners} from './thread-engine'
@@ -80,12 +68,6 @@ const sameStringSet = (a: ReadonlySet, b: ReadonlySet) => {
return true
}
-const emptyParticipantInfo: T.Chat.ParticipantInfo = {
- all: [],
- contactName: new Map(),
- name: [],
-}
-
const formatTextForQuoting = (text: string) =>
text
.split('\n')
@@ -97,10 +79,12 @@ ConversationThreadIDContext.displayName = 'ConversationThreadIDContext'
export type ConversationThreadState = {
accountsInfoMap: Map
- // Bumped on every messagesClear. The desktop list remounts on it: LegendList cannot recover
+ // The identity of the loaded window: bumped whenever the window is dropped (messagesClear) or the
+ // conversation under it changes. Two things read it. thread-window refuses any response fetched
+ // against an older generation, and the desktop list remounts on it - LegendList cannot recover
// from a non-empty -> empty -> non-empty data transition (it resets its layout state and waits
// for a container layout event that never comes), so the thread renders blank forever.
- clearVersion: number
+ generation: number
explodingMode: number
flipStatusMap: Map
loaded: boolean
@@ -109,15 +93,9 @@ export type ConversationThreadState = {
messageMap: Map
messageOrdinals?: ReadonlyArray
// Set between a messagesClear and the reload that refills the window, so a notification arriving
- // in that gap cannot install itself as the new window. Cleared once that load settles, however it
- // settles - see clearWindowGate.
+ // in that gap cannot install itself as the new window. thread-window decides which load may take
+ // it down; see releaseWindowGate.
windowCleared?: boolean
- // The load that owns the gate above: the first one to claim it after the clear, which is the
- // reload the clear issued. Only that load may drop the gate. clearVersion alone cannot tell two
- // loads of the same conversation apart, and a second load at the same generation - a
- // ChatThreadsStale reload, say - would otherwise settle first and take down a gate the reload is
- // still relying on.
- windowGateOwner?: number
messageTypeMap: Map
moreToLoadBack: boolean
moreToLoadForward: boolean
@@ -144,7 +122,7 @@ const makeEmptyThreadState = (): ConversationThreadState =>
produce(
{
accountsInfoMap: new Map(),
- clearVersion: 0,
+ generation: 0,
explodingMode: 0,
flipStatusMap: new Map(),
liveUpdateVersion: 0,
@@ -173,54 +151,7 @@ const makeInitialThreadState = (id: T.Chat.ConversationIDKey) => {
const makeThreadStore = (id: T.Chat.ConversationIDKey) =>
createStore(() => makeInitialThreadState(id))
-export type ThreadLoadStatusOptions = {
- isThreadLoadCurrent?: () => boolean
- onThreadLoadStatus?: ThreadLoadStatusReporter
-}
-
-type SelectedConversationOptions = ThreadLoadStatusOptions & {
- allowMarkAsRead?: boolean
- skipThreadLoad?: boolean
-}
-
export type ScrollDirection = 'none' | 'back' | 'forward'
-export type LoadMoreMessagesParams = ThreadLoadStatusOptions & {
- allowMarkAsRead?: boolean
- centeredMessageID?: {
- conversationIDKey: T.Chat.ConversationIDKey
- highlightMode: T.Chat.CenterOrdinalHighlightMode
- messageID: T.Chat.MessageID
- }
- forceContainsLatestCalc?: boolean
- knownRemotes?: ReadonlyArray
- messageIDControl?: T.RPCChat.MessageIDControl | null
- numberOfMessagesToLoad?: number
- reason: string
- // Internal: set only by the empty-back-page reload in thread-load.tsx, carrying the oldest
- // message ID the previous attempt saw. Each reload must reach strictly further back than that,
- // which is what stops it looping. Callers leave it unset.
- retryBelowMessageID?: T.Chat.MessageID
- // How many times the back-page reload has already chained. See maxBackPageReloads.
- retryCount?: number
- scrollDirection?: ScrollDirection
-}
-type LoadMoreMessages = ((p: LoadMoreMessagesParams) => void) & {cancel: () => void}
-type LoadMessagesCentered = (
- messageID: T.Chat.MessageID,
- highlightMode: T.Chat.CenterOrdinalHighlightMode,
- options?: ThreadLoadStatusOptions
-) => void
-type LoadOlderMessagesDueToScroll = (
- numOrdinals: number,
- options?: ThreadLoadStatusOptions
-) => void
-type LoadNewerMessagesDueToScroll = (
- numOrdinals: number,
- options?: ThreadLoadStatusOptions
-) => void
-type JumpToRecent = (options?: ThreadLoadStatusOptions) => void
-type MessagesClear = () => void
-type SelectedConversation = (options?: SelectedConversationOptions) => void
export type ConversationThreadActions = {
addMessages: (
messages: ReadonlyArray,
@@ -233,12 +164,12 @@ export type ConversationThreadActions = {
centered: boolean
disableActiveMarkRead?: boolean
enableActiveMarkRead: boolean
- forceContainsLatestCalc?: boolean
messages: ReadonlyArray
moreToLoad: boolean
reconcile?: ThreadLoadReconcile
scrollDirection: ScrollDirection
}) => void
+ bumpWindowGeneration: () => void
clearUnfurlPrompt: (messageID: T.Chat.MessageID, domain: string) => void
deleteMessages: (p: {
messageIDs?: ReadonlyArray
@@ -252,17 +183,15 @@ export type ConversationThreadActions = {
explodedBy?: string,
liveUpdate?: boolean
) => void
- claimWindowGate: (loadID: number) => void
- clearWindowGate: (loadID: number) => void
getSnapshot: () => ConversationThreadState
- loadMoreMessages: LoadMoreMessages
markThreadAsRead: () => void
setMarkReadBlocked: (blocked: boolean) => void
messageDelete: (ordinal: T.Chat.Ordinal) => void
messageReplyPrivately: (ordinal: T.Chat.Ordinal) => void
- messagesClear: MessagesClear
+ messagesClear: () => void
receivePaymentInfo: (messageID: T.Chat.MessageID, paymentInfo: T.Chat.ChatPaymentInfo) => void
receiveRequestInfo: (messageID: T.Chat.MessageID, requestInfo: T.Chat.ChatRequestInfo) => void
+ releaseWindowGate: () => void
retryMessage: (outboxID: T.Chat.OutboxID) => void
setExplodingMode: (seconds: number, incoming?: boolean) => void
setMessageErrored: (outboxID: T.Chat.OutboxID, reason: string, errorTyp?: number) => void
@@ -298,11 +227,6 @@ const ConversationThreadActionsContext = React.createContext void
-
export const useConversationThreadID = () => {
const conversationIDKey = React.useContext(ConversationThreadIDContext)
if (!conversationIDKey) {
@@ -319,26 +243,6 @@ export const useConversationThreadActions = () => {
return actions
}
-const useScrollLoadGate = () => {
- const lastScrollNumOrdinalsRef = React.useRef(0)
- const lastScrollTimeRef = React.useRef(0)
- return (numOrdinals: number) => {
- const now = Date.now()
- if (numOrdinals !== lastScrollNumOrdinalsRef.current) {
- lastScrollNumOrdinalsRef.current = numOrdinals
- lastScrollTimeRef.current = now
- return true
- }
-
- const ok = now - lastScrollTimeRef.current > 500
- if (ok) {
- lastScrollNumOrdinalsRef.current = numOrdinals
- lastScrollTimeRef.current = now
- }
- return ok
- }
-}
-
export const useConversationThreadSelector = (
selector: (snapshot: ConversationThreadState) => TValue
) => {
@@ -535,48 +439,11 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) =>
centered: boolean
disableActiveMarkRead?: boolean
enableActiveMarkRead: boolean
- forceContainsLatestCalc?: boolean
messages: ReadonlyArray
moreToLoad: boolean
reconcile?: ThreadLoadReconcile
scrollDirection: ScrollDirection
}) => {
- const rendered = p.messages.filter(m => m.conversationMessage !== false && m.type !== 'deleted')
- // Judged on what this pass carried rather than on the state of the window, so the gate turns
- // on the one thing that decides it: whether this pass put a row on screen.
- const carriedRenderedMessage = rendered.length > 0
- // A 'none' load fetches the newest page, and a window with more to load forward does not
- // reach it. Merging the two leaves ordinals with a hole through the middle, and the branch
- // below then reports that window as containing the latest message - which is the gap this
- // whole invariant is about, arriving through a ChatThreadsStale reload while the reader sits
- // on a search result. Both conditions are needed: a window that already reaches the newest
- // message merges fine, and so does a page that overlaps what we hold, however far back the
- // reader is. Neither holds here, so the page is left alone rather than applied - the reader
- // keeps their window, and jumping to recent (which empties it first) is what replaces it.
- const beforeApply = threadStore.getState()
- const windowOrdinals = beforeApply.messageOrdinals
- const floor = windowOrdinals?.[0]
- const ceiling = windowOrdinals?.[windowOrdinals.length - 1]
- if (
- p.scrollDirection === 'none' &&
- rendered.length &&
- beforeApply.moreToLoadForward &&
- floor !== undefined &&
- ceiling !== undefined
- ) {
- let lowest = Number.MAX_SAFE_INTEGER
- let highest = Number.MIN_SAFE_INTEGER
- for (const m of rendered) {
- lowest = Math.min(lowest, m.ordinal)
- highest = Math.max(highest, m.ordinal)
- }
- if (lowest > ceiling || highest < floor) {
- logger.info(
- `applyThreadLoad: page ${lowest}-${highest} does not reach window ${floor}-${ceiling}, ignoring`
- )
- return
- }
- }
updateThreadState(s => {
s.loaded = true
// The reconciling pass runs even with nothing to add: the warm reload where nothing changed
@@ -589,15 +456,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) =>
addMessagesToThreadState(s, p.messages, {reconcile: p.reconcile})
clearOptimisticReactionsForMessagesInThreadState(s, p.messages)
}
- // Only a pass that actually rendered something drops the gate. A cold cache sends an empty
- // cached pass ahead of the full response, and a page can be all tombstones: dropping the
- // gate on either would let a notification arriving before the real page install itself as
- // the whole window and strand once that page lands. A load that ends without ever producing
- // an ordinal releases the gate in its own finally instead - see clearWindowGate.
- if (carriedRenderedMessage) {
- s.windowCleared = false
- s.windowGateOwner = undefined
- }
switch (p.scrollDirection) {
case 'forward':
s.moreToLoadForward = p.moreToLoad
@@ -610,7 +468,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) =>
// A centered window may already include the latest message; leaving
// moreToLoadForward true would drop live incoming messages and block mark-read.
let containsLatest = false
- if (p.centered && p.forceContainsLatestCalc) {
+ if (p.centered) {
const {maxVisibleMsgID} = getMeta(id)
const ordinal = findLast(s.messageOrdinals ?? [], o => !!s.messageMap.get(o)?.id)
const message = ordinal ? s.messageMap.get(ordinal) : undefined
@@ -962,41 +820,22 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) =>
markThreadAsRead()
}
)
- // The reload a clear issues claims the gate, so a load that merely happens to be running at the
- // same clear generation cannot drop it out from under that reload. First claim wins: the clear
- // issues its reload synchronously, so that reload is the first to get here.
- const claimWindowGate = React.useEffectEvent((loadID: number) => {
- const s = threadStore.getState()
- if (!s.windowCleared || s.windowGateOwner !== undefined) {
- return
- }
+ // Which load may take the gate down is thread-window's decision; this only performs it.
+ const releaseWindowGate = React.useEffectEvent(() => {
updateThreadState(d => {
- d.windowGateOwner = loadID
+ d.windowCleared = false
})
})
- // applyThreadLoad drops the gate when a load refills the window, but a load can end without ever
- // applying: offline, scchatnotinteam, or a response that carries no thread. Left alone the gate
- // would keep dropping notifications for the life of the provider, with no window to correct it.
- const clearWindowGate = React.useEffectEvent((loadID: number) => {
- const s = threadStore.getState()
- if (!s.windowCleared) {
- return
- }
- // An unclaimed gate is released by whoever settles first: nothing claimed it, so there is no
- // reload in flight to protect, and leaving it up would strand the thread.
- if (s.windowGateOwner !== undefined && s.windowGateOwner !== loadID) {
- return
- }
+ const bumpWindowGeneration = React.useEffectEvent(() => {
updateThreadState(d => {
- d.windowCleared = false
- d.windowGateOwner = undefined
+ d.generation += 1
})
})
const messagesClear = React.useEffectEvent(() => {
activeMarkReadEnabledRef.current = false
shownUsernameCache.clear()
updateThreadState(s => {
- s.clearVersion += 1
+ s.generation += 1
s.pendingOutboxToOrdinal.clear()
s.loaded = false
// Mark the gap. A notification landing between here and the reload would otherwise face an
@@ -1005,7 +844,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) =>
// arbitrary one, jumpToRecent the newest page - so nothing arriving first can be placed
// against what is coming.
s.windowCleared = true
- s.windowGateOwner = undefined
s.messageIDToOrdinal.clear()
s.messageMap.clear()
s.messageOrdinals = undefined
@@ -1103,46 +941,25 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) =>
}
)
const [threadActions] = React.useState(() => {
- const impl = (p: LoadMoreMessagesParams) => loadConversationThreadMessages(id, p, threadActions)
- const throttled = throttle(impl, 500)
- // The throttle keeps only the last trailing call, so a centered or jump-to-recent
- // load issued between two other loads would be silently dropped — after
- // loadMessagesCentered already cleared the thread. Run those immediately instead.
- const loadMoreMessages: LoadMoreMessages = Object.assign(
- (p: LoadMoreMessagesParams) => {
- if (p.centeredMessageID || p.messageIDControl || p.reason === 'jump to recent') {
- throttled.cancel()
- impl(p)
- } else {
- throttled(p)
- }
- },
- {
- cancel: () => {
- throttled.cancel()
- },
- }
- )
const threadActions: ConversationThreadActions = {
addMessages,
addOptimisticReaction,
applyThreadLoad,
+ bumpWindowGeneration,
clearUnfurlPrompt,
- claimWindowGate,
- clearWindowGate,
completeAttachmentDownload,
deleteMessages,
explodeMessages,
failAttachmentDownload,
finishAttachmentDownload,
getSnapshot,
- loadMoreMessages,
markThreadAsRead,
messageDelete,
messageReplyPrivately,
messagesClear,
receivePaymentInfo,
receiveRequestInfo,
+ releaseWindowGate,
removeOptimisticReaction,
retryMessage,
setAttachmentMobileSaving,
@@ -1165,11 +982,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) =>
}
return threadActions
})
- React.useEffect(() => {
- return () => {
- threadActions.loadMoreMessages.cancel()
- }
- }, [threadActions])
useThreadEngineListeners(id, threadActions)
return (
@@ -1229,103 +1041,6 @@ export const getConversationThreadDisplayMessage = (
export const useConversationThreadMessage = (ordinal: T.Chat.Ordinal) =>
useConversationThreadSelector(snapshot => getConversationThreadDisplayMessage(snapshot, ordinal))
-export const useConversationThreadLoadMoreMessages = () => useConversationThreadActions().loadMoreMessages
-
-const useConversationThreadMessagesClear = () => useConversationThreadActions().messagesClear
-
-export const useConversationThreadLoadOlderMessagesDueToScroll = () => {
- const threadStore = useConversationThreadStore()
- const loadMoreMessages = useConversationThreadLoadMoreMessages()
- const okToLoadMore = useScrollLoadGate()
-
- const loadOlderMessagesDueToScroll: LoadOlderMessagesDueToScroll = (numOrdinals, options) => {
- if (!threadStore.getState().moreToLoadBack) {
- logger.info('bail: scrolling back and at the end')
- return
- }
-
- if (!numOrdinals) {
- return
- }
-
- if (!okToLoadMore(numOrdinals)) {
- return
- }
-
- loadMoreMessages({
- ...(options ?? {}),
- numberOfMessagesToLoad: numMessagesOnScrollback,
- reason: 'scroll back',
- scrollDirection: 'back',
- })
- }
- return loadOlderMessagesDueToScroll
-}
-
-export const useConversationThreadLoadNewerMessagesDueToScroll = () => {
- const loadMoreMessages = useConversationThreadLoadMoreMessages()
- const okToLoadMore = useScrollLoadGate()
-
- const loadNewerMessagesDueToScroll: LoadNewerMessagesDueToScroll = (numOrdinals, options) => {
- if (!numOrdinals) {
- return
- }
-
- if (!okToLoadMore(numOrdinals)) {
- return
- }
-
- loadMoreMessages({
- ...(options ?? {}),
- numberOfMessagesToLoad: numMessagesOnScrollback,
- reason: 'scroll forward',
- scrollDirection: 'forward',
- })
- }
- return loadNewerMessagesDueToScroll
-}
-
-export const useConversationThreadLoadMessagesCentered = () => {
- const conversationIDKey = useConversationThreadID()
- const loadMoreMessages = useConversationThreadLoadMoreMessages()
- const messagesClear = useConversationThreadMessagesClear()
-
- const loadMessagesCentered: LoadMessagesCentered = (messageID, highlightMode, options) => {
- messagesClear()
- loadMoreMessages({
- centeredMessageID: {
- conversationIDKey,
- highlightMode,
- messageID,
- },
- forceContainsLatestCalc: true,
- messageIDControl: {
- mode: T.RPCChat.MessageIDControlMode.centered,
- num: numMessagesOnInitialLoad,
- pivot: messageID,
- },
- ...(options ?? {}),
- reason: 'centered',
- })
- }
- return loadMessagesCentered
-}
-
-export const useConversationThreadJumpToRecent = () => {
- const {setMarkReadBlocked} = useConversationThreadActions()
- const loadMoreMessages = useConversationThreadLoadMoreMessages()
- const messagesClear = useConversationThreadMessagesClear()
-
- const jumpToRecent: JumpToRecent = options => {
- setMarkReadBlocked(false)
- // The newest window is disjoint from wherever the reader was, so merging the two would leave a
- // gap in the ordinals. Drop the old window first, the way a centered jump does.
- messagesClear()
- loadMoreMessages({...(options ?? {}), reason: 'jump to recent'})
- }
- return jumpToRecent
-}
-
export const useConversationThreadMarkThreadAsRead = () => useConversationThreadActions().markThreadAsRead
export const useConversationThreadSetMarkAsUnread = () => useConversationThreadActions().setMarkAsUnread
@@ -1338,34 +1053,6 @@ export const useConversationThreadMessageActions = () => {
return {messageDelete, messageReplyPrivately, toggleMessageCollapse, toggleMessageReaction, unfurlRemove}
}
-export const useConversationThreadSelectedConversation = () => {
- const conversationIDKey = useConversationThreadID()
- const loadMoreMessages = useConversationThreadLoadMoreMessages()
-
- const selectedConversation: SelectedConversation = (options?: SelectedConversationOptions) => {
- const {skipThreadLoad, ...loadStatusOptions} = options ?? {}
- clearChatTimeCache()
-
- unboxRows([conversationIDKey])
-
- const username = useCurrentUserState.getState().username
- const participantInfo = getInboxConversationParticipants(conversationIDKey) ?? emptyParticipantInfo
- const otherParticipants = Meta.getRowParticipants(participantInfo, username || '')
- if (otherParticipants.length === 1) {
- const otherUsername = otherParticipants[0] || ''
-
- if (otherUsername && !otherUsername.includes('@')) {
- useUsersState.getState().dispatch.getBio(otherUsername)
- }
- }
-
- if (!skipThreadLoad) {
- loadMoreMessages({...loadStatusOptions, reason: 'focused'})
- }
- }
- return selectedConversation
-}
-
export const useConversationThreadToggleSearch = () => {
const conversationIDKey = useConversationThreadID()
return (hide?: boolean, query?: string) => {
diff --git a/shared/chat/conversation/thread-engine.tsx b/shared/chat/conversation/thread-engine.tsx
index d8fed1eaf88f..e47611e5b96d 100644
--- a/shared/chat/conversation/thread-engine.tsx
+++ b/shared/chat/conversation/thread-engine.tsx
@@ -228,6 +228,37 @@ export const applyEphemeralPurgeToThread = (
}
}
+// The stale-thread notifications, kept with the rest of the chat notification listeners rather
+// than in the module that reacts to them. Both mean the same thing - the window we hold may no
+// longer match the service - and the reaction is the caller's: thread-window reloads the newest
+// page.
+export const useThreadStaleReloadListeners = (
+ id: T.Chat.ConversationIDKey,
+ reloadStaleThread: () => void
+): void => {
+ useEngineActionListener('chat.1.NotifyChat.ChatThreadsStale', action => {
+ const hasStaleThread = (action.payload.params.updates ?? []).some(
+ update => T.Chat.conversationIDToKey(update.convID) === id
+ )
+ if (hasStaleThread) {
+ reloadStaleThread()
+ }
+ })
+
+ useEngineActionListener('chat.1.NotifyChat.ChatInboxSynced', action => {
+ const {syncRes} = action.payload.params
+ if (syncRes.syncType !== T.RPCChat.SyncInboxResType.incremental) {
+ return
+ }
+ const hasStaleThread = (syncRes.incremental.items ?? []).some(
+ item => T.Chat.stringToConversationIDKey(item.conv.convID) === id
+ )
+ if (hasStaleThread) {
+ reloadStaleThread()
+ }
+ })
+}
+
export const useThreadEngineListeners = (
id: T.Chat.ConversationIDKey,
threadActions: ConversationThreadActions
diff --git a/shared/chat/conversation/thread-load-status-context.test.tsx b/shared/chat/conversation/thread-load-status-context.test.tsx
deleted file mode 100644
index 797ac75ffebc..000000000000
--- a/shared/chat/conversation/thread-load-status-context.test.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-/** @jest-environment jsdom */
-///
-import {act, cleanup, renderHook} from '@testing-library/react'
-import type * as React from 'react'
-import * as T from '@/constants/types'
-import {notifyEngineActionListeners} from '@/engine/action-listener'
-import {resetAllStores} from '@/util/zustand'
-import {useCurrentUserState} from '@/stores/current-user'
-import {
- ConversationThreadLoadStatusProvider,
- useThreadLoadStatus,
- useThreadLoadStatusOptions,
- useThreadLoadStatusReporter,
-} from './thread-load-status-context'
-import {ConversationThreadProvider} from './thread-context'
-
-const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4]))
-const otherConvID = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8]))
-
-const flushPromises = async () => {
- for (let i = 0; i < 5; i++) {
- await Promise.resolve()
- }
-}
-
-beforeEach(() => {
- jest.spyOn(T.RPCChat, 'localRequestInboxUnboxRpcPromise').mockResolvedValue(undefined)
- useCurrentUserState.getState().dispatch.setBootstrap({
- deviceID: 'device-id',
- deviceName: 'test-device',
- uid: 'uid',
- username: 'alice',
- })
-})
-
-afterEach(() => {
- cleanup()
- jest.restoreAllMocks()
- resetAllStores()
-})
-
-const wrapper = ({children}: {children: React.ReactNode}) => (
-
-
- {children}
-
-
-)
-
-test('thread load status reporter ignores stale conversation statuses', () => {
- const {result} = renderHook(
- () => ({
- report: useThreadLoadStatusReporter(),
- status: useThreadLoadStatus(),
- }),
- {wrapper}
- )
-
- expect(result.current.status).toBe(T.RPCChat.UIChatThreadStatusTyp.none)
-
- act(() => {
- result.current.report(otherConvID, T.RPCChat.UIChatThreadStatusTyp.server)
- })
- expect(result.current.status).toBe(T.RPCChat.UIChatThreadStatusTyp.none)
-
- act(() => {
- result.current.report(convID, T.RPCChat.UIChatThreadStatusTyp.server)
- })
- expect(result.current.status).toBe(T.RPCChat.UIChatThreadStatusTyp.server)
-})
-
-test('thread load options invalidate when the mounted provider unmounts', () => {
- const {result, unmount} = renderHook(() => useThreadLoadStatusOptions(), {wrapper})
- const options = result.current
-
- expect(options.isThreadLoadCurrent?.()).toBe(true)
-
- unmount()
-
- expect(options.isThreadLoadCurrent?.()).toBe(false)
-})
-
-test('mounted stale-thread reload reports status through the provider', async () => {
- jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
- p.incomingCallMap['chat.1.chatUi.chatThreadStatus']?.({
- status: {typ: T.RPCChat.UIChatThreadStatusTyp.server},
- })
- await Promise.resolve()
- return {offline: false}
- })
- const {result} = renderHook(() => useThreadLoadStatus(), {wrapper})
-
- act(() => {
- notifyEngineActionListeners({
- payload: {
- params: {
- uid: '',
- updates: [
- {
- convID: T.Chat.keyToConversationID(convID),
- updateType: T.RPCChat.StaleUpdateType.newactivity,
- },
- ],
- },
- },
- type: 'chat.1.NotifyChat.ChatThreadsStale',
- } as never)
- })
- await act(async () => {
- await flushPromises()
- })
-
- expect(result.current).toBe(T.RPCChat.UIChatThreadStatusTyp.server)
-})
diff --git a/shared/chat/conversation/thread-load-status-context.tsx b/shared/chat/conversation/thread-load-status-context.tsx
deleted file mode 100644
index 6b5475628268..000000000000
--- a/shared/chat/conversation/thread-load-status-context.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-import * as React from 'react'
-import * as T from '@/constants/types'
-import {useEngineActionListener} from '@/engine/action-listener'
-import logger from '@/logger'
-import {
- type ThreadLoadStatusOptions,
- type ThreadLoadStatusReporter,
- useConversationThreadLoadMoreMessages,
- useConversationThreadSelectedConversation,
-} from './thread-context'
-
-type ThreadLoadStatusState = {
- conversationIDKey: T.Chat.ConversationIDKey
- status: T.RPCChat.UIChatThreadStatusTyp
-}
-
-type ThreadLoadStatusOptionsCache = {
- generation: number
- options: ThreadLoadStatusOptions
-}
-
-type ThreadLoadStatusStatusContextType = T.RPCChat.UIChatThreadStatusTyp
-
-type ThreadLoadStatusActionContextType = {
- getThreadLoadStatusOptions: () => ThreadLoadStatusOptions
- onThreadLoadStatus: ThreadLoadStatusReporter
-}
-
-const noThreadLoadStatus = T.RPCChat.UIChatThreadStatusTyp.none
-const ignoreThreadLoadStatus: ThreadLoadStatusReporter = () => {}
-const ignoreThreadLoadStatusOptions = () => ({
- isThreadLoadCurrent: () => true,
- onThreadLoadStatus: ignoreThreadLoadStatus,
-})
-
-const missingThreadLoadStatusContext = () => {
- throw new Error('Missing ConversationThreadLoadStatusProvider in the tree')
-}
-
-const missingThreadLoadStatusOptions = () => {
- missingThreadLoadStatusContext()
- return ignoreThreadLoadStatusOptions()
-}
-
-const ThreadLoadStatusContext = React.createContext(noThreadLoadStatus)
-ThreadLoadStatusContext.displayName = 'ThreadLoadStatusContext'
-
-const ThreadLoadStatusActionContext = React.createContext({
- getThreadLoadStatusOptions: missingThreadLoadStatusOptions,
- onThreadLoadStatus: missingThreadLoadStatusContext,
-})
-ThreadLoadStatusActionContext.displayName = 'ThreadLoadStatusActionContext'
-
-export const useThreadLoadStatus = () => React.useContext(ThreadLoadStatusContext)
-
-export const useThreadLoadStatusReporter = () =>
- React.useContext(ThreadLoadStatusActionContext).onThreadLoadStatus
-
-export const useThreadLoadStatusOptions = () =>
- React.useContext(ThreadLoadStatusActionContext).getThreadLoadStatusOptions()
-
-export const useThreadLoadStatusOptionsGetter = () =>
- React.useContext(ThreadLoadStatusActionContext).getThreadLoadStatusOptions
-
-export const ConversationThreadLoadStatusProvider = (
- p: React.PropsWithChildren<{
- allowMarkReadOnLoad?: boolean
- id: T.Chat.ConversationIDKey
- skipThreadLoadOnSelection?: boolean
- }>
-) => {
- const {allowMarkReadOnLoad = true, children, id, skipThreadLoadOnSelection = false} = p
- const [initialSkipThreadLoadOnSelection] = React.useState(skipThreadLoadOnSelection)
- const loadMoreMessages = useConversationThreadLoadMoreMessages()
- const selectedConversation = useConversationThreadSelectedConversation()
- const currentIDRef = React.useRef(id)
- React.useLayoutEffect(() => {
- currentIDRef.current = id
- }, [id])
- const threadLoadGenerationRef = React.useRef(0)
- const mountedRef = React.useRef(true)
- const threadLoadStatusOptionsRef = React.useRef(undefined)
- const [threadLoadStatusState, setThreadLoadStatusState] = React.useState(() => ({
- conversationIDKey: id,
- status: noThreadLoadStatus,
- }))
- const [threadLoadStatusActions] = React.useState(() => {
- const onThreadLoadStatus: ThreadLoadStatusReporter = (conversationIDKey, status) => {
- if (conversationIDKey !== currentIDRef.current) {
- return
- }
- setThreadLoadStatusState(previous =>
- previous.conversationIDKey === conversationIDKey && previous.status === status
- ? previous
- : {conversationIDKey, status}
- )
- }
-
- const getThreadLoadStatusOptions = (): ThreadLoadStatusOptions => {
- const generation = threadLoadGenerationRef.current
- const cached = threadLoadStatusOptionsRef.current
- if (cached?.generation === generation) {
- return cached.options
- }
- const options = {
- isThreadLoadCurrent: () => mountedRef.current && threadLoadGenerationRef.current === generation,
- onThreadLoadStatus,
- }
- threadLoadStatusOptionsRef.current = {generation, options}
- return options
- }
-
- return {getThreadLoadStatusOptions, onThreadLoadStatus}
- })
- const {getThreadLoadStatusOptions} = threadLoadStatusActions
-
- React.useEffect(() => {
- mountedRef.current = true
- return () => {
- mountedRef.current = false
- }
- }, [])
-
- React.useEffect(() => {
- return () => {
- // Only invalidate generation when the conversation changes. In React StrictMode,
- // effects run twice (mount → cleanup → remount) with the same id — incrementing here
- // would discard the first RPC's callbacks as stale while the daemon deduplicates
- // the second RPC and sends no data.
- if (currentIDRef.current !== id) {
- threadLoadGenerationRef.current += 1
- }
- }
- }, [id])
-
- const status =
- threadLoadStatusState.conversationIDKey === id ? threadLoadStatusState.status : noThreadLoadStatus
-
- const reloadStaleThread = () => {
- loadMoreMessages({
- allowMarkAsRead: allowMarkReadOnLoad,
- ...getThreadLoadStatusOptions(),
- reason: 'got stale',
- })
- }
-
- useEngineActionListener('chat.1.NotifyChat.ChatThreadsStale', action => {
- const hasStaleThread = (action.payload.params.updates ?? []).some(
- update => T.Chat.conversationIDToKey(update.convID) === id
- )
- if (hasStaleThread) {
- reloadStaleThread()
- }
- })
-
- useEngineActionListener('chat.1.NotifyChat.ChatInboxSynced', action => {
- const {syncRes} = action.payload.params
- if (syncRes.syncType !== T.RPCChat.SyncInboxResType.incremental) {
- return
- }
- const hasStaleThread = (syncRes.incremental.items ?? []).some(
- item => T.Chat.stringToConversationIDKey(item.conv.convID) === id
- )
- if (hasStaleThread) {
- reloadStaleThread()
- }
- })
-
- const selectConversation = React.useEffectEvent(() => {
- selectedConversation({
- allowMarkAsRead: allowMarkReadOnLoad,
- ...getThreadLoadStatusOptions(),
- skipThreadLoad: initialSkipThreadLoadOnSelection,
- })
- })
-
- React.useEffect(() => {
- logger.info(
- `ConversationThreadLoadStatusProvider: selecting thread: ${id} skipThreadLoad=${initialSkipThreadLoadOnSelection}`
- )
- selectConversation()
- }, [id, initialSkipThreadLoadOnSelection])
-
- return (
-
- {children}
-
- )
-}
diff --git a/shared/chat/conversation/thread-load.test.tsx b/shared/chat/conversation/thread-load.test.tsx
index 993a38961ce2..0d5ad57c0e3b 100644
--- a/shared/chat/conversation/thread-load.test.tsx
+++ b/shared/chat/conversation/thread-load.test.tsx
@@ -6,20 +6,9 @@ import {
getExplodingModeFromGregorItems,
getLastOrdinalFromSnapshot,
getOrdinalForMessageIDInSnapshot,
- loadConversationThreadMessages,
- maxBackPageReloads,
- numMessagesOnScrollback,
scrollDirectionToPagination,
} from './thread-load'
-import * as ThreadRpc from './thread-rpc'
-import {resetAllStores} from '@/util/zustand'
-import {useCurrentUserState} from '@/stores/current-user'
-import type {ThreadLoadReconcile} from './thread-message-state'
-import type {
- ConversationThreadActions,
- ConversationThreadState,
- LoadMoreMessagesParams,
-} from './thread-context'
+import type {ConversationThreadState} from './thread-context'
const conversationIDKey = T.Chat.stringToConversationIDKey('conv1')
const otherConversationIDKey = T.Chat.stringToConversationIDKey('conv2')
@@ -166,626 +155,3 @@ describe('snapshot helpers', () => {
expect(getOrdinalForMessageIDInSnapshot(snapshot, messageID(7))).toBeNull()
})
})
-
-describe('a back page that adds no ordinals reloads itself', () => {
- const flushPromises = async () => {
- for (let i = 0; i < 200; i++) {
- await Promise.resolve()
- }
- }
-
- // A stand-in for the real store that keeps the one behaviour under test: applying a load adds an
- // ordinal per message EXCEPT the ones addMessagesToThreadState drops, which is `deleted`. A fake
- // that grows unconditionally would make every page look productive and hide the bug; one that
- // never grows would make every page look empty and hide the opposite bug.
- const trackingActions = () => {
- const ordinals = new Set([
- T.Chat.numberToOrdinal(7152),
- T.Chat.numberToOrdinal(7153),
- ])
- const actions = {
- applyThreadLoad: jest.fn((p: {messages: ReadonlyArray}) => {
- for (const m of p.messages) {
- if (m.type !== 'deleted') {
- ordinals.add(m.ordinal)
- }
- }
- }),
- claimWindowGate: jest.fn(),
- clearWindowGate: jest.fn(),
- getSnapshot: () =>
- ({
- liveUpdateVersion: 0,
- loaded: true,
- messageIDToOrdinal: new Map(),
- messageMap: new Map(),
- messageOrdinals: [...ordinals].sort((a, b) => a - b),
- pendingOutboxToOrdinal: new Map(),
- }) as unknown as ConversationThreadState,
- // The reload goes through the action, which in the store is the throttled loadMoreMessages.
- // Standing in the unthrottled call here keeps these tests about the reload chain rather than
- // about lodash timers; the throttle itself is store wiring.
- loadMoreMessages: jest.fn((p: LoadMoreMessagesParams) => {
- loadConversationThreadMessages(conversationIDKey, p, actions)
- }),
- markThreadAsRead: jest.fn(),
- } as unknown as ConversationThreadActions
- return actions
- }
-
- // Hidden placeholders are what a DELETE-superseded message arrives as, and what becomes `deleted`
- // on this side. They carry real message IDs, which is what bounds the reload.
- const tombstones = (from: number, to: number) =>
- Array.from({length: from - to + 1}, (_, i) => ({
- placeholder: {hidden: true, messageID: T.Chat.numberToMessageID(from - i)},
- state: T.RPCChat.MessageUnboxedState.placeholder,
- }))
-
- // hidden: false parses to a `placeholder`, which the thread does render and keep an ordinal for.
- const visible = (from: number, to: number) =>
- Array.from({length: from - to + 1}, (_, i) => ({
- placeholder: {hidden: false, messageID: T.Chat.numberToMessageID(from - i)},
- state: T.RPCChat.MessageUnboxedState.placeholder,
- }))
-
- // Each call walks one page further back, exactly as the service does, until it runs out.
- const mockWalkingBack = (oldestOverall: number) => {
- let next = 7151
- return jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- const from = next
- const to = Math.max(oldestOverall, from - numMessagesOnScrollback + 1)
- next = to - 1
- await Promise.resolve()
- p.onFullThread?.(
- JSON.stringify({messages: tombstones(from, to), pagination: {last: to <= oldestOverall, num: 100}})
- )
- return undefined as never
- })
- }
-
- const loadBack = (actions: ConversationThreadActions) =>
- loadConversationThreadMessages(
- conversationIDKey,
- {numberOfMessagesToLoad: numMessagesOnScrollback, reason: 'scroll back', scrollDirection: 'back'},
- actions
- )
-
- beforeEach(() => {
- useCurrentUserState.getState().dispatch.setBootstrap({
- deviceID: 'device-id',
- deviceName: 'testuser-mac',
- uid: 'uid',
- username: 'testuser',
- })
- })
-
- afterEach(() => {
- jest.restoreAllMocks()
- resetAllStores()
- })
-
- test('keeps paging through a run of tombstones until the pager says it is done', async () => {
- // 7151 down to 6952 is two pages of 100, so one reload after the first call.
- const rpc = mockWalkingBack(6952)
- loadBack(trackingActions())
- await flushPromises()
- expect(rpc).toHaveBeenCalledTimes(2)
- })
-
- test('walks a run of tombstones that ends before the cap', async () => {
- const oldest = 6752
- const rpc = mockWalkingBack(oldest)
- loadBack(trackingActions())
- await flushPromises()
- expect(rpc).toHaveBeenCalledTimes(Math.ceil((7151 - oldest + 1) / numMessagesOnScrollback))
- })
-
- test('stops at the reload cap rather than walking an expunged history', async () => {
- // A channel whose history was largely expunged has far more tombstones than the chain should
- // walk off one gesture. It stops at the cap and hands the thread back; scrolling away and back
- // fires onStartReached again and starts a fresh chain from where this one stopped.
- const rpc = mockWalkingBack(1)
- loadBack(trackingActions())
- await flushPromises()
- expect(rpc).toHaveBeenCalledTimes(maxBackPageReloads + 1)
- })
-
- test('stops if a page fails to reach further back', async () => {
- // A service that keeps handing back the same window must not spin us forever. Progress in
- // message ID is the only thing permitting another attempt.
- const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- await Promise.resolve()
- p.onFullThread?.(
- JSON.stringify({messages: tombstones(7151, 7052), pagination: {last: false, num: 100}})
- )
- return undefined as never
- })
- loadBack(trackingActions())
- await flushPromises()
- expect(rpc).toHaveBeenCalledTimes(2)
- })
-
- test('does not reload when the page actually added ordinals', async () => {
- // Renderable messages, so the store grows and the list will ask for the next page itself.
- const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- await Promise.resolve()
- p.onFullThread?.(
- JSON.stringify({messages: visible(7151, 7052), pagination: {last: false, num: 100}})
- )
- return undefined as never
- })
- loadBack(trackingActions())
- await flushPromises()
- expect(rpc).toHaveBeenCalledTimes(1)
- })
-
- test('reloads when a warm cache delivers the tombstones', async () => {
- // The reported bug's own shape: the conversation is already in local storage, so PullLocalOnly
- // wins and the cached pass carries the page - which is entirely tombstones. Judging only the
- // full pass, or refusing to judge at all once a cached pass arrived, leaves this inert.
- let next = 7151
- const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- const from = next
- const to = Math.max(6952, from - numMessagesOnScrollback + 1)
- next = to - 1
- await Promise.resolve()
- const body = JSON.stringify({
- messages: tombstones(from, to),
- pagination: {last: to <= 6952, num: 100},
- })
- p.onCachedThread?.(body)
- // The full pass is INCREMENTAL once a cached thread has been sent.
- p.onFullThread?.(JSON.stringify({messages: tombstones(to, to), pagination: {last: to <= 6952, num: 100}}))
- return undefined as never
- })
- loadBack(trackingActions())
- await flushPromises()
- expect(rpc).toHaveBeenCalledTimes(2)
- })
-
- test('does not reload after a cached pass already delivered the page', async () => {
- // The normal warm-cache sequence: PullLocalOnly wins, the cached pass carries the whole page,
- // and the full pass that follows is INCREMENTAL - only the messages that changed, every one of
- // them already in the window. On ordinal count alone that is indistinguishable from a page of
- // tombstones, and reloading on it walks the client back through the entire conversation.
- const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- await Promise.resolve()
- p.onCachedThread?.(
- JSON.stringify({messages: visible(7151, 7052), pagination: {last: false, num: 100}})
- )
- p.onFullThread?.(
- JSON.stringify({messages: visible(7052, 7052), pagination: {last: false, num: 100}})
- )
- return undefined as never
- })
- loadBack(trackingActions())
- await flushPromises()
- expect(rpc).toHaveBeenCalledTimes(1)
- })
-
- test('stops when the window is cleared under it', async () => {
- // jump to recent and a centered jump both clear then reload. A chain still walking backwards
- // would prepend pages into a window the reader has just left, producing the disjoint ordinals
- // this whole branch exists to prevent.
- let calls = 0
- const ordinals = new Set([T.Chat.numberToOrdinal(7152)])
- let clearVersion = 0
- const actions = {
- applyThreadLoad: jest.fn(),
- claimWindowGate: jest.fn(),
- getSnapshot: () =>
- ({
- clearVersion,
- liveUpdateVersion: 0,
- loaded: true,
- messageIDToOrdinal: new Map(),
- messageMap: new Map(),
- messageOrdinals: [...ordinals].sort((a, b) => a - b),
- pendingOutboxToOrdinal: new Map(),
- }) as unknown as ConversationThreadState,
- markThreadAsRead: jest.fn(),
- } as unknown as ConversationThreadActions
- const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- calls++
- await Promise.resolve()
- // Someone hits jump-to-recent while the first page is in flight.
- if (calls === 1) {
- clearVersion = 1
- }
- p.onFullThread?.(
- JSON.stringify({messages: tombstones(7151, 7052), pagination: {last: false, num: 100}})
- )
- return undefined as never
- })
- loadBack(actions)
- await flushPromises()
- expect(rpc).toHaveBeenCalledTimes(1)
- })
-
- test('does not reload an initial load', async () => {
- const rpc = mockWalkingBack(6152)
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, trackingActions())
- await flushPromises()
- expect(rpc).toHaveBeenCalledTimes(1)
- })
-})
-
-describe('a load releases the window gate it was issued under', () => {
- const flushPromises = async () => {
- for (let i = 0; i < 200; i++) {
- await Promise.resolve()
- }
- }
-
- // clearVersion is the only thing that separates two loads of the same conversation:
- // isThreadLoadCurrent is keyed on a generation that moves only when the conversation changes or
- // the thread unmounts, so both loads call themselves current.
- const gateActions = (clearVersion: () => number) =>
- ({
- applyThreadLoad: jest.fn(),
- claimWindowGate: jest.fn(),
- clearWindowGate: jest.fn(),
- getSnapshot: () =>
- ({
- clearVersion: clearVersion(),
- liveUpdateVersion: 0,
- loaded: true,
- messageIDToOrdinal: new Map(),
- messageMap: new Map(),
- messageOrdinals: undefined,
- pendingOutboxToOrdinal: new Map(),
- }) as unknown as ConversationThreadState,
- loadMoreMessages: jest.fn(),
- markThreadAsRead: jest.fn(),
- }) as unknown as ConversationThreadActions
-
- beforeEach(() => {
- useCurrentUserState.getState().dispatch.setBootstrap({
- deviceID: 'device-id',
- deviceName: 'testuser-mac',
- uid: 'uid',
- username: 'testuser',
- })
- })
-
- afterEach(() => {
- jest.restoreAllMocks()
- resetAllStores()
- })
-
- test('releases it when the load bails before the rpc is even made', async () => {
- // The clear issues its reload synchronously, so if that reload is the one bailing there is
- // nothing else coming to take the gate down and the thread stops receiving messages for good.
- const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock')
- const actions = gateActions(() => 3)
- loadConversationThreadMessages(
- conversationIDKey,
- {isThreadLoadCurrent: () => false, reason: 'focused'},
- actions
- )
- await flushPromises()
-
- expect(rpc).not.toHaveBeenCalled()
- expect(actions.clearWindowGate).toHaveBeenCalledTimes(1)
- })
-
- test('releases it when the load ends without ever applying', async () => {
- // A response that carries no thread: applyThreadLoad never runs, so nothing else would take the
- // gate down. Left up it drops every notification for the life of the provider.
- jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async () => {
- await Promise.resolve()
- return undefined as never
- })
- const actions = gateActions(() => 3)
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, actions)
- await flushPromises()
-
- expect(actions.clearWindowGate).toHaveBeenCalledTimes(1)
- })
-
- test('does not apply a response that arrives after a clear', async () => {
- // The back page is in flight when the reader taps jump-to-recent: messagesClear empties the
- // window and starts its own load. Applying this one anyway repopulates the window the clear
- // dropped and lowers the gate the new load is relying on, and the two disjoint pages then
- // merge - the stranded-row bug the gate exists to prevent.
- let clearVersion = 3
- jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- await Promise.resolve()
- clearVersion = 4
- p.onFullThread?.(
- JSON.stringify({
- messages: [
- {
- placeholder: {hidden: false, messageID: T.Chat.numberToMessageID(7152)},
- state: T.RPCChat.MessageUnboxedState.placeholder,
- },
- ],
- pagination: {last: false, num: 100},
- })
- )
- return undefined as never
- })
- const actions = gateActions(() => clearVersion)
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, actions)
- await flushPromises()
-
- expect(actions.applyThreadLoad).not.toHaveBeenCalled()
- expect(actions.clearWindowGate).not.toHaveBeenCalled()
- })
-
- test('does not apply a response while another load owns the gate', async () => {
- // Two loads issued after the same clear: the reload the clear started owns the gate, and a
- // ChatThreadsStale reload fired behind it answers first. clearVersion cannot tell them apart -
- // it moved once, for the clear both of them started after. Applying this one would fill the
- // cleared window with the newest page while the owner is still fetching a disjoint region, and
- // the owner's page would then merge into it.
- let claimed = -1
- const actions = {
- applyThreadLoad: jest.fn(),
- claimWindowGate: jest.fn((loadID: number) => {
- claimed = loadID
- }),
- clearWindowGate: jest.fn(),
- getSnapshot: () =>
- ({
- clearVersion: 3,
- liveUpdateVersion: 0,
- loaded: true,
- messageIDToOrdinal: new Map(),
- messageMap: new Map(),
- messageOrdinals: undefined,
- pendingOutboxToOrdinal: new Map(),
- // Someone else got here first.
- windowCleared: true,
- windowGateOwner: claimed + 1,
- }) as unknown as ConversationThreadState,
- loadMoreMessages: jest.fn(),
- markThreadAsRead: jest.fn(),
- } as unknown as ConversationThreadActions
- jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- await Promise.resolve()
- p.onFullThread?.(
- JSON.stringify({
- messages: [
- {
- placeholder: {hidden: false, messageID: T.Chat.numberToMessageID(7152)},
- state: T.RPCChat.MessageUnboxedState.placeholder,
- },
- ],
- pagination: {last: false, num: 100},
- })
- )
- return undefined as never
- })
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, actions)
- await flushPromises()
-
- expect(actions.applyThreadLoad).not.toHaveBeenCalled()
- })
-
- test('applies the response of the load that owns the gate', async () => {
- // The other half: the gate is up and this is the reload that claimed it, so it is the one
- // allowed to refill the window.
- let claimed = -1
- const actions = {
- applyThreadLoad: jest.fn(),
- claimWindowGate: jest.fn((loadID: number) => {
- claimed = loadID
- }),
- clearWindowGate: jest.fn(),
- getSnapshot: () =>
- ({
- clearVersion: 3,
- liveUpdateVersion: 0,
- loaded: true,
- messageIDToOrdinal: new Map(),
- messageMap: new Map(),
- messageOrdinals: undefined,
- pendingOutboxToOrdinal: new Map(),
- windowCleared: true,
- windowGateOwner: claimed,
- }) as unknown as ConversationThreadState,
- loadMoreMessages: jest.fn(),
- markThreadAsRead: jest.fn(),
- } as unknown as ConversationThreadActions
- jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- await Promise.resolve()
- p.onFullThread?.(
- JSON.stringify({
- messages: [
- {
- placeholder: {hidden: false, messageID: T.Chat.numberToMessageID(7152)},
- state: T.RPCChat.MessageUnboxedState.placeholder,
- },
- ],
- pagination: {last: false, num: 100},
- })
- )
- return undefined as never
- })
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, actions)
- await flushPromises()
-
- expect(actions.applyThreadLoad).toHaveBeenCalled()
- })
-
- test('leaves a newer clear’s gate alone', async () => {
- // The load is in flight when the user taps a search result: messagesClear bumps clearVersion and
- // starts its own load. This one must not pull down the gate that one is relying on - the load
- // generation does not move between two loads of the same conversation, so it cannot tell them
- // apart on its own.
- let clearVersion = 3
- jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async () => {
- await Promise.resolve()
- clearVersion = 4
- return undefined as never
- })
- const actions = gateActions(() => clearVersion)
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, actions)
- await flushPromises()
-
- expect(actions.clearWindowGate).not.toHaveBeenCalled()
- })
-})
-describe('only a pass that can account for a whole window reconciles', () => {
- const flushPromises = async () => {
- for (let i = 0; i < 200; i++) {
- await Promise.resolve()
- }
- }
-
- const page = (from: number, to: number) =>
- Array.from({length: from - to + 1}, (_, i) => ({
- placeholder: {hidden: false, messageID: T.Chat.numberToMessageID(from - i)},
- state: T.RPCChat.MessageUnboxedState.placeholder,
- }))
-
- const recordingActions = () =>
- ({
- applyThreadLoad: jest.fn(),
- claimWindowGate: jest.fn(),
- clearWindowGate: jest.fn(),
- getSnapshot: () =>
- ({
- clearVersion: 0,
- liveUpdateVersion: 0,
- loaded: true,
- messageIDToOrdinal: new Map(),
- messageMap: new Map(),
- messageOrdinals: undefined,
- pendingOutboxToOrdinal: new Map(),
- }) as unknown as ConversationThreadState,
- loadMoreMessages: jest.fn(),
- markThreadAsRead: jest.fn(),
- }) as unknown as ConversationThreadActions
-
- const mockPasses = (cached: string, full: string) =>
- jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- await Promise.resolve()
- p.onCachedThread?.(cached)
- p.onFullThread?.(full)
- return undefined as never
- })
-
- // Whether the last pass of a load was the one that reconciles. What gets pruned is the store's
- // business - addMessagesToThreadState fills the carried set itself - so these tests only check
- // which passes are allowed to ask for it; the thread-context suite covers the pruning.
- const prunedOnLastPass = (actions: ConversationThreadActions) => {
- const calls = (actions.applyThreadLoad as unknown as jest.Mock).mock.calls
- return (calls.at(-1)?.[0] as {reconcile?: ThreadLoadReconcile} | undefined)?.reconcile?.prune
- }
-
- beforeEach(() => {
- useCurrentUserState.getState().dispatch.setBootstrap({
- deviceID: 'device-id',
- deviceName: 'testuser-mac',
- uid: 'uid',
- username: 'testuser',
- })
- })
-
- afterEach(() => {
- jest.restoreAllMocks()
- resetAllStores()
- })
-
- test('reconciles on a full pass that followed an empty cached one', async () => {
- // First open after a db nuke: PullLocalOnly finds nothing, but its collector suppresses the miss
- // and a cached pass is sent anyway, carrying no messages. INCREMENTAL against an empty local
- // thread filters nothing out, so the full pass really is the whole window - and only a whole
- // window may prune the stale ordinals a cache repair left behind.
- const actions = recordingActions()
- mockPasses(
- JSON.stringify({messages: null, pagination: {last: false, num: 100}}),
- JSON.stringify({messages: page(7153, 7152), pagination: {last: false, num: 100}})
- )
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, actions)
- await flushPromises()
-
- expect(prunedOnLastPass(actions)).toBe(true)
- })
-
- test('does not reconcile when a cached pass arrived but another load owned the window', async () => {
- // The gate-owner guard is the one guard that can turn a cached pass away and still let the full
- // pass behind it through: the owner drops the gate in between. The service counts that cached
- // pass as sent either way, so the full pass is INCREMENTAL - a handful of changed messages - and
- // a span built from those alone covers every row between them with nothing recorded as present.
- // That is not a stale-row cleanup, it is deleting the thread.
- let claimed = -1
- let ownedByAnother = true
- const actions = {
- applyThreadLoad: jest.fn(),
- claimWindowGate: jest.fn((loadID: number) => {
- claimed = loadID
- }),
- clearWindowGate: jest.fn(),
- getSnapshot: () =>
- ({
- clearVersion: 0,
- liveUpdateVersion: 0,
- loaded: true,
- messageIDToOrdinal: new Map(),
- messageMap: new Map(),
- messageOrdinals: undefined,
- pendingOutboxToOrdinal: new Map(),
- windowCleared: ownedByAnother,
- windowGateOwner: claimed + 1,
- }) as unknown as ConversationThreadState,
- loadMoreMessages: jest.fn(),
- markThreadAsRead: jest.fn(),
- } as unknown as ConversationThreadActions
- jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- await Promise.resolve()
- p.onCachedThread?.(
- JSON.stringify({messages: page(7153, 7052), pagination: {last: false, num: 100}})
- )
- // The load that owned the gate settles here, so the full pass is no longer refused.
- ownedByAnother = false
- p.onFullThread?.(
- JSON.stringify({messages: page(7153, 7150), pagination: {last: false, num: 100}})
- )
- return undefined as never
- })
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, actions)
- await flushPromises()
-
- expect(actions.applyThreadLoad).not.toHaveBeenCalled()
- })
-
- test('does not reconcile when the service never reported a cached pass', async () => {
- // The service records the cached thread as sent before it marshals it, so a failure there
- // leaves the full pass INCREMENTAL against a pass we were never shown. The cached callback
- // firing - with a thread, or with the nil a cold cache sends - is the only sign we get that
- // this did not happen.
- const actions = recordingActions()
- jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
- await Promise.resolve()
- p.onFullThread?.(
- JSON.stringify({messages: page(7153, 7150), pagination: {last: false, num: 100}})
- )
- return undefined as never
- })
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, actions)
- await flushPromises()
-
- expect(prunedOnLastPass(actions)).toBe(false)
- })
-
- test('reconciles on the full pass of a warm-cache load, against both passes', async () => {
- // The warm-cache sequence: the cached pass carries the page and the full pass behind it is
- // INCREMENTAL, only what changed. Neither is a window on its own - but INCREMENTAL walks the
- // authoritative window and omits only what the cached pass already carried unchanged, so the
- // two together are that window, and the range spans both. Judging the full pass alone would
- // give up pruning on every conversation the cache is warm for, which is all of them after the
- // first open.
- const actions = recordingActions()
- mockPasses(
- JSON.stringify({messages: page(7153, 7052), pagination: {last: false, num: 100}}),
- JSON.stringify({messages: page(7153, 7153), pagination: {last: false, num: 100}})
- )
- loadConversationThreadMessages(conversationIDKey, {reason: 'focused'}, actions)
- await flushPromises()
-
- expect(prunedOnLastPass(actions)).toBe(true)
- })
-})
diff --git a/shared/chat/conversation/thread-load.tsx b/shared/chat/conversation/thread-load.tsx
index 1798882eb82f..561dcf805ab6 100644
--- a/shared/chat/conversation/thread-load.tsx
+++ b/shared/chat/conversation/thread-load.tsx
@@ -1,36 +1,16 @@
import * as Common from '@/constants/chat/common'
-import * as Message from '@/constants/chat/message'
import * as Meta from '@/constants/chat/meta'
-import * as Strings from '@/constants/strings'
import * as T from '@/constants/types'
-import {navigateToInbox} from '@/constants/router'
import logger from '@/logger'
import {findLast} from '@/util/arrays'
import {ignorePromise} from '@/constants/utils'
import {RPCError} from '@/util/errors'
-import {persistRoute} from '@/util/storeless-actions'
import {uint8ArrayToString} from '@/util/uint8array'
import {useCurrentUserState} from '@/stores/current-user'
import {useConfigState} from '@/stores/config'
-import {type ThreadLoadReconcile, getOrdinalForMessageID} from './thread-message-state'
-import {getInboxConversationMeta, updateInboxConversationMeta} from '@/chat/inbox/metadata'
-import {loadThreadNonblock, threadLoadReasonToRPCReason} from './thread-rpc'
-import type {
- ConversationThreadActions,
- ConversationThreadState,
- LoadMoreMessagesParams,
- ScrollDirection,
-} from './thread-context'
-
-// Identifies one load, so the window gate can tell two loads of the same conversation apart.
-// Only ever compared for equality, never ordered.
-let nextLoadID = 0
-
-export const numMessagesOnInitialLoad = isMobile ? 20 : 100
-// How far the no-new-ordinals back-page chain will walk on its own before handing the thread back to
-// the reader. See the reload block in loadConversationThreadMessages.
-export const maxBackPageReloads = 10
-export const numMessagesOnScrollback = 100
+import {getOrdinalForMessageID} from './thread-message-state'
+import {getInboxConversationMeta} from '@/chat/inbox/metadata'
+import type {ConversationThreadState, ScrollDirection} from './thread-context'
const ignoreErrors = [
T.RPCGen.StatusCode.scgenericapierror,
@@ -160,319 +140,3 @@ export const scrollDirectionToPagination = (
}
return pagination
}
-
-export const loadConversationThreadMessages = (
- conversationIDKey: T.Chat.ConversationIDKey,
- p: LoadMoreMessagesParams,
- actions: ConversationThreadActions
-) => {
- if (!T.Chat.isValidConversationIDKey(conversationIDKey)) {
- return
- }
- const {
- scrollDirection = 'none',
- numberOfMessagesToLoad = numMessagesOnInitialLoad,
- retryBelowMessageID,
- retryCount = 0,
- } = p
- const {
- allowMarkAsRead = true,
- reason,
- forceContainsLatestCalc,
- messageIDControl,
- knownRemotes,
- centeredMessageID,
- isThreadLoadCurrent,
- onThreadLoadStatus,
- } = p
- const isCurrentThreadLoad = () => isThreadLoadCurrent?.() ?? true
-
- const f = async () => {
- const loadStartedSnapshot = actions.getSnapshot()
- const clearVersionAtLoadStart = loadStartedSnapshot.clearVersion
- // applyThreadLoad drops the window gate when a load refills the window, but a load can end
- // without ever applying: offline, scchatnotinteam, a response carrying no thread, or a bail
- // before the RPC is even made. Left alone the gate would keep dropping notifications for the
- // life of the provider, which is a thread that silently stops receiving messages.
- //
- // Keyed on clearVersion, not on isThreadLoadCurrent: the load generation only moves when the
- // conversation changes or the thread unmounts, so two loads of the same conversation both call
- // themselves current. A load that started before the clear would otherwise pull down the gate
- // belonging to the load that started after it, while that one is still in flight.
- //
- // clearVersion alone still cannot separate two loads issued after the same clear, so the gate
- // is also owned: first claim wins, and only the owner may drop it. Claimed here, before the
- // first await, rather than when a response arrives - both clear paths bypass the load throttle
- // (see loadMoreMessages in thread-context) and call in synchronously, so the reload the clear
- // issued is always the first to get here, and a load that ends without ever applying still has
- // to be the one that releases.
- const loadID = nextLoadID++
- actions.claimWindowGate(loadID)
- const releaseWindowGate = () => {
- if (actions.getSnapshot().clearVersion === clearVersionAtLoadStart) {
- actions.clearWindowGate(loadID)
- }
- }
- // Every bail from here on releases, including the two that used to sit above the claim: the
- // clear issues its reload synchronously, so if that reload is the one bailing there is nothing
- // else coming to take the gate down, and the thread stops receiving messages for good.
- if (!isCurrentThreadLoad()) {
- logger.info('loadMoreMessages: bail: stale mounted thread load')
- releaseWindowGate()
- return
- }
-
- if (!conversationIDKey || !T.Chat.isValidConversationIDKey(conversationIDKey)) {
- logger.info('loadMoreMessages: bail: no conversationIDKey')
- releaseWindowGate()
- return
- }
-
- const currentMeta = getMeta(conversationIDKey)
- if (currentMeta.membershipType === 'youAreReset' || currentMeta.rekeyers.size > 0) {
- logger.info('loadMoreMessages: bail: we are reset')
- releaseWindowGate()
- return
- }
- const loadStartedLiveUpdateVersion = loadStartedSnapshot.liveUpdateVersion
- const protectLoadedFocusRefresh =
- loadStartedSnapshot.loaded &&
- scrollDirection === 'none' &&
- !centeredMessageID &&
- !messageIDControl &&
- (reason === 'focused' || reason === 'tab selected')
- logger.info(
- `loadMoreMessages: calling rpc convo: ${conversationIDKey} num: ${numberOfMessagesToLoad} reason: ${reason}`
- )
-
- const loadingKey = Strings.waitingKeyChatThreadLoad(conversationIDKey)
- // What this load has put in the window, filled in by addMessagesToThreadState as each pass
- // applies. Once the service has sent a cached thread it switches the full response to
- // INCREMENTAL, which walks the authoritative window and sends only the messages that cached
- // pass did not already carry unchanged (mergeLocalRemoteThread in go/chat/uithreadloader.go,
- // where localSentThread is that exact pass). Neither pass is a whole window on its own, so the
- // two are gathered here and the last one reconciles against the both of them.
- const carried = new Set()
- // A load is all or nothing. Once one of its passes is turned away, the rest of them are too:
- // the service filters each pass against what it has already sent this load, so the ones that
- // follow a refused pass are a subset of a window we never took, and both ways of using them
- // are wrong. Merging one into whatever refilled the window in the meantime is the disjoint
- // window this whole invariant exists to prevent; reconciling against one takes out every row
- // between the few messages it happens to carry.
- let refusedAPass = false
- // Whether the service's cached goroutine reported at all - with a thread, or with the nil it
- // sends when the local cache had nothing. It is the only evidence the client gets that the
- // full pass was not filtered behind our back: the service records the cached thread as sent
- // before it marshals it, so a marshal failure there leaves us with an INCREMENTAL full pass
- // and no sign of the pass it was filtered against (LoadNonblock in
- // go/chat/uithreadloader.go). No report, no reconciling.
- let sawCachedReport = false
- // The reload below is judged against the whole load, not one pass of it. A warm-cache load
- // delivers the page on the cached pass and then an INCREMENTAL full pass carrying only what
- // changed, so measuring the full pass alone says "added nothing" for a perfectly good page.
- // Measuring from before either pass tells the two apart: a page of real messages moves this,
- // a page of tombstones does not, wherever it arrived.
- const floorAtLoadStart = loadStartedSnapshot.messageOrdinals?.[0]
- let oldestSeenThisLoad = Number.MAX_SAFE_INTEGER as T.Chat.MessageID
- const onGotThread = (thread: string, why: string) => {
- if (!thread) {
- return
- }
- if (refusedAPass) {
- logger.info(`loadMoreMessages: pass ignored, an earlier one of this load was: ${why}`)
- return
- }
- const refuse = (msg: string) => {
- refusedAPass = true
- logger.info(msg)
- }
- if (!isCurrentThreadLoad()) {
- refuse(`loadMoreMessages: stale response ignored: ${why}`)
- return
- }
- // A clear under us - jump to recent, a centered jump - dropped the window this load was
- // paging against, and the reload that follows fetches a disjoint region. isCurrentThreadLoad
- // does not catch it: the load generation only moves when the conversation changes or the
- // thread unmounts, so a load that started before the clear still calls itself current.
- // Applying it anyway would repopulate the cleared window and lower the gate belonging to the
- // reload, which then merges its own page into the leftovers.
- const snapshotAtResponse = actions.getSnapshot()
- if (snapshotAtResponse.clearVersion !== clearVersionAtLoadStart) {
- refuse(`loadMoreMessages: response ignored after clear: ${why}`)
- return
- }
- // clearVersion cannot separate two loads issued after the same clear, and the second one is
- // not hypothetical: a ChatThreadsStale or ChatInboxSynced reload fires with scrollDirection
- // 'none' and fetches the newest page, not the region the clear asked for. If it answers
- // first it would fill the cleared window with that disjoint page and drop the gate, and the
- // reload the clear issued would then merge its own page into the leftovers - exactly the
- // ordinal gap the gate exists to prevent. While the gate is up only its owner may refill the
- // window; once the owner settles the gate is down and everyone applies normally again.
- if (
- snapshotAtResponse.windowCleared &&
- snapshotAtResponse.windowGateOwner !== undefined &&
- snapshotAtResponse.windowGateOwner !== loadID
- ) {
- refuse(`loadMoreMessages: response ignored, another load owns the window: ${why}`)
- return
- }
- if (protectLoadedFocusRefresh && snapshotAtResponse.liveUpdateVersion !== loadStartedLiveUpdateVersion) {
- refuse(
- `loadMoreMessages: stale response ignored after live update: ${why} reason=${reason} convID=${conversationIDKey}`
- )
- return
- }
-
- const {username, devicename} = getCurrentUser()
- const {messages, pagination} = Message.parseUIMessagesJSON(
- conversationIDKey,
- thread,
- username,
- devicename,
- () => getLastOrdinalFromSnapshot(actions.getSnapshot())
- )
- const moreToLoad = pagination ? !pagination.last : true
- const canMarkReadForThreadWindow =
- allowMarkAsRead &&
- !centeredMessageID &&
- !messageIDControl &&
- scrollDirection !== 'back' &&
- reason !== 'findNewestConversation' &&
- reason !== 'findNewestConversationFromLayout'
- // Reconciling is only safe against a whole window, and a single pass is not one: the cached
- // pass is whatever the local cache holds, gaps included, and the full pass behind it carries
- // only what changed. The full pass is the last one, so it is the one that prunes - against
- // everything both passes delivered. Waiting instead for a pass with no cached one before it
- // would leave the stale-row cleanup running on cold caches only, which is where ghost rows
- // are least likely to be: a reopened conversation is warm every time.
- const reconcile: ThreadLoadReconcile | undefined =
- scrollDirection === 'none' ? {carried, prune: why === 'full' && sawCachedReport} : undefined
- for (const m of messages) {
- if (m.id > 0 && m.id < oldestSeenThisLoad) {
- oldestSeenThisLoad = m.id
- }
- }
- actions.applyThreadLoad({
- centered: !!centeredMessageID,
- disableActiveMarkRead: !allowMarkAsRead || !!centeredMessageID || !!messageIDControl,
- enableActiveMarkRead: canMarkReadForThreadWindow,
- forceContainsLatestCalc,
- messages,
- moreToLoad,
- reconcile,
- scrollDirection,
- })
- const after = actions.getSnapshot()
- // A back page can be composed entirely of messages the thread will never render: a message
- // superseded by a DELETE arrives as a hidden placeholder, becomes `deleted`, and addMessages
- // drops it. The ordinal list is then identical to what it was, so the list never fires
- // onStartReached again and scrollback stops even though the pager says there is more. Ask for
- // the next page ourselves.
- //
- // The tombstones still carry message IDs, and each page reaches further back than the last,
- // so requiring strict progress terminates: message IDs are finite and only ever decrease
- // here. Strict progress alone is a weak bound though - a channel whose history was largely
- // expunged has tens of thousands of them, which is minutes of paging off one gesture - so the
- // chain also stops after maxBackPageReloads. Stopping is safe: the reader is still pinned at
- // the top with an unchanged list, and scrolling away and back fires onStartReached again,
- // which starts a fresh chain from wherever this one left off.
- const floorAfter = after.messageOrdinals?.[0]
- const windowGrewDownward =
- floorAfter !== undefined && (floorAtLoadStart === undefined || floorAfter < floorAtLoadStart)
- if (
- scrollDirection === 'back' &&
- // The full pass is the last one of a load, so by here the whole load has been applied.
- why === 'full' &&
- moreToLoad &&
- // The floor, not the count: a page can add real messages while its `deleted` entries
- // remove more from the window, which nets negative on a count but is real progress.
- !windowGrewDownward &&
- oldestSeenThisLoad < (retryBelowMessageID ?? Number.MAX_SAFE_INTEGER) &&
- retryCount < maxBackPageReloads
- ) {
- logger.info(
- `loadMoreMessages: back page added no ordinals, reloading below ${oldestSeenThisLoad} (${
- retryCount + 1
- }/${maxBackPageReloads}): convID: ${conversationIDKey}`
- )
- // Through the action, not loadConversationThreadMessages directly: the action carries the
- // 500ms throttle and the unmount cancel(), and a long run of tombstones would otherwise
- // issue these back to back with no pacing. The throttle only ever drops a call that a
- // later load supersedes, and that load extends the window or retries in turn.
- //
- // The delay has a cost: the next page comes from a cursor the daemon holds, not one we
- // send. pgmode is SERVER (see thread-rpc), so `next` resolves against convPageStatus in the
- // service, and any first-page request resets it (applyPagerModeOutgoing in
- // go/chat/uithreadloader.go) - which every scrollDirection 'none' load is, stale and focus
- // reloads included. One landing inside the throttle window makes this retry fetch near the
- // top of the thread instead of the next page back. It fails closed rather than looping:
- // oldestSeenThisLoad is then no lower than retryBelowMessageID, so the chain stops and the
- // reader is left where another scroll gesture starts a fresh one.
- //
- // Sizing, for the same reason the chain is bounded at all: a full run is 11 sequential
- // 100-message RPCs off one gesture, several seconds of paging with nothing visible moving.
- actions.loadMoreMessages({
- ...p,
- retryBelowMessageID: oldestSeenThisLoad,
- retryCount: retryCount + 1,
- })
- }
-
- if (canMarkReadForThreadWindow) {
- actions.markThreadAsRead()
- }
- }
-
- const pagination = messageIDControl
- ? null
- : scrollDirectionToPagination(scrollDirection, numberOfMessagesToLoad)
- try {
- const results = await loadThreadNonblock({
- conversationIDKey,
- knownRemotes,
- messageIDControl,
- onCachedThread: thread => {
- sawCachedReport = true
- onGotThread(thread, 'cached')
- },
- onFullThread: thread => onGotThread(thread, 'full'),
- onThreadStatus: status => {
- logger.info(
- `loadMoreMessages: thread status received: convID: ${conversationIDKey} typ: ${status.typ}`
- )
- if (isCurrentThreadLoad()) {
- onThreadLoadStatus?.(conversationIDKey, status.typ)
- }
- },
- pagination,
- reason: threadLoadReasonToRPCReason(reason),
- waitingKey: loadingKey,
- })
- if (!isCurrentThreadLoad()) {
- return
- }
- updateInboxConversationMeta(conversationIDKey, {offline: results.offline})
- } catch (error) {
- if (!isCurrentThreadLoad()) {
- return
- }
- if (error instanceof RPCError) {
- logger.warn(`loadMoreMessages: error: ${error.desc}`)
- if (error.code === T.RPCGen.StatusCode.scchatnotinteam) {
- // We're no longer in this conv's team. Clear the persisted last-route
- // (ui.routeState2) so app startup doesn't keep restoring and reloading
- // this conv, which would re-trigger this error on every launch.
- persistRoute(true, true, () => useConfigState.getState().startup.loaded)
- navigateToInbox(true, 'maybeKickedFromTeam')
- }
- if (error.code !== T.RPCGen.StatusCode.scteamreaderror) {
- throw error
- }
- }
- } finally {
- releaseWindowGate()
- }
- }
-
- ignorePromise(f())
-}
diff --git a/shared/chat/conversation/thread-window.test.tsx b/shared/chat/conversation/thread-window.test.tsx
new file mode 100644
index 000000000000..4d7725e6a4e3
--- /dev/null
+++ b/shared/chat/conversation/thread-window.test.tsx
@@ -0,0 +1,1726 @@
+/** @jest-environment jsdom */
+///
+import * as Common from '@/constants/chat/common'
+import * as Message from '@/constants/chat/message'
+import * as Meta from '@/constants/chat/meta'
+import * as T from '@/constants/types'
+import * as ThreadRpc from './thread-rpc'
+import HiddenString from '@/util/hidden-string'
+import {act, cleanup, renderHook} from '@testing-library/react'
+import type * as React from 'react'
+import {metasReceived} from '@/chat/inbox/metadata'
+import {notifyEngineActionListeners} from '@/engine/action-listener'
+import {resetAllStores} from '@/util/zustand'
+import {useConfigState} from '@/stores/config'
+import {useCurrentUserState} from '@/stores/current-user'
+import {useShellState} from '@/stores/shell'
+import {
+ ConversationThreadProvider,
+ type ConversationThreadActions,
+ type ConversationThreadState,
+ useConversationThreadActions,
+ useConversationThreadMarkThreadAsRead,
+ useConversationThreadMessage,
+ useConversationThreadSelector,
+ useConversationThreadStore,
+} from './thread-context'
+import {
+ ConversationThreadWindowProvider,
+ maxBackPageReloads,
+ numMessagesOnScrollback,
+ runThreadWindowLoad,
+ useRequestWindow,
+ useThreadLoadStatus,
+ useThreadWindow,
+} from './thread-window'
+
+const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4]))
+const otherConvID = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8]))
+
+const flushPromises = async () => {
+ for (let i = 0; i < 200; i++) {
+ await Promise.resolve()
+ }
+}
+
+const textAt = (n: number) =>
+ Message.makeMessageText({
+ author: 'alice',
+ conversationIDKey: convID,
+ id: T.Chat.numberToMessageID(n),
+ ordinal: T.Chat.numberToOrdinal(n),
+ text: new HiddenString(`message ${n}`),
+ timestamp: 100,
+ })
+
+const makeTextMessage = () =>
+ Message.makeMessageText({
+ author: 'alice',
+ conversationIDKey: convID,
+ id: T.Chat.numberToMessageID(301),
+ ordinal: T.Chat.numberToOrdinal(301),
+ outboxID: T.Chat.stringToOutboxID('outbox-1'),
+ text: new HiddenString('stale message'),
+ timestamp: 100,
+ })
+
+const makeValidTextUIMessage = (
+ serverMsgID: T.Chat.MessageID,
+ text: string,
+ outboxID = ''
+): T.RPCChat.UIMessage => ({
+ state: T.RPCChat.MessageUnboxedState.valid,
+ valid: {
+ atMentions: null,
+ bodySummary: text,
+ botUsername: '',
+ channelMention: T.RPCChat.ChannelMention.none,
+ channelNameMentions: null,
+ ctime: 200,
+ decoratedTextBody: null,
+ etime: 0,
+ explodedBy: null,
+ hasPairwiseMacs: false,
+ isCollapsed: false,
+ isDeleteable: true,
+ isEditable: true,
+ isEphemeral: false,
+ isEphemeralExpired: false,
+ messageBody: {
+ messageType: T.RPCChat.MessageType.text,
+ text: {
+ body: text,
+ payments: null,
+ replyTo: null,
+ replyToUID: null,
+ teamMentions: null,
+ userMentions: null,
+ },
+ },
+ messageID: T.Chat.messageIDToNumber(serverMsgID),
+ outboxID,
+ paymentInfos: null,
+ pinnedMessageID: null,
+ reactions: {},
+ replyTo: null,
+ requestInfo: null,
+ senderDeviceID: new Uint8Array([1]),
+ senderDeviceName: 'bob-device',
+ senderDeviceRevokedAt: null,
+ senderDeviceType: 'desktop',
+ senderUID: new Uint8Array([2]),
+ senderUsername: 'bob',
+ superseded: false,
+ unfurls: null,
+ },
+})
+
+const threadJSON = (msgIDs: ReadonlyArray, last = true) =>
+ JSON.stringify({
+ messages: msgIDs.map(id => makeValidTextUIMessage(id, `m${id}`)),
+ pagination: {last, next: '', num: 100, previous: ''},
+ })
+
+type WindowProviderProps = {
+ allowMarkReadOnLoad?: boolean
+ skipThreadLoadOnSelection?: boolean
+}
+
+const makeWrapper = (p: WindowProviderProps = {}) => {
+ const {allowMarkReadOnLoad = true, skipThreadLoadOnSelection = true} = p
+ return function Wrapper({children}: {children: React.ReactNode}) {
+ return (
+
+
+ {children}
+
+
+ )
+ }
+}
+
+// The whole public surface of the module, in one hook, so a test reads as "ask for a window, then
+// look at the window".
+const useHarness = () => ({
+ actions: useConversationThreadActions(),
+ markThreadAsRead: useConversationThreadMarkThreadAsRead(),
+ requestWindow: useRequestWindow(),
+ status: useThreadLoadStatus(),
+ window: useThreadWindow(),
+})
+
+const renderWindow = (p: WindowProviderProps = {}) => renderHook(useHarness, {wrapper: makeWrapper(p)})
+
+beforeEach(() => {
+ jest.spyOn(T.RPCChat, 'localRequestInboxUnboxRpcPromise').mockResolvedValue(undefined)
+ useCurrentUserState.getState().dispatch.setBootstrap({
+ deviceID: 'device-id',
+ deviceName: 'test-device',
+ uid: 'uid',
+ username: 'alice',
+ })
+ metasReceived(
+ [{...Meta.makeConversationMeta(), conversationIDKey: convID, readMsgID: T.Chat.numberToMessageID(0)}],
+ undefined,
+ {force: true}
+ )
+})
+
+afterEach(() => {
+ cleanup()
+ jest.restoreAllMocks()
+ resetAllStores()
+})
+
+describe('requestWindow turns a place in the thread into a load', () => {
+ test('a centered anchor drops the window it replaces and pivots the rpc on the message', async () => {
+ const loadThread = jest
+ .spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener')
+ .mockResolvedValue({offline: false})
+ const {result} = renderHook(
+ () => ({...useHarness(), staleMessage: useConversationThreadMessage(T.Chat.numberToOrdinal(301))}),
+ {wrapper: makeWrapper()}
+ )
+
+ act(() => {
+ result.current.actions.addMessages([makeTextMessage()])
+ })
+ expect(result.current.staleMessage?.id).toBe(T.Chat.numberToMessageID(301))
+
+ act(() => {
+ result.current.requestWindow({
+ anchor: {centeredOn: T.Chat.numberToMessageID(999)},
+ reason: 'centered',
+ })
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.staleMessage).toBeUndefined()
+ expect(result.current.window.ordinals).toEqual([])
+ expect(loadThread).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: expect.objectContaining({
+ query: expect.objectContaining({
+ messageIDControl: expect.objectContaining({
+ mode: T.RPCChat.MessageIDControlMode.centered,
+ pivot: T.Chat.numberToMessageID(999),
+ }),
+ }),
+ }),
+ })
+ )
+ })
+
+ test('jump to recent reloads the newest page, reports status and marks the thread read', async () => {
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
+ const markAsRead = jest
+ .spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise')
+ .mockResolvedValue({offline: false})
+ const msgID = T.Chat.numberToMessageID(202)
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadStatus']?.({
+ status: {typ: T.RPCChat.UIChatThreadStatusTyp.server},
+ })
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({thread: threadJSON([msgID])})
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow()
+
+ act(() => {
+ result.current.requestWindow({anchor: 'newest', reason: 'jump to recent'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.status).toBe(T.RPCChat.UIChatThreadStatusTyp.server)
+ expect(markAsRead).toHaveBeenCalledWith({
+ conversationID: T.Chat.keyToConversationID(convID),
+ forceUnread: false,
+ msgID,
+ })
+ })
+
+ test('jump to recent drops the old window instead of merging a disjoint one into it', async () => {
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
+ jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
+ thread: threadJSON([T.Chat.numberToMessageID(9001)]),
+ })
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow()
+
+ // The reader is deep in old history.
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: [textAt(101), textAt(102)],
+ moreToLoad: true,
+ scrollDirection: 'none',
+ })
+ })
+
+ act(() => {
+ result.current.requestWindow({anchor: 'newest', reason: 'jump to recent'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ // Only the newest window survives. If the old one were merged in, ordinals would read
+ // [101, 102, 9001] with an 8899-wide hole.
+ expect(result.current.window.ordinals).toEqual([T.Chat.numberToOrdinal(9001)])
+ })
+
+ test('an older anchor is refused once the window reaches the oldest message', async () => {
+ const rpc = jest
+ .spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener')
+ .mockResolvedValue({offline: false})
+ const {result} = renderWindow()
+
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: [textAt(101)],
+ moreToLoad: false,
+ scrollDirection: 'back',
+ })
+ })
+ act(() => {
+ result.current.requestWindow({anchor: 'older', reason: 'scroll back'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(rpc).not.toHaveBeenCalled()
+ })
+
+ test('a newer anchor is refused once the window reaches the latest message', async () => {
+ const rpc = jest
+ .spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener')
+ .mockResolvedValue({offline: false})
+ const {result} = renderWindow()
+
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: [textAt(101)],
+ moreToLoad: false,
+ scrollDirection: 'none',
+ })
+ })
+ expect(result.current.window.moreToLoadForward).toBe(false)
+ act(() => {
+ result.current.requestWindow({anchor: 'newer', reason: 'scroll forward'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(rpc).not.toHaveBeenCalled()
+ })
+
+ test('scrollback loads older messages without marking the thread read', async () => {
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
+ thread: threadJSON([T.Chat.numberToMessageID(201)], false),
+ })
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const markAsRead = jest
+ .spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise')
+ .mockResolvedValue({offline: false})
+ const {result} = renderWindow()
+
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: [makeTextMessage()],
+ moreToLoad: true,
+ scrollDirection: 'back',
+ })
+ })
+ act(() => {
+ result.current.requestWindow({anchor: 'older', reason: 'scroll back'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(markAsRead).not.toHaveBeenCalled()
+ })
+
+ test('a provider that disallows mark read on load does not arm active or explicit mark read', async () => {
+ useConfigState.setState({loggedIn: true})
+ useShellState.getState().dispatch.setActive(false)
+ jest
+ .spyOn(Common, 'isUserActivelyLookingAtThisThread')
+ .mockImplementation(() => useShellState.getState().active)
+ const markAsRead = jest
+ .spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise')
+ .mockResolvedValue({offline: false})
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
+ thread: threadJSON([T.Chat.numberToMessageID(203)]),
+ })
+ await Promise.resolve()
+ return {offline: false}
+ })
+ // The mount-time selection load is the one allowMarkReadOnLoad governs.
+ const {result} = renderWindow({allowMarkReadOnLoad: false, skipThreadLoadOnSelection: false})
+
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(markAsRead).not.toHaveBeenCalled()
+
+ act(() => {
+ useShellState.getState().dispatch.setActive(true)
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(markAsRead).not.toHaveBeenCalled()
+
+ act(() => {
+ result.current.markThreadAsRead()
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(markAsRead).not.toHaveBeenCalled()
+ })
+
+ test('the mount-time selection load can be skipped for a thread about to be centered', async () => {
+ const rpc = jest
+ .spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener')
+ .mockResolvedValue({offline: false})
+ renderWindow({skipThreadLoadOnSelection: true})
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).not.toHaveBeenCalled()
+
+ cleanup()
+ renderWindow({skipThreadLoadOnSelection: false})
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(1)
+ })
+})
+
+// The full jump -> scroll-to-bottom -> stale-reload chain behind normal/container.tsx's
+// allowMarkReadOnLoad. Jumping to a highlighted message mounts the thread with
+// skipThreadLoadOnSelection (the centered load replaces the select-on-mount load) and blocks
+// mark-read. The block is NOT permanent: applyThreadLoad releases it as soon as the user scrolls
+// to the latest message ('forward' with no moreToLoad). The stale reload that follows -
+// ChatThreadsStale fires on every mobile background -> foreground - must then be free to mark the
+// thread read. The stale reload reads allowMarkReadOnLoad through useEffectEvent, i.e. the latest
+// render's value, so a caller that derived it from the one-shot highlight and froze it at `false`
+// would leave the conversation badged unread for as long as the thread stayed mounted.
+const staleThreadUpdate = {
+ payload: {
+ params: {
+ uid: '',
+ updates: [
+ {convID: T.Chat.keyToConversationID(convID), updateType: T.RPCChat.StaleUpdateType.newactivity},
+ ],
+ },
+ },
+ type: 'chat.1.NotifyChat.ChatThreadsStale',
+} as never
+
+describe('a stale thread reloads the newest page', () => {
+ const renderJumpedThenScrolledToBottom = (allowMarkReadOnLoad: boolean) => {
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
+ const markAsRead = jest
+ .spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise')
+ .mockResolvedValue({offline: false})
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
+ thread: threadJSON([T.Chat.numberToMessageID(203)]),
+ })
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow({allowMarkReadOnLoad, skipThreadLoadOnSelection: true})
+ // jumping to a highlighted message blocks mark-read
+ act(() => {
+ result.current.actions.setMarkReadBlocked(true)
+ })
+ // ...then the user scrolls all the way forward to the latest message, releasing the block
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: [makeTextMessage()],
+ moreToLoad: false,
+ scrollDirection: 'forward',
+ })
+ })
+ return markAsRead
+ }
+
+ test('a stale reload after a jump and a scroll to the bottom marks the thread read', async () => {
+ const markAsRead = renderJumpedThenScrolledToBottom(true)
+
+ act(() => {
+ notifyEngineActionListeners(staleThreadUpdate)
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(markAsRead).toHaveBeenCalledTimes(1)
+ })
+
+ // The counterfactual: exactly what deriving allowMarkReadOnLoad from the one-shot highlight did.
+ test('a stale reload that disallows mark read leaves the thread unread even once the block is gone', async () => {
+ const markAsRead = renderJumpedThenScrolledToBottom(false)
+
+ act(() => {
+ notifyEngineActionListeners(staleThreadUpdate)
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(markAsRead).not.toHaveBeenCalled()
+ })
+
+ test('a stale reload reports its thread status through the provider', async () => {
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadStatus']?.({
+ status: {typ: T.RPCChat.UIChatThreadStatusTyp.server},
+ })
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow()
+
+ act(() => {
+ notifyEngineActionListeners(staleThreadUpdate)
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.status).toBe(T.RPCChat.UIChatThreadStatusTyp.server)
+ })
+
+ test('a stale thread notification for another conversation is ignored', async () => {
+ const rpc = jest
+ .spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener')
+ .mockResolvedValue({offline: false})
+ renderWindow()
+
+ act(() => {
+ notifyEngineActionListeners({
+ payload: {
+ params: {
+ uid: '',
+ updates: [
+ {
+ convID: T.Chat.keyToConversationID(otherConvID),
+ updateType: T.RPCChat.StaleUpdateType.newactivity,
+ },
+ ],
+ },
+ },
+ type: 'chat.1.NotifyChat.ChatThreadsStale',
+ } as never)
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(rpc).not.toHaveBeenCalled()
+ })
+
+ test('an incremental inbox sync carrying this conversation reloads it too', async () => {
+ const rpc = jest
+ .spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener')
+ .mockResolvedValue({offline: false})
+ renderWindow()
+
+ act(() => {
+ notifyEngineActionListeners({
+ payload: {
+ params: {
+ syncRes: {
+ incremental: {
+ items: [{conv: {convID: T.Chat.conversationIDKeyToString(convID)}}],
+ },
+ syncType: T.RPCChat.SyncInboxResType.incremental,
+ },
+ },
+ },
+ type: 'chat.1.NotifyChat.ChatInboxSynced',
+ } as never)
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(rpc).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('a response may only become the window it was fetched against', () => {
+ test('a refreshed window does not overwrite reaction updates streamed into it', async () => {
+ const targetMsgID = T.Chat.numberToMessageID(301)
+ const targetOrdinal = T.Chat.numberToOrdinal(301)
+ let incomingCallMap:
+ | Parameters[0]['incomingCallMap']
+ | undefined
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ incomingCallMap = p.incomingCallMap
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderHook(
+ () => ({...useHarness(), message: useConversationThreadMessage(targetOrdinal)}),
+ {wrapper: makeWrapper()}
+ )
+
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: true,
+ messages: [makeTextMessage()],
+ moreToLoad: false,
+ scrollDirection: 'none',
+ })
+ })
+
+ act(() => {
+ result.current.requestWindow({anchor: 'newest', reason: 'tab selected'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(incomingCallMap).toBeDefined()
+
+ act(() => {
+ notifyEngineActionListeners({
+ payload: {
+ params: {
+ activity: {
+ activityType: T.RPCChat.ChatActivityType.reactionUpdate,
+ reactionUpdate: {
+ convID: T.Chat.keyToConversationID(convID),
+ reactionUpdates: [
+ {
+ reactions: {
+ reactions: {
+ ':+1:': {
+ decorated: ':+1:',
+ users: {
+ alice: {
+ ctime: 300,
+ reactionMsgID: T.Chat.messageIDToNumber(T.Chat.numberToMessageID(99)),
+ },
+ },
+ },
+ },
+ },
+ targetMsgID: T.Chat.messageIDToNumber(targetMsgID),
+ },
+ ],
+ userReacjis: {skinTone: T.RPCGen.ReacjiSkinTone.none, topReacjis: null},
+ },
+ },
+ },
+ },
+ type: 'chat.1.NotifyChat.NewChatActivity',
+ } as never)
+ })
+
+ expect(result.current.message?.reactions?.get(':+1:')?.users.map(u => u.username)).toEqual(['alice'])
+
+ act(() => {
+ incomingCallMap?.['chat.1.chatUi.chatThreadFull']?.({
+ thread: JSON.stringify({
+ messages: [makeValidTextUIMessage(targetMsgID, 'stale server copy')],
+ pagination: {last: true, next: '', num: 20, previous: ''},
+ }),
+ })
+ })
+
+ expect(result.current.message?.reactions?.get(':+1:')?.users.map(u => u.username)).toEqual(['alice'])
+ })
+
+ test('a stale reload does not merge the newest page into a centered window', async () => {
+ // The reader taps a search result and sits on the window around it, with more to load forward.
+ // A ChatThreadsStale reload fetches the newest page, which is nowhere near that window: merging
+ // the two leaves ordinals with a hole through the middle and then calls the result the latest
+ // message, which is the gap this invariant is about.
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
+ thread: threadJSON([T.Chat.numberToMessageID(9900), T.Chat.numberToMessageID(9901)], false),
+ })
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow()
+
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: true,
+ enableActiveMarkRead: false,
+ messages: [textAt(7000), textAt(7001)],
+ moreToLoad: true,
+ scrollDirection: 'none',
+ })
+ })
+ expect(result.current.window.moreToLoadForward).toBe(true)
+
+ act(() => {
+ notifyEngineActionListeners(staleThreadUpdate)
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.window.ordinals).toEqual([7000, 7001])
+ // ...and the window still knows it has not reached the latest message.
+ expect(result.current.window.moreToLoadForward).toBe(true)
+ })
+
+ test('a newest page that reaches the window is still merged', async () => {
+ // The other side of the rule. A reader near the bottom gets a page that overlaps what they
+ // hold, so there is no hole to open and the refresh must land.
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
+ thread: threadJSON([T.Chat.numberToMessageID(9901), T.Chat.numberToMessageID(9902)], false),
+ })
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow()
+
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: true,
+ enableActiveMarkRead: false,
+ messages: [textAt(9900), textAt(9901)],
+ moreToLoad: true,
+ scrollDirection: 'none',
+ })
+ })
+
+ act(() => {
+ notifyEngineActionListeners(staleThreadUpdate)
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.window.ordinals).toEqual([9900, 9901, 9902])
+ })
+
+ test('a response arriving after the provider unmounts is not applied', async () => {
+ let incomingCallMap:
+ | Parameters[0]['incomingCallMap']
+ | undefined
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ incomingCallMap = p.incomingCallMap
+ await Promise.resolve()
+ return {offline: false}
+ })
+ let snapshot: (() => ConversationThreadState) | undefined
+ const {result, unmount} = renderHook(
+ () => {
+ const store = useConversationThreadStore()
+ snapshot = () => store.getState()
+ return useHarness()
+ },
+ {wrapper: makeWrapper()}
+ )
+
+ act(() => {
+ result.current.requestWindow({anchor: 'newest', reason: 'focused'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(incomingCallMap).toBeDefined()
+
+ unmount()
+
+ act(() => {
+ incomingCallMap?.['chat.1.chatUi.chatThreadFull']?.({
+ thread: threadJSON([T.Chat.numberToMessageID(9001)]),
+ })
+ })
+
+ expect(snapshot?.().messageOrdinals).toBeUndefined()
+ })
+})
+
+// The gate rules are the sharpest part of the module and the hardest to reach through a rendered
+// list, so they are driven straight at the arbitration entry point - with the real store and the
+// real actions behind it, and a real gate record rather than hand-set flags.
+describe('the window gate', () => {
+ type LoadContext = Omit[0], 'load' | 'reload'>
+ type WindowLoad = Parameters[0]['load']
+
+ const newestLoad = (over: Partial = {}): WindowLoad => ({
+ allowMarkAsRead: true,
+ numberOfMessagesToLoad: 100,
+ reason: 'focused',
+ retryCount: 0,
+ scrollDirection: 'none',
+ ...over,
+ })
+
+ const renderContext = () => {
+ let mounted = true
+ const {result} = renderHook(
+ () => ({
+ actions: useConversationThreadActions(),
+ ordinals: useConversationThreadSelector(s => s.messageOrdinals),
+ store: useConversationThreadStore(),
+ }),
+ {
+ wrapper: ({children}: {children: React.ReactNode}) => (
+ {children}
+ ),
+ }
+ )
+ const context: LoadContext = {
+ actions: result.current.actions,
+ conversationIDKey: convID,
+ gate: {nextLoadID: 0, refillOwner: undefined},
+ isMounted: () => mounted,
+ onThreadLoadStatus: () => {},
+ store: result.current.store,
+ }
+ return {
+ context,
+ result,
+ unmountProvider: () => {
+ mounted = false
+ },
+ }
+ }
+
+ const drive = (context: LoadContext, load: WindowLoad) => {
+ runThreadWindowLoad({
+ ...context,
+ load,
+ reload: (next: WindowLoad) => {
+ drive(context, next)
+ },
+ })
+ }
+
+ test('only the load that claimed the gate may drop it', async () => {
+ // The generation cannot separate two loads of the same conversation: it moves once, for the
+ // clear both of them started after. The reader taps a search result, messagesClear issues the
+ // centered reload, and a ChatThreadsStale notification then fires a second load. If that one
+ // settles first - no thread, an error - it would take the gate down while the reload is still
+ // in flight, and a push landing in what is left of the gap strands exactly as it did before
+ // the gate existed.
+ let release: (() => void) | undefined
+ jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(
+ async () =>
+ new Promise(resolve => {
+ release = () => resolve(undefined as never)
+ })
+ )
+ const {context, result} = renderContext()
+ act(() => {
+ result.current.actions.messagesClear()
+ })
+
+ // The reload the clear issued claims the gate...
+ drive(context, newestLoad({reason: 'centered'}))
+ await act(async () => {
+ await flushPromises()
+ })
+ const owner = release
+ release = undefined
+
+ // ...and the stale-thread load behind it loses the race.
+ drive(context, newestLoad({reason: 'got stale'}))
+ await act(async () => {
+ await flushPromises()
+ })
+ await act(async () => {
+ release?.()
+ await flushPromises()
+ })
+ expect(context.store.getState().windowCleared).toBe(true)
+
+ await act(async () => {
+ owner?.()
+ await flushPromises()
+ })
+ expect(context.store.getState().windowCleared).toBe(false)
+ })
+
+ test('releases the gate when the load bails before the rpc is even made', async () => {
+ // The clear issues its reload synchronously, so if that reload is the one bailing there is
+ // nothing else coming to take the gate down and the thread stops receiving messages for good.
+ const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock')
+ const {context, result, unmountProvider} = renderContext()
+ act(() => {
+ result.current.actions.messagesClear()
+ })
+ unmountProvider()
+
+ drive(context, newestLoad())
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(rpc).not.toHaveBeenCalled()
+ expect(context.store.getState().windowCleared).toBe(false)
+ })
+
+ test('releases the gate when the load ends without ever applying', async () => {
+ // A response that carries no thread: nothing else would take the gate down, and left up it
+ // drops every notification for the life of the provider.
+ jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async () => {
+ await Promise.resolve()
+ return undefined as never
+ })
+ const {context, result} = renderContext()
+ act(() => {
+ result.current.actions.messagesClear()
+ })
+
+ drive(context, newestLoad())
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(context.store.getState().windowCleared).toBe(false)
+ })
+
+ test('does not apply a response that arrives after a clear, and leaves the new gate alone', async () => {
+ // The back page is in flight when the reader taps jump-to-recent: messagesClear empties the
+ // window and starts its own load. Applying this one anyway repopulates the window the clear
+ // dropped and lowers the gate the new load is relying on, and the two disjoint pages then
+ // merge - the stranded-row bug the gate exists to prevent.
+ const {context, result} = renderContext()
+ jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ act(() => {
+ result.current.actions.messagesClear()
+ })
+ p.onFullThread?.(threadJSON([T.Chat.numberToMessageID(7152)], false))
+ return undefined as never
+ })
+
+ drive(context, newestLoad())
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.ordinals).toBeUndefined()
+ expect(context.store.getState().windowCleared).toBe(true)
+ })
+
+ test('the load that owns the gate refills the window and drops it', async () => {
+ jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ p.onFullThread?.(threadJSON([T.Chat.numberToMessageID(7152)], false))
+ return undefined as never
+ })
+ const {context, result} = renderContext()
+ act(() => {
+ result.current.actions.messagesClear()
+ })
+
+ drive(context, newestLoad({reason: 'centered'}))
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.ordinals).toEqual([T.Chat.numberToOrdinal(7152)])
+ expect(context.store.getState().windowCleared).toBe(false)
+ })
+
+ test('a disjoint cached pass retires the load instead of letting the full pass prune', async () => {
+ // The cached pass is whatever the local cache holds and it can be a page from somewhere else
+ // entirely - here, newer messages while the reader sits on a centered window. Skipping just that
+ // pass is not enough: the service has already recorded that a cached thread was sent, so the
+ // INCREMENTAL full pass behind it arrives carrying only what changed and, with nothing in
+ // `carried` from the pass that was dropped, reconciles the whole window against those few rows -
+ // deleting everything between them and marking an old window read on the way past.
+ let sendFull: (() => void) | undefined
+ jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ // Entirely above the loaded window: it cannot join.
+ p.onCachedThread?.(threadJSON([T.Chat.numberToMessageID(9000)], false))
+ return new Promise(resolve => {
+ sendFull = () => {
+ p.onFullThread?.(
+ threadJSON([T.Chat.numberToMessageID(7150), T.Chat.numberToMessageID(7153)], false)
+ )
+ resolve(undefined as never)
+ }
+ })
+ })
+ const {context, result} = renderContext()
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: true,
+ enableActiveMarkRead: false,
+ messages: [textAt(7150), textAt(7151), textAt(7152), textAt(7153)],
+ moreToLoad: true,
+ scrollDirection: 'none',
+ })
+ })
+ const before = result.current.ordinals
+
+ drive(context, newestLoad({reason: 'focused'}))
+ await act(async () => {
+ await flushPromises()
+ })
+ await act(async () => {
+ sendFull?.()
+ await flushPromises()
+ })
+
+ // The window the reader is looking at is still all there: 7151 and 7152 sit inside the span the
+ // full pass would have reconciled against, and are exactly what the prune would have taken.
+ expect(result.current.ordinals).toEqual(before)
+ })
+
+ test('an empty pass during a jump-to-recent gap leaves the gate up', async () => {
+ // A cold cache sends a cached pass carrying no messages ahead of the full response. Dropping
+ // the gate on it reopens the gap: a notification landing before the real page becomes the sole
+ // ordinal, and the page that follows is disjoint from it.
+ let sendFull: (() => void) | undefined
+ jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ p.onCachedThread?.(JSON.stringify({messages: null, pagination: {last: false, num: 100}}))
+ return new Promise(resolve => {
+ sendFull = () => {
+ p.onFullThread?.(threadJSON([T.Chat.numberToMessageID(9001)], false))
+ resolve(undefined as never)
+ }
+ })
+ })
+ const {context, result} = renderContext()
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: [textAt(7152), textAt(7153)],
+ moreToLoad: true,
+ scrollDirection: 'none',
+ })
+ })
+ act(() => {
+ result.current.actions.messagesClear()
+ })
+
+ drive(context, newestLoad({reason: 'jump to recent'}))
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(context.store.getState().windowCleared).toBe(true)
+
+ // A push landing in the gap cannot install itself as the whole window.
+ act(() => {
+ result.current.actions.addMessages([textAt(7155)], {liveUpdate: true})
+ })
+ await act(async () => {
+ sendFull?.()
+ await flushPromises()
+ })
+
+ expect(result.current.ordinals).toEqual([T.Chat.numberToOrdinal(9001)])
+ })
+})
+
+describe('a back page that adds no ordinals reloads itself', () => {
+ type LoadContext = Omit[0], 'load' | 'reload'>
+ type WindowLoad = Parameters[0]['load']
+
+ // A real store seeded with a window, so a page's productivity is judged the way the thread judges
+ // it: a `deleted` message adds no ordinal, a renderable one does.
+ const contextFor = (store: LoadContext['store'], actions: ConversationThreadActions) => {
+ const context: LoadContext = {
+ actions,
+ conversationIDKey: convID,
+ gate: {nextLoadID: 0, refillOwner: undefined},
+ isMounted: () => true,
+ onThreadLoadStatus: () => {},
+ store,
+ }
+ return context
+ }
+
+ const drive = (context: LoadContext, load: WindowLoad) => {
+ runThreadWindowLoad({
+ ...context,
+ load,
+ reload: (next: WindowLoad) => {
+ drive(context, next)
+ },
+ })
+ }
+
+ // Hidden placeholders are what a DELETE-superseded message arrives as, and what becomes `deleted`
+ // on this side. They carry real message IDs, which is what bounds the reload.
+ const tombstones = (from: number, to: number) =>
+ Array.from({length: from - to + 1}, (_, i) => ({
+ placeholder: {hidden: true, messageID: T.Chat.numberToMessageID(from - i)},
+ state: T.RPCChat.MessageUnboxedState.placeholder,
+ }))
+
+ // hidden: false parses to a `placeholder`, which the thread does render and keep an ordinal for.
+ const visible = (from: number, to: number) =>
+ Array.from({length: from - to + 1}, (_, i) => ({
+ placeholder: {hidden: false, messageID: T.Chat.numberToMessageID(from - i)},
+ state: T.RPCChat.MessageUnboxedState.placeholder,
+ }))
+
+ // Each call walks one page further back, exactly as the service does, until it runs out.
+ const mockWalkingBack = (oldestOverall: number) => {
+ let next = 7151
+ return jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ const from = next
+ const to = Math.max(oldestOverall, from - numMessagesOnScrollback + 1)
+ next = to - 1
+ await Promise.resolve()
+ p.onFullThread?.(
+ JSON.stringify({messages: tombstones(from, to), pagination: {last: to <= oldestOverall, num: 100}})
+ )
+ return undefined as never
+ })
+ }
+
+ const seededContext = () => {
+ const {result} = renderHook(
+ () => ({actions: useConversationThreadActions(), store: useConversationThreadStore()}),
+ {
+ wrapper: ({children}: {children: React.ReactNode}) => (
+ {children}
+ ),
+ }
+ )
+ const {actions} = result.current
+ act(() => {
+ actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: [textAt(7152), textAt(7153)],
+ moreToLoad: true,
+ scrollDirection: 'back',
+ })
+ })
+ return contextFor(result.current.store, result.current.actions)
+ }
+
+ const loadBack = (context: LoadContext) =>
+ drive(context, {
+ allowMarkAsRead: true,
+ numberOfMessagesToLoad: numMessagesOnScrollback,
+ reason: 'scroll back',
+ retryCount: 0,
+ scrollDirection: 'back',
+ })
+
+ test('keeps paging through a run of tombstones until the pager says it is done', async () => {
+ // 7151 down to 6952 is two pages of 100, so one reload after the first call.
+ const rpc = mockWalkingBack(6952)
+ loadBack(seededContext())
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(2)
+ })
+
+ test('walks a run of tombstones that ends before the cap', async () => {
+ const oldest = 6752
+ const rpc = mockWalkingBack(oldest)
+ loadBack(seededContext())
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(Math.ceil((7151 - oldest + 1) / numMessagesOnScrollback))
+ })
+
+ test('stops at the reload cap rather than walking an expunged history', async () => {
+ // A channel whose history was largely expunged has far more tombstones than the chain should
+ // walk off one gesture. It stops at the cap and hands the thread back; scrolling away and back
+ // fires onStartReached again and starts a fresh chain from where this one stopped.
+ const rpc = mockWalkingBack(1)
+ loadBack(seededContext())
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(maxBackPageReloads + 1)
+ })
+
+ test('stops if a page fails to reach further back', async () => {
+ // A service that keeps handing back the same window must not spin us forever. Progress in
+ // message ID is the only thing permitting another attempt.
+ const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ p.onFullThread?.(
+ JSON.stringify({messages: tombstones(7151, 7052), pagination: {last: false, num: 100}})
+ )
+ return undefined as never
+ })
+ loadBack(seededContext())
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(2)
+ })
+
+ test('does not reload when the page actually added ordinals', async () => {
+ // Renderable messages, so the store grows and the list will ask for the next page itself.
+ const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ p.onFullThread?.(JSON.stringify({messages: visible(7151, 7052), pagination: {last: false, num: 100}}))
+ return undefined as never
+ })
+ loadBack(seededContext())
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(1)
+ })
+
+ test('reloads when a warm cache delivers the tombstones', async () => {
+ // The reported bug's own shape: the conversation is already in local storage, so PullLocalOnly
+ // wins and the cached pass carries the page - which is entirely tombstones. Judging only the
+ // full pass, or refusing to judge at all once a cached pass arrived, leaves this inert.
+ let next = 7151
+ const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ const from = next
+ const to = Math.max(6952, from - numMessagesOnScrollback + 1)
+ next = to - 1
+ await Promise.resolve()
+ p.onCachedThread?.(
+ JSON.stringify({messages: tombstones(from, to), pagination: {last: to <= 6952, num: 100}})
+ )
+ // The full pass is INCREMENTAL once a cached thread has been sent.
+ p.onFullThread?.(
+ JSON.stringify({messages: tombstones(to, to), pagination: {last: to <= 6952, num: 100}})
+ )
+ return undefined as never
+ })
+ loadBack(seededContext())
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(2)
+ })
+
+ test('does not reload after a cached pass already delivered the page', async () => {
+ // The normal warm-cache sequence: PullLocalOnly wins, the cached pass carries the whole page,
+ // and the full pass that follows is INCREMENTAL - only the messages that changed, every one of
+ // them already in the window. On ordinal count alone that is indistinguishable from a page of
+ // tombstones, and reloading on it walks the client back through the entire conversation.
+ const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ p.onCachedThread?.(JSON.stringify({messages: visible(7151, 7052), pagination: {last: false, num: 100}}))
+ p.onFullThread?.(JSON.stringify({messages: visible(7052, 7052), pagination: {last: false, num: 100}}))
+ return undefined as never
+ })
+ loadBack(seededContext())
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(1)
+ })
+
+ test('stops when the window is cleared under it', async () => {
+ // jump to recent and a centered jump both clear then reload. A chain still walking backwards
+ // would prepend pages into a window the reader has just left, producing the disjoint ordinals
+ // this whole branch exists to prevent.
+ let calls = 0
+ const {result} = renderHook(
+ () => ({actions: useConversationThreadActions(), store: useConversationThreadStore()}),
+ {
+ wrapper: ({children}: {children: React.ReactNode}) => (
+ {children}
+ ),
+ }
+ )
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: [textAt(7152)],
+ moreToLoad: true,
+ scrollDirection: 'back',
+ })
+ })
+ const rpc = jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ calls++
+ await Promise.resolve()
+ // Someone hits jump-to-recent while the first page is in flight.
+ if (calls === 1) {
+ act(() => {
+ result.current.actions.messagesClear()
+ })
+ }
+ p.onFullThread?.(
+ JSON.stringify({messages: tombstones(7151, 7052), pagination: {last: false, num: 100}})
+ )
+ return undefined as never
+ })
+ loadBack(contextFor(result.current.store, result.current.actions))
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(1)
+ })
+
+ test('does not reload an initial load', async () => {
+ const rpc = mockWalkingBack(6152)
+ drive(seededContext(), {
+ allowMarkAsRead: true,
+ numberOfMessagesToLoad: 100,
+ reason: 'focused',
+ retryCount: 0,
+ scrollDirection: 'none',
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+ expect(rpc).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('only a pass that can account for a whole window reconciles', () => {
+ const page = (from: number, to: number) =>
+ Array.from({length: from - to + 1}, (_, i) => ({
+ placeholder: {hidden: false, messageID: T.Chat.numberToMessageID(from - i)},
+ state: T.RPCChat.MessageUnboxedState.placeholder,
+ }))
+
+ const mockPasses = (cached: string, full: string) =>
+ jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ p.onCachedThread?.(cached)
+ p.onFullThread?.(full)
+ return undefined as never
+ })
+
+ // Whether the last pass of a load was the one that reconciles. What gets pruned is the store's
+ // business - addMessagesToThreadState fills the carried set itself - so these tests only check
+ // which passes are allowed to ask for it; the thread-context suite covers the pruning.
+ const renderRecording = () => {
+ const {result} = renderHook(
+ () => ({actions: useConversationThreadActions(), store: useConversationThreadStore()}),
+ {
+ wrapper: ({children}: {children: React.ReactNode}) => (
+ {children}
+ ),
+ }
+ )
+ const applyThreadLoad = jest.spyOn(result.current.actions, 'applyThreadLoad')
+ const load = () =>
+ runThreadWindowLoad({
+ actions: result.current.actions,
+ conversationIDKey: convID,
+ gate: {nextLoadID: 0, refillOwner: undefined},
+ load: {
+ allowMarkAsRead: true,
+ numberOfMessagesToLoad: 100,
+ reason: 'focused',
+ retryCount: 0,
+ scrollDirection: 'none',
+ },
+ isMounted: () => true,
+ onThreadLoadStatus: () => {},
+ reload: () => {},
+ store: result.current.store,
+ })
+ const prunedOnLastPass = () =>
+ applyThreadLoad.mock.calls.at(-1)?.[0].reconcile?.prune
+ return {applyThreadLoad, load, prunedOnLastPass}
+ }
+
+ test('reconciles on a full pass that followed an empty cached one', async () => {
+ // First open after a db nuke: PullLocalOnly finds nothing, but its collector suppresses the
+ // miss and a cached pass is sent anyway, carrying no messages. INCREMENTAL against an empty
+ // local thread filters nothing out, so the full pass really is the whole window - and only a
+ // whole window may prune the stale ordinals a cache repair left behind.
+ mockPasses(
+ JSON.stringify({messages: null, pagination: {last: false, num: 100}}),
+ JSON.stringify({messages: page(7153, 7152), pagination: {last: false, num: 100}})
+ )
+ const {load, prunedOnLastPass} = renderRecording()
+ load()
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(prunedOnLastPass()).toBe(true)
+ })
+
+ test('does not reconcile when the service never reported a cached pass', async () => {
+ // The service records the cached thread as sent before it marshals it, so a failure there
+ // leaves the full pass INCREMENTAL against a pass we were never shown. The cached callback
+ // firing - with a thread, or with the nil a cold cache sends - is the only sign we get that
+ // this did not happen.
+ jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ p.onFullThread?.(JSON.stringify({messages: page(7153, 7150), pagination: {last: false, num: 100}}))
+ return undefined as never
+ })
+ const {load, prunedOnLastPass} = renderRecording()
+ load()
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(prunedOnLastPass()).toBe(false)
+ })
+
+ test('reconciles on the full pass of a warm-cache load, against both passes', async () => {
+ // The warm-cache sequence: the cached pass carries the page and the full pass behind it is
+ // INCREMENTAL, only what changed. Neither is a window on its own - but INCREMENTAL walks the
+ // authoritative window and omits only what the cached pass already carried unchanged, so the
+ // two together are that window, and the range spans both. Judging the full pass alone would
+ // give up pruning on every conversation the cache is warm for, which is all of them after the
+ // first open.
+ mockPasses(
+ JSON.stringify({messages: page(7153, 7052), pagination: {last: false, num: 100}}),
+ JSON.stringify({messages: page(7153, 7153), pagination: {last: false, num: 100}})
+ )
+ const {load, prunedOnLastPass} = renderRecording()
+ load()
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(prunedOnLastPass()).toBe(true)
+ })
+
+ test('does not reconcile when a cached pass arrived but another load owned the window', async () => {
+ // The gate-owner guard is the one guard that can turn a cached pass away and still let the full
+ // pass behind it through: the owner drops the gate in between. The service counts that cached
+ // pass as sent either way, so the full pass is INCREMENTAL - a handful of changed messages -
+ // and a span built from those alone covers every row between them with nothing recorded as
+ // present. That is not a stale-row cleanup, it is deleting the thread.
+ const {result} = renderHook(
+ () => ({actions: useConversationThreadActions(), store: useConversationThreadStore()}),
+ {
+ wrapper: ({children}: {children: React.ReactNode}) => (
+ {children}
+ ),
+ }
+ )
+ act(() => {
+ result.current.actions.messagesClear()
+ })
+ const applyThreadLoad = jest.spyOn(result.current.actions, 'applyThreadLoad')
+ // Another load got to the cleared window first and still holds it.
+ const gate: {nextLoadID: number; refillOwner: number | undefined} = {nextLoadID: 1, refillOwner: 0}
+ jest.spyOn(ThreadRpc, 'loadThreadNonblock').mockImplementation(async p => {
+ await Promise.resolve()
+ p.onCachedThread?.(JSON.stringify({messages: page(7153, 7052), pagination: {last: false, num: 100}}))
+ // The load that owned the gate settles here, so the full pass is no longer refused.
+ gate.refillOwner = undefined
+ p.onFullThread?.(JSON.stringify({messages: page(7153, 7150), pagination: {last: false, num: 100}}))
+ return undefined as never
+ })
+ runThreadWindowLoad({
+ actions: result.current.actions,
+ conversationIDKey: convID,
+ gate,
+ load: {
+ allowMarkAsRead: true,
+ numberOfMessagesToLoad: 100,
+ reason: 'focused',
+ retryCount: 0,
+ scrollDirection: 'none',
+ },
+ isMounted: () => true,
+ onThreadLoadStatus: () => {},
+ reload: () => {},
+ store: result.current.store,
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(applyThreadLoad).not.toHaveBeenCalled()
+ })
+})
+
+// End to end: a load reconciles the window it refills. Which passes may ask to reconcile is
+// covered above; these are about what the store then does with the answer.
+describe('a load reconciles the window it refills', () => {
+ test('a warm-cache load prunes against both passes, not either one alone', async () => {
+ // Regression: once the service has sent a cached thread it switches the full response to
+ // INCREMENTAL, so the full pass only carries what changed. Treating either pass on its own as
+ // authoritative deleted real messages that were still in the thread. The two together are the
+ // window - INCREMENTAL walks it and omits only what the cached pass already carried - so the
+ // range spans both, and everything inside it that either pass carried survives.
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
+ jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
+ const ids = [301, 302, 303, 304].map(T.Chat.numberToMessageID)
+ // The cache holds the older three; only 304 changed, so that is all the full pass carries. The
+ // span is what makes this the dangerous shape: a range of [301..304] computed from the full pass
+ // alone covers 302 and 303, which are absent from it and would be pruned.
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadCached']?.({thread: threadJSON(ids.slice(0, 3))})
+ await Promise.resolve()
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({thread: threadJSON([ids[3]!])})
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow()
+
+ // Seed a settled four-message window the way a whole-window full pass would.
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: ids.map(id =>
+ Message.makeMessageText({
+ author: 'alice',
+ conversationIDKey: convID,
+ id,
+ ordinal: T.Chat.numberToOrdinal(T.Chat.messageIDToNumber(id)),
+ outboxID: undefined,
+ text: new HiddenString(`m${id}`),
+ timestamp: 100,
+ })
+ ),
+ moreToLoad: false,
+ scrollDirection: 'none',
+ })
+ })
+ expect(result.current.window.ordinals).toEqual([301, 302, 303, 304])
+
+ act(() => {
+ result.current.requestWindow({anchor: 'newest', reason: 'test'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.window.ordinals).toEqual([301, 302, 303, 304])
+ })
+
+ test('a warm-cache load does not prune a message sitting on its outbox ordinal', async () => {
+ // A message you sent keeps the fractional ordinal it had in the outbox, so the ordinal it parses
+ // with - its server one - is not the ordinal it occupies. The prune walks the window, so what
+ // the passes delivered has to be recorded in the window's terms too; recording the parsed
+ // ordinal deletes the row it was meant to protect.
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
+ jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
+ const outboxID = T.Chat.stringToOutboxID('sent-1')
+ const sentOrdinal = T.Chat.numberToOrdinal(302.001)
+
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadCached']?.({
+ thread: JSON.stringify({
+ messages: [
+ makeValidTextUIMessage(T.Chat.numberToMessageID(301), 'm301'),
+ makeValidTextUIMessage(T.Chat.numberToMessageID(302), 'm302'),
+ makeValidTextUIMessage(T.Chat.numberToMessageID(303), 'mine', 'sent-1'),
+ ],
+ pagination: {last: true, next: '', num: 100, previous: ''},
+ }),
+ })
+ await Promise.resolve()
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
+ thread: JSON.stringify({
+ messages: [makeValidTextUIMessage(T.Chat.numberToMessageID(301), 'm301 edited')],
+ pagination: {last: true, next: '', num: 100, previous: ''},
+ }),
+ })
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow()
+
+ // The window as it stands after the send settled: the message is at its outbox ordinal, indexed
+ // under the server ID the service will send it back as.
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: [
+ Message.makeMessageText({
+ author: 'alice',
+ conversationIDKey: convID,
+ id: T.Chat.numberToMessageID(301),
+ ordinal: T.Chat.numberToOrdinal(301),
+ outboxID: undefined,
+ text: new HiddenString('m301'),
+ timestamp: 100,
+ }),
+ Message.makeMessageText({
+ author: 'alice',
+ conversationIDKey: convID,
+ id: T.Chat.numberToMessageID(302),
+ ordinal: T.Chat.numberToOrdinal(302),
+ outboxID: undefined,
+ text: new HiddenString('m302'),
+ timestamp: 100,
+ }),
+ Message.makeMessageText({
+ author: 'testuser',
+ conversationIDKey: convID,
+ id: T.Chat.numberToMessageID(303),
+ ordinal: sentOrdinal,
+ outboxID,
+ text: new HiddenString('mine'),
+ timestamp: 100,
+ }),
+ ],
+ moreToLoad: false,
+ scrollDirection: 'none',
+ })
+ })
+ expect(result.current.window.ordinals).toEqual([301, 302, sentOrdinal])
+
+ act(() => {
+ result.current.requestWindow({anchor: 'newest', reason: 'test'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.window.ordinals).toEqual([301, 302, sentOrdinal])
+ })
+
+ test('a full pass that changed nothing still reconciles the window', async () => {
+ // The ordinary warm reload: the cached pass is the window and the INCREMENTAL full pass behind it
+ // carries nothing at all, because nothing changed. That is still an authoritative answer about
+ // the span, so a row the service no longer has is still a ghost - skipping the prune for want of
+ // messages to add leaves it on screen until the conversation is reopened.
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
+ jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
+ const ids = [301, 302, 303].map(T.Chat.numberToMessageID)
+
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadCached']?.({
+ thread: JSON.stringify({
+ messages: [ids[0]!, ids[2]!].map(id => makeValidTextUIMessage(id, `m${id}`)),
+ pagination: {last: true, next: '', num: 100, previous: ''},
+ }),
+ })
+ await Promise.resolve()
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({
+ thread: JSON.stringify({messages: null, pagination: {last: true, next: '', num: 100, previous: ''}}),
+ })
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow()
+
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: ids.map(id =>
+ Message.makeMessageText({
+ author: 'alice',
+ conversationIDKey: convID,
+ id,
+ ordinal: T.Chat.numberToOrdinal(T.Chat.messageIDToNumber(id)),
+ outboxID: undefined,
+ text: new HiddenString(`m${id}`),
+ timestamp: 100,
+ })
+ ),
+ moreToLoad: false,
+ scrollDirection: 'none',
+ })
+ })
+ expect(result.current.window.ordinals).toEqual([301, 302, 303])
+
+ act(() => {
+ result.current.requestWindow({anchor: 'newest', reason: 'test'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.window.ordinals).toEqual([301, 303])
+ })
+
+ test('a warm-cache load still prunes a row neither pass carries', async () => {
+ // The other half of the same rule: a row inside the range that neither pass returned is a ghost -
+ // a cache repair left it behind, or it was deleted while we were away - and reconciling it away
+ // is what the range is for. Gating on a full pass with no cached one before it would have given
+ // this up for every conversation the cache is warm for.
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true)
+ jest.spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise').mockResolvedValue({offline: false})
+ const ids = [301, 302, 303, 304].map(T.Chat.numberToMessageID)
+ // 303 is in neither pass, and it sits inside the span the two of them cover.
+ jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => {
+ p.incomingCallMap['chat.1.chatUi.chatThreadCached']?.({thread: threadJSON([ids[0]!, ids[1]!])})
+ await Promise.resolve()
+ p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({thread: threadJSON([ids[3]!])})
+ await Promise.resolve()
+ return {offline: false}
+ })
+ const {result} = renderWindow()
+
+ act(() => {
+ result.current.actions.applyThreadLoad({
+ centered: false,
+ enableActiveMarkRead: false,
+ messages: ids.map(id =>
+ Message.makeMessageText({
+ author: 'alice',
+ conversationIDKey: convID,
+ id,
+ ordinal: T.Chat.numberToOrdinal(T.Chat.messageIDToNumber(id)),
+ outboxID: undefined,
+ text: new HiddenString(`m${id}`),
+ timestamp: 100,
+ })
+ ),
+ moreToLoad: false,
+ scrollDirection: 'none',
+ })
+ })
+ expect(result.current.window.ordinals).toEqual([301, 302, 303, 304])
+
+ act(() => {
+ result.current.requestWindow({anchor: 'newest', reason: 'test'})
+ })
+ await act(async () => {
+ await flushPromises()
+ })
+
+ expect(result.current.window.ordinals).toEqual([301, 302, 304])
+ })
+})
diff --git a/shared/chat/conversation/thread-window.tsx b/shared/chat/conversation/thread-window.tsx
new file mode 100644
index 000000000000..bdde287ef78b
--- /dev/null
+++ b/shared/chat/conversation/thread-window.tsx
@@ -0,0 +1,758 @@
+import * as Message from '@/constants/chat/message'
+import * as Meta from '@/constants/chat/meta'
+import * as React from 'react'
+import * as Strings from '@/constants/strings'
+import * as T from '@/constants/types'
+import logger from '@/logger'
+import throttle from 'lodash/throttle'
+import {clearChatTimeCache} from '@/util/timestamp'
+import {getInboxConversationParticipants, unboxRows, updateInboxConversationMeta} from '@/chat/inbox/metadata'
+import {ignorePromise} from '@/constants/utils'
+import {loadThreadNonblock, threadLoadReasonToRPCReason} from './thread-rpc'
+import {navigateToInbox} from '@/constants/router'
+import {persistRoute} from '@/util/storeless-actions'
+import {RPCError} from '@/util/errors'
+import {useConfigState} from '@/stores/config'
+import {useCurrentUserState} from '@/stores/current-user'
+import {useShallow} from '@/util/zustand'
+import {useThreadStaleReloadListeners} from './thread-engine'
+import {useUsersState} from '@/stores/users'
+import type {ThreadLoadReconcile} from './thread-message-state'
+import {
+ getCurrentUser,
+ getLastOrdinalFromSnapshot,
+ getMeta,
+ scrollDirectionToPagination,
+} from './thread-load'
+import {
+ type ConversationThreadActions,
+ type ConversationThreadState,
+ type ScrollDirection,
+ useConversationThreadActions,
+ useConversationThreadSelector,
+ useConversationThreadStore,
+} from './thread-context'
+
+export const numMessagesOnInitialLoad = isMobile ? 20 : 100
+export const numMessagesOnScrollback = 100
+// How far the no-new-ordinals back-page chain will walk on its own before handing the thread back to
+// the reader. See the reload block in runThreadLoad.
+export const maxBackPageReloads = 10
+
+const emptyOrdinals: ReadonlyArray = []
+
+const emptyParticipantInfo: T.Chat.ParticipantInfo = {
+ all: [],
+ contactName: new Map(),
+ name: [],
+}
+
+// Where the window the caller wants is anchored.
+// - 'newest': the newest page. A 'jump to recent' reason drops the window it replaces first, since
+// the newest page is disjoint from wherever a scrolled-back reader was.
+// - 'older' / 'newer': one more page past the corresponding edge of the window we hold.
+// - {centeredOn}: a window around one message, which always replaces the window we hold.
+export type WindowAnchor = 'newest' | 'older' | 'newer' | {centeredOn: T.Chat.MessageID}
+
+export type WindowRequest = {anchor: WindowAnchor; reason: string}
+export type RequestWindow = (p: WindowRequest) => void
+
+export type ThreadWindow = {
+ generation: number
+ loaded: boolean
+ moreToLoadBack: boolean
+ moreToLoadForward: boolean
+ ordinals: ReadonlyArray
+}
+
+// Everything a load needs once requestWindow has turned an anchor into one. Not exported: callers
+// name a place in the thread, the module decides how to fetch it.
+type WindowLoad = {
+ allowMarkAsRead: boolean
+ centeredOn?: T.Chat.MessageID
+ numberOfMessagesToLoad: number
+ reason: string
+ // The oldest message ID the previous attempt saw, carried by the empty-back-page reload below.
+ // Each reload must reach strictly further back than that, which is what stops it looping.
+ retryBelowMessageID?: T.Chat.MessageID
+ // How many times the back-page reload has already chained. See maxBackPageReloads.
+ retryCount: number
+ scrollDirection: ScrollDirection
+}
+
+// The one record arbitrating which response may become the loaded window, alongside the store's
+// `generation`.
+//
+// `generation` moves on every clear and on a conversation change, so a response fetched against a
+// window that no longer exists is refused. It cannot separate two loads issued after the SAME
+// clear, and the second one is not hypothetical: a ChatThreadsStale reload fires with an anchor of
+// 'newest' and fetches a region the clear never asked for. So the refill after a clear is also
+// owned - first claim wins, and only the owner may refill or release - and `nextLoadID` names the
+// loads for that comparison. Only ever compared for equality, never ordered.
+type WindowGate = {
+ nextLoadID: number
+ refillOwner: number | undefined
+}
+
+const noThreadLoadStatus = T.RPCChat.UIChatThreadStatusTyp.none
+
+const ThreadLoadStatusContext = React.createContext(noThreadLoadStatus)
+ThreadLoadStatusContext.displayName = 'ThreadLoadStatusContext'
+
+const missingRequestWindow: RequestWindow = () => {
+ throw new Error('Missing ConversationThreadWindowProvider in the tree')
+}
+
+const RequestWindowContext = React.createContext(missingRequestWindow)
+RequestWindowContext.displayName = 'RequestWindowContext'
+
+export const useThreadLoadStatus = () => React.useContext(ThreadLoadStatusContext)
+
+export const useRequestWindow = () => React.useContext(RequestWindowContext)
+
+// The loaded window, as everything that renders it needs to see it. `generation` is the identity of
+// the window itself: it moves when the window is dropped and reloaded, which is the only time a
+// virtualized list has to forget the layout it measured.
+export const useThreadWindow = (): ThreadWindow =>
+ useConversationThreadSelector(
+ useShallow((s: ConversationThreadState) => ({
+ generation: s.generation,
+ loaded: s.loaded,
+ moreToLoadBack: s.moreToLoadBack,
+ moreToLoadForward: s.moreToLoadForward,
+ ordinals: s.messageOrdinals ?? emptyOrdinals,
+ }))
+ )
+
+// Repeat gate for the two scroll edges. The list re-fires its edge callbacks while the same window
+// is on screen, so a request that names the same ordinal count as the last one is only let through
+// once the previous one has had time to answer.
+const makeScrollRepeatGate = () => {
+ let lastNumOrdinals = 0
+ let lastTime = 0
+ return (numOrdinals: number) => {
+ const now = Date.now()
+ if (numOrdinals !== lastNumOrdinals) {
+ lastNumOrdinals = numOrdinals
+ lastTime = now
+ return true
+ }
+ const ok = now - lastTime > 500
+ if (ok) {
+ lastNumOrdinals = numOrdinals
+ lastTime = now
+ }
+ return ok
+ }
+}
+
+type ThreadWindowProviderProps = React.PropsWithChildren<{
+ allowMarkReadOnLoad?: boolean
+ id: T.Chat.ConversationIDKey
+ skipThreadLoadOnSelection?: boolean
+}>
+
+export const ConversationThreadWindowProvider = (p: ThreadWindowProviderProps) => {
+ const {allowMarkReadOnLoad = true, children, id, skipThreadLoadOnSelection = false} = p
+ const [initialSkipThreadLoadOnSelection] = React.useState(skipThreadLoadOnSelection)
+ const actions = useConversationThreadActions()
+ const store = useConversationThreadStore()
+
+ const currentIDRef = React.useRef(id)
+ React.useLayoutEffect(() => {
+ currentIDRef.current = id
+ }, [id])
+ // A load that outlives its provider must not keep chaining reloads or reporting status. The
+ // generation cannot express this on its own: in StrictMode the provider mounts, unmounts and
+ // remounts with the same id, and moving the generation there would discard the first RPC's
+ // callbacks while the daemon deduplicates the second one and sends no data.
+ const mountedRef = React.useRef(true)
+ React.useEffect(() => {
+ mountedRef.current = true
+ return () => {
+ mountedRef.current = false
+ }
+ }, [])
+ React.useEffect(() => {
+ return () => {
+ // Only when the conversation actually changed, for the StrictMode reason above.
+ if (currentIDRef.current !== id) {
+ actions.bumpWindowGeneration()
+ }
+ }
+ }, [actions, id])
+
+ const [statusState, setStatusState] = React.useState<{
+ conversationIDKey: T.Chat.ConversationIDKey
+ status: T.RPCChat.UIChatThreadStatusTyp
+ }>(() => ({conversationIDKey: id, status: noThreadLoadStatus}))
+
+ const getConversationIDKey = React.useEffectEvent(() => id)
+ const isMounted = React.useEffectEvent(() => mountedRef.current)
+
+ const onThreadLoadStatus = React.useEffectEvent(
+ (conversationIDKey: T.Chat.ConversationIDKey, status: T.RPCChat.UIChatThreadStatusTyp) => {
+ if (conversationIDKey !== id) {
+ return
+ }
+ setStatusState(previous =>
+ previous.conversationIDKey === conversationIDKey && previous.status === status
+ ? previous
+ : {conversationIDKey, status}
+ )
+ }
+ )
+
+ // The gate and the load throttle live together, in one object built once, because the reload
+ // chain has to come back through the throttle: a long run of tombstones would otherwise issue its
+ // pages back to back with no pacing.
+ const [loader] = React.useState(() => {
+ const gate: WindowGate = {nextLoadID: 0, refillOwner: undefined}
+ const runNow = (load: WindowLoad) => {
+ runThreadWindowLoad({
+ actions,
+ conversationIDKey: getConversationIDKey(),
+ gate,
+ load,
+ isMounted,
+ onThreadLoadStatus,
+ reload: loadWindow,
+ store,
+ })
+ }
+ const throttled = throttle(runNow, 500)
+ // The clearing requests (a centered jump, jump to recent) bypass the throttle: they empty the
+ // window before loading, and a trailing-edge throttle would drop the reload that refills it.
+ function loadWindow(load: WindowLoad) {
+ if (load.centeredOn || load.reason === 'jump to recent') {
+ throttled.cancel()
+ runNow(load)
+ } else {
+ throttled(load)
+ }
+ }
+ return {
+ cancel: () => {
+ throttled.cancel()
+ },
+ loadWindow,
+ releaseRefillOwner: () => {
+ gate.refillOwner = undefined
+ },
+ }
+ })
+ const {loadWindow} = loader
+ React.useEffect(() => () => loader.cancel(), [loader])
+
+ // One gate per edge: a back page and a forward page carrying the same ordinal count are two
+ // different requests, and sharing a gate would let either swallow the other.
+ const [olderGate] = React.useState(makeScrollRepeatGate)
+ const [newerGate] = React.useState(makeScrollRepeatGate)
+
+ // Drop the window before reloading. Both callers reload a region disjoint from the one being
+ // dropped - a centered jump an arbitrary one, jump to recent the newest page - so merging the two
+ // would leave ordinals with a hole through the middle.
+ const clearWindow = React.useEffectEvent(() => {
+ loader.releaseRefillOwner()
+ actions.messagesClear()
+ })
+
+ const onRequestWindow = React.useEffectEvent((request: WindowRequest) => {
+ const {anchor, reason} = request
+ if (typeof anchor === 'object') {
+ clearWindow()
+ loadWindow({
+ allowMarkAsRead: true,
+ centeredOn: anchor.centeredOn,
+ numberOfMessagesToLoad: numMessagesOnInitialLoad,
+ reason,
+ retryCount: 0,
+ scrollDirection: 'none',
+ })
+ return
+ }
+ const snapshot = store.getState()
+ const numOrdinals = snapshot.messageOrdinals?.length ?? 0
+ switch (anchor) {
+ case 'newest': {
+ if (reason === 'jump to recent') {
+ actions.setMarkReadBlocked(false)
+ clearWindow()
+ }
+ loadWindow({
+ // Only the automatic loads this module issues itself are held back by the provider's
+ // allowMarkReadOnLoad; a reader asking for the newest page has asked to be caught up.
+ allowMarkAsRead: true,
+ numberOfMessagesToLoad: numMessagesOnInitialLoad,
+ reason,
+ retryCount: 0,
+ scrollDirection: 'none',
+ })
+ return
+ }
+ case 'older': {
+ if (!snapshot.moreToLoadBack) {
+ logger.info('requestWindow: bail: scrolling back and at the end')
+ return
+ }
+ if (!numOrdinals || !olderGate(numOrdinals)) {
+ return
+ }
+ loadWindow({
+ allowMarkAsRead: true,
+ numberOfMessagesToLoad: numMessagesOnScrollback,
+ reason,
+ retryCount: 0,
+ scrollDirection: 'back',
+ })
+ return
+ }
+ case 'newer': {
+ if (!snapshot.moreToLoadForward) {
+ return
+ }
+ if (!numOrdinals || !newerGate(numOrdinals)) {
+ return
+ }
+ loadWindow({
+ allowMarkAsRead: true,
+ numberOfMessagesToLoad: numMessagesOnScrollback,
+ reason,
+ retryCount: 0,
+ scrollDirection: 'forward',
+ })
+ return
+ }
+ }
+ })
+
+ // Stable identity: the context value must not change every render, or every consumer re-renders.
+ const [requestWindow] = React.useState(
+ () => (request: WindowRequest) => onRequestWindow(request)
+ )
+
+ const reloadStaleThread = React.useEffectEvent(() => {
+ loadWindow({
+ allowMarkAsRead: allowMarkReadOnLoad,
+ numberOfMessagesToLoad: numMessagesOnInitialLoad,
+ reason: 'got stale',
+ retryCount: 0,
+ scrollDirection: 'none',
+ })
+ })
+ useThreadStaleReloadListeners(id, reloadStaleThread)
+
+ const selectConversation = React.useEffectEvent(() => {
+ clearChatTimeCache()
+ unboxRows([id])
+ const username = useCurrentUserState.getState().username
+ const participantInfo = getInboxConversationParticipants(id) ?? emptyParticipantInfo
+ const otherParticipants = Meta.getRowParticipants(participantInfo, username || '')
+ if (otherParticipants.length === 1) {
+ const otherUsername = otherParticipants[0] || ''
+ if (otherUsername && !otherUsername.includes('@')) {
+ useUsersState.getState().dispatch.getBio(otherUsername)
+ }
+ }
+ if (initialSkipThreadLoadOnSelection) {
+ return
+ }
+ loadWindow({
+ allowMarkAsRead: allowMarkReadOnLoad,
+ numberOfMessagesToLoad: numMessagesOnInitialLoad,
+ reason: 'focused',
+ retryCount: 0,
+ scrollDirection: 'none',
+ })
+ })
+ React.useEffect(() => {
+ logger.info(
+ `ConversationThreadWindowProvider: selecting thread: ${id} skipThreadLoad=${initialSkipThreadLoadOnSelection}`
+ )
+ selectConversation()
+ }, [id, initialSkipThreadLoadOnSelection])
+
+ const status = statusState.conversationIDKey === id ? statusState.status : noThreadLoadStatus
+
+ return (
+
+ {children}
+
+ )
+}
+
+// The whole of the arbitration, in one place. Split out of the provider so the rules read as one
+// sequence rather than as a component body, and so a test can drive them without React.
+export const runThreadWindowLoad = (p: {
+ actions: ConversationThreadActions
+ conversationIDKey: T.Chat.ConversationIDKey
+ gate: WindowGate
+ load: WindowLoad
+ isMounted: () => boolean
+ onThreadLoadStatus: (
+ conversationIDKey: T.Chat.ConversationIDKey,
+ status: T.RPCChat.UIChatThreadStatusTyp
+ ) => void
+ reload: (load: WindowLoad) => void
+ store: {getState: () => ConversationThreadState}
+}) => {
+ const {actions, conversationIDKey, gate, isMounted, load, onThreadLoadStatus, reload, store} = p
+ if (!T.Chat.isValidConversationIDKey(conversationIDKey)) {
+ return
+ }
+ const {
+ allowMarkAsRead,
+ centeredOn,
+ numberOfMessagesToLoad,
+ reason,
+ retryBelowMessageID,
+ retryCount,
+ scrollDirection,
+ } = load
+
+ const f = async () => {
+ const loadStartedSnapshot = store.getState()
+ const generationAtLoadStart = loadStartedSnapshot.generation
+ // Whether the window this load was fetched against is still the loaded one. The generation
+ // moves on every clear and on a conversation change, so a response that would repopulate a
+ // window the reader has already left - jump to recent, a centered jump, a different
+ // conversation - is refused rather than merged into whatever replaced it.
+ const ownsTheWindow = () => store.getState().generation === generationAtLoadStart
+ const isCurrentLoad = () => isMounted() && ownsTheWindow()
+
+ // applyThreadLoad drops the window gate when a load refills the window, but a load can end
+ // without ever applying: offline, scchatnotinteam, a response carrying no thread, or a bail
+ // before the RPC is even made. Left alone the gate would keep dropping notifications for the
+ // life of the provider, which is a thread that silently stops receiving messages.
+ //
+ // Claimed here, before the first await, rather than when a response arrives: both clearing
+ // requests bypass the load throttle and call in synchronously, so the reload the clear issued
+ // is always the first to get here, and a load that ends without ever applying still has to be
+ // the one that releases.
+ const loadID = gate.nextLoadID++
+ if (loadStartedSnapshot.windowCleared && gate.refillOwner === undefined) {
+ gate.refillOwner = loadID
+ }
+ // Not gated on the provider still being mounted: an unmounted load is exactly the one with
+ // nothing coming after it, so leaving the gate up would strand the window for good.
+ const releaseWindowGate = () => {
+ if (!ownsTheWindow() || !store.getState().windowCleared) {
+ return
+ }
+ // An unclaimed gate is released by whoever settles first: nothing claimed it, so there is no
+ // reload in flight to protect, and leaving it up would strand the thread.
+ if (gate.refillOwner !== undefined && gate.refillOwner !== loadID) {
+ return
+ }
+ gate.refillOwner = undefined
+ actions.releaseWindowGate()
+ }
+ // Every bail from here on releases: the clear issues its reload synchronously, so if that
+ // reload is the one bailing there is nothing else coming to take the gate down, and the thread
+ // stops receiving messages for good.
+ if (!isCurrentLoad()) {
+ logger.info('requestWindow: bail: stale thread load')
+ releaseWindowGate()
+ return
+ }
+
+ const currentMeta = getMeta(conversationIDKey)
+ if (currentMeta.membershipType === 'youAreReset' || currentMeta.rekeyers.size > 0) {
+ logger.info('requestWindow: bail: we are reset')
+ releaseWindowGate()
+ return
+ }
+ const loadStartedLiveUpdateVersion = loadStartedSnapshot.liveUpdateVersion
+ // A refresh of a window we already hold must not overwrite what a notification streamed into it
+ // while the RPC was out. Kept apart from the generation on purpose: this is content churn
+ // inside one window, not a new window, and folding the two would remount the thread list on
+ // every incoming message.
+ const protectLoadedFocusRefresh =
+ loadStartedSnapshot.loaded &&
+ scrollDirection === 'none' &&
+ !centeredOn &&
+ (reason === 'focused' || reason === 'tab selected')
+ logger.info(
+ `requestWindow: calling rpc convo: ${conversationIDKey} num: ${numberOfMessagesToLoad} reason: ${reason}`
+ )
+
+ const loadingKey = Strings.waitingKeyChatThreadLoad(conversationIDKey)
+ // What this load has put in the window, filled in by addMessagesToThreadState as each pass
+ // applies. Once the service has sent a cached thread it switches the full response to
+ // INCREMENTAL, which walks the authoritative window and sends only the messages that cached
+ // pass did not already carry unchanged (mergeLocalRemoteThread in go/chat/uithreadloader.go,
+ // where localSentThread is that exact pass). Neither pass is a whole window on its own, so the
+ // two are gathered here and the last one reconciles against the both of them.
+ const carried = new Set()
+ // A load is all or nothing. Once one of its passes is turned away, the rest of them are too:
+ // the service filters each pass against what it has already sent this load, so the ones that
+ // follow a refused pass are a subset of a window we never took, and both ways of using them
+ // are wrong. Merging one into whatever refilled the window in the meantime is the disjoint
+ // window this whole invariant exists to prevent; reconciling against one takes out every row
+ // between the few messages it happens to carry.
+ let refusedAPass = false
+ // Whether the service's cached goroutine reported at all - with a thread, or with the nil it
+ // sends when the local cache had nothing. It is the only evidence the client gets that the
+ // full pass was not filtered behind our back: the service records the cached thread as sent
+ // before it marshals it, so a marshal failure there leaves us with an INCREMENTAL full pass
+ // and no sign of the pass it was filtered against (LoadNonblock in
+ // go/chat/uithreadloader.go). No report, no reconciling.
+ let sawCachedReport = false
+ // The reload below is judged against the whole load, not one pass of it. A warm-cache load
+ // delivers the page on the cached pass and then an INCREMENTAL full pass carrying only what
+ // changed, so measuring the full pass alone says "added nothing" for a perfectly good page.
+ // Measuring from before either pass tells the two apart: a page of real messages moves this,
+ // a page of tombstones does not, wherever it arrived.
+ const floorAtLoadStart = loadStartedSnapshot.messageOrdinals?.[0]
+ let oldestSeenThisLoad = Number.MAX_SAFE_INTEGER as T.Chat.MessageID
+ const onGotThread = (thread: string, why: string) => {
+ if (!thread) {
+ return
+ }
+ if (refusedAPass) {
+ logger.info(`requestWindow: pass ignored, an earlier one of this load was: ${why}`)
+ return
+ }
+ const refuse = (msg: string) => {
+ refusedAPass = true
+ logger.info(msg)
+ }
+ if (!isCurrentLoad()) {
+ refuse(`requestWindow: response ignored, the window it was fetched against is gone: ${why}`)
+ return
+ }
+ // The generation cannot separate two loads issued after the same clear, and the second one is
+ // not hypothetical: a ChatThreadsStale reload fetches the newest page, not the region the
+ // clear asked for. If it answers first it would fill the cleared window with that disjoint
+ // page and drop the gate, and the reload the clear issued would then merge its own page into
+ // the leftovers. While the gate is up only its owner may refill the window; once the owner
+ // settles the gate is down and everyone applies normally again.
+ const snapshotAtResponse = store.getState()
+ if (
+ snapshotAtResponse.windowCleared &&
+ gate.refillOwner !== undefined &&
+ gate.refillOwner !== loadID
+ ) {
+ refuse(`requestWindow: response ignored, another load owns the window: ${why}`)
+ return
+ }
+ if (protectLoadedFocusRefresh && snapshotAtResponse.liveUpdateVersion !== loadStartedLiveUpdateVersion) {
+ refuse(
+ `requestWindow: stale response ignored after live update: ${why} reason=${reason} convID=${conversationIDKey}`
+ )
+ return
+ }
+
+ const {username, devicename} = getCurrentUser()
+ const {messages, pagination} = Message.parseUIMessagesJSON(
+ conversationIDKey,
+ thread,
+ username,
+ devicename,
+ () => getLastOrdinalFromSnapshot(store.getState())
+ )
+ const moreToLoad = pagination ? !pagination.last : true
+ const canMarkReadForThreadWindow =
+ allowMarkAsRead &&
+ !centeredOn &&
+ scrollDirection !== 'back' &&
+ reason !== 'findNewestConversation' &&
+ reason !== 'findNewestConversationFromLayout'
+ // Reconciling is only safe against a whole window, and a single pass is not one: the cached
+ // pass is whatever the local cache holds, gaps included, and the full pass behind it carries
+ // only what changed. The full pass is the last one, so it is the one that prunes - against
+ // everything both passes delivered. Waiting instead for a pass with no cached one before it
+ // would leave the stale-row cleanup running on cold caches only, which is where ghost rows
+ // are least likely to be: a reopened conversation is warm every time.
+ const reconcile: ThreadLoadReconcile | undefined =
+ scrollDirection === 'none' ? {carried, prune: why === 'full' && sawCachedReport} : undefined
+ for (const m of messages) {
+ if (m.id > 0 && m.id < oldestSeenThisLoad) {
+ oldestSeenThisLoad = m.id
+ }
+ }
+ if (!mayJoinWindow(snapshotAtResponse, messages, scrollDirection)) {
+ // A disjoint page is the case the refusal exists for, so it retires the whole load rather
+ // than just this pass. A warm cache sends the page on the cached pass and an INCREMENTAL
+ // full pass behind it carrying only what changed - so letting the load continue past a
+ // skipped cached pass leaves `sawCachedReport` set with nothing in `carried`, and an empty
+ // full pass then joins unopposed and prunes a centered window against the handful of rows
+ // it happens to hold, marking an old window read on the way out.
+ refuse(`requestWindow: pass ignored, it does not join the loaded window: ${why}`)
+ return
+ }
+ actions.applyThreadLoad({
+ centered: !!centeredOn,
+ disableActiveMarkRead: !allowMarkAsRead || !!centeredOn,
+ enableActiveMarkRead: canMarkReadForThreadWindow,
+ messages,
+ moreToLoad,
+ reconcile,
+ scrollDirection,
+ })
+ // Only a pass that actually rendered something drops the gate. A cold cache sends an empty
+ // cached pass ahead of the full response, and a page can be all tombstones: dropping the gate
+ // on either would let a notification arriving before the real page install itself as the
+ // whole window and strand once that page lands. A load that ends without ever producing an
+ // ordinal releases the gate in its own finally instead.
+ if (renderedMessages(messages).length) {
+ gate.refillOwner = undefined
+ actions.releaseWindowGate()
+ }
+ const after = store.getState()
+ // A back page can be composed entirely of messages the thread will never render: a message
+ // superseded by a DELETE arrives as a hidden placeholder, becomes `deleted`, and addMessages
+ // drops it. The ordinal list is then identical to what it was, so the list never fires
+ // onStartReached again and scrollback stops even though the pager says there is more. Ask for
+ // the next page ourselves.
+ //
+ // The tombstones still carry message IDs, and each page reaches further back than the last,
+ // so requiring strict progress terminates: message IDs are finite and only ever decrease
+ // here. Strict progress alone is a weak bound though - a channel whose history was largely
+ // expunged has tens of thousands of them, which is minutes of paging off one gesture - so the
+ // chain also stops after maxBackPageReloads. Stopping is safe: the reader is still pinned at
+ // the top with an unchanged list, and scrolling away and back fires onStartReached again,
+ // which starts a fresh chain from wherever this one left off.
+ const floorAfter = after.messageOrdinals?.[0]
+ const windowGrewDownward =
+ floorAfter !== undefined && (floorAtLoadStart === undefined || floorAfter < floorAtLoadStart)
+ if (
+ scrollDirection === 'back' &&
+ // The full pass is the last one of a load, so by here the whole load has been applied.
+ why === 'full' &&
+ moreToLoad &&
+ // The floor, not the count: a page can add real messages while its `deleted` entries
+ // remove more from the window, which nets negative on a count but is real progress.
+ !windowGrewDownward &&
+ oldestSeenThisLoad < (retryBelowMessageID ?? Number.MAX_SAFE_INTEGER) &&
+ retryCount < maxBackPageReloads
+ ) {
+ logger.info(
+ `requestWindow: back page added no ordinals, reloading below ${oldestSeenThisLoad} (${
+ retryCount + 1
+ }/${maxBackPageReloads}): convID: ${conversationIDKey}`
+ )
+ // Back through the throttled entry point, not straight into another load: a long run of
+ // tombstones would otherwise issue these back to back with no pacing. The throttle only
+ // ever drops a call that a later load supersedes, and that load extends the window or
+ // retries in turn.
+ //
+ // The delay has a cost: the next page comes from a cursor the daemon holds, not one we
+ // send. pgmode is SERVER (see thread-rpc), so `next` resolves against convPageStatus in the
+ // service, and any first-page request resets it (applyPagerModeOutgoing in
+ // go/chat/uithreadloader.go) - which every 'none' load is, stale and focus reloads
+ // included. One landing inside the throttle window makes this retry fetch near the top of
+ // the thread instead of the next page back. It fails closed rather than looping:
+ // oldestSeenThisLoad is then no lower than retryBelowMessageID, so the chain stops and the
+ // reader is left where another scroll gesture starts a fresh one.
+ //
+ // Sizing, for the same reason the chain is bounded at all: a full run is 11 sequential
+ // 100-message RPCs off one gesture, several seconds of paging with nothing visible moving.
+ reload({...load, retryBelowMessageID: oldestSeenThisLoad, retryCount: retryCount + 1})
+ }
+
+ if (canMarkReadForThreadWindow) {
+ actions.markThreadAsRead()
+ }
+ }
+
+ const messageIDControl = centeredOn
+ ? {mode: T.RPCChat.MessageIDControlMode.centered, num: numberOfMessagesToLoad, pivot: centeredOn}
+ : null
+ const pagination = messageIDControl
+ ? null
+ : scrollDirectionToPagination(scrollDirection, numberOfMessagesToLoad)
+ try {
+ const results = await loadThreadNonblock({
+ conversationIDKey,
+ messageIDControl,
+ onCachedThread: thread => {
+ sawCachedReport = true
+ onGotThread(thread, 'cached')
+ },
+ onFullThread: thread => onGotThread(thread, 'full'),
+ onThreadStatus: status => {
+ logger.info(
+ `requestWindow: thread status received: convID: ${conversationIDKey} typ: ${status.typ}`
+ )
+ if (isCurrentLoad()) {
+ onThreadLoadStatus(conversationIDKey, status.typ)
+ }
+ },
+ pagination,
+ reason: threadLoadReasonToRPCReason(reason),
+ waitingKey: loadingKey,
+ })
+ if (!isCurrentLoad()) {
+ return
+ }
+ updateInboxConversationMeta(conversationIDKey, {offline: results.offline})
+ } catch (error) {
+ if (!isCurrentLoad()) {
+ return
+ }
+ if (error instanceof RPCError) {
+ logger.warn(`requestWindow: error: ${error.desc}`)
+ if (error.code === T.RPCGen.StatusCode.scchatnotinteam) {
+ // We're no longer in this conv's team. Clear the persisted last-route
+ // (ui.routeState2) so app startup doesn't keep restoring and reloading
+ // this conv, which would re-trigger this error on every launch.
+ persistRoute(true, true, () => useConfigState.getState().startup.loaded)
+ navigateToInbox(true, 'maybeKickedFromTeam')
+ }
+ if (error.code !== T.RPCGen.StatusCode.scteamreaderror) {
+ throw error
+ }
+ }
+ } finally {
+ releaseWindowGate()
+ }
+ }
+
+ ignorePromise(f())
+}
+
+const renderedMessages = (messages: ReadonlyArray) =>
+ messages.filter(m => m.conversationMessage !== false && m.type !== 'deleted')
+
+// Whether a page may join the window we hold, judged on what it carried rather than on the state of
+// the window.
+//
+// A 'none' load fetches the newest page, and a window with more to load forward does not reach it.
+// Merging the two leaves ordinals with a hole through the middle, and the window then reports
+// itself as containing the latest message - which is the gap this whole invariant is about,
+// arriving through a ChatThreadsStale reload while the reader sits on a search result. Both
+// conditions are needed: a window that already reaches the newest message merges fine, and so does
+// a page that overlaps what we hold, however far back the reader is.
+const mayJoinWindow = (
+ snapshot: ConversationThreadState,
+ messages: ReadonlyArray,
+ scrollDirection: ScrollDirection
+) => {
+ const rendered = renderedMessages(messages)
+ const windowOrdinals = snapshot.messageOrdinals
+ const floor = windowOrdinals?.[0]
+ const ceiling = windowOrdinals?.[windowOrdinals.length - 1]
+ if (
+ scrollDirection !== 'none' ||
+ !rendered.length ||
+ !snapshot.moreToLoadForward ||
+ floor === undefined ||
+ ceiling === undefined
+ ) {
+ return true
+ }
+ let lowest = Number.MAX_SAFE_INTEGER
+ let highest = Number.MIN_SAFE_INTEGER
+ for (const m of rendered) {
+ lowest = Math.min(lowest, m.ordinal)
+ highest = Math.max(highest, m.ordinal)
+ }
+ if (lowest > ceiling || highest < floor) {
+ logger.info(
+ `requestWindow: page ${lowest}-${highest} does not reach window ${floor}-${ceiling}, ignoring`
+ )
+ return false
+ }
+ return true
+}
diff --git a/shared/chat/readme.md b/shared/chat/readme.md
index c77311b5c35e..df507deff25e 100644
--- a/shared/chat/readme.md
+++ b/shared/chat/readme.md
@@ -5,14 +5,14 @@ How chat works:
Chat data is split across several focused stores instead of one global redux tree. Roughly:
- **Inbox metadata** (`chat/inbox/metadata.tsx`, `useInboxMetadataState`) is the single owner of conversation `meta` (trustedState, snippet, participants pointer, draft, timestamp, etc.) and `participants`. All meta writes go through `metasReceived`, which version-gates each incoming meta against the currently stored one (`Meta.updateMeta`) so a stale/out-of-order update can't clobber newer data. Callers that already merged from the current meta (e.g. `updateInboxConversationMeta`, error metas, incremental inbox sync) pass `{force: true}` to bypass gating. Converters live in `constants/chat/meta.tsx` (`baseMetaFromUIItem` is the shared base used by the various `*ToConversationMeta` functions).
-- **Per-conversation thread store** (`chat/conversation/thread-context.tsx`) is a vanilla zustand store created fresh per mounted `ConversationThreadProvider` and destroyed when the provider unmounts. It holds `messageMap`/`messageOrdinals`/`messageIDToOrdinal`/`messageTypeMap`/`pendingOutboxToOrdinal`, live `typing` (a `Set`), exploding mode, and payment/request/flip/unfurl maps. It reads conversation meta from the inbox metadata store rather than owning its own copy (`useThreadMeta`, `getMeta`). The module is split: `thread-engine.tsx` holds engine-notification handlers (`applyMessagesUpdatedToThread`, `applyIncomingMutationToThread`, etc.) and `thread-load.tsx` holds thread-load logic (RPC calls, exploding-mode-from-gregor, pagination sizing).
+- **Per-conversation thread store** (`chat/conversation/thread-context.tsx`) is a vanilla zustand store created fresh per mounted `ConversationThreadProvider` and destroyed when the provider unmounts. It holds `messageMap`/`messageOrdinals`/`messageIDToOrdinal`/`messageTypeMap`/`pendingOutboxToOrdinal`, live `typing` (a `Set`), exploding mode, and payment/request/flip/unfurl maps. It reads conversation meta from the inbox metadata store rather than owning its own copy (`useThreadMeta`, `getMeta`). The module is split: `thread-engine.tsx` holds engine-notification handlers (`applyMessagesUpdatedToThread`, `applyIncomingMutationToThread`, etc.), `thread-window.tsx` owns the loaded window (the load RPC, the response gate, the clear-and-reload), and `thread-load.tsx` is what both draw on: meta and current-user lookups, exploding-mode-from-gregor, snapshot accessors, pagination sizing.
- **Inbox rows are computed, not cached.** `chat/inbox/rows-state.tsx` exposes `useInboxRowSmall`/`useInboxRowBig`, which `useMemo` a display row from: inbox metadata (meta + participants), `chat/inbox/layout-state.tsx` (a memoized index built from the service's `UIInboxLayout`, used as a fallback for rows whose meta isn't trusted yet), `chat/inbox/badge-state.tsx` (badge/unread counts, fully replaced from each `BadgeState` RPC payload), and `chat/inbox/typing-state.tsx` (per-conversation typing username sets, merged in from `ChatTypingUpdate`). Merge precedence is one rule: meta wins whenever it's `trusted` or `error`; otherwise the layout row fills the gaps (snippet, draft, time, mute, name-split participants).
- **Message conversion** lives in `constants/chat/message.tsx` (`uiMessageToMessage` converts a single RPC `UIMessage` to the internal `Message` type; `parseUIMessagesJSON` does the same for a JSON-stringified array, used for bulk thread-load ingestion).
- **Orange line** (the "new messages" divider) is a small standalone store, `chat/conversation/orange-line-context.tsx` (`useExplicitOrangeLineState`), keyed by conversationIDKey -> `{ordinal, version}`.
## How data flows in
-Engine notifications land in `shared/constants/init/shared.tsx`'s `_onEngineIncoming`, which calls `handleConvoEngineIncoming` (`chat/inbox/engine.tsx`) directly for chat-relevant action types. That function is the inbox-side router: it turns RPC notifications (`ChatConvUpdate`, `NewChatActivity`, `ChatTypingUpdate`, `ChatParticipantsInfo`, `ChatThreadsStale`, etc.) into calls against the metadata store (`metasReceived`, `metaReceivedError`, `updateInboxConversationMeta`), the typing store (`updateInboxTyping`), or an unbox request (`unboxRows`/`forceUnboxRowsForService`). Thread-specific engine events (message updates/mutations, reactions, attachments) are instead handled by `thread-engine.tsx`'s listeners, wired up per-conversation inside `thread-context.tsx` (`useThreadEngineListeners`) so they only run while that conversation's provider is mounted. Thread loads (initial, scrollback, centered, jump-to-recent) go through `loadMoreMessages` -> `loadConversationThreadMessages` in `thread-load.tsx`, which issues the RPC and calls back into the thread store's `applyThreadLoad`.
+Engine notifications land in `shared/constants/init/shared.tsx`'s `_onEngineIncoming`, which calls `handleConvoEngineIncoming` (`chat/inbox/engine.tsx`) directly for chat-relevant action types. That function is the inbox-side router: it turns RPC notifications (`ChatConvUpdate`, `NewChatActivity`, `ChatTypingUpdate`, `ChatParticipantsInfo`, `ChatThreadsStale`, etc.) into calls against the metadata store (`metasReceived`, `metaReceivedError`, `updateInboxConversationMeta`), the typing store (`updateInboxTyping`), or an unbox request (`unboxRows`/`forceUnboxRowsForService`). Thread-specific engine events (message updates/mutations, reactions, attachments) are instead handled by `thread-engine.tsx`'s listeners, wired up per-conversation inside `thread-context.tsx` (`useThreadEngineListeners`) so they only run while that conversation's provider is mounted. Thread loads (initial, scrollback, centered, jump-to-recent) all go through one door: callers name a place in the thread with `requestWindow({anchor, reason})` (`thread-window.tsx`, anchor `'newest' | 'older' | 'newer' | {centeredOn}`) and read the window back with `useThreadWindow()`. Behind it, `runThreadWindowLoad` issues the RPC and calls back into the thread store's `applyThreadLoad`; which response may become the loaded window is arbitrated there by a private gate and the store's `generation`.
## Lifecycle