From 87d7d71ea5434a103f0c786ffc2da55258840894 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 8 Jul 2026 13:39:20 +0200 Subject: [PATCH 01/36] feat: bump stream-chat and introduce new hooks --- examples/SampleApp/metro.config.js | 7 +++++-- package.json | 1 + package/src/hooks/index.ts | 2 ++ package/src/hooks/useChannel.ts | 19 +++++++++++++++++++ package/src/hooks/useMessagePaginator.ts | 17 +++++++++++++++++ yarn.lock | 9 ++++----- 6 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 package/src/hooks/useChannel.ts create mode 100644 package/src/hooks/useMessagePaginator.ts diff --git a/examples/SampleApp/metro.config.js b/examples/SampleApp/metro.config.js index c96816d4be..15f251c5c1 100644 --- a/examples/SampleApp/metro.config.js +++ b/examples/SampleApp/metro.config.js @@ -19,6 +19,9 @@ const metroExclusionList = require( const exclusionList = metroExclusionList.default || metroExclusionList; const packageDirPath = PATH.resolve(__dirname, '../../package'); const nativePackageDirPath = PATH.resolve(__dirname, '../../package/native-package'); +// Local checkout of stream-chat-js (portaled via root package.json `resolutions`). +// Metro needs the path explicitly — it does not honor Yarn's portal symlink for native bundling. +const streamChatLocalPath = '/Users/isekovanic/Projects/stream-chat-js'; const symlinked = { 'stream-chat-react-native': nativePackageDirPath, @@ -68,7 +71,7 @@ const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); const extraNodeModules = uniqueModules.reduce((acc, item) => { acc[item.packageName] = item.modulePath; return acc; -}, {}); +}, { 'stream-chat': streamChatLocalPath }); config.resolver.blockList = exclusionList(blockList); config.resolver.extraNodeModules = extraNodeModules; @@ -76,6 +79,6 @@ config.resolver.extraNodeModules = extraNodeModules; config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; // add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath]; +config.watchFolders = [packageDirPath, streamChatLocalPath]; module.exports = config; diff --git a/package.json b/package.json index a3e8cef113..a0b48341e0 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "examples/TypeScriptMessaging" ], "resolutions": { + "stream-chat": "portal:/Users/isekovanic/Projects/stream-chat-js", "@types/react": "^19.2.0", "@shopify/flash-list": "patch:@shopify/flash-list@npm%3A2.3.1#~/.yarn/patches/@shopify-flash-list-npm-2.3.1-8b5fd40241.patch" }, diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index ba43aa48f0..a31e202253 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -17,6 +17,8 @@ export * from './useViewport'; export * from './useScreenDimensions'; export * from './useScreenOrientation'; export * from './useStateStore'; +export * from './useChannel'; +export * from './useMessagePaginator'; export * from './usePendingAttachmentUpload'; export * from './useStableCallback'; export * from './useLoadingImage'; diff --git a/package/src/hooks/useChannel.ts b/package/src/hooks/useChannel.ts new file mode 100644 index 0000000000..7e11d3f720 --- /dev/null +++ b/package/src/hooks/useChannel.ts @@ -0,0 +1,19 @@ +import type { Channel } from 'stream-chat'; + +import { useChannelContext } from '../contexts/channelContext/ChannelContext'; +import { useThreadContext } from '../contexts/threadContext/ThreadContext'; + +/** + * Resolves the active `Channel` instance: the thread's channel when rendered inside a + * thread, otherwise the `ChannelContext` channel. + * + * Thread-aware analogue of stream-chat-react's `useChannel()`. Prefer this over reading + * `channel` from `useChannelContext()` directly in message/thread interaction code, so the + * same code path targets the correct channel in both the main list and a thread. + */ +export const useChannel = (): Channel => { + const { channel } = useChannelContext(); + const { threadInstance } = useThreadContext(); + + return threadInstance?.channel ?? channel; +}; diff --git a/package/src/hooks/useMessagePaginator.ts b/package/src/hooks/useMessagePaginator.ts new file mode 100644 index 0000000000..8a751491ec --- /dev/null +++ b/package/src/hooks/useMessagePaginator.ts @@ -0,0 +1,17 @@ +import type { MessagePaginator } from 'stream-chat'; + +import { useChannelContext } from '../contexts/channelContext/ChannelContext'; +import { useThreadContext } from '../contexts/threadContext/ThreadContext'; + +/** + * Resolves the active `MessagePaginator` from `stream-chat`: the thread's paginator when + * rendered inside a thread, otherwise the channel's main-list paginator. + * + * Must be called within a `` subtree (and optionally a `` subtree). + */ +export const useMessagePaginator = (): MessagePaginator => { + const { channel } = useChannelContext(); + const { threadInstance } = useThreadContext(); + + return threadInstance?.messagePaginator ?? channel.messagePaginator; +}; diff --git a/yarn.lock b/yarn.lock index 14410c95fd..53da654f7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19683,9 +19683,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.50.0": - version: 9.50.0 - resolution: "stream-chat@npm:9.50.0" +"stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js::locator=root%40workspace%3A.": + version: 0.0.0-use.local + resolution: "stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js::locator=root%40workspace%3A." dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -19701,9 +19701,8 @@ __metadata: built: true husky: built: true - checksum: 10c0/be5941d74946bd1a1371192dd71933a991fd4d8dd070975ee47dca31f938a9c604b6590af5639932f433735907e76a03a7d3519df23c0807f55ef189b21e0c1d languageName: node - linkType: hard + linkType: soft "stream-combiner2@npm:~1.1.1": version: 1.1.1 From 3888d4843a82e14136c52c15b8d05eff149b03f6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 8 Jul 2026 13:44:48 +0200 Subject: [PATCH 02/36] feat: include message action handlers --- examples/SampleApp/metro.config.js | 11 ++- package/src/components/Channel/Channel.tsx | 9 ++ .../useChannelRequestHandlers.test.ts | 97 +++++++++++++++++++ .../hooks/useChannelRequestHandlers.ts | 83 ++++++++++++++++ 4 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts create mode 100644 package/src/components/Channel/hooks/useChannelRequestHandlers.ts diff --git a/examples/SampleApp/metro.config.js b/examples/SampleApp/metro.config.js index 15f251c5c1..c3d30e5ce4 100644 --- a/examples/SampleApp/metro.config.js +++ b/examples/SampleApp/metro.config.js @@ -68,10 +68,13 @@ const uniqueModules = dependencyPackageNames.map((packageName) => { const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); // provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce((acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; -}, { 'stream-chat': streamChatLocalPath }); +const extraNodeModules = uniqueModules.reduce( + (acc, item) => { + acc[item.packageName] = item.modulePath; + return acc; + }, + { 'stream-chat': streamChatLocalPath }, +); config.resolver.blockList = exclusionList(blockList); config.resolver.extraNodeModules = extraNodeModules; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 4e4848ca7b..b317dfdbac 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -25,6 +25,7 @@ import { } from 'stream-chat'; import { useChannelDataState } from './hooks/useChannelDataState'; +import { useChannelRequestHandlers } from './hooks/useChannelRequestHandlers'; import { useCreateChannelContext } from './hooks/useCreateChannelContext'; import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessageInputContext'; @@ -548,6 +549,14 @@ const ChannelWithContext = (props: PropsWithChildren) = const channelId = channel?.id || ''; const pollCreationEnabled = !channel.disconnected && !!channel?.id && channel?.getConfig()?.polls; + // Register the integrator's custom message-request overrides into channel.configState so the + // stream-chat message-operations engine (send/retry/update via *WithLocalUpdate) honors them. + useChannelRequestHandlers({ + channel, + doSendMessageRequest, + doUpdateMessageRequest, + }); + const { copyStateFromChannel, initStateFromChannel, diff --git a/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts b/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts new file mode 100644 index 0000000000..bdb66f52cc --- /dev/null +++ b/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts @@ -0,0 +1,97 @@ +import { renderHook } from '@testing-library/react-native'; +import type { Channel, LocalMessage, Message } from 'stream-chat'; + +import { useChannelRequestHandlers } from '../useChannelRequestHandlers'; + +// NOTE: `stream-chat` is portaled to a local checkout during this migration; a runtime +// (value) import of it breaks jest resolution. Everything from `stream-chat` here is a +// type-only import (erased at compile time), and `configState` is faked, so no runtime +// `require('stream-chat')` happens. + +type FakeRequestHandlers = Record unknown>; +type FakeConfig = { requestHandlers?: FakeRequestHandlers }; + +const createChannel = ( + sendMessage: jest.Mock = jest.fn().mockResolvedValue({ message: { id: 'fallback' } }), +) => { + let config: FakeConfig = {}; + const configState = { + getLatestValue: (): FakeConfig => config, + partialNext: (patch: FakeConfig) => { + config = { ...config, ...patch }; + }, + }; + const channel = { cid: 'messaging:test', configState, sendMessage } as unknown as Channel; + return { channel, configState, getHandlers: () => config.requestHandlers, sendMessage }; +}; + +const localMessage = { id: 'm1', text: 'hi' } as unknown as LocalMessage; +const message = { text: 'hi' } as unknown as Message; + +describe('useChannelRequestHandlers', () => { + it('registers no managed handlers when no overrides are provided', () => { + const { channel, getHandlers } = createChannel(); + + renderHook(() => useChannelRequestHandlers({ channel })); + + expect(getHandlers()).toBeUndefined(); + }); + + it('registers send + retry from doSendMessageRequest and returns the override response', async () => { + const { channel, getHandlers } = createChannel(); + const doSendMessageRequest = jest.fn().mockResolvedValue({ message: { id: 'override' } }); + + renderHook(() => useChannelRequestHandlers({ channel, doSendMessageRequest })); + + expect(getHandlers()?.sendMessageRequest).toBeDefined(); + // send and retry share the same wrapper. + expect(getHandlers()?.retrySendMessageRequest).toBe(getHandlers()?.sendMessageRequest); + + const result = await getHandlers()?.sendMessageRequest?.({ localMessage, message }); + expect(doSendMessageRequest).toHaveBeenCalledWith('messaging:test', message, undefined); + expect(result).toEqual({ message: { id: 'override' } }); + }); + + it('falls back to channel.sendMessage when the override resolves without a message', async () => { + const { channel, getHandlers, sendMessage } = createChannel(); + const doSendMessageRequest = jest.fn().mockResolvedValue(undefined); + + renderHook(() => useChannelRequestHandlers({ channel, doSendMessageRequest })); + + const result = await getHandlers()?.sendMessageRequest?.({ localMessage, message }); + expect(sendMessage).toHaveBeenCalledWith(message, undefined); + expect(result).toEqual({ message: { id: 'fallback' } }); + }); + + it('registers updateMessageRequest from doUpdateMessageRequest', async () => { + const { channel, getHandlers } = createChannel(); + const doUpdateMessageRequest = jest.fn().mockResolvedValue({ message: { id: 'updated' } }); + + renderHook(() => useChannelRequestHandlers({ channel, doUpdateMessageRequest })); + + const result = await getHandlers()?.updateMessageRequest?.({ localMessage }); + expect(doUpdateMessageRequest).toHaveBeenCalledWith('messaging:test', localMessage, undefined); + expect(result).toEqual({ message: { id: 'updated' } }); + }); + + it('clears managed handlers when overrides are removed, preserving unrelated handlers', () => { + const { channel, configState, getHandlers } = createChannel(); + const markReadRequest = jest.fn(); + configState.partialNext({ requestHandlers: { markReadRequest } }); + + const doSendMessageRequest = jest.fn(); + const { rerender } = renderHook( + ({ send }: { send?: typeof doSendMessageRequest }) => + useChannelRequestHandlers({ channel, doSendMessageRequest: send }), + { initialProps: { send: doSendMessageRequest as typeof doSendMessageRequest | undefined } }, + ); + expect(getHandlers()?.sendMessageRequest).toBeDefined(); + + rerender({ send: undefined }); + + expect(getHandlers()?.sendMessageRequest).toBeUndefined(); + expect(getHandlers()?.retrySendMessageRequest).toBeUndefined(); + // an unrelated handler registered elsewhere must be preserved. + expect(getHandlers()?.markReadRequest).toBe(markReadRequest); + }); +}); diff --git a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts new file mode 100644 index 0000000000..43ebb65749 --- /dev/null +++ b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts @@ -0,0 +1,83 @@ +import { useEffect } from 'react'; + +import type { + Channel, + ChannelInstanceConfig, + Message, + SendMessageAPIResponse, + SendMessageOptions, + StreamChat, + UpdateMessageOptions, +} from 'stream-chat'; + +type RequestHandlers = NonNullable; + +export type ChannelRequestHandlersParams = { + channel: Channel; + /** Overrides the default send/retry request. Mirrors the `` prop. */ + doSendMessageRequest?: ( + channelId: string, + message: Message, + options?: SendMessageOptions, + ) => Promise; + /** Overrides the default update request. Mirrors the `` prop. */ + doUpdateMessageRequest?: ( + channelId: string, + updatedMessage: Parameters[0], + options?: UpdateMessageOptions, + ) => ReturnType; +}; + +/** + * Registers the integrator's custom message-request overrides into + * `channel.configState.requestHandlers` so the `stream-chat` message-operations engine + * (`channel.sendMessageWithLocalUpdate` / `retrySendMessageWithLocalUpdate` / + * `updateMessageWithLocalUpdate`) honors them. + * + * The handlers this hook manages are (re)written whenever the channel or an override + * changes; overrides that are not provided are cleared so the client defaults apply. + * Delete and mark-read are intentionally left to the client default / the mark-read flow. + */ +export const useChannelRequestHandlers = ({ + channel, + doSendMessageRequest, + doUpdateMessageRequest, +}: ChannelRequestHandlersParams) => { + useEffect(() => { + const currentRequestHandlers = channel.configState.getLatestValue().requestHandlers; + const nextRequestHandlers: RequestHandlers = { ...(currentRequestHandlers ?? {}) }; + + // Reset the handlers this hook manages, then register only the provided overrides. + delete nextRequestHandlers.retrySendMessageRequest; + delete nextRequestHandlers.sendMessageRequest; + delete nextRequestHandlers.updateMessageRequest; + + if (doSendMessageRequest) { + const sendMessageRequest: RequestHandlers['sendMessageRequest'] = async ({ + message, + options, + }) => { + const response = await doSendMessageRequest(channel.cid, message as Message, options); + if (response?.message) { + return { message: response.message }; + } + const fallback = await channel.sendMessage(message as Message, options); + return { message: fallback.message }; + }; + + nextRequestHandlers.sendMessageRequest = sendMessageRequest; + nextRequestHandlers.retrySendMessageRequest = sendMessageRequest; + } + + if (doUpdateMessageRequest) { + nextRequestHandlers.updateMessageRequest = async ({ localMessage, options }) => ({ + message: (await doUpdateMessageRequest(channel.cid, localMessage, options)).message, + }); + } + + channel.configState.partialNext({ + requestHandlers: + Object.keys(nextRequestHandlers).length > 0 ? nextRequestHandlers : undefined, + }); + }, [channel, doSendMessageRequest, doUpdateMessageRequest]); +}; From 9f10e373d91e5a9882858622beb0ade7ee5328dd Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 8 Jul 2026 14:20:37 +0200 Subject: [PATCH 03/36] feat: use stat stores --- package/src/components/Channel/Channel.tsx | 66 +++++++++---------- .../Channel/__tests__/Channel.test.tsx | 10 +-- .../Channel/hooks/useChannelDataState.ts | 61 +---------------- .../hooks/useCreateOwnCapabilitiesContext.ts | 43 +++++------- .../hooks/useChannelMembersState.ts | 23 +++---- .../hooks/useChannelOnlineMemberCount.ts | 18 +++-- .../ChannelList/hooks/useMutedUsers.ts | 17 ++--- .../Chat/hooks/useClientMutedUsers.ts | 24 +++---- .../Message/hooks/useMessageDeliveryData.ts | 53 +++++---------- .../Message/hooks/useMessageReadData.ts | 54 +++++---------- .../src/hooks/useChannelOwnCapabilities.ts | 19 +++--- 11 files changed, 130 insertions(+), 258 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index b317dfdbac..2a969b007e 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -12,19 +12,22 @@ import { EventHandler, LocalMessage, localMessageToNewMessagePayload, + MembersState, MessageLabel, MessageResponse, Reaction, + ReadState, SendMessageAPIResponse, SendMessageOptions, StreamChat, Event as StreamEvent, Message as StreamMessage, Thread, + TypingUsersState, UpdateMessageOptions, + WatcherState, } from 'stream-chat'; -import { useChannelDataState } from './hooks/useChannelDataState'; import { useChannelRequestHandlers } from './hooks/useChannelRequestHandlers'; import { useCreateChannelContext } from './hooks/useCreateChannelContext'; @@ -85,7 +88,7 @@ import { useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; import { TypingProvider } from '../../contexts/typingContext/TypingContext'; -import { useStableCallback } from '../../hooks'; +import { useStableCallback, useStateStore } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; import { useAttachmentPickerBottomSheet } from '../../hooks/useAttachmentPickerBottomSheet'; @@ -386,6 +389,14 @@ export type ChannelPropsWithContext = Pick & initializeOnMount?: boolean; }; +const membersStateSelector = (state: MembersState) => ({ members: state.members }); +const readStateSelector = (state: ReadState) => ({ read: state.read }); +const typingStateSelector = (state: TypingUsersState) => ({ typing: state.typing }); +const watcherStateSelector = (state: WatcherState) => ({ + watcherCount: state.watcherCount, + watchers: state.watchers, +}); + const ChannelWithContext = (props: PropsWithChildren) => { const { disableAttachmentPicker = !isImageMediaLibraryAvailable(), @@ -557,13 +568,15 @@ const ChannelWithContext = (props: PropsWithChildren) = doUpdateMessageRequest, }); - const { - copyStateFromChannel, - initStateFromChannel, - setRead, - setTyping, - state: channelState, - } = useChannelDataState(channel); + // Channel scalar state (members/read/typing/watchers) is sourced reactively from + // stream-chat's per-channel StateStores. + const { members } = useStateStore(channel.state.membersStore, membersStateSelector); + const { read } = useStateStore(channel.state.readStore, readStateSelector); + const { typing } = useStateStore(channel.state.typingStore, typingStateSelector); + const { watcherCount, watchers } = useStateStore( + channel.state.watcherStore, + watcherStateSelector, + ); const { copyMessagesStateFromChannel: rawCopyMessagesStateFromChannel, @@ -593,20 +606,6 @@ const ChannelWithContext = (props: PropsWithChildren) = const { setMessages: copyMessagesStateFromChannel, viewabilityChangedCallback } = usePrunableMessageList({ maximumMessageLimit, setMessages: rawCopyMessagesStateFromChannel }); - const setReadThrottled = useMemo( - () => - throttle( - () => { - if (channel) { - setRead(channel); - } - }, - stateUpdateThrottleInterval, - throttleOptions, - ), - [channel, stateUpdateThrottleInterval, setRead], - ); - const copyMessagesStateFromChannelThrottled = useMemo( () => throttle( @@ -626,14 +625,13 @@ const ChannelWithContext = (props: PropsWithChildren) = throttle( () => { if (channel) { - copyStateFromChannel(channel); copyMessagesStateFromChannel(channel); } }, stateUpdateThrottleInterval, throttleOptions, ), - [stateUpdateThrottleInterval, channel, copyStateFromChannel, copyMessagesStateFromChannel], + [stateUpdateThrottleInterval, channel, copyMessagesStateFromChannel], ); const handleEvent: EventHandler = useStableCallback((event) => { @@ -651,11 +649,8 @@ const ChannelWithContext = (props: PropsWithChildren) = return; } - // If the event is typing.start or typing.stop, set the typing state + // Typing state is sourced reactively from channel.state.typingStore; nothing to copy here. if (event.type === 'typing.start' || event.type === 'typing.stop') { - if (event.user?.id !== client.userID) { - setTyping(channel); - } return; } else { if (thread?.id) { @@ -709,7 +704,7 @@ const ChannelWithContext = (props: PropsWithChildren) = } if (event.type === 'message.read' || event.type === 'notification.mark_read') { - setReadThrottled(); + // Read state is sourced reactively from channel.state.readStore; nothing to copy here. return; } @@ -741,7 +736,6 @@ const ChannelWithContext = (props: PropsWithChildren) = } if (!errored) { - initStateFromChannel(channel); loadInitialMessagesStateFromChannel(channel, channel.state.messagePagination.hasPrev); } @@ -1620,8 +1614,8 @@ const ChannelWithContext = (props: PropsWithChildren) = markRead, maximumMessageLimit, maxTimeBetweenGroupedMessages, - members: channelState.members ?? {}, - read: channelState.read ?? {}, + members: members ?? {}, + read: read ?? {}, reloadChannel, scrollToFirstUnreadThreshold, setChannelUnreadState, @@ -1630,8 +1624,8 @@ const ChannelWithContext = (props: PropsWithChildren) = targetedMessage, threadList, uploadAbortControllerRef, - watcherCount: channelState.watcherCount, - watchers: channelState.watchers, + watcherCount, + watchers, }); // This is mainly a hack to get around an issue with sendMessage not being passed correctly as a @@ -1762,7 +1756,7 @@ const ChannelWithContext = (props: PropsWithChildren) = }); const typingContext = useCreateTypingContext({ - typing: channelState.typing ?? {}, + typing: typing ?? {}, }); const audioPlayerContext = useMemo( diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index dedad14568..7de5938bea 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -28,11 +28,7 @@ import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Attachment } from '../../Attachment/Attachment'; import { Chat } from '../../Chat/Chat'; import { Channel } from '../Channel'; -import { - channelInitialState, - useChannelDataState, - useChannelMessageDataState, -} from '../hooks/useChannelDataState'; +import { channelInitialState, useChannelMessageDataState } from '../hooks/useChannelDataState'; import * as MessageListPaginationHooks from '../hooks/useMessageListPagination'; // This component is used for performing effects in a component that consumes ChannelContext, @@ -452,11 +448,11 @@ describe('Channel initial load useEffect', () => { renderComponent({ channel }); const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); - const { result: channelState } = renderHook(() => useChannelDataState(channel)); await waitFor(() => expect(watchSpy).toHaveBeenCalled()); await waitFor(() => expect(channelMessageState.current.state.messages!).toHaveLength(10)); - await waitFor(() => expect(Object.keys(channelState.current.state.members!)).toHaveLength(10)); + // members now come reactively from channel.state.membersStore (via the shim getter). + await waitFor(() => expect(Object.keys(channel.state.members)).toHaveLength(10)); }); function getElementsAround(array: T[], key: keyof T, id: unknown) { diff --git a/package/src/components/Channel/hooks/useChannelDataState.ts b/package/src/components/Channel/hooks/useChannelDataState.ts index 1a7990c27d..b201242a02 100644 --- a/package/src/components/Channel/hooks/useChannelDataState.ts +++ b/package/src/components/Channel/hooks/useChannelDataState.ts @@ -160,61 +160,6 @@ export const useChannelMessageDataState = (channel: Channel) => { }; }; -/** - * The useChannelThreadState hook that handles the state for the channel member, read, typing, watchers, etc. - */ -export const useChannelDataState = (channel: Channel) => { - const [state, setState] = useState({ - members: channel.state.members, - read: channel.state.read, - typing: {}, - watcherCount: 0, - watchers: {}, - }); - - const initStateFromChannel = useCallback( - (channel: Channel) => { - setState({ - ...state, - members: { ...channel.state.members }, - read: { ...channel.state.read }, - typing: { ...channel.state.typing }, - watcherCount: channel.state.watcher_count, - watchers: { ...channel.state.watchers }, - }); - }, - [state], - ); - - const copyStateFromChannel = useCallback((channel: Channel) => { - setState((prev) => ({ - ...prev, - members: { ...channel.state.members }, - read: { ...channel.state.read }, - watcherCount: channel.state.watcher_count, - watchers: { ...channel.state.watchers }, - })); - }, []); - - const setRead = useCallback((channel: Channel) => { - setState((prev) => ({ - ...prev, - read: { ...channel.state.read }, // Synchronize the read state from the channel - })); - }, []); - - const setTyping = useCallback((channel: Channel) => { - setState((prev) => ({ - ...prev, - typing: { ...channel.state.typing }, // Synchronize the typing state from the channel - })); - }, []); - - return { - copyStateFromChannel, - initStateFromChannel, - setRead, - setTyping, - state, - }; -}; +// Channel scalar state (members/read/typing/watchers) now comes reactively from +// stream-chat's channel.state StateStores (membersStore/readStore/typingStore/watcherStore), +// consumed directly via useStateStore — no React-state mirror needed. diff --git a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts index 053c477db7..ecb885698d 100644 --- a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts +++ b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import type { Channel } from 'stream-chat'; @@ -7,6 +7,11 @@ import { OwnCapabilitiesContextValue, OwnCapability, } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +const selector = (state: { ownCapabilities: string[] }) => ({ + ownCapabilities: state.ownCapabilities, +}); export const useCreateOwnCapabilitiesContext = ({ channel, @@ -15,35 +20,19 @@ export const useCreateOwnCapabilitiesContext = ({ channel: Channel; overrideCapabilities?: Partial; }) => { - const [own_capabilities, setOwnCapabilites] = useState( - JSON.stringify(channel.data?.own_capabilities as Array), - ); + // Sourced reactively from channel.state.ownCapabilitiesStore (kept up to date by the client + // on watch/query and `capabilities.changed`). + const { ownCapabilities = [] } = + useStateStore(channel.state.ownCapabilitiesStore, selector) ?? {}; + const overrideCapabilitiesStr = overrideCapabilities ? JSON.stringify(Object.values(overrideCapabilities)) : null; - - // Effect to watch for changes in channel.data?.own_capabilities and update the own_capabilities state accordingly. - useEffect(() => { - setOwnCapabilites(JSON.stringify(channel.data?.own_capabilities as Array)); - }, [channel.data?.own_capabilities]); - - // Effect to listen to the `capabilities.changed` event. - useEffect(() => { - const listener = channel.on('capabilities.changed', (event) => { - if (event.own_capabilities) { - setOwnCapabilites(JSON.stringify(event.own_capabilities as Array)); - } - }); - - return () => { - listener.unsubscribe(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const ownCapabilitiesStr = JSON.stringify(ownCapabilities); const ownCapabilitiesContext: OwnCapabilitiesContextValue = useMemo(() => { - const capabilities = (own_capabilities || []) as Array; - const ownCapabilitiesContext = Object.keys(allOwnCapabilities).reduce( + const capabilities = ownCapabilities as Array; + return Object.keys(allOwnCapabilities).reduce( (result, capability) => ({ ...result, [capability]: @@ -52,10 +41,8 @@ export const useCreateOwnCapabilitiesContext = ({ }), {} as OwnCapabilitiesContextValue, ); - - return ownCapabilitiesContext; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channel.id, overrideCapabilitiesStr, own_capabilities]); + }, [channel.id, overrideCapabilitiesStr, ownCapabilitiesStr]); return ownCapabilitiesContext; }; diff --git a/package/src/components/ChannelList/hooks/useChannelMembersState.ts b/package/src/components/ChannelList/hooks/useChannelMembersState.ts index 0d150e62fa..fd06d966c4 100644 --- a/package/src/components/ChannelList/hooks/useChannelMembersState.ts +++ b/package/src/components/ChannelList/hooks/useChannelMembersState.ts @@ -1,21 +1,18 @@ -import { Channel, ChannelMemberResponse, EventTypes } from 'stream-chat'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; -import { useSelectedChannelState } from '../../../hooks/useSelectedChannelState'; +import { useStateStore } from '../../../hooks/useStateStore'; -const selector = (channel: Channel) => channel.state.members; -const keys: EventTypes[] = [ - 'member.added', - 'member.updated', - 'member.removed', - 'user.banned', - 'user.unbanned', - 'user.presence.changed', - 'user.updated', -]; +const selector = (state: { members: Record }) => ({ + members: state.members, +}); + +/** + * Returns the channel's members, sourced reactively from `channel.state.membersStore`. + */ export function useChannelMembersState(channel: Channel): Record; export function useChannelMembersState( channel?: Channel, ): Record | undefined; export function useChannelMembersState(channel?: Channel) { - return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys }); + return useStateStore(channel?.state?.membersStore, selector)?.members; } diff --git a/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts b/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts index 2fc758f85c..8df2764ef1 100644 --- a/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts +++ b/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts @@ -1,17 +1,15 @@ -import { Channel, EventTypes } from 'stream-chat'; +import type { Channel } from 'stream-chat'; -import { useChatContext } from '../../../contexts'; -import { useSyncClientEventsToChannel } from '../../../hooks/useSyncClientEvents'; +import { useStateStore } from '../../../hooks/useStateStore'; -const selector = (channel: Channel) => { - return channel.state.watcher_count ?? 0; -}; - -const keys: EventTypes[] = ['user.watching.start', 'user.watching.stop']; +const selector = (state: { watcherCount: number }) => ({ watcherCount: state.watcherCount }); +/** + * Returns the channel's online (watcher) count, sourced reactively from + * `channel.state.watcherStore`. + */ export function useChannelOnlineMemberCount(channel: Channel): number; export function useChannelOnlineMemberCount(channel?: Channel): number | undefined; export function useChannelOnlineMemberCount(channel?: Channel) { - const { client } = useChatContext(); - return useSyncClientEventsToChannel({ channel, client, selector, stateChangeEventKeys: keys }); + return useStateStore(channel?.state?.watcherStore, selector)?.watcherCount; } diff --git a/package/src/components/ChannelList/hooks/useMutedUsers.ts b/package/src/components/ChannelList/hooks/useMutedUsers.ts index fdab2ff06b..b49bfee0c4 100644 --- a/package/src/components/ChannelList/hooks/useMutedUsers.ts +++ b/package/src/components/ChannelList/hooks/useMutedUsers.ts @@ -1,19 +1,14 @@ -import { Channel, EventTypes, Mute, StreamChat } from 'stream-chat'; +import type { Mute } from 'stream-chat'; import { useChatContext } from '../../../contexts'; -import { useSyncClientEvents } from '../../../hooks/useSyncClientEvents'; +import { useStateStore } from '../../../hooks/useStateStore'; -const selector = (client: StreamChat) => client.mutedUsers; -const keys: EventTypes[] = ['health.check', 'notification.mutes_updated']; +const selector = (state: { mutedUsers: Mute[] }) => ({ mutedUsers: state.mutedUsers }); -export function useMutedUsers(): Array; /** - * - * @param @deprecated _channel - This parameter is deprecated because it is no longer necessary. It is kept for backwards compatibility only. - * @returns + * Returns the current user's muted users, sourced reactively from `client.mutedUsersStore`. */ -export function useMutedUsers(_channel: Channel): Array | undefined; -export function useMutedUsers(_channel?: Channel): Array { +export function useMutedUsers(): Mute[] { const { client } = useChatContext(); - return useSyncClientEvents({ client, selector, stateChangeEventKeys: keys }); + return useStateStore(client?.mutedUsersStore, selector)?.mutedUsers ?? []; } diff --git a/package/src/components/Chat/hooks/useClientMutedUsers.ts b/package/src/components/Chat/hooks/useClientMutedUsers.ts index 46f84de9b5..c42807ab25 100644 --- a/package/src/components/Chat/hooks/useClientMutedUsers.ts +++ b/package/src/components/Chat/hooks/useClientMutedUsers.ts @@ -1,19 +1,11 @@ -import { useEffect, useState } from 'react'; +import type { Mute, StreamChat } from 'stream-chat'; -import type { Event, Mute, StreamChat } from 'stream-chat'; +import { useStateStore } from '../../../hooks/useStateStore'; -export const useClientMutedUsers = (client: StreamChat) => { - const [mutedUsers, setMutedUsers] = useState(client?.mutedUsers || []); +const selector = (state: { mutedUsers: Mute[] }) => ({ mutedUsers: state.mutedUsers }); - useEffect(() => { - const handleEvent = (event: Event) => { - setMutedUsers((mutes) => event.me?.mutes || mutes || []); - }; - - const listener = client?.on('notification.mutes_updated', handleEvent); - return () => listener?.unsubscribe(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setMutedUsers]); - - return mutedUsers; -}; +/** + * Returns the client's muted users, sourced reactively from `client.mutedUsersStore`. + */ +export const useClientMutedUsers = (client: StreamChat): Mute[] => + useStateStore(client?.mutedUsersStore, selector)?.mutedUsers ?? []; diff --git a/package/src/components/Message/hooks/useMessageDeliveryData.ts b/package/src/components/Message/hooks/useMessageDeliveryData.ts index 508ec4a202..75af50df8c 100644 --- a/package/src/components/Message/hooks/useMessageDeliveryData.ts +++ b/package/src/components/Message/hooks/useMessageDeliveryData.ts @@ -1,46 +1,29 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; -import { Event, LocalMessage, UserResponse } from 'stream-chat'; +import type { LocalMessage, MessageReceiptsSnapshot, UserResponse } from 'stream-chat'; import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; -import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { useStableCallback } from '../../../hooks'; +import { useStateStore } from '../../../hooks/useStateStore'; +const EMPTY_DELIVERED: UserResponse[] = []; + +/** + * Returns the users the given message has been delivered to, sourced reactively from the + * channel's `messageReceiptsTracker.snapshotStore` (precomputed `deliveredByMessageId`). + * Updates whenever the receipts snapshot changes — no manual event subscription required. + */ export const useMessageDeliveredData = ({ message }: { message?: LocalMessage }) => { const { channel } = useChannelContext(); - const { client } = useChatContext(); - - const messageIdRef = useRef(message?.id); - - const calculate = useStableCallback(() => { - if (!message?.created_at) { - return []; - } - const messageRef = { - msgId: message.id, - timestampMs: new Date(message.created_at).getTime(), - }; - return channel.messageReceiptsTracker.deliveredForMessage(messageRef); - }); - - const [deliveredTo, setDeliveredTo] = useState(() => calculate()); + const messageId = message?.id ?? ''; - if (!!messageIdRef.current && !!message?.id && messageIdRef.current !== message.id) { - setDeliveredTo(calculate()); - messageIdRef.current = message.id; - } + const selector = useCallback( + (snapshot: MessageReceiptsSnapshot) => ({ + deliveredTo: snapshot.deliveredByMessageId[messageId] ?? EMPTY_DELIVERED, + }), + [messageId], + ); - useEffect(() => { - const { unsubscribe } = channel.on('message.delivered', (event: Event) => { - /** - * An optimization to only re-calculate if the event is received by a different user. - */ - if (event.user?.id !== client.user?.id) { - setDeliveredTo(calculate()); - } - }); - return unsubscribe; - }, [channel, calculate, client.user?.id]); + const { deliveredTo } = useStateStore(channel.messageReceiptsTracker.snapshotStore, selector); return deliveredTo; }; diff --git a/package/src/components/Message/hooks/useMessageReadData.ts b/package/src/components/Message/hooks/useMessageReadData.ts index 1c72027c7d..9b619eca20 100644 --- a/package/src/components/Message/hooks/useMessageReadData.ts +++ b/package/src/components/Message/hooks/useMessageReadData.ts @@ -1,47 +1,29 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; -import { Event, LocalMessage, UserResponse } from 'stream-chat'; +import type { LocalMessage, MessageReceiptsSnapshot, UserResponse } from 'stream-chat'; import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; -import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { useStableCallback } from '../../../hooks'; +import { useStateStore } from '../../../hooks/useStateStore'; +const EMPTY_READERS: UserResponse[] = []; + +/** + * Returns the users who have read the given message, sourced reactively from the channel's + * `messageReceiptsTracker.snapshotStore` (precomputed `readersByMessageId`). Updates whenever + * the receipts snapshot changes — no manual event subscription required. + */ export const useMessageReadData = ({ message }: { message?: LocalMessage }) => { const { channel } = useChannelContext(); - const { client } = useChatContext(); - - const messageIdRef = useRef(message?.id); - - const calculate = useStableCallback(() => { - if (!message?.created_at) { - return []; - } - const messageRef = { - msgId: message.id, - timestampMs: new Date(message.created_at).getTime(), - }; - - return channel.messageReceiptsTracker.readersForMessage(messageRef); - }); - - const [readBy, setReadBy] = useState(() => calculate()); + const messageId = message?.id ?? ''; - if (!!messageIdRef.current && !!message?.id && messageIdRef.current !== message.id) { - setReadBy(calculate()); - messageIdRef.current = message.id; - } + const selector = useCallback( + (snapshot: MessageReceiptsSnapshot) => ({ + readBy: snapshot.readersByMessageId[messageId] ?? EMPTY_READERS, + }), + [messageId], + ); - useEffect(() => { - const { unsubscribe } = channel.on('message.read', (event: Event) => { - /** - * An optimization to only re-calculate if the event is received by a different user. - */ - if (event.user?.id !== client.user?.id) { - setReadBy(calculate()); - } - }); - return unsubscribe; - }, [channel, calculate, client.user?.id]); + const { readBy } = useStateStore(channel.messageReceiptsTracker.snapshotStore, selector); return readBy; }; diff --git a/package/src/hooks/useChannelOwnCapabilities.ts b/package/src/hooks/useChannelOwnCapabilities.ts index 94f52bdb86..a5377df5fd 100644 --- a/package/src/hooks/useChannelOwnCapabilities.ts +++ b/package/src/hooks/useChannelOwnCapabilities.ts @@ -1,12 +1,15 @@ -import { Channel, EventTypes } from 'stream-chat'; +import type { Channel } from 'stream-chat'; -import { useSelectedChannelState } from './useSelectedChannelState'; +import { useStateStore } from './useStateStore'; -const selector = (channel: Channel) => channel.data?.own_capabilities as string[] | undefined; -const keys: EventTypes[] = ['capabilities.changed']; +const selector = (state: { ownCapabilities: string[] }) => ({ + ownCapabilities: state.ownCapabilities, +}); -export function useChannelOwnCapabilities(channel: Channel): string[] | undefined; -export function useChannelOwnCapabilities(channel?: Channel): string[] | undefined; -export function useChannelOwnCapabilities(channel?: Channel) { - return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys }); +/** + * Returns the current user's capabilities for the channel, sourced reactively from + * `channel.state.ownCapabilitiesStore`. + */ +export function useChannelOwnCapabilities(channel?: Channel): string[] | undefined { + return useStateStore(channel?.state?.ownCapabilitiesStore, selector)?.ownCapabilities; } From 0872d914e614f45355b278f846c3ec5aa939d69f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 8 Jul 2026 17:20:19 +0200 Subject: [PATCH 04/36] feat: reactive message list first pass --- package/src/components/Channel/Channel.tsx | 128 ++- .../useMessageListPagination.test.tsx | 799 +++--------------- .../hooks/useMessageListPagination.tsx | 347 ++------ 3 files changed, 282 insertions(+), 992 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 2a969b007e..aeaa71c1cb 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -489,8 +489,6 @@ const ChannelWithContext = (props: PropsWithChildren) = messageSwipeToReplyHitSlop, messageTextNumberOfLines, myMessageTheme, - // TODO: Think about this one - newMessageStateUpdateThrottleInterval = defaultThrottleInterval, onLongPressMessage, onPressInMessage, onPressMessage, @@ -504,7 +502,6 @@ const ChannelWithContext = (props: PropsWithChildren) = setThreadMessages, shouldShowUnreadUnderlay = true, shouldSyncChannel, - stateUpdateThrottleInterval = defaultThrottleInterval, supportedReactions = reactionData, t, thread: threadFromProps, @@ -579,10 +576,8 @@ const ChannelWithContext = (props: PropsWithChildren) = ); const { - copyMessagesStateFromChannel: rawCopyMessagesStateFromChannel, loadChannelAroundMessage: loadChannelAroundMessageFn, loadChannelAtFirstUnreadMessage, - loadInitialMessagesStateFromChannel, loadLatestMessages, loadMore, loadMoreRecent, @@ -603,36 +598,14 @@ const ChannelWithContext = (props: PropsWithChildren) = return !!messageId || shouldLoadInitialChannelAtFirstUnreadMessage(); }); - const { setMessages: copyMessagesStateFromChannel, viewabilityChangedCallback } = - usePrunableMessageList({ maximumMessageLimit, setMessages: rawCopyMessagesStateFromChannel }); - - const copyMessagesStateFromChannelThrottled = useMemo( - () => - throttle( - () => { - if (channel) { - copyMessagesStateFromChannel(channel); - } - }, - newMessageStateUpdateThrottleInterval, - throttleOptions, - ), - [channel, newMessageStateUpdateThrottleInterval, copyMessagesStateFromChannel], - ); - - const copyChannelState = useMemo( - () => - throttle( - () => { - if (channel) { - copyMessagesStateFromChannel(channel); - } - }, - stateUpdateThrottleInterval, - throttleOptions, - ), - [stateUpdateThrottleInterval, channel, copyMessagesStateFromChannel], - ); + // The message list is now backed reactively by channel.messagePaginator; the WS event handlers no + // longer copy channel.state into React state. Viewport tracking is still needed by the list. + // TODO(paginator-pruning): re-implement pruning as a window cap over messagePaginator.state.items + // (the paginator retains all loaded items in its ItemIndex). + const { viewabilityChangedCallback } = usePrunableMessageList({ + maximumMessageLimit, + setMessages: () => {}, + }); const handleEvent: EventHandler = useStableCallback((event) => { if (shouldSyncChannel) { @@ -681,34 +654,11 @@ const ChannelWithContext = (props: PropsWithChildren) = setChannelUnreadState(undefined); } - // only update channel state if the events are not the previously subscribed useEffect's subscription events - if (channel) { - // we skip the new message events if we've already done an optimistic update for the new message - if (event.type === 'message.new' || event.type === 'notification.message_new') { - const messageId = event.message?.id ?? ''; - if ( - event.user?.id !== client.userID || - !optimisticallyUpdatedNewMessages.has(messageId) - ) { - copyMessagesStateFromChannelThrottled(); - } - optimisticallyUpdatedNewMessages.delete(messageId); - return; - } - - if (event.type === 'message.read_locally') { - // When local unread reset happens, the count is already updated in the client state, - // and the preview badge / unread divider are handled elsewhere, so there is nothing - // to copy into channel state here. Thus, we skip it. - return; - } - - if (event.type === 'message.read' || event.type === 'notification.mark_read') { - // Read state is sourced reactively from channel.state.readStore; nothing to copy here. - return; - } - - copyChannelState(); + // The message list is backed reactively by channel.messagePaginator (channel._handleChannelEvent + // ingests message.new/updated/deleted + reaction events), and read/typing/members come from + // their reactive stores — so the WS handler no longer copies channel.state into React state. + if (event.type === 'message.new' || event.type === 'notification.message_new') { + optimisticallyUpdatedNewMessages.delete(event.message?.id ?? ''); } } }); @@ -736,7 +686,12 @@ const ChannelWithContext = (props: PropsWithChildren) = } if (!errored) { - loadInitialMessagesStateFromChannel(channel, channel.state.messagePagination.hasPrev); + // Seed the paginator for a cold open (deep link / push). Channels reached via the channel + // list are already seeded by client.hydrateActiveChannels, so guard on an empty paginator + // to avoid a redundant fetch. + if (!channel.messagePaginator.state.getLatestValue().items?.length) { + await channel.messagePaginator.reload(); + } } if (client.user?.id && channel.state.read[client.user.id]) { @@ -773,7 +728,6 @@ const ChannelWithContext = (props: PropsWithChildren) = initChannel(); return () => { - copyChannelState.cancel(); loadMoreThreadFinished.cancel(); listener?.unsubscribe(); }; @@ -941,11 +895,13 @@ const ChannelWithContext = (props: PropsWithChildren) = } if (!thread) { - copyChannelState(); - const failedMessages = getRecoverableFailedMessages(channelMessagesState.messages); + await channel.messagePaginator.reload(); if (failedMessages?.length) { channel.state.addMessagesSorted(failedMessages); + failedMessages.forEach((m) => + channel.messagePaginator.ingestItem(channel.state.formatMessage(m)), + ); } await markRead(); channel.state.setIsUpToDate(true); @@ -1065,16 +1021,19 @@ const ChannelWithContext = (props: PropsWithChildren) = * MESSAGE METHODS */ const updateMessage: MessagesContextValue['updateMessage'] = useStableCallback( - (updatedMessage, extraState = {}, throttled = false) => { + (updatedMessage, extraState = {}) => { if (!channel) { return; } + // Keep legacy channel.state in sync (many readers remain) and ingest into the reactive + // paginator that now backs the message list. channel.state.addMessageSorted(updatedMessage, true); - if (throttled) { - copyMessagesStateFromChannelThrottled(); + const formatted = channel.state.formatMessage(updatedMessage); + if (updatedMessage.parent_id) { + threadInstance?.messagePaginator?.ingestItem(formatted); } else { - copyMessagesStateFromChannel(channel); + channel.messagePaginator.ingestItem(formatted); } if (thread && updatedMessage.parent_id) { @@ -1089,7 +1048,11 @@ const ChannelWithContext = (props: PropsWithChildren) = if (channel) { channel.state.removeMessage(oldMessage); channel.state.addMessageSorted(newMessage, true); - copyMessagesStateFromChannel(channel); + const paginator = newMessage.parent_id + ? threadInstance?.messagePaginator + : channel.messagePaginator; + paginator?.removeItem({ id: oldMessage.id }); + paginator?.ingestItem(channel.state.formatMessage(newMessage)); if (thread && newMessage.parent_id) { const threadMessages = channel.state.threads[newMessage.parent_id] || []; @@ -1380,7 +1343,10 @@ const ChannelWithContext = (props: PropsWithChildren) = async (message) => { if (channel) { channel.state.removeMessage(message); - copyMessagesStateFromChannel(channel); + const paginator = message.parent_id + ? threadInstance?.messagePaginator + : channel.messagePaginator; + paginator?.removeItem({ id: message.id }); if (thread) { setThreadMessages(channel.state.threads[thread.id] || []); @@ -1415,7 +1381,13 @@ const ChannelWithContext = (props: PropsWithChildren) = user: client.user, }); - copyMessagesStateFromChannel(channel); + const reactedMessage = channel.state.findMessage(messageId); + if (reactedMessage) { + (reactedMessage.parent_id + ? threadInstance?.messagePaginator + : channel.messagePaginator + )?.ingestItem(reactedMessage); + } } const sendReactionResponse = await channel.sendReaction(...payload); @@ -1477,7 +1449,13 @@ const ChannelWithContext = (props: PropsWithChildren) = updated_at: '', }); - copyMessagesStateFromChannel(channel); + const reactedMessage = channel.state.findMessage(messageId); + if (reactedMessage) { + (reactedMessage.parent_id + ? threadInstance?.messagePaginator + : channel.messagePaginator + )?.ingestItem(reactedMessage); + } } await channel.deleteReaction(...payload); diff --git a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx b/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx index c88a9a07b0..245e3aeb07 100644 --- a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx +++ b/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx @@ -1,692 +1,171 @@ -import React, { PropsWithChildren } from 'react'; +import { act, cleanup, renderHook } from '@testing-library/react-native'; +import type { Channel, LocalMessage } from 'stream-chat'; -import { act, cleanup, renderHook, waitFor } from '@testing-library/react-native'; -import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat'; - -import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; -import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; -import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; -import { generateChannelResponse } from '../../../mock-builders/generator/channel'; -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { NotificationTargetProvider } from '../../Notifications/NotificationTargetContext'; -import { channelInitialState } from '../hooks/useChannelDataState'; -import * as ChannelStateHooks from '../hooks/useChannelDataState'; import { useMessageListPagination } from '../hooks/useMessageListPagination'; -const createChatWrapper = - (client: StreamChat) => - ({ children }: PropsWithChildren) => ( - - - {children} - - - ); - -describe('useMessageListPagination', () => { - let chatClient: StreamChat; - let channel: ChannelType; - - const mockedHook = ( - state: Partial, - values?: Partial>, - ) => - jest.spyOn(ChannelStateHooks, 'useChannelMessageDataState').mockImplementation( - () => - ({ - copyMessagesStateFromChannel: jest.fn(), - jumpToLatestMessage: jest.fn(), - jumpToMessageFinished: jest.fn(), - loadInitialMessagesStateFromChannel: jest.fn(), - loadMoreFinished: jest.fn(), - loadMoreRecentFinished: jest.fn(), - setLoading: jest.fn(), - setLoadingMore: jest.fn(), - setLoadingMoreRecent: jest.fn(), - state: { ...channelInitialState, ...state }, - ...values, - }) as unknown as ReturnType, - ); - - beforeEach(async () => { - // Reset all modules before each test - jest.resetModules(); - const user = generateUser({ id: 'id', name: 'name' }); - chatClient = await getTestClientWithUser(user); +// NOTE: `stream-chat` is portaled during this migration; a runtime (value) import of it breaks +// jest resolution. Everything from `stream-chat` here is a type-only import, and the paginator is +// faked, so no runtime `require('stream-chat')` happens. + +jest.mock('../../Notifications', () => ({ + useNotificationApi: () => ({ addNotification: jest.fn() }), +})); +jest.mock('../../../contexts/translationContext/TranslationContext', () => ({ + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +type PaginatorStateValue = { + hasMoreHead: boolean; + hasMoreTail: boolean; + isLoading: boolean; + items?: LocalMessage[]; +}; + +const makeStore = (value: T) => ({ + getLatestValue: () => value, + subscribeWithSelector: () => () => {}, +}); - const mockedChannel = generateChannelResponse({ - messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), - }); +const makePaginator = (state: PaginatorStateValue, focusedMessageId?: string) => ({ + hasMoreHead: state.hasMoreHead, + hasMoreTail: state.hasMoreTail, + jumpToMessage: jest.fn().mockResolvedValue(true), + jumpToTheFirstUnreadMessage: jest.fn().mockResolvedValue(true), + jumpToTheLatestMessage: jest.fn().mockResolvedValue(true), + messageFocusSignal: makeStore({ + signal: focusedMessageId ? { messageId: focusedMessageId } : null, + }), + state: makeStore(state), + toHead: jest.fn().mockResolvedValue(undefined), + toTail: jest.fn().mockResolvedValue(undefined), +}); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - channel = chatClient.channel('messaging', mockedChannel.channel.id); - await channel.watch(); - }); +const makeChannel = (paginator: ReturnType) => + ({ messagePaginator: paginator }) as unknown as Channel; - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); +describe('useMessageListPagination', () => { + afterEach(cleanup); + + it('maps paginator state (tailward/older→hasMore, headward/newer→hasMoreNewer, items→messages)', () => { + const items = [{ id: 'a' }, { id: 'b' }] as unknown as LocalMessage[]; + const paginator = makePaginator({ + hasMoreHead: false, + hasMoreTail: true, + isLoading: false, + items, + }); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + expect(result.current.state.messages).toBe(items); + expect(result.current.state.hasMore).toBe(true); + expect(result.current.state.hasMoreNewer).toBe(false); }); - it('should set the state when the loadLatestMessages is called', async () => { - const loadMessageIntoState = jest.fn(() => { - channel.state.messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; + it('loadMore (older) delegates to paginator.toTail()', async () => { + const paginator = makePaginator({ + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + items: [], }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - const { result } = renderHook(() => useMessageListPagination({ channel })); - + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); await act(async () => { - await result.current.loadLatestMessages(); - }); - - await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(1); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.messages!.length).toBe(20); + await result.current.loadMore(); }); + expect(paginator.toTail).toHaveBeenCalledTimes(1); + expect(paginator.toHead).not.toHaveBeenCalled(); }); - describe('loadMore', () => { - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); - }); - it('should not set the state when the loadMore function is called and hasPrev is false', async () => { - const queryFn = jest.fn(); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: false, - }, - } as unknown as typeof channel.state; - channel.query = queryFn as typeof channel.query; - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMore(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(0); - }); - }); - - it('should not set the state when the loading more and loading more recent boolean are true', async () => { - const queryFn = jest.fn(); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - channel.query = queryFn as typeof channel.query; - - mockedHook({ loadingMore: true, loadingMoreRecent: true }); - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMore(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(0); - }); + it('loadMoreRecent (newer) delegates to paginator.toHead()', async () => { + const paginator = makePaginator({ + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + items: [], }); - - it('should set the state when the loadMore function is called and hasPrev is true and loadingMore is false and loadingMoreRecent is false', async () => { - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - const queryFn = jest.fn(() => { - channel.state.messages = Array.from({ length: 40 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - } as unknown as typeof channel.state; - channel.query = queryFn as unknown as typeof channel.query; - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMore(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledWith({ - messages: { id_lt: messages[0].id, limit: 25 }, - watchers: { - limit: 25, - }, - }); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.messages!.length).toBe(40); - }); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadMoreRecent(); }); + expect(paginator.toHead).toHaveBeenCalledTimes(1); + expect(paginator.toTail).not.toHaveBeenCalled(); }); - describe('loadMoreRecent', () => { - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); - }); - - it('should not set the state when the loadMoreRecent function is called and hasNext is false', async () => { - const queryFn = jest.fn(); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: false, - hasPrev: true, - }, - } as unknown as typeof channel.state; - channel.query = queryFn as typeof channel.query; - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMoreRecent(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(0); - }); + it('does not paginate when the paginator has no more in the requested direction', async () => { + const paginator = makePaginator({ + hasMoreHead: false, + hasMoreTail: false, + isLoading: false, + items: [], }); - - it('should not set the state when the loading more and loading more recent boolean are true', async () => { - const queryFn = jest.fn(); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - channel.query = queryFn as typeof channel.query; - - mockedHook({ loadingMore: true, loadingMoreRecent: true }); - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMoreRecent(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(0); - }); - }); - - it('should set the state when the loadMoreRecent function is called and hasNext is true and loadingMore is false and loadingMoreRecent is false', async () => { - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - const queryFn = jest.fn(() => { - channel.state.messages = Array.from({ length: 40 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - } as unknown as typeof channel.state; - channel.query = queryFn as unknown as typeof channel.query; - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMoreRecent(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledWith({ - messages: { id_gt: messages[messages.length - 1].id, limit: 10 }, - watchers: { limit: 10 }, - }); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.messages!.length).toBe(40); - }); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadMore(); + await result.current.loadMoreRecent(); }); + expect(paginator.toTail).not.toHaveBeenCalled(); + expect(paginator.toHead).not.toHaveBeenCalled(); }); - describe('loadChannelAroundMessage', () => { - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); + it('loadLatestMessages delegates to paginator.jumpToTheLatestMessage()', async () => { + const paginator = makePaginator({ + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + items: [], }); - - it('should not do anything when the messageId to search for is not passed', async () => { - const loadMessageIntoState = jest.fn(() => { - channel.state.messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadChannelAroundMessage({ messageId: undefined }); - }); - - await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(0); - }); - }); - - it('should call the loadMessageIntoState function when the messageId to search for is passed and set the state', async () => { - const loadMessageIntoState = jest.fn(() => { - channel.state.messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: false, - hasPrev: true, - }, - } as unknown as typeof channel.state; - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadChannelAroundMessage({ messageId: 'message-5' }); - }); - - await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(1); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.hasMoreNewer).toBe(false); - expect(result.current.state.messages!.length).toBe(20); - expect(result.current.state.targetedMessageId).toBe('message-5'); - }); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadLatestMessages(); }); + expect(paginator.jumpToTheLatestMessage).toHaveBeenCalledTimes(1); }); - describe('loadChannelAtFirstUnreadMessage', () => { - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); - }); - - it('should not do anything when the unread count is 0', async () => { - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - const loadMessageIntoState = jest.fn(() => { - channel.state.messages = messages; - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - - const user = generateUser(); - const channelUnreadState = { - unread_messages: 0, - user, - }; - - const jumpToMessageFinishedMock = jest.fn(); - mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({ - channelUnreadState: channelUnreadState as unknown as Parameters< - typeof result.current.loadChannelAtFirstUnreadMessage - >[0]['channelUnreadState'], - }); - }); - - await waitFor(() => { - expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes(0); - }); + it('loadChannelAroundMessage jumps to the message and targets it', async () => { + const paginator = makePaginator({ + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + items: [], }); - - const generateMessageArray = (length = 20) => - Array.from({ length }, (_, i) => generateMessage({ id: String(i), text: `message-${i}` })); - - type TestCaseUnreadState = { - first_unread_message_id?: string; - last_read_message_id?: string; - unread_messages: number; - }; - - type TestCase = { - channelUnreadState: (messages: LocalMessage[]) => TestCaseUnreadState; - expectedCalls: { - jumpToMessageFinishedCalls: number; - loadMessageIntoStateCalls: number; - setChannelUnreadStateCalls: number; - setTargetedMessageIdCalls: number; - targetedMessageId: (messages: LocalMessage[]) => string; - }; - initialMessages: LocalMessage[]; - name: string; - setupLoadMessageIntoState: ((channel: ChannelType) => jest.Mock) | null; - }; - - // Test cases with different scenarios - const testCases: TestCase[] = [ - { - channelUnreadState: (messages) => ({ - first_unread_message_id: messages[2].id, - unread_messages: 2, - }), - expectedCalls: { - jumpToMessageFinishedCalls: 1, - loadMessageIntoStateCalls: 0, - setChannelUnreadStateCalls: 0, - setTargetedMessageIdCalls: 1, - targetedMessageId: (messages) => messages[2].id, - }, - initialMessages: generateMessageArray(), - name: 'first_unread_message_id present in current message set', - setupLoadMessageIntoState: null, - }, - { - channelUnreadState: () => ({ - first_unread_message_id: '21', - unread_messages: 2, - }), - expectedCalls: { - jumpToMessageFinishedCalls: 1, - loadMessageIntoStateCalls: 1, - setChannelUnreadStateCalls: 0, - setTargetedMessageIdCalls: 1, - targetedMessageId: () => '21', - }, - initialMessages: generateMessageArray(), - name: 'first_unread_message_id not present in current message set', - setupLoadMessageIntoState: (channel) => { - const loadMessageIntoState = jest.fn(() => { - const newMessages = Array.from({ length: 20 }, (_, i) => - generateMessage({ id: String(i + 21), text: `message-${i + 21}` }), - ); - channel.state.messages = newMessages; - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - (channel.state as unknown as { loadMessageIntoState: jest.Mock }).loadMessageIntoState = - loadMessageIntoState; - return loadMessageIntoState; - }, - }, - { - channelUnreadState: (messages) => ({ - last_read_message_id: messages[2].id, - unread_messages: 2, - }), - expectedCalls: { - jumpToMessageFinishedCalls: 1, - loadMessageIntoStateCalls: 0, - setChannelUnreadStateCalls: 1, - setTargetedMessageIdCalls: 1, - targetedMessageId: (messages) => messages[3].id, - }, - initialMessages: generateMessageArray(), - name: 'last_read_message_id present in current message set', - setupLoadMessageIntoState: null, - }, - { - channelUnreadState: () => ({ - last_read_message_id: '21', - unread_messages: 2, - }), - expectedCalls: { - jumpToMessageFinishedCalls: 1, - loadMessageIntoStateCalls: 1, - setChannelUnreadStateCalls: 1, - setTargetedMessageIdCalls: 1, - targetedMessageId: () => '22', - }, - initialMessages: generateMessageArray(), - name: 'last_read_message_id not present in current message set', - setupLoadMessageIntoState: (channel) => { - const loadMessageIntoState = jest.fn(() => { - const newMessages = Array.from({ length: 20 }, (_, i) => - generateMessage({ id: String(i + 21), text: `message-${i + 21}` }), - ); - channel.state.messages = newMessages; - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - (channel.state as unknown as { loadMessageIntoState: jest.Mock }).loadMessageIntoState = - loadMessageIntoState; - return loadMessageIntoState; - }, - }, - ]; - - it.each(testCases)('$name', async (testCase) => { - // Setup channel state - const messages = testCase.initialMessages; - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - } as unknown as typeof channel.state; - - // Setup additional mocks if needed - const loadMessageIntoStateMock = testCase.setupLoadMessageIntoState - ? testCase.setupLoadMessageIntoState(channel) - : null; - - // Generate user and channel unread state - const user = generateUser(); - const channelUnreadState = { - user, - ...testCase.channelUnreadState(messages), - }; - - // Setup mocks - const jumpToMessageFinishedMock = jest.fn(); - mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - const setChannelUnreadStateMock = jest.fn(); - const setTargetedMessageIdMock = jest.fn((message) => message); - - // Execute the method - await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({ - channelUnreadState: channelUnreadState as unknown as Parameters< - typeof result.current.loadChannelAtFirstUnreadMessage - >[0]['channelUnreadState'], - setChannelUnreadState: setChannelUnreadStateMock, - setTargetedMessage: setTargetedMessageIdMock, - }); - }); - - // Verify expectations - await waitFor(() => { - if (loadMessageIntoStateMock) { - expect(loadMessageIntoStateMock).toHaveBeenCalledTimes( - testCase.expectedCalls.loadMessageIntoStateCalls, - ); - } - - expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes( - testCase.expectedCalls.jumpToMessageFinishedCalls, - ); - - expect(setChannelUnreadStateMock).toHaveBeenCalledTimes( - testCase.expectedCalls.setChannelUnreadStateCalls, - ); - - expect(setTargetedMessageIdMock).toHaveBeenCalledTimes( - testCase.expectedCalls.setTargetedMessageIdCalls, - ); - - if (testCase.expectedCalls.targetedMessageId) { - const expectedMessageId = testCase.expectedCalls.targetedMessageId(messages); - expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedMessageId); - } - }); + const setTargetedMessage = jest.fn(); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadChannelAroundMessage({ messageId: 'm7', setTargetedMessage }); }); - - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ - created_at: new Date('2021-09-01T00:00:00.000Z'), - id: String(i), - text: `message-${i}`, - }), + expect(paginator.jumpToMessage).toHaveBeenCalledWith( + 'm7', + expect.objectContaining({ focusReason: 'jump-to-message' }), ); + expect(setTargetedMessage).toHaveBeenCalledWith('m7'); + }); - const user = generateUser(); - - it.each` - scenario | last_read | expectedQueryCalls | expectedJumpToMessageFinishedCalls | expectedSetChannelUnreadStateCalls | expectedSetTargetedMessageCalls | expectedTargetedMessageId - ${'when last_read matches a message'} | ${new Date(messages[10].created_at)} | ${0} | ${1} | ${1} | ${1} | ${'10'} - ${'when last_read does not match any message'} | ${new Date('2021-09-02T00:00:00.000Z')} | ${1} | ${0} | ${0} | ${0} | ${undefined} - `( - '$scenario', - async ({ - expectedJumpToMessageFinishedCalls, - expectedQueryCalls, - expectedSetChannelUnreadStateCalls, - expectedSetTargetedMessageCalls, - expectedTargetedMessageId, - last_read, - }: { - expectedJumpToMessageFinishedCalls: number; - expectedQueryCalls: number; - expectedSetChannelUnreadStateCalls: number; - expectedSetTargetedMessageCalls: number; - expectedTargetedMessageId: string | undefined; - last_read: Date; - }) => { - // Set up channel state - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - } as unknown as typeof channel.state; - - const channelUnreadState = { - last_read, - unread_messages: 2, - user, - }; - - // Mock query if needed - const queryMock = jest.fn().mockResolvedValue({ messages: [] }); - channel.query = queryMock as unknown as typeof channel.query; - - // Set up mocks - const jumpToMessageFinishedMock = jest.fn(); - mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); - const setChannelUnreadStateMock = jest.fn(); - const setTargetedMessageIdMock = jest.fn((message) => message); - const addNotificationSpy = jest.spyOn(chatClient.notifications, 'add'); - - // Render hook - const { result } = renderHook(() => useMessageListPagination({ channel }), { - wrapper: createChatWrapper(chatClient), - }); - - // Act - await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({ - channelUnreadState, - setChannelUnreadState: setChannelUnreadStateMock, - setTargetedMessage: setTargetedMessageIdMock, - }); - }); - - // Assert - await waitFor(() => { - expect(queryMock).toHaveBeenCalledTimes(expectedQueryCalls); - if (expectedQueryCalls) { - expect(addNotificationSpy).toHaveBeenCalledWith({ - message: 'Failed to jump to the first unread message', - options: { - originalError: expect.any(Error), - severity: 'error', - tags: ['target:channel:channel:messaging:general'], - type: 'channel:jumpToFirstUnread:failed', - }, - origin: { - context: { feature: 'jumpToFirstUnread' }, - emitter: 'Channel', - }, - }); - } - expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes( - expectedJumpToMessageFinishedCalls, - ); - expect(setChannelUnreadStateMock).toHaveBeenCalledTimes( - expectedSetChannelUnreadStateCalls, - ); - expect(setTargetedMessageIdMock).toHaveBeenCalledTimes(expectedSetTargetedMessageCalls); - - if (expectedTargetedMessageId !== undefined) { - expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedTargetedMessageId); - } - }); - }, + it('loadChannelAtFirstUnreadMessage jumps to first unread and targets the focused message', async () => { + const paginator = makePaginator( + { hasMoreHead: true, hasMoreTail: true, isLoading: false, items: [] }, + 'm5', ); + const setTargetedMessage = jest.fn(); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadChannelAtFirstUnreadMessage({ setTargetedMessage } as Parameters< + typeof result.current.loadChannelAtFirstUnreadMessage + >[0]); + }); + expect(paginator.jumpToTheFirstUnreadMessage).toHaveBeenCalledTimes(1); + expect(setTargetedMessage).toHaveBeenCalledWith('m5'); }); }); diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx index 44e74e41e5..becc69c25f 100644 --- a/package/src/components/Channel/hooks/useMessageListPagination.tsx +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -1,189 +1,95 @@ -import { useRef } from 'react'; +import { useState } from 'react'; -import debounce from 'lodash/debounce'; -import { Channel, ChannelState, MessageResponse } from 'stream-chat'; - -import { useChannelMessageDataState } from './useChannelDataState'; +import { Channel, LocalMessage } from 'stream-chat'; import { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; -import { useStableCallback } from '../../../hooks'; -import { findInMessagesByDate, findInMessagesById } from '../../../utils/utils'; +import { useStableCallback, useStateStore } from '../../../hooks'; import { useNotificationApi } from '../../Notifications'; -const defaultDebounceInterval = 500; -const debounceOptions = { - leading: true, - trailing: true, +const DEFAULT_HIGHLIGHT_DURATION = 3000; + +type MessagePaginatorState = { + hasMoreHead: boolean; + hasMoreTail: boolean; + isLoading: boolean; + items?: LocalMessage[]; }; +// Direction mapping (stream-chat MessagePaginator): +// tailward === id_lt === OLDER messages -> loadMore / hasMore +// headward === id_gt === NEWER messages -> loadMoreRecent / hasMoreNewer +const selector = (state: MessagePaginatorState) => ({ + hasMore: state.hasMoreTail, + hasMoreNewer: state.hasMoreHead, + isLoading: state.isLoading, + messages: state.items, +}); + /** - * The useMessageListPagination hook handles pagination for the message list. - * It provides functionality to load more messages, load more recent messages, load latest messages, and load channel around a specific message. + * The useMessageListPagination hook exposes the channel's message list + pagination, sourced + * from `channel.messagePaginator` (stream-chat). Pagination, jump-to-message, jump-to-latest and + * jump-to-first-unread are delegated to the paginator; the directional loading flags are kept as + * local UI state around the paginator calls. * - * @param channel The channel object for which the message list pagination is being handled. + * @param channel The channel whose message list is paginated. */ export const useMessageListPagination = ({ channel }: { channel: Channel }) => { const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); - const { - copyMessagesStateFromChannel, - jumpToLatestMessage, - jumpToMessageFinished, - loadInitialMessagesStateFromChannel, - loadMoreFinished: loadMoreFinishedFn, - loadMoreRecentFinished: loadMoreRecentFinishedFn, - setLoading, - setLoadingMore, - setLoadingMoreRecent, - state, - } = useChannelMessageDataState(channel); + const paginator = channel.messagePaginator; - // hard limit to prevent you from scrolling faster than 1 page per 2 seconds - const loadMoreFinished = useRef( - debounce( - (hasMore: boolean, messages: ChannelState['messages']) => { - loadMoreFinishedFn(hasMore, messages); - }, - defaultDebounceInterval, - debounceOptions, - ), - ).current; + const { hasMore, hasMoreNewer, isLoading, messages } = + useStateStore(paginator.state, selector) ?? {}; - // hard limit to prevent you from scrolling faster than 1 page per 2 seconds - const loadMoreRecentFinished = useRef( - debounce( - (hasMore: boolean, newMessages: ChannelState['messages']) => { - loadMoreRecentFinishedFn(hasMore, newMessages); - }, - defaultDebounceInterval, - debounceOptions, - ), - ).current; + const [loadingMore, setLoadingMore] = useState(false); + const [loadingMoreRecent, setLoadingMoreRecent] = useState(false); /** - * This function loads the latest messages in the channel. + * Loads the latest (newest) messages in the channel. */ const loadLatestMessages = useStableCallback(async () => { try { - setLoading(true); - await channel.state.loadMessageIntoState('latest'); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - jumpToLatestMessage(); + await paginator.jumpToTheLatestMessage(); } catch (err) { console.warn('Loading latest messages failed with error:', err); } }); /** - * This function loads more messages before the first message in current channel state. + * Loads older messages (before the oldest loaded message). */ - const loadMore = useStableCallback(async (limit: number = 25) => { - if (!channel.state.messagePagination.hasPrev) { - return; - } - - if (state.loadingMore || state.loadingMoreRecent) { + const loadMore = useStableCallback(async () => { + if (!paginator.hasMoreTail || loadingMore || loadingMoreRecent) { return; } - setLoadingMore(true); - const oldestMessage = state.messages?.[0]; - const oldestID = oldestMessage?.id; - try { - await channel.query({ - messages: { id_lt: oldestID, limit }, - watchers: { limit }, - }); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - setLoadingMore(false); + await paginator.toTail(); } catch (e) { - setLoadingMore(false); console.warn('Message pagination(fetching old messages) request failed with error:', e); + } finally { + setLoadingMore(false); } }); /** - * This function loads more messages after the most recent message in current channel state. + * Loads newer messages (after the most recent loaded message). */ - const loadMoreRecent = useStableCallback(async (limit: number = 10) => { - if (!channel.state.messagePagination.hasNext) { - return; - } - - if (state.loadingMore || state.loadingMoreRecent) { + const loadMoreRecent = useStableCallback(async () => { + if (!paginator.hasMoreHead || loadingMore || loadingMoreRecent) { return; } - setLoadingMoreRecent(true); - const newestMessage = state.messages?.[state?.messages.length - 1]; - const newestID = newestMessage?.id; - try { - await channel.query({ - messages: { id_gt: newestID, limit }, - watchers: { limit }, - }); - loadMoreRecentFinished(channel.state.messagePagination.hasNext, channel.state.messages); + await paginator.toHead(); } catch (e) { - setLoadingMoreRecent(false); console.warn('Message pagination(fetching new messages) request failed with error:', e); - return; + } finally { + setLoadingMoreRecent(false); } }); - /** - * Loads channel around a specific message - * - * @param messageId If undefined, channel will be loaded at most recent message. - */ - const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = - useStableCallback( - async ({ limit = 25, messageId: messageIdToLoadAround, setTargetedMessage }) => { - if (!messageIdToLoadAround) { - return; - } - setLoadingMore(true); - setLoading(true); - try { - await channel.state.loadMessageIntoState(messageIdToLoadAround, undefined, limit); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - jumpToMessageFinished(channel.state.messagePagination.hasNext, messageIdToLoadAround); - - if (setTargetedMessage) { - setTargetedMessage(messageIdToLoadAround); - } - } catch (error) { - setLoadingMore(false); - setLoading(false); - console.warn( - 'Message pagination(fetching messages in the channel around a message id) request failed with error:', - error, - ); - return; - } - }, - ); - - /** - * Fetch messages around a specific timestamp. - */ - const fetchMessagesAround = useStableCallback( - async (channel: Channel, timestamp: string, limit: number): Promise => { - try { - const { messages } = await channel.query( - { messages: { created_at_around: timestamp, limit } }, - 'new', - ); - return messages; - } catch (error) { - console.error('Error fetching messages around timestamp:', error); - throw error; - } - }, - ); - const notifyJumpToFirstUnreadError = useStableCallback((error: unknown) => { addNotification({ message: t('Failed to jump to the first unread message'), @@ -197,134 +103,61 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => { }); /** - * Loads channel at first unread message. + * Loads the channel around a specific message and targets it for highlight. + * + * @param messageId If undefined, no-op. */ - const loadChannelAtFirstUnreadMessage: ChannelContextValue['loadChannelAtFirstUnreadMessage'] = - useStableCallback( - async ({ channelUnreadState, limit = 25, setChannelUnreadState, setTargetedMessage }) => { - try { - if (!channelUnreadState?.unread_messages) { - return; - } - const { first_unread_message_id, last_read, last_read_message_id } = channelUnreadState; - let firstUnreadMessageId = first_unread_message_id; - let lastReadMessageId = last_read_message_id; - let isInCurrentMessageSet = false; - const messagesState = channel.state.messages; - - // If the first unread message is already in the current message set, we don't need to load more messages. - if (firstUnreadMessageId) { - const messageIdx = findInMessagesById(messagesState, firstUnreadMessageId); - isInCurrentMessageSet = messageIdx !== -1; - } - // If the last read message is already in the current message set, we don't need to load more messages, and we set the first unread message id as that is what we want to operate on. - else if (lastReadMessageId) { - const messageIdx = findInMessagesById(messagesState, lastReadMessageId); - isInCurrentMessageSet = messageIdx !== -1; - firstUnreadMessageId = messageIdx > -1 ? messagesState[messageIdx + 1]?.id : undefined; - } else { - const lastReadTimestamp = last_read.getTime(); - const { index: lastReadIdx, message: lastReadMessage } = findInMessagesByDate( - messagesState, - last_read, - ); - if (lastReadMessage) { - lastReadMessageId = lastReadMessage.id; - firstUnreadMessageId = messagesState[lastReadIdx + 1].id; - isInCurrentMessageSet = !!firstUnreadMessageId; - } else { - setLoadingMore(true); - setLoading(true); - let messages; - try { - messages = await fetchMessagesAround(channel, last_read.toISOString(), limit); - } catch (error) { - setLoading(false); - loadMoreFinished(channel.state.messagePagination.hasPrev, messagesState); - notifyJumpToFirstUnreadError(error); - return; - } - - const firstMessageWithCreationDate = messages.find((msg) => msg.created_at); - if (!firstMessageWithCreationDate) { - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - throw new Error('Failed to jump to first unread message id.'); - } - const firstMessageTimestamp = new Date( - firstMessageWithCreationDate.created_at as string, - ).getTime(); - - if (lastReadTimestamp < firstMessageTimestamp) { - // whole channel is unread - firstUnreadMessageId = firstMessageWithCreationDate.id; - } else { - const result = findInMessagesByDate(messages, last_read); - lastReadMessageId = result.message?.id; - } - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - } - } - - // If we still don't have the first and last read message id, we can't proceed. - if (!firstUnreadMessageId && !lastReadMessageId) { - throw new Error('Failed to jump to first unread message id.'); - } - - // If the first unread message is not in the current message set, we need to load message around the id. - if (!isInCurrentMessageSet) { - try { - setLoadingMore(true); - setLoading(true); - const targetedMessage = (firstUnreadMessageId || lastReadMessageId) as string; - await channel.state.loadMessageIntoState(targetedMessage, undefined, limit); - /** - * if the index of the last read message on the page is beyond the half of the page, - * we have arrived to the oldest page of the channel - */ - const indexOfTarget = channel.state.messages.findIndex( - (message) => message.id === targetedMessage, - ); - - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - firstUnreadMessageId = - firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1].id; - } catch (error) { - setLoading(false); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - notifyJumpToFirstUnreadError(error); - return; - } - } - - if (!firstUnreadMessageId) { - throw new Error('Failed to jump to first unread message id.'); - } - if (!first_unread_message_id && setChannelUnreadState) { - setChannelUnreadState({ - ...channelUnreadState, - first_unread_message_id: firstUnreadMessageId, - last_read_message_id: lastReadMessageId, - }); - } + const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = + useStableCallback(async ({ messageId: messageIdToLoadAround, setTargetedMessage }) => { + if (!messageIdToLoadAround) { + return; + } + try { + await paginator.jumpToMessage(messageIdToLoadAround, { + focusReason: 'jump-to-message', + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, + }); + setTargetedMessage?.(messageIdToLoadAround); + } catch (error) { + console.warn( + 'Message pagination(fetching messages around a message id) request failed with error:', + error, + ); + } + }); - jumpToMessageFinished(channel.state.messagePagination.hasNext, firstUnreadMessageId); - if (setTargetedMessage) { - setTargetedMessage(firstUnreadMessageId); - } - } catch (error) { - notifyJumpToFirstUnreadError(error); + /** + * Loads the channel at the first unread message. The paginator resolves the first-unread id + * from its unread snapshot / channel read state. + */ + const loadChannelAtFirstUnreadMessage: ChannelContextValue['loadChannelAtFirstUnreadMessage'] = + useStableCallback(async ({ setTargetedMessage }) => { + try { + await paginator.jumpToTheFirstUnreadMessage({ + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, + }); + const focusedMessageId = paginator.messageFocusSignal.getLatestValue().signal?.messageId; + if (focusedMessageId) { + setTargetedMessage?.(focusedMessageId); } - }, - ); + } catch (error) { + notifyJumpToFirstUnreadError(error); + } + }); return { - copyMessagesStateFromChannel, loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, - loadInitialMessagesStateFromChannel, loadLatestMessages, loadMore, loadMoreRecent, - state, + state: { + hasMore, + hasMoreNewer, + loading: !!isLoading && !messages?.length, + loadingMore, + loadingMoreRecent, + messages, + }, }; }; From 8633abf4a11586c9fb48a34cf6a8820f2294b2cc Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Jul 2026 03:41:37 +0200 Subject: [PATCH 05/36] feat: add mark read/unread --- package/src/components/Channel/Channel.tsx | 110 ++++++++---------- .../hooks/useChannelRequestHandlers.ts | 15 ++- .../components/MessageList/MessageList.tsx | 46 ++------ 3 files changed, 67 insertions(+), 104 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index aeaa71c1cb..e2b821f5dc 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -535,6 +535,32 @@ const ChannelWithContext = (props: PropsWithChildren) = }, [channelUnreadStateStore], ); + + // Unread state is owned by the LLC: channel.messagePaginator.unreadStateSnapshot is the single + // source of truth (the paginator updates it on seeding / message.new / notification.mark_unread / + // truncate). Mirror it into channelUnreadStateStore so existing consumers keep working. + useEffect(() => { + const snapshotStore = channel.messagePaginator.unreadStateSnapshot; + const applyUnreadSnapshot = (snapshot: { + firstUnreadMessageId: string | null; + lastReadAt: Date | null; + lastReadMessageId: string | null; + unreadCount: number; + }) => { + setChannelUnreadState( + snapshot.firstUnreadMessageId || snapshot.lastReadMessageId || snapshot.unreadCount + ? { + first_unread_message_id: snapshot.firstUnreadMessageId ?? undefined, + last_read: snapshot.lastReadAt ?? new Date(0), + last_read_message_id: snapshot.lastReadMessageId ?? undefined, + unread_messages: snapshot.unreadCount, + } + : undefined, + ); + }; + applyUnreadSnapshot(snapshotStore.getLatestValue()); + return snapshotStore.subscribe(applyUnreadSnapshot); + }, [channel, setChannelUnreadState]); const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); const syncingChannelRef = useRef(false); @@ -561,6 +587,7 @@ const ChannelWithContext = (props: PropsWithChildren) = // stream-chat message-operations engine (send/retry/update via *WithLocalUpdate) honors them. useChannelRequestHandlers({ channel, + doMarkReadRequest, doSendMessageRequest, doUpdateMessageRequest, }); @@ -638,21 +665,8 @@ const ChannelWithContext = (props: PropsWithChildren) = } } - if (event.type === 'notification.mark_unread') { - if (!(event.last_read_at && event.user)) { - return; - } - setChannelUnreadState({ - first_unread_message_id: event.first_unread_message_id, - last_read: new Date(event.last_read_at), - last_read_message_id: event.last_read_message_id, - unread_messages: event.unread_messages ?? 0, - }); - } - - if (event.type === 'channel.truncated' && event.cid === channel.cid) { - setChannelUnreadState(undefined); - } + // notification.mark_unread + channel.truncated update channel.messagePaginator.unreadStateSnapshot + // in the LLC; that snapshot is mirrored into channelUnreadStateStore, so no manual handling here. // The message list is backed reactively by channel.messagePaginator (channel._handleChannelEvent // ingests message.new/updated/deleted + reaction events), and read/typing/members come from @@ -694,32 +708,18 @@ const ChannelWithContext = (props: PropsWithChildren) = } } - if (client.user?.id && channel.state.read[client.user.id]) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { user, ...ownReadState } = channel.state.read[client.user.id]; - setChannelUnreadState(ownReadState); - } + // The paginator's unreadStateSnapshot is populated by the seed/reload above and mirrored into + // channelUnreadStateStore, so there's no manual initial read-state copy here. if (messageId) { await loadChannelAroundMessage({ messageId, setTargetedMessage }); } else if (shouldLoadAtFirstUnread) { - const clientUserId = client.user?.id; - if (!clientUserId) { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { user, ...ownReadState } = channel.state.read[clientUserId]; - - await loadChannelAtFirstUnreadMessage({ - channelUnreadState: ownReadState, - setChannelUnreadState, - setTargetedMessage, - }); + // jumpToTheFirstUnreadMessage resolves the first-unread id from the paginator's snapshot. + await loadChannelAtFirstUnreadMessage({ setTargetedMessage }); } if (unreadCount > 0 && markReadOnMount) { - await markRead({ updateChannelUnreadState: false }); + await markRead(); } listener = channel.on(handleEvent); @@ -776,46 +776,28 @@ const ChannelWithContext = (props: PropsWithChildren) = * CHANNEL METHODS */ const markReadInternal: ChannelContextValue['markRead'] = throttle( - async (options?: MarkReadFunctionOptions) => { - const { updateChannelUnreadState = true } = options ?? {}; + async () => { if (!channel || channel?.disconnected) { return; } - // When read events are disabled (e.g. livestreams) we cannot mark read on the backend. If the - // client opted into a local unread count, reset it locally instead so the user's "caught up" - // state is reflected without a server round trip. + // Read events disabled (e.g. livestreams): if the client opted into a local unread count, + // reset it locally (dispatches message.read_locally) — no backend round trip. The paginator's + // unread snapshot updates from that, and is mirrored into channelUnreadStateStore. if (!clientChannelConfig?.read_events) { if (client.options.isLocalUnreadCountEnabled) { - const event = channel.markReadLocally(); - if (updateChannelUnreadState && event && lastReadRef.current) { - setChannelUnreadState({ - last_read: lastReadRef.current, - last_read_message_id: event.last_read_message_id, - unread_messages: 0, - }); - lastReadRef.current = new Date(); - } + channel.markReadLocally(); } return; } - if (doMarkReadRequest) { - doMarkReadRequest(channel, updateChannelUnreadState ? setChannelUnreadState : undefined); - } else { - try { - const response = await channel.markRead(); - if (updateChannelUnreadState && response && lastReadRef.current) { - setChannelUnreadState({ - last_read: lastReadRef.current, - last_read_message_id: response?.event.last_read_message_id, - unread_messages: 0, - }); - lastReadRef.current = new Date(); - } - } catch (err) { - console.log('Error marking channel as read:', err); - } + // channel.markRead() delegates to client.messageDeliveryReporter.markRead(this), which honors + // any custom markReadRequest handler registered in channel.configState (see + // useChannelRequestHandlers). Unread state updates in the LLC snapshot, mirrored into the store. + try { + await channel.markRead(); + } catch (err) { + console.log('Error marking channel as read:', err); } }, defaultThrottleInterval, diff --git a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts index 43ebb65749..2c0b65244b 100644 --- a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts +++ b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts @@ -14,6 +14,8 @@ type RequestHandlers = NonNullable; export type ChannelRequestHandlersParams = { channel: Channel; + /** Overrides the default mark-read request. Mirrors the `` prop. */ + doMarkReadRequest?: (channel: Channel) => void; /** Overrides the default send/retry request. Mirrors the `` prop. */ doSendMessageRequest?: ( channelId: string, @@ -40,6 +42,7 @@ export type ChannelRequestHandlersParams = { */ export const useChannelRequestHandlers = ({ channel, + doMarkReadRequest, doSendMessageRequest, doUpdateMessageRequest, }: ChannelRequestHandlersParams) => { @@ -48,10 +51,20 @@ export const useChannelRequestHandlers = ({ const nextRequestHandlers: RequestHandlers = { ...(currentRequestHandlers ?? {}) }; // Reset the handlers this hook manages, then register only the provided overrides. + delete nextRequestHandlers.markReadRequest; delete nextRequestHandlers.retrySendMessageRequest; delete nextRequestHandlers.sendMessageRequest; delete nextRequestHandlers.updateMessageRequest; + if (doMarkReadRequest) { + // RN's doMarkReadRequest performs the custom mark-read itself (returns void); its obsolete 2nd + // (setChannelUnreadUiState) arg is dropped now that unread state is the paginator snapshot. + nextRequestHandlers.markReadRequest = ({ channel: markReadChannel }) => { + doMarkReadRequest(markReadChannel); + return Promise.resolve(null); + }; + } + if (doSendMessageRequest) { const sendMessageRequest: RequestHandlers['sendMessageRequest'] = async ({ message, @@ -79,5 +92,5 @@ export const useChannelRequestHandlers = ({ requestHandlers: Object.keys(nextRequestHandlers).length > 0 ? nextRequestHandlers : undefined, }); - }, [channel, doSendMessageRequest, doUpdateMessageRequest]); + }, [channel, doMarkReadRequest, doSendMessageRequest, doUpdateMessageRequest]); }; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 2ef08ac23b..3f0f782e51 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + AppState, FlatList, FlatListProps, FlatList as FlatListType, @@ -15,7 +16,7 @@ import Animated from 'react-native-reanimated'; import debounce from 'lodash/debounce'; -import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; +import type { Channel, Event, LocalMessage } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction'; @@ -188,20 +189,6 @@ const hasReadLastMessage = (channel: Channel, userId: string) => { return latestMessageIdInChannel === lastReadMessageIdServer; }; -const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageResponse) => { - if (!newMessage) return; - let previousLastMessage; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (!msg?.id) break; - if (msg.id !== newMessage.id) { - previousLastMessage = msg; - break; - } - } - return previousLastMessage; -}; - type MessageListPropsWithContext = Pick< AttachmentPickerContextValue, 'closePicker' | 'attachmentPickerStore' @@ -218,7 +205,6 @@ type MessageListPropsWithContext = Pick< | 'markRead' | 'reloadChannel' | 'scrollToFirstUnreadThreshold' - | 'setChannelUnreadState' | 'setTargetedMessage' | 'targetedMessage' | 'threadList' @@ -356,7 +342,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onThreadSelect, readEvents, reloadChannel, - setChannelUnreadState, setFlatListRef, setTargetedMessage, targetedMessage, @@ -658,6 +643,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const shouldMarkRead = () => { const channelUnreadState = channelUnreadStateStore.channelUnreadState; return ( + AppState.currentState === 'active' && !channelUnreadState?.first_unread_message_id && !scrollToBottomButtonVisible && client.user?.id && @@ -666,26 +652,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }; const handleEvent = async (event: Event) => { + // The unread count is owned by channel.messagePaginator.unreadStateSnapshot (mirrored into + // channelUnreadStateStore); we no longer manually bump it here. We only mark the channel read + // when the user is caught up at the bottom and the app is foregrounded. const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; - const isMyOwnMessage = event.message?.user?.id === client.user?.id; - const channelUnreadState = channelUnreadStateStore.channelUnreadState; - // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState when its a received message. - if ( - (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && - !isMyOwnMessage - ) { - const previousUnreadCount = channelUnreadState?.unread_messages ?? 0; - const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); - setChannelUnreadState({ - ...channelUnreadState, - last_read: - channelUnreadState?.last_read ?? - (previousUnreadCount === 0 && previousLastMessage?.created_at - ? new Date(previousLastMessage.created_at) - : new Date(0)), // not having information about the last read message means the whole channel is unread, - unread_messages: previousUnreadCount + 1, - }); - } else if (mainChannelUpdated && shouldMarkRead()) { + if (mainChannelUpdated && shouldMarkRead()) { await markRead(); } }; @@ -701,7 +672,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { client.user?.id, markRead, scrollToBottomButtonVisible, - setChannelUnreadState, threadList, ]); @@ -1427,7 +1397,6 @@ export const MessageList = (props: MessageListProps) => { markRead, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, setTargetedMessage, targetedMessage, threadList, @@ -1472,7 +1441,6 @@ export const MessageList = (props: MessageListProps) => { readEvents, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, setTargetedMessage, shouldShowUnreadUnderlay, targetedMessage, From 75503781e71e5c1fc218acd29411bba6cf249479 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Jul 2026 10:42:28 +0200 Subject: [PATCH 06/36] feat: thread state --- package/src/components/Channel/Channel.tsx | 50 +++++++++---------- .../Channel/hooks/useCreateThreadContext.ts | 41 +++++++++------ 2 files changed, 50 insertions(+), 41 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index e2b821f5dc..ff098bd15d 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -517,13 +517,16 @@ const ChannelWithContext = (props: PropsWithChildren) = const components = useComponentsContext(); const { KeyboardCompatibleView, LoadingErrorIndicator } = components; - const { thread: threadProps, threadInstance } = threadFromProps; + const { thread: threadProps, threadInstance: threadInstanceFromProps } = threadFromProps; const styles = useStyles(); const [deleted, setDeleted] = useState(false); const [error, setError] = useState(false); const lastReadRef = useRef(undefined); const [thread, setThread] = useState(threadProps || null); + const [threadInstance, setThreadInstance] = useState( + threadInstanceFromProps ?? null, + ); const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); const [channelUnreadStateStore] = useState(() => new ChannelUnreadStateStore()); @@ -807,28 +810,15 @@ const ChannelWithContext = (props: PropsWithChildren) = const markRead = useStableCallback(markReadInternal); const reloadThread = useStableCallback(async () => { - if (!channel || !thread?.id) { + if (!channel || !thread?.id || !threadInstance) { return; } setThreadLoadingMore(true); try { - const parentID = thread.id; - - const limit = 50; - // channel.state.threads[parentID] = []; - const queryResponse = await channel.getReplies(parentID, { - limit, - }); - - const updatedHasMore = queryResponse.messages.length === limit; - const updatedThreadMessages = channel.state.threads[parentID] || []; - loadMoreThreadFinished(updatedHasMore, updatedThreadMessages); - const { messages } = await channel.getMessagesById([parentID]); - const [threadMessage] = messages; - if (threadMessage && !threadInstance) { - const formattedMessage = channel.state.formatMessage(threadMessage); - setThread(formattedMessage); - } + // Rehydrate the thread instance (parent + read state) and reload its reply paginator. + await threadInstance.reload(); + await threadInstance.messagePaginator.reload(); + setThreadLoadingMore(false); } catch (err) { console.warn('Thread loading request failed with error', err); if (err instanceof Error) { @@ -1449,24 +1439,30 @@ const ChannelWithContext = (props: PropsWithChildren) = */ const openThread: ThreadContextValue['openThread'] = useCallback( (message) => { + // Construct a stream-chat Thread instance for the opened parent message; its + // messagePaginator drives the reply list + optimistic reply ops. + const newThreadInstance = new Thread({ channel, client, parentMessage: message }); setThread(message); - + setThreadInstance(newThreadInstance); + // Seed the reply paginator so replies render on open. + newThreadInstance.messagePaginator + .reload() + .catch((err) => console.warn('Thread reply load failed with error:', err)); if (channel.initialized) { + // Mark the thread read on open. A freshly-constructed minimal thread has ownUnreadCount 0, + // so threadInstance.markRead() would no-op; channel.markRead({thread_id}) marks it reliably + // (and still delegates to the messageDeliveryReporter). channel.markRead({ thread_id: message.id }); } - // This was causing inconsistencies within the thread state as well as being responsible - // of threads essentially never unloading (due to all of the previous threads + 50 loading - // every time we'd run this). It seemingly has no impact (other than a performance boost) - // and having it was causing issues with the Threads V2 architecture. - // setThreadMessages(newThreadMessages); }, - [channel, setThread], + [channel, client], ); const closeThread: ThreadContextValue['closeThread'] = useCallback(() => { setThread(null); + setThreadInstance(null); setThreadMessages([]); - }, [setThread, setThreadMessages]); + }, [setThreadMessages]); // hard limit to prevent you from scrolling faster than 1 page per 2 seconds const loadMoreThreadFinished = useRef( diff --git a/package/src/components/Channel/hooks/useCreateThreadContext.ts b/package/src/components/Channel/hooks/useCreateThreadContext.ts index 6af2aa5a79..427180df8c 100644 --- a/package/src/components/Channel/hooks/useCreateThreadContext.ts +++ b/package/src/components/Channel/hooks/useCreateThreadContext.ts @@ -1,14 +1,23 @@ -import { ThreadState } from 'stream-chat'; +import { LocalMessage } from 'stream-chat'; import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; import { useStateStore } from '../../../hooks'; -const selector = (nextValue: ThreadState) => - ({ - isLoadingNext: nextValue.pagination.isLoadingNext, - isLoadingPrev: nextValue.pagination.isLoadingPrev, - latestReplies: nextValue.replies, - }) as const; +type ThreadMessagePaginatorState = { + hasMoreHead: boolean; + hasMoreTail: boolean; + isLoading: boolean; + items?: LocalMessage[]; +}; + +// Reply list is sourced from the thread's messagePaginator (optimistic thread ops write there, +// not to the legacy state.replies). tailward === older replies (loadMoreThread); headward === +// newer replies (loadMoreRecentThread). +const selector = (state: ThreadMessagePaginatorState) => ({ + hasMore: state.hasMoreTail, + isLoading: state.isLoading, + messages: state.items, +}); export const useCreateThreadContext = ({ allowThreadMessagesInChannel, @@ -24,17 +33,21 @@ export const useCreateThreadContext = ({ threadLoadingMore, threadMessages, }: ThreadContextValue) => { - const { isLoadingNext, isLoadingPrev, latestReplies } = - useStateStore(threadInstance?.state, selector) ?? {}; + const { hasMore, isLoading, messages } = + useStateStore(threadInstance?.messagePaginator?.state, selector) ?? {}; const contextAdapter = threadInstance ? { - loadMoreRecentThread: threadInstance.loadNextPage, - loadMoreThread: threadInstance.loadPrevPage, + loadMoreRecentThread: async () => { + await threadInstance.messagePaginator.toHead(); + }, + loadMoreThread: async () => { + await threadInstance.messagePaginator.toTail(); + }, + threadHasMore: hasMore ?? false, threadInstance, - threadLoadingMore: isLoadingPrev, - threadLoadingMoreRecent: isLoadingNext, - threadMessages: latestReplies ?? [], + threadLoadingMore: !!isLoading, + threadMessages: messages ?? [], } : {}; From ee53121e6b71324dc564bb733ac3ce553188473f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Jul 2026 11:29:34 +0200 Subject: [PATCH 07/36] chore: context storm cleanup --- package/src/components/Channel/Channel.tsx | 48 ++++++-------- .../Channel/hooks/useCreateTypingContext.ts | 17 ----- .../MessageList/TypingIndicatorContainer.tsx | 16 +++-- .../__tests__/TypingIndicator.test.tsx | 63 ++++++++++++------- .../MessageList/hooks/useTypingUsers.ts | 10 ++- .../MessageList/utils/filterTypingUsers.ts | 9 +-- package/src/components/index.ts | 1 - package/src/contexts/__tests__/index.test.tsx | 5 -- package/src/contexts/index.ts | 1 - .../contexts/typingContext/TypingContext.tsx | 36 ----------- 10 files changed, 82 insertions(+), 124 deletions(-) delete mode 100644 package/src/components/Channel/hooks/useCreateTypingContext.ts delete mode 100644 package/src/contexts/typingContext/TypingContext.tsx diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index ff098bd15d..34ed18221b 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -23,7 +23,6 @@ import { Event as StreamEvent, Message as StreamMessage, Thread, - TypingUsersState, UpdateMessageOptions, WatcherState, } from 'stream-chat'; @@ -40,8 +39,6 @@ import { useCreatePaginatedMessageListContext } from './hooks/useCreatePaginated import { useCreateThreadContext } from './hooks/useCreateThreadContext'; -import { useCreateTypingContext } from './hooks/useCreateTypingContext'; - import { useMessageListPagination } from './hooks/useMessageListPagination'; import { useTargetedMessage } from './hooks/useTargetedMessage'; @@ -87,7 +84,6 @@ import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; -import { TypingProvider } from '../../contexts/typingContext/TypingContext'; import { useStableCallback, useStateStore } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; @@ -391,7 +387,6 @@ export type ChannelPropsWithContext = Pick & const membersStateSelector = (state: MembersState) => ({ members: state.members }); const readStateSelector = (state: ReadState) => ({ read: state.read }); -const typingStateSelector = (state: TypingUsersState) => ({ typing: state.typing }); const watcherStateSelector = (state: WatcherState) => ({ watcherCount: state.watcherCount, watchers: state.watchers, @@ -595,11 +590,10 @@ const ChannelWithContext = (props: PropsWithChildren) = doUpdateMessageRequest, }); - // Channel scalar state (members/read/typing/watchers) is sourced reactively from + // Channel scalar state (members/read/watchers) is sourced reactively from // stream-chat's per-channel StateStores. const { members } = useStateStore(channel.state.membersStore, membersStateSelector); const { read } = useStateStore(channel.state.readStore, readStateSelector); - const { typing } = useStateStore(channel.state.typingStore, typingStateSelector); const { watcherCount, watchers } = useStateStore( channel.state.watcherStore, watcherStateSelector, @@ -1711,10 +1705,6 @@ const ChannelWithContext = (props: PropsWithChildren) = threadMessages, }); - const typingContext = useCreateTypingContext({ - typing: typing ?? {}, - }); - const audioPlayerContext = useMemo( () => ({ allowConcurrentAudioPlayback }), [allowConcurrentAudioPlayback], @@ -1751,25 +1741,23 @@ const ChannelWithContext = (props: PropsWithChildren) = > - - - - - - - - - - {children} - - - - - - - - - + + + + + + + + + {children} + + + + + + + + diff --git a/package/src/components/Channel/hooks/useCreateTypingContext.ts b/package/src/components/Channel/hooks/useCreateTypingContext.ts deleted file mode 100644 index 960177b8de..0000000000 --- a/package/src/components/Channel/hooks/useCreateTypingContext.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useMemo } from 'react'; - -import type { TypingContextValue } from '../../../contexts/typingContext/TypingContext'; - -export const useCreateTypingContext = ({ typing }: TypingContextValue) => { - const typingValue = Object.keys(typing).join(); - - const typingContext: TypingContextValue = useMemo( - () => ({ - typing, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [typingValue], - ); - - return typingContext; -}; diff --git a/package/src/components/MessageList/TypingIndicatorContainer.tsx b/package/src/components/MessageList/TypingIndicatorContainer.tsx index d410baf4c9..6115cfd215 100644 --- a/package/src/components/MessageList/TypingIndicatorContainer.tsx +++ b/package/src/components/MessageList/TypingIndicatorContainer.tsx @@ -1,12 +1,15 @@ import React, { PropsWithChildren } from 'react'; import { StyleSheet, View } from 'react-native'; +import { TypingUsersState } from 'stream-chat'; + import { filterTypingUsers } from './utils/filterTypingUsers'; +import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; -import { TypingContextValue, useTypingContext } from '../../contexts/typingContext/TypingContext'; +import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; const styles = StyleSheet.create({ @@ -17,8 +20,12 @@ const styles = StyleSheet.create({ }, }); -type TypingIndicatorContainerPropsWithContext = Pick & - Pick & +const typingSelector = (state: TypingUsersState) => ({ typing: state.typing }); + +type TypingIndicatorContainerPropsWithContext = { typing: TypingUsersState['typing'] } & Pick< + ChatContextValue, + 'client' +> & Pick; const TypingIndicatorContainerWithContext = ( @@ -49,9 +56,10 @@ export type TypingIndicatorContainerProps = PropsWithChildren< >; export const TypingIndicatorContainer = (props: TypingIndicatorContainerProps) => { - const { typing } = useTypingContext(); + const { channel } = useChannelContext(); const { client } = useChatContext(); const { thread } = useThreadContext(); + const { typing } = useStateStore(channel.state.typingStore, typingSelector) ?? { typing: {} }; return ; }; diff --git a/package/src/components/MessageList/__tests__/TypingIndicator.test.tsx b/package/src/components/MessageList/__tests__/TypingIndicator.test.tsx index 4d37e202de..eb2cb8b96c 100644 --- a/package/src/components/MessageList/__tests__/TypingIndicator.test.tsx +++ b/package/src/components/MessageList/__tests__/TypingIndicator.test.tsx @@ -2,34 +2,38 @@ import React from 'react'; import { cleanup, render, waitFor } from '@testing-library/react-native'; -import type { Event, StreamChat } from 'stream-chat'; - -import { TypingProvider } from '../../../contexts/typingContext/TypingContext'; +import type { TypingUsersState } from 'stream-chat'; +import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; import { generateStaticUser, generateUser } from '../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Chat } from '../../Chat/Chat'; import { TypingIndicator } from '../TypingIndicator'; afterEach(cleanup); describe('TypingIndicator', () => { - let chatClient: StreamChat; - it('should render typing indicator for two users', async () => { const user0 = generateUser(); const user1 = generateUser(); const user2 = generateUser(); - chatClient = await getTestClientWithUser({ id: user0.id }); - await chatClient.setUser(user0, 'testToken'); - const typing = { user1: { user: user1 }, user2: { user: user2 } }; + const { + channels: [channel], + client, + } = await initiateClientWithChannels({ customUser: user0 }); + channel.state.typingStore.partialNext({ + typing: { + user1: { user: user1 }, + user2: { user: user2 }, + } as unknown as TypingUsersState['typing'], + }); const { getAllByTestId, getByTestId } = render( - - }}> + + - + , ); await waitFor(() => { @@ -42,15 +46,19 @@ describe('TypingIndicator', () => { const user0 = generateUser(); const user1 = generateUser(); - chatClient = await getTestClientWithUser({ id: user0.id }); - await chatClient.setUser(user0, 'testToken'); - const typing = { user1: { user: user1 } }; + const { + channels: [channel], + client, + } = await initiateClientWithChannels({ customUser: user0 }); + channel.state.typingStore.partialNext({ + typing: { user1: { user: user1 } } as unknown as TypingUsersState['typing'], + }); const { getAllByTestId, getByTestId } = render( - - }}> + + - + , ); await waitFor(() => { @@ -64,15 +72,22 @@ describe('TypingIndicator', () => { const user1 = generateStaticUser(1); const user2 = generateStaticUser(3); - chatClient = await getTestClientWithUser({ id: user0.id }); - await chatClient.setUser(user0, 'testToken'); - const typing = { user1: { user: user1 }, user2: { user: user2 } }; + const { + channels: [channel], + client, + } = await initiateClientWithChannels({ customUser: user0 }); + channel.state.typingStore.partialNext({ + typing: { + user1: { user: user1 }, + user2: { user: user2 }, + } as unknown as TypingUsersState['typing'], + }); const { toJSON } = render( - - }}> + + - + , ); await waitFor(() => { diff --git a/package/src/components/MessageList/hooks/useTypingUsers.ts b/package/src/components/MessageList/hooks/useTypingUsers.ts index b4e3a52248..c86ae04e05 100644 --- a/package/src/components/MessageList/hooks/useTypingUsers.ts +++ b/package/src/components/MessageList/hooks/useTypingUsers.ts @@ -1,12 +1,18 @@ import { useMemo } from 'react'; -import { useChatContext, useThreadContext, useTypingContext } from '../../../contexts'; +import { TypingUsersState } from 'stream-chat'; + +import { useChannelContext, useChatContext, useThreadContext } from '../../../contexts'; +import { useStateStore } from '../../../hooks'; import { filterTypingUsers } from '../utils/filterTypingUsers'; +const selector = (state: TypingUsersState) => ({ typing: state.typing }); + export const useTypingUsers = () => { const { client } = useChatContext(); + const { channel } = useChannelContext(); const { thread } = useThreadContext(); - const { typing } = useTypingContext(); + const { typing } = useStateStore(channel.state.typingStore, selector) ?? { typing: {} }; return useMemo(() => filterTypingUsers({ client, thread, typing }), [client, thread, typing]); }; diff --git a/package/src/components/MessageList/utils/filterTypingUsers.ts b/package/src/components/MessageList/utils/filterTypingUsers.ts index e84e9d5783..ae2cfc3614 100644 --- a/package/src/components/MessageList/utils/filterTypingUsers.ts +++ b/package/src/components/MessageList/utils/filterTypingUsers.ts @@ -1,11 +1,12 @@ -import { UserResponse } from 'stream-chat'; +import { TypingUsersState, UserResponse } from 'stream-chat'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; -import type { TypingContextValue } from '../../../contexts/typingContext/TypingContext'; -type FilterTypingUsersParams = Pick & - Pick & +type FilterTypingUsersParams = { typing: TypingUsersState['typing'] } & Pick< + ChatContextValue, + 'client' +> & Pick; export const filterTypingUsers = ({ client, thread, typing }: FilterTypingUsersParams) => { diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 58f07e9441..a10c7b70f5 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -36,7 +36,6 @@ export * from './Channel/hooks/useCreateInputMessageInputContext'; export * from './Channel/hooks/useCreateMessagesContext'; export * from './Channel/hooks/useCreatePaginatedMessageListContext'; export * from './Channel/hooks/useCreateThreadContext'; -export * from './Channel/hooks/useCreateTypingContext'; export * from './Channel/hooks/useTargetedMessage'; /** Channel List exports*/ diff --git a/package/src/contexts/__tests__/index.test.tsx b/package/src/contexts/__tests__/index.test.tsx index ec79514ab4..a1a9243879 100644 --- a/package/src/contexts/__tests__/index.test.tsx +++ b/package/src/contexts/__tests__/index.test.tsx @@ -16,7 +16,6 @@ import { usePaginatedMessageListContext, useTheme, useThreadContext, - useTypingContext, } from '../'; import { useChannelsStateContext } from '../channelsStateContext/ChannelsStateContext'; @@ -45,10 +44,6 @@ describe('contexts hooks in a component throws an error with message when not wr useOwnCapabilitiesContext, 'The useOwnCapabilitiesContext hook was called outside the Channel Component. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', ], - [ - useTypingContext, - 'The useTypingContext hook was called outside of the TypingContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ], [ useTheme, 'The useThemeContext hook was called outside the ThemeContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index 6344249b01..ab0515aa36 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -27,7 +27,6 @@ export * from './threadContext/ThreadContext'; export * from './threadsContext/ThreadsContext'; export * from './threadsContext/ThreadListItemContext'; export * from './translationContext'; -export * from './typingContext/TypingContext'; export * from './utils/getDisplayName'; export * from './channelDetailsContext'; export * from './channelAddMembersContext/ChannelAddMembersContext'; diff --git a/package/src/contexts/typingContext/TypingContext.tsx b/package/src/contexts/typingContext/TypingContext.tsx deleted file mode 100644 index 86f70c5560..0000000000 --- a/package/src/contexts/typingContext/TypingContext.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { PropsWithChildren, useContext } from 'react'; - -import type { ChannelState } from 'stream-chat'; - -import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; - -import { isTestEnvironment } from '../utils/isTestEnvironment'; - -export type TypingContextValue = { - typing: ChannelState['typing']; -}; - -export const TypingContext = React.createContext(DEFAULT_BASE_CONTEXT_VALUE as TypingContextValue); - -export const TypingProvider = ({ - children, - value, -}: PropsWithChildren<{ - value: TypingContextValue; -}>) => ( - - {children} - -); - -export const useTypingContext = () => { - const contextValue = useContext(TypingContext) as unknown as TypingContextValue; - - if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { - throw new Error( - 'The useTypingContext hook was called outside of the TypingContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ); - } - - return contextValue; -}; From 685fb30802236029a733f573a72ecea40b572dc6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Jul 2026 12:00:54 +0200 Subject: [PATCH 08/36] feat: use state directly instead of UI sdk state --- package/src/components/Channel/Channel.tsx | 25 +-------- .../Channel/__tests__/Channel.test.tsx | 2 - .../Channel/hooks/useCreateChannelContext.ts | 19 ------- package/src/components/Message/Message.tsx | 22 +++++--- .../Message/MessageItemView/MessageFooter.tsx | 3 +- .../MessageInput/MessageComposer.tsx | 17 ++++-- .../channelContext/ChannelContext.tsx | 52 +------------------ .../messageContext/MessageContext.tsx | 8 +-- 8 files changed, 37 insertions(+), 111 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 34ed18221b..a200b3a5fb 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -12,11 +12,9 @@ import { EventHandler, LocalMessage, localMessageToNewMessagePayload, - MembersState, MessageLabel, MessageResponse, Reaction, - ReadState, SendMessageAPIResponse, SendMessageOptions, StreamChat, @@ -24,7 +22,6 @@ import { Message as StreamMessage, Thread, UpdateMessageOptions, - WatcherState, } from 'stream-chat'; import { useChannelRequestHandlers } from './hooks/useChannelRequestHandlers'; @@ -84,7 +81,7 @@ import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; -import { useStableCallback, useStateStore } from '../../hooks'; +import { useStableCallback } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; import { useAttachmentPickerBottomSheet } from '../../hooks/useAttachmentPickerBottomSheet'; @@ -385,13 +382,6 @@ export type ChannelPropsWithContext = Pick & initializeOnMount?: boolean; }; -const membersStateSelector = (state: MembersState) => ({ members: state.members }); -const readStateSelector = (state: ReadState) => ({ read: state.read }); -const watcherStateSelector = (state: WatcherState) => ({ - watcherCount: state.watcherCount, - watchers: state.watchers, -}); - const ChannelWithContext = (props: PropsWithChildren) => { const { disableAttachmentPicker = !isImageMediaLibraryAvailable(), @@ -590,15 +580,6 @@ const ChannelWithContext = (props: PropsWithChildren) = doUpdateMessageRequest, }); - // Channel scalar state (members/read/watchers) is sourced reactively from - // stream-chat's per-channel StateStores. - const { members } = useStateStore(channel.state.membersStore, membersStateSelector); - const { read } = useStateStore(channel.state.readStore, readStateSelector); - const { watcherCount, watchers } = useStateStore( - channel.state.watcherStore, - watcherStateSelector, - ); - const { loadChannelAroundMessage: loadChannelAroundMessageFn, loadChannelAtFirstUnreadMessage, @@ -1564,8 +1545,6 @@ const ChannelWithContext = (props: PropsWithChildren) = markRead, maximumMessageLimit, maxTimeBetweenGroupedMessages, - members: members ?? {}, - read: read ?? {}, reloadChannel, scrollToFirstUnreadThreshold, setChannelUnreadState, @@ -1574,8 +1553,6 @@ const ChannelWithContext = (props: PropsWithChildren) = targetedMessage, threadList, uploadAbortControllerRef, - watcherCount, - watchers, }); // This is mainly a hack to get around an issue with sendMessage not being passed correctly as a diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 7de5938bea..7051560965 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -265,7 +265,6 @@ describe('Channel', () => { channel, client: chatClient, markRead: () => {}, - watcherCount: 5, }; render( @@ -285,7 +284,6 @@ describe('Channel', () => { expect(ctx.channel).toBeInstanceOf(Object); expect(ctx.client).toBeInstanceOf(StreamChat); expect(ctx.markRead).toBeInstanceOf(Function); - expect(ctx.watcherCount).toBe(5); }); }); }); diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 67a495449b..1525b8fbb5 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -19,8 +19,6 @@ export const useCreateChannelContext = ({ markRead, maxTimeBetweenGroupedMessages, maximumMessageLimit, - members, - read, reloadChannel, scrollToFirstUnreadThreshold, setChannelUnreadState, @@ -29,17 +27,8 @@ export const useCreateChannelContext = ({ targetedMessage, threadList, uploadAbortControllerRef, - watcherCount, - watchers, }: ChannelContextValue) => { const channelId = channel?.id; - const membersLength = Object.keys(members).length; - - const readUsers = Object.values(read); - const readUsersLength = readUsers.length; - const readUsersLastReads = readUsers - .map(({ last_read }) => last_read?.toISOString() ?? '') - .join(); const channelContext: ChannelContextValue = useMemo( () => ({ @@ -59,8 +48,6 @@ export const useCreateChannelContext = ({ markRead, maximumMessageLimit, maxTimeBetweenGroupedMessages, - members, - read, reloadChannel, scrollToFirstUnreadThreshold, setChannelUnreadState, @@ -69,8 +56,6 @@ export const useCreateChannelContext = ({ targetedMessage, threadList, uploadAbortControllerRef, - watcherCount, - watchers, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -80,12 +65,8 @@ export const useCreateChannelContext = ({ isChannelActive, highlightedMessageId, loading, - membersLength, - readUsersLength, - readUsersLastReads, targetedMessage, threadList, - watcherCount, maximumMessageLimit, ], ); diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 212386b269..65e626309f 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -11,7 +11,13 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Portal } from 'react-native-teleport'; -import type { Attachment, LocalMessage, MentionEntity, UserResponse } from 'stream-chat'; +import type { + Attachment, + LocalMessage, + MembersState, + MentionEntity, + UserResponse, +} from 'stream-chat'; import { useCreateMessageContext } from './hooks/useCreateMessageContext'; import { useMessageActionHandlers } from './hooks/useMessageActionHandlers'; @@ -56,7 +62,7 @@ import { useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; -import { useStableCallback } from '../../hooks'; +import { useStableCallback, useStateStore } from '../../hooks'; import { isVideoPlayerAvailable, NativeHandlers } from '../../native'; import { closeOverlay, @@ -186,9 +192,8 @@ export type MessageActionHandlers = { export type MessagePropsWithContext = Pick< ChannelContextValue, - 'channel' | 'enforceUniqueReaction' | 'members' -> & - Pick & + 'channel' | 'enforceUniqueReaction' +> & { members: MembersState['members'] } & Pick & Partial< Omit< MessageContextValue, @@ -1127,6 +1132,8 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit const MemoizedMessage = React.memo(MessageWithContext, areEqual) as typeof MessageWithContext; +const messageMembersSelector = (state: MembersState) => ({ members: state.members }); + export type MessageProps = Partial< Omit > & @@ -1140,7 +1147,10 @@ export type MessageProps = Partial< */ export const Message = (props: MessageProps) => { const { message } = props; - const { channel, enforceUniqueReaction, members } = useChannelContext(); + const { channel, enforceUniqueReaction } = useChannelContext(); + const { members } = useStateStore(channel.state.membersStore, messageMembersSelector) ?? { + members: {}, + }; const chatContext = useChatContext(); const { dismissKeyboard } = useKeyboardContext(); const messagesContext = useMessagesContext(); diff --git a/package/src/components/Message/MessageItemView/MessageFooter.tsx b/package/src/components/Message/MessageItemView/MessageFooter.tsx index 2335b2bd8a..e089fafd94 100644 --- a/package/src/components/Message/MessageItemView/MessageFooter.tsx +++ b/package/src/components/Message/MessageItemView/MessageFooter.tsx @@ -3,7 +3,6 @@ import { StyleSheet, Text, View } from 'react-native'; import type { Attachment, LocalMessage } from 'stream-chat'; -import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; import { Alignment, @@ -162,7 +161,7 @@ const MemoizedMessageFooter = React.memo( areEqual, ) as typeof MessageFooterWithContext; -export type MessageFooterProps = Partial> & +export type MessageFooterProps = Partial> & MessageFooterComponentProps & { alignment?: Alignment; lastGroupMessage?: boolean; diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index ea19ec9a78..1107be5701 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -10,7 +10,7 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { type UserResponse } from 'stream-chat'; +import { type MembersState, type UserResponse, type WatcherState } from 'stream-chat'; import { MicPositionProvider } from './contexts/MicPositionContext'; @@ -138,8 +138,10 @@ const useStyles = () => { }; type MessageComposerPropsWithContext = Pick & - Pick & - Pick< + Pick & { + members: MembersState['members']; + watchers: WatcherState['watchers']; + } & Pick< MessageInputContextValue, | 'audioRecorderManager' | 'additionalTextInputProps' @@ -175,6 +177,9 @@ const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ height: state.height, }); +const membersSelector = (state: MembersState) => ({ members: state.members }); +const watchersSelector = (state: WatcherState) => ({ watchers: state.watchers }); + const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { const { additionalTextInputProps, @@ -613,7 +618,11 @@ export const MessageComposer = (props: MessageComposerProps) => { const { isOnline } = useChatContext(); const ownCapabilities = useOwnCapabilitiesContext(); - const { channel, members, watchers } = useChannelContext(); + const { channel } = useChannelContext(); + const { members } = useStateStore(channel.state.membersStore, membersSelector) ?? { members: {} }; + const { watchers } = useStateStore(channel.state.watcherStore, watchersSelector) ?? { + watchers: {}, + }; const { audioRecorderManager, diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index e9de901882..60f6e261d0 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import type { Channel, ChannelState } from 'stream-chat'; +import type { Channel } from 'stream-chat'; import { MarkReadFunctionOptions } from '../../components/Channel/Channel'; import { @@ -82,31 +82,6 @@ export type ChannelContextValue = { }) => Promise; markRead: (options?: MarkReadFunctionOptions) => void; - /** - * - * ```json - * { - * "thierry-123": { - * "id": "thierry-123", - * "role": "user", - * "created_at": "2019-04-03T14:42:47.087869Z", - * "updated_at": "2019-04-16T09:20:03.982283Z", - * "last_active": "2019-04-16T11:23:51.168113408+02:00", - * "online": true - * }, - * "vishal-123": { - * "id": "vishal-123", - * "role": "user", - * "created_at": "2019-05-03T14:42:47.087869Z", - * "updated_at": "2019-05-16T09:20:03.982283Z", - * "last_active": "2019-06-16T11:23:51.168113408+02:00", - * "online": false - * } - * } - * ``` - */ - members: ChannelState['members']; - read: ChannelState['read']; reloadChannel: () => Promise; scrollToFirstUnreadThreshold: number; setChannelUnreadState: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; @@ -148,31 +123,6 @@ export type ChannelContextValue = { */ targetedMessage?: string; threadList?: boolean; - watcherCount?: ChannelState['watcher_count']; - /** - * - * ```json - * { - * "thierry-123": { - * "id": "thierry-123", - * "role": "user", - * "created_at": "2019-04-03T14:42:47.087869Z", - * "updated_at": "2019-04-16T09:20:03.982283Z", - * "last_active": "2019-04-16T11:23:51.168113408+02:00", - * "online": true - * }, - * "vishal-123": { - * "id": "vishal-123", - * "role": "user", - * "created_at": "2019-05-03T14:42:47.087869Z", - * "updated_at": "2019-05-16T09:20:03.982283Z", - * "last_active": "2019-06-16T11:23:51.168113408+02:00", - * "online": false - * } - * } - * ``` - */ - watchers?: ChannelState['watchers']; }; export const ChannelContext = React.createContext( diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 6dcd1585e4..6bbf46ff96 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -1,7 +1,7 @@ import React, { PropsWithChildren, useContext } from 'react'; import type { View } from 'react-native'; -import type { Attachment, LocalMessage } from 'stream-chat'; +import type { Attachment, ChannelState, LocalMessage } from 'stream-chat'; import type { ActionHandler } from '../../components/Attachment/Attachment'; import type { ReactionSummary } from '../../components/Message/hooks/useProcessReactions'; @@ -157,8 +157,10 @@ export type MessageContextValue = { * TODO: V9: Change function params to an object */ onThreadSelect?: (message: LocalMessage, targetedMessageId?: string) => void; -} & Pick & - Pick; +} & Pick & { members: ChannelState['members'] } & Pick< + MessageComposerAPIContextValue, + 'setQuotedMessage' + >; export const MessageContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as MessageContextValue, From 5f836086296ba3d10f27ca042eefb07043bd05ae Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Jul 2026 13:13:35 +0200 Subject: [PATCH 09/36] feat: remove PaginatedMessageListContext --- package/src/components/Channel/Channel.tsx | 69 ++++----------- .../useCreatePaginatedMessageListContext.ts | 42 ---------- .../InlineLoadingMoreIndicator.tsx | 11 ++- .../InlineLoadingMoreRecentIndicator.tsx | 11 ++- ...InlineLoadingMoreRecentThreadIndicator.tsx | 4 +- .../MessageList/MessageFlashList.tsx | 38 ++++++--- .../components/MessageList/MessageList.tsx | 41 ++++++--- .../__tests__/useMessageList.test.tsx | 74 ++++++----------- .../MessageList/hooks/useMessageList.ts | 24 ++++-- package/src/components/index.ts | 1 - package/src/contexts/__tests__/index.test.tsx | 5 -- package/src/contexts/index.ts | 1 - .../PaginatedMessageListContext.tsx | 83 ------------------- 13 files changed, 127 insertions(+), 277 deletions(-) delete mode 100644 package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts delete mode 100644 package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index a200b3a5fb..c99b7ec604 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -32,7 +32,6 @@ import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessage import { useCreateMessagesContext } from './hooks/useCreateMessagesContext'; import { useCreateOwnCapabilitiesContext } from './hooks/useCreateOwnCapabilitiesContext'; -import { useCreatePaginatedMessageListContext } from './hooks/useCreatePaginatedMessageListContext'; import { useCreateThreadContext } from './hooks/useCreateThreadContext'; @@ -67,10 +66,6 @@ import { OwnCapabilitiesContextValue, OwnCapabilitiesProvider, } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; -import { - PaginatedMessageListContextValue, - PaginatedMessageListProvider, -} from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, @@ -85,7 +80,6 @@ import { useStableCallback } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; import { useAttachmentPickerBottomSheet } from '../../hooks/useAttachmentPickerBottomSheet'; -import { usePrunableMessageList } from '../../hooks/usePrunableMessageList'; import { isDocumentPickerAvailable, isImageMediaLibraryAvailable, @@ -227,9 +221,6 @@ export type ChannelPropsWithContext = Pick & > > & Pick & - Partial< - Pick - > & Pick & Partial< Pick< @@ -452,8 +443,6 @@ const ChannelWithContext = (props: PropsWithChildren) = isMessageAIGenerated = () => false, keyboardBehavior, keyboardVerticalOffset, - loadingMore: loadingMoreProp, - loadingMoreRecent: loadingMoreRecentProp, markdownRules, markReadOnMount = true, maxTimeBetweenGroupedMessages, @@ -584,8 +573,6 @@ const ChannelWithContext = (props: PropsWithChildren) = loadChannelAroundMessage: loadChannelAroundMessageFn, loadChannelAtFirstUnreadMessage, loadLatestMessages, - loadMore, - loadMoreRecent, state: channelMessagesState, } = useMessageListPagination({ channel, @@ -603,15 +590,6 @@ const ChannelWithContext = (props: PropsWithChildren) = return !!messageId || shouldLoadInitialChannelAtFirstUnreadMessage(); }); - // The message list is now backed reactively by channel.messagePaginator; the WS event handlers no - // longer copy channel.state into React state. Viewport tracking is still needed by the list. - // TODO(paginator-pruning): re-implement pruning as a window cap over messagePaginator.state.items - // (the paginator retains all loaded items in its ItemIndex). - const { viewabilityChangedCallback } = usePrunableMessageList({ - maximumMessageLimit, - setMessages: () => {}, - }); - const handleEvent: EventHandler = useStableCallback((event) => { if (shouldSyncChannel) { /** @@ -1596,21 +1574,6 @@ const ChannelWithContext = (props: PropsWithChildren) = setInputRef, }); - const messageListContext = useCreatePaginatedMessageListContext({ - channelId, - hasMore: channelMessagesState.hasMore, - loadingMore: loadingMoreProp !== undefined ? loadingMoreProp : channelMessagesState.loadingMore, - loadingMoreRecent: - loadingMoreRecentProp !== undefined - ? loadingMoreRecentProp - : channelMessagesState.loadingMoreRecent, - loadLatestMessages, - loadMore, - loadMoreRecent, - messages: channelMessagesState.messages ?? [], - viewabilityChangedCallback, - }); - const messagesContext = useCreateMessagesContext({ additionalPressableProps, channelId, @@ -1718,23 +1681,21 @@ const ChannelWithContext = (props: PropsWithChildren) = > - - - - - - - - - {children} - - - - - - - - + + + + + + + + {children} + + + + + + + diff --git a/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts b/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts deleted file mode 100644 index a06bafa032..0000000000 --- a/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useMemo } from 'react'; - -import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; - -import { reduceMessagesToString } from '../../../utils/utils'; - -export const useCreatePaginatedMessageListContext = ({ - channelId, - hasMore, - loadingMore, - loadingMoreRecent, - loadLatestMessages, - loadMore, - loadMoreRecent, - messages, - setLoadingMore, - setLoadingMoreRecent, - viewabilityChangedCallback, -}: PaginatedMessageListContextValue & { - channelId?: string; -}) => { - const messagesStr = reduceMessagesToString(messages); - - const paginatedMessagesContext: PaginatedMessageListContextValue = useMemo( - () => ({ - hasMore, - loadingMore, - loadingMoreRecent, - loadLatestMessages, - loadMore, - loadMoreRecent, - messages, - setLoadingMore, - setLoadingMoreRecent, - viewabilityChangedCallback, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [channelId, hasMore, loadingMore, loadingMoreRecent, messagesStr, viewabilityChangedCallback], - ); - - return paginatedMessagesContext; -}; diff --git a/package/src/components/MessageList/InlineLoadingMoreIndicator.tsx b/package/src/components/MessageList/InlineLoadingMoreIndicator.tsx index dd7d5cefd4..89a618c469 100644 --- a/package/src/components/MessageList/InlineLoadingMoreIndicator.tsx +++ b/package/src/components/MessageList/InlineLoadingMoreIndicator.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { usePaginatedMessageListContext } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; const styles = StyleSheet.create({ @@ -53,8 +52,8 @@ const MemoizedInlineLoadingMoreIndicator = React.memo( areEqual, ) as typeof InlineLoadingMoreIndicatorWithContext; -export const InlineLoadingMoreIndicator = () => { - const { loadingMore } = usePaginatedMessageListContext(); - - return ; -}; +export const InlineLoadingMoreIndicator = ({ + loadingMore, +}: InlineLoadingMoreIndicatorPropsWithContext) => ( + +); diff --git a/package/src/components/MessageList/InlineLoadingMoreRecentIndicator.tsx b/package/src/components/MessageList/InlineLoadingMoreRecentIndicator.tsx index c531f15754..308dfb56f6 100644 --- a/package/src/components/MessageList/InlineLoadingMoreRecentIndicator.tsx +++ b/package/src/components/MessageList/InlineLoadingMoreRecentIndicator.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { usePaginatedMessageListContext } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; @@ -54,8 +53,8 @@ const MemoizedInlineLoadingMoreRecentIndicator = React.memo( areEqual, ) as typeof InlineLoadingMoreRecentIndicatorWithContext; -export const InlineLoadingMoreRecentIndicator = () => { - const { loadingMoreRecent } = usePaginatedMessageListContext(); - - return ; -}; +export const InlineLoadingMoreRecentIndicator = ({ + loadingMoreRecent, +}: InlineLoadingMoreRecentIndicatorPropsWithContext) => ( + +); diff --git a/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx b/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx index a752a49513..73e8a97701 100644 --- a/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx +++ b/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx @@ -54,7 +54,9 @@ const MemoizedInlineLoadingMoreRecentIndicator = React.memo( areEqual, ) as typeof InlineLoadingMoreRecentIndicatorWithContext; -export const InlineLoadingMoreRecentThreadIndicator = () => { +export const InlineLoadingMoreRecentThreadIndicator = ( + _props: InlineLoadingMoreRecentThreadIndicatorPropsWithContext, +) => { const { threadLoadingMoreRecent } = useThreadContext(); return ; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 2fd8404012..fd3764aaa8 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -14,6 +14,7 @@ import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; + import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction'; import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; import { useTypingUsers } from './hooks/useTypingUsers'; @@ -47,10 +48,6 @@ import { OwnCapabilitiesContextValue, useOwnCapabilitiesContext, } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; -import { - PaginatedMessageListContextValue, - usePaginatedMessageListContext, -} from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; @@ -61,6 +58,7 @@ import { MessageInputHeightState } from '../../state-store/message-input-height- import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { transitions } from '../../utils/animations/transitions'; +import { useMessageListPagination } from '../Channel/hooks/useMessageListPagination'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; import { excludeCanceledUploadNotifications } from '../Notifications/notificationFilters'; import { PortalWhileClosingView } from '../UIComponents/PortalWhileClosingView'; @@ -145,9 +143,12 @@ type MessageFlashListPropsWithContext = Pick< Pick< MessageInputContextValue, 'allowSendBeforeAttachmentsUpload' | 'messageInputFloating' | 'messageInputHeightStore' - > & - Pick & - Pick< + > & { + loadMore: () => Promise; + loadMoreRecent: () => Promise; + loadingMore?: boolean; + loadingMoreRecent?: boolean; + } & Pick< MessagesContextValue, 'disableTypingIndicator' | 'FlatList' | 'myMessageTheme' | 'shouldShowUnreadUnderlay' > & @@ -187,7 +188,7 @@ type MessageFlashListPropsWithContext = Pick< * This is a [ListFooterComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList * used in MessageList. Should be used for header if inverted is false */ - HeaderComponent?: React.ComponentType; + HeaderComponent?: React.ComponentType<{ loadingMore?: boolean }>; /** Whether or not the FlatList is inverted. Defaults to true */ inverted?: boolean; /** Turn off grouping of messages by user */ @@ -332,6 +333,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => isLiveStreaming = false, loadChannelAroundMessage, loading, + loadingMore, + loadingMoreRecent, loadMore, loadMoreRecent, loadMoreRecentThread, @@ -411,6 +414,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({ isFlashList: true, isLiveStreaming, + maximumMessageLimit, threadList, }); @@ -1089,6 +1093,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => currentListHeightRef.current = height; }); + const ListHeaderComponent = useCallback( + () => , + [HeaderComponent, loadingMore], + ); + const ListFooterComponent = useCallback(() => { if (FooterComponent) { return ; @@ -1096,7 +1105,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return ( - + {!disableTypingIndicator && TypingIndicator && ( @@ -1107,6 +1116,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }, [ FooterComponent, LoadingMoreRecentIndicator, + loadingMoreRecent, TypingIndicator, TypingIndicatorContainer, disableTypingIndicator, @@ -1142,7 +1152,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => keyboardShouldPersistTaps='handled' keyExtractor={keyExtractor} ListFooterComponent={ListFooterComponent} - ListHeaderComponent={HeaderComponent} + ListHeaderComponent={ListHeaderComponent} maintainVisibleContentPosition={maintainVisibleContentPosition} onMomentumScrollEnd={onUserScrollEvent} onScroll={handleScroll} @@ -1301,7 +1311,11 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { client } = useChatContext(); const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); - const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); + const { + loadMore, + loadMoreRecent, + state: { loadingMore, loadingMoreRecent }, + } = useMessageListPagination({ channel }); const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = @@ -1328,6 +1342,8 @@ export const MessageFlashList = (props: MessageFlashListProps) => { loading, loadMore, loadMoreRecent, + loadingMore, + loadingMoreRecent, loadMoreRecentThread, loadMoreThread, markRead, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 3f0f782e51..566dfb068a 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -19,6 +19,7 @@ import debounce from 'lodash/debounce'; import type { Channel, Event, LocalMessage } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; + import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction'; import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; @@ -60,10 +61,6 @@ import { OwnCapabilitiesContextValue, useOwnCapabilitiesContext, } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; -import { - PaginatedMessageListContextValue, - usePaginatedMessageListContext, -} from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; @@ -74,6 +71,7 @@ import { MessageInputHeightState } from '../../state-store/message-input-height- import { primitives } from '../../theme'; import { transitions } from '../../utils/animations/transitions'; import { useIncomingMessageAnnouncements } from '../Accessibility/hooks/useIncomingMessageAnnouncements'; +import { useMessageListPagination } from '../Channel/hooks/useMessageListPagination'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; import { excludeCanceledUploadNotifications } from '../Notifications/notificationFilters'; import { PortalWhileClosingView } from '../UIComponents'; @@ -210,9 +208,13 @@ type MessageListPropsWithContext = Pick< | 'threadList' | 'maximumMessageLimit' > & - Pick & - Pick & - Pick< + Pick & { + loadMore: () => Promise; + loadMoreRecent: () => Promise; + hasMore?: boolean; + loadingMore?: boolean; + loadingMoreRecent?: boolean; + } & Pick< MessagesContextValue, 'disableTypingIndicator' | 'FlatList' | 'myMessageTheme' | 'shouldShowUnreadUnderlay' > & @@ -242,12 +244,12 @@ type MessageListPropsWithContext = Pick< /** * UI component for footer of message list. By default message list will use `InlineLoadingMoreIndicator` * as FooterComponent. If you want to implement your own inline loading indicator, you can access `loadingMore` - * from context. + * from props. * * This is a [ListHeaderComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList * used in MessageList. Should be used for header by default if inverted is true or defaulted */ - FooterComponent?: React.ComponentType; + FooterComponent?: React.ComponentType<{ loadingMore?: boolean }>; /** * UI component for header of message list. By default message list will use `InlineLoadingMoreRecentIndicator` * as HeaderComponent. If you want to implement your own inline loading indicator, you can access `loadingMoreRecent` @@ -328,6 +330,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { isLiveStreaming = false, loadChannelAroundMessage, loading, + loadingMore, + loadingMoreRecent, loadMore, loadMoreRecent, loadMoreRecentThread, @@ -392,6 +396,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({ isLiveStreaming, + maximumMessageLimit, threadList, }); @@ -1227,6 +1232,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { viewportHeightRef.current = nextViewportHeight; }); + const ListFooterComponent = useCallback( + () => , + [FooterComponent, loadingMore], + ); + const ListHeaderComponent = useCallback(() => { if (HeaderComponent) { return ; @@ -1234,7 +1244,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return ( <> - + {!disableTypingIndicator && TypingIndicator && ( @@ -1245,6 +1255,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }, [ HeaderComponent, LoadingMoreRecentIndicator, + loadingMoreRecent, TypingIndicator, TypingIndicatorContainer, disableTypingIndicator, @@ -1282,7 +1293,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { inverted={inverted} keyboardShouldPersistTaps='handled' keyExtractor={keyExtractor} - ListFooterComponent={FooterComponent} + ListFooterComponent={ListFooterComponent} ListHeaderComponent={ListHeaderComponent} /** If autoscrollToTopThreshold is 10, we scroll to recent only if before the update, the list was already at the @@ -1407,7 +1418,11 @@ export const MessageList = (props: MessageListProps) => { useMessagesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = useMessageInputContext(); - const { loadMore, loadMoreRecent, hasMore } = usePaginatedMessageListContext(); + const { + loadMore, + loadMoreRecent, + state: { hasMore, loadingMore, loadingMoreRecent }, + } = useMessageListPagination({ channel }); const { loadMoreRecentThread, loadMoreThread, threadHasMore, thread, threadInstance } = useThreadContext(); @@ -1448,6 +1463,8 @@ export const MessageList = (props: MessageListProps) => { threadInstance, threadList, hasMore, + loadingMore, + loadingMoreRecent, threadHasMore, }} {...props} diff --git a/package/src/components/MessageList/__tests__/useMessageList.test.tsx b/package/src/components/MessageList/__tests__/useMessageList.test.tsx index 2d0c0a3336..50882b8920 100644 --- a/package/src/components/MessageList/__tests__/useMessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/useMessageList.test.tsx @@ -1,66 +1,40 @@ -import React, { FC } from 'react'; +import React from 'react'; import { renderHook } from '@testing-library/react-native'; -import type { StreamChat } from 'stream-chat'; - -import { useCreatePaginatedMessageListContext } from '../../../components/Channel/hooks/useCreatePaginatedMessageListContext'; -import { - ChatContext, - ChatContextValue, - PaginatedMessageListContextValue, - PaginatedMessageListProvider, -} from '../../../contexts'; +import type { Channel, LocalMessage } from 'stream-chat'; +import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; - import { useMessageList } from '../hooks/useMessageList'; -const clientUser = generateUser(); -let chatClient: StreamChat; - -beforeEach(async () => { - chatClient = await getTestClientWithUser(clientUser); -}); - const messages = new Array(10) .fill(undefined) - .map((_: undefined, id: number) => generateMessage({ id: String(id) })); - -const Providers: FC<{ children: React.ReactNode }> = ({ children }) => { - const messageListContext = useCreatePaginatedMessageListContext({ - messages, - } as unknown as PaginatedMessageListContextValue); - - return ( - - - {children} - - - ); -}; + .map((_: undefined, id: number) => + generateMessage({ id: String(id) }), + ) as unknown as LocalMessage[]; describe('useMessageList', () => { + let channel: Channel; + + beforeEach(async () => { + const { + channels: [ch], + } = await initiateClientWithChannels(); + channel = ch; + // The message list is sourced reactively from channel.messagePaginator. + channel.messagePaginator.state.partialNext({ items: messages }); + }); + it('should always return a list of reversed messages', () => { - const { result } = renderHook( - () => - useMessageList({ - noGroupByUser: true, - threadList: false, - } as unknown as Parameters[0]), - { wrapper: Providers }, + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} ); - const reversedMessages = messages.reverse(); + + const { result } = renderHook(() => useMessageList({ threadList: false }), { wrapper }); + + const reversedMessages = [...messages].reverse(); expect(result.current.processedMessageList.map(({ id }) => id)).toEqual( reversedMessages.map(({ id }) => id), ); diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 3ff51c61a9..ef49c163f5 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -2,15 +2,17 @@ import { useMemo } from 'react'; import type { LocalMessage } from 'stream-chat'; -import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; -import { useRAFCoalescedValue } from '../../../hooks'; +import { useRAFCoalescedValue, useStateStore } from '../../../hooks'; +import { usePrunableMessageList } from '../../../hooks/usePrunableMessageList'; export type UseMessageListParams = { threadList?: boolean; isLiveStreaming?: boolean; isFlashList?: boolean; + maximumMessageLimit?: number; }; /** @@ -22,11 +24,23 @@ export type MessageGroupStyles = { [key: string]: string[]; }; +const EMPTY_MESSAGES: LocalMessage[] = []; + +const messageListSelector = (state: { items?: LocalMessage[] }) => ({ messages: state.items }); + export const useMessageList = (params: UseMessageListParams) => { - const { threadList, isLiveStreaming, isFlashList = false } = params; - const { messages, viewabilityChangedCallback } = usePaginatedMessageListContext(); + const { threadList, isLiveStreaming, isFlashList = false, maximumMessageLimit } = params; + const { channel } = useChannelContext(); + // The channel message list is sourced reactively from channel.messagePaginator (channel-specific, + // NOT the thread-aware useMessagePaginator — so the main list keeps showing channel messages while + // a thread is open). Thread reply lists read threadMessages from the ThreadContext instead. + const { messages } = useStateStore(channel.messagePaginator.state, messageListSelector) ?? {}; + const { viewabilityChangedCallback } = usePrunableMessageList({ + maximumMessageLimit, + setMessages: () => {}, + }); const { threadMessages } = useThreadContext(); - const messageList = threadList ? threadMessages : messages; + const messageList = (threadList ? threadMessages : messages) ?? EMPTY_MESSAGES; const processedMessageList = useMemo(() => { const newMessageList: LocalMessage[] = []; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index a10c7b70f5..b8dd9479b4 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -34,7 +34,6 @@ export * from './Channel/Channel'; export * from './Channel/hooks/useCreateChannelContext'; export * from './Channel/hooks/useCreateInputMessageInputContext'; export * from './Channel/hooks/useCreateMessagesContext'; -export * from './Channel/hooks/useCreatePaginatedMessageListContext'; export * from './Channel/hooks/useCreateThreadContext'; export * from './Channel/hooks/useTargetedMessage'; diff --git a/package/src/contexts/__tests__/index.test.tsx b/package/src/contexts/__tests__/index.test.tsx index a1a9243879..09d14f3b2c 100644 --- a/package/src/contexts/__tests__/index.test.tsx +++ b/package/src/contexts/__tests__/index.test.tsx @@ -13,7 +13,6 @@ import { useMessagesContext, useOverlayContext, useOwnCapabilitiesContext, - usePaginatedMessageListContext, useTheme, useThreadContext, } from '../'; @@ -32,10 +31,6 @@ describe('contexts hooks in a component throws an error with message when not wr useOverlayContext, 'The useOverlayContext hook was called outside the OverlayContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', ], - [ - usePaginatedMessageListContext, - 'The usePaginatedMessageListContext hook was called outside of the PaginatedMessageList provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ], [ useChannelsStateContext, 'The useChannelsStateContext hook was called outside the ChannelStateContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index ab0515aa36..c59653016b 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -17,7 +17,6 @@ export * from './messageInputContext/hooks/useAttachmentManagerState'; export * from './messageInputContext/hooks/useMessageComposerHasSendableData'; export * from './messageComposerContext/MessageComposerAPIContext'; export * from './messagesContext/MessagesContext'; -export * from './paginatedMessageListContext/PaginatedMessageListContext'; export * from './overlayContext/OverlayContext'; export * from './overlayContext/OverlayProvider'; export * from './ownCapabilitiesContext/OwnCapabilitiesContext'; diff --git a/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx b/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx deleted file mode 100644 index 7f14c58439..0000000000 --- a/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { PropsWithChildren, useContext } from 'react'; - -import type { ChannelState } from 'stream-chat'; - -import { ViewabilityChangedCallbackInput } from '../../hooks/usePrunableMessageList'; -import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; - -import { isTestEnvironment } from '../utils/isTestEnvironment'; - -export type PaginatedMessageListContextValue = { - /** - * Load latest messages - * @returns Promise - */ - loadLatestMessages: () => Promise; - /** - * Load more messages - */ - loadMore: (limit?: number) => Promise; - /** - * Load more recent messages - */ - loadMoreRecent: (limit?: number) => Promise; - /** - * Messages from client state - */ - messages: ChannelState['messages']; - /** - * A callback that is to be passed to onViewableItemsChanged in the underlying `MessageList` - */ - viewabilityChangedCallback: (config: ViewabilityChangedCallbackInput) => void; - /** - * Has more messages to load - */ - hasMore?: boolean; - /** - * Is loading more messages - */ - loadingMore?: boolean; - /** - * Is loading more recent messages - */ - loadingMoreRecent?: boolean; - /** - * Set loadingMore - */ - setLoadingMore?: (loadingMore: boolean) => void; - /** - * Set loadingMoreRecent - */ - setLoadingMoreRecent?: (loadingMoreRecent: boolean) => void; -}; - -export const PaginatedMessageListContext = React.createContext( - DEFAULT_BASE_CONTEXT_VALUE as PaginatedMessageListContextValue, -); - -export const PaginatedMessageListProvider = ({ - children, - value, -}: PropsWithChildren<{ - value?: PaginatedMessageListContextValue; -}>) => ( - - {children} - -); - -export const usePaginatedMessageListContext = () => { - const contextValue = useContext( - PaginatedMessageListContext, - ) as unknown as PaginatedMessageListContextValue; - - if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { - throw new Error( - 'The usePaginatedMessageListContext hook was called outside of the PaginatedMessageList provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ); - } - - return contextValue; -}; From ca2eca4b9ee0fed60cc4fc1b5702e6b21fcce3cb Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Jul 2026 13:53:43 +0200 Subject: [PATCH 10/36] chore: add ai migration guide for state layer --- ai-docs/ai-migration-v9-to-v10.md | 193 ++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 ai-docs/ai-migration-v9-to-v10.md diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md new file mode 100644 index 0000000000..7cf15005ee --- /dev/null +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -0,0 +1,193 @@ +# stream-chat-react-native v9 → v10 — Agent Migration Guide + +> Machine-oriented migration reference for AI coding agents, mirroring the style +> of `ai-migration.md` (v8 → v9). **WORK IN PROGRESS.** v10 adopts the +> `stream-chat` v10 reactive state layer (instance-owned `StateStore`s + +> `messagePaginator`). This file currently documents the **context-teardown +> breaking changes** that have shipped; more v10 changes (optimistic-ops +> migration, targeting, unread-state) are not yet landed and will be appended. + +## 0. For the agent (read first) + +1. **Your training data predates v10.** Do not rely on memory for v10 symbols or + export paths. Verify against the installed SDK source under + `node_modules/stream-chat-react-native-core/src/`. +2. **The unifying idea:** state that used to be copied into React context is now + read reactively from `stream-chat` instance stores via the SDK hook + `useStateStore(store, selector)`. The channel exposes per-concern stores on + `channel.state.*` and a message paginator on `channel.messagePaginator`. +3. **Resolution hooks** (already present since early v10): + - `useChannelContext().channel` — the active `Channel` instance. + - `useMessagePaginator()` — `threadInstance?.messagePaginator ?? channel.messagePaginator`. + - `useStateStore(store, selector)` — subscribe to a `StateStore` with a + memo-stable selector (return a stable object; do not allocate fresh arrays + inside the selector). +4. **Detect before editing.** Run §1; skip any section with zero hits. + +## 1. Detection (run first) + +Run each ripgrep against the customer's app source root. Zero hits = skip. + +```bash +# §2 — TypingContext removed +rg '\b(useTypingContext|TypingProvider|TypingContext|TypingContextValue|useCreateTypingContext)\b' src/ + +# §3 — ChannelContext scalar fields removed +rg 'useChannelContext\(\)' -A6 src/ | rg '\b(members|read|watchers|watcherCount)\b' + +# §4 — PaginatedMessageListContext removed +rg '\b(usePaginatedMessageListContext|PaginatedMessageListProvider|PaginatedMessageListContextValue|useCreatePaginatedMessageListContext)\b' src/ + +# §5 — custom FooterComponent / HeaderComponent reading loadingMore from context +rg '\b(FooterComponent|HeaderComponent)\b' src/ +``` + +## 2. `TypingContext` removed → `channel.state.typingStore` + +Removed symbols: `TypingContext`, `TypingProvider`, `useTypingContext`, +`TypingContextValue`, `useCreateTypingContext`. + +**Before (v9):** + +```tsx +import { useTypingContext } from 'stream-chat-react-native'; + +const { typing } = useTypingContext(); +``` + +**After (v10):** + +```tsx +import { useChannelContext, useStateStore } from 'stream-chat-react-native'; +import type { TypingUsersState } from 'stream-chat'; + +const typingSelector = (state: TypingUsersState) => ({ typing: state.typing }); + +const { channel } = useChannelContext(); +const { typing } = useStateStore(channel.state.typingStore, typingSelector) ?? { typing: {} }; +``` + +`typing` has the same shape as before (`Record`). + +## 3. `ChannelContext` scalar state fields removed → `channel.state.*Store` + +`useChannelContext()` no longer returns `members`, `read`, `watchers`, or +`watcherCount`. Read them from the per-channel stores. + +| Removed context field | Replacement store | Store state key | +|---|---|---| +| `members` | `channel.state.membersStore` | `members` | +| `read` | `channel.state.readStore` | `read` | +| `watchers` | `channel.state.watcherStore` | `watchers` | +| `watcherCount` | `channel.state.watcherStore` | `watcherCount` | + +**Before (v9):** + +```tsx +const { members, watcherCount } = useChannelContext(); +``` + +**After (v10):** + +```tsx +import { useChannelContext, useStateStore } from 'stream-chat-react-native'; +import type { MembersState, WatcherState } from 'stream-chat'; + +const membersSelector = (s: MembersState) => ({ members: s.members }); +const watcherSelector = (s: WatcherState) => ({ + watcherCount: s.watcherCount, + watchers: s.watchers, +}); + +const { channel } = useChannelContext(); +const { members } = useStateStore(channel.state.membersStore, membersSelector) ?? { members: {} }; +const { watcherCount } = useStateStore(channel.state.watcherStore, watcherSelector) ?? {}; +``` + +Note: `members` remains available on the per-message `MessageContext` +(`useMessageContext().members`) for message-level consumers such as a custom +message footer — only the **channel-level** `ChannelContext.members` was removed. + +## 4. `PaginatedMessageListContext` removed → `useMessagePaginator()` / `channel.messagePaginator` + +Removed symbols: `PaginatedMessageListContext`, `PaginatedMessageListProvider`, +`usePaginatedMessageListContext`, `PaginatedMessageListContextValue`, +`useCreatePaginatedMessageListContext`. + +The message-list state (messages / hasMore / loading) now lives on the paginator. +**Direction mapping** (non-obvious): the paginator is bi-directional — `tail` = +older messages, `head` = newer. + +| Removed context field | Replacement | +|---|---| +| `messages` | `channel.messagePaginator.state.items` | +| `hasMore` (older) | `channel.messagePaginator.state.hasMoreTail` | +| `hasMoreNewer` | `channel.messagePaginator.state.hasMoreHead` | +| `loading` | `channel.messagePaginator.state.isLoading` | +| `loadMore()` (older) | `channel.messagePaginator.toTail()` | +| `loadMoreRecent()` (newer) | `channel.messagePaginator.toHead()` | +| `loadLatestMessages()` | `channel.messagePaginator.jumpToTheLatestMessage()` | + +**Before (v9):** + +```tsx +const { messages, hasMore, loadMore } = usePaginatedMessageListContext(); +``` + +**After (v10):** + +```tsx +import { useChannelContext, useStateStore } from 'stream-chat-react-native'; +import type { LocalMessage } from 'stream-chat'; + +const selector = (s: { items?: LocalMessage[]; hasMoreTail: boolean }) => ({ + messages: s.items, + hasMore: s.hasMoreTail, +}); + +const { channel } = useChannelContext(); +const { messages, hasMore } = useStateStore(channel.messagePaginator.state, selector) ?? {}; +const loadMore = () => channel.messagePaginator.toTail(); +``` + +Use `channel.messagePaginator` for the channel message list. For thread replies, +use `useMessagePaginator()` (thread-aware) — but note the **main** channel list +must read `channel.messagePaginator` directly so it keeps showing channel +messages while a thread is open. + +## 5. Custom `FooterComponent` / `HeaderComponent` receive `loadingMore` as a prop + +The inline loading indicators no longer read `loadingMore` / +`loadingMoreRecent` from `PaginatedMessageListContext`; the list owns those flags +and passes them as props. If you override the message list's loading indicator: + +- **`MessageList`** (inverted): custom `FooterComponent` now receives + `{ loadingMore?: boolean }` as a prop. +- **`MessageFlashList`**: custom `HeaderComponent` now receives + `{ loadingMore?: boolean }` as a prop. + +**Before (v9):** + +```tsx +const MyFooter = () => { + const { loadingMore } = usePaginatedMessageListContext(); + return loadingMore ? : null; +}; +``` + +**After (v10):** + +```tsx +const MyFooter = ({ loadingMore }: { loadingMore?: boolean }) => + loadingMore ? : null; +``` + +No-prop override components remain assignable (the prop is optional), so +overrides that don't read the flag need no change. + +## 6. Verify + +- Typecheck the customer app; the removed symbols surface as + "Property does not exist" / "Cannot find name" errors — fix each per §2–§5. +- Run the app: typing indicator, member/watcher counts, message-list scroll + pagination (both directions), and any custom loading-indicator override. From 6773480628fdac5d3102326b4164f74fe7a4e5df Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Jul 2026 21:57:48 +0200 Subject: [PATCH 11/36] chore: optimistic updates for edit and delete --- package/src/components/Channel/Channel.tsx | 89 +++++-------------- .../MessageInputContext.tsx | 3 +- 2 files changed, 22 insertions(+), 70 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index c99b7ec604..cceafb3d56 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -12,7 +12,6 @@ import { EventHandler, LocalMessage, localMessageToNewMessagePayload, - MessageLabel, MessageResponse, Reaction, SendMessageAPIResponse, @@ -1213,51 +1212,11 @@ const ChannelWithContext = (props: PropsWithChildren) = if (!channel) { throw new Error('Channel has not been initialized'); } - - const cid = channel.cid; - const currentMessage = channel.state.findMessage(localMessage.id, localMessage.parent_id); - const isFailedMessage = - currentMessage?.status === MessageStatusTypes.FAILED || - localMessage.status === MessageStatusTypes.FAILED; - const optimisticEditedAt = new Date(); - const optimisticEditedAtString = optimisticEditedAt.toISOString(); - const optimisticMessage = { - ...currentMessage, - ...localMessage, - cid, - message_text_updated_at: isFailedMessage ? undefined : optimisticEditedAtString, - updated_at: optimisticEditedAt, - } as unknown as LocalMessage; - - updateMessage(optimisticMessage); - threadInstance?.updateParentMessageOrReplyLocally( - optimisticMessage as unknown as MessageResponse, - ); - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...optimisticMessage, cid }, - }), - { method: 'updateMessage' }, - ); - - const response = doUpdateMessageRequest - ? await doUpdateMessageRequest(cid, localMessage, options) - : await client.updateMessage(localMessage, undefined, options); - - if (response?.message) { - updateMessage(response.message); - threadInstance?.updateParentMessageOrReplyLocally(response.message); - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...response.message, cid }, - }), - { method: 'updateMessage' }, - ); - } - - return response; + // The LLC handles the optimistic local update (ingest into the paginator), the network + // request (honoring any doUpdateMessageRequest registered into channel.configState in + // useChannelRequestHandlers), the received/failed state transitions, and offline queueing. + // Thread edits route through the thread instance's own message operations. + await (threadInstance ?? channel).updateMessageWithLocalUpdate({ localMessage, options }); }, ); @@ -1324,37 +1283,31 @@ const ChannelWithContext = (props: PropsWithChildren) = const deleteMessage: MessagesContextValue['deleteMessage'] = useStableCallback( async (message, optionsOrHardDelete = false) => { - let options: DeleteMessageOptions = {}; - if (typeof optionsOrHardDelete === 'boolean') { - options = optionsOrHardDelete ? { hardDelete: true } : {}; - } else if (optionsOrHardDelete?.deleteForMe) { - options = { deleteForMe: true }; - } else if (optionsOrHardDelete?.hardDelete) { - options = { hardDelete: true }; - } - if (!channel.id) { + if (!channel?.id) { throw new Error('Channel has not been initialized yet'); } + // A failed (never-sent) message exists only locally — remove it without a server delete. if (message.status === MessageStatusTypes.FAILED) { await removeMessage(message); return; } - const updatedMessage = { - ...message, - cid: channel.cid, - deleted_at: new Date(), - type: 'deleted' as MessageLabel, - }; - updateMessage(updatedMessage); - - threadInstance?.upsertReplyLocally({ message: updatedMessage }); - const data = await client.deleteMessage(message.id, options); - - if (data?.message) { - updateMessage({ ...data.message }); + let options: DeleteMessageOptions = {}; + if (typeof optionsOrHardDelete === 'boolean') { + options = optionsOrHardDelete ? { hardDelete: true } : {}; + } else if (optionsOrHardDelete?.deleteForMe) { + options = { deleteForMe: true }; + } else if (optionsOrHardDelete?.hardDelete) { + options = { hardDelete: true }; } + + // The LLC performs the delete request (honoring any configState delete handler) and ingests + // the deleted message into the paginator. Thread deletes route through the thread instance. + await (threadInstance ?? channel).deleteMessageWithLocalUpdate({ + localMessage: message, + options, + }); }, ); diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index bca66a675a..db2ecd8675 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -14,7 +14,6 @@ import { LocalMessage, MessageComposer, SendMessageOptions, - StreamChat, Message as StreamMessage, UpdateMessageOptions, UploadRequestFn, @@ -126,7 +125,7 @@ export type InputMessageInputContextValue = { editMessage: (params: { localMessage: LocalMessage; options?: UpdateMessageOptions; - }) => ReturnType; + }) => Promise; /** * Controls what happens when the attach button is pressed while the attachment picker is open. From 9ea3ec547a4bcee0b926023327e2ed0c0ca78fa3 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Jul 2026 01:36:16 +0200 Subject: [PATCH 12/36] fix: pagination past a certain page --- .../src/components/Channel/hooks/useMessageListPagination.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx index becc69c25f..a4c83ab804 100644 --- a/package/src/components/Channel/hooks/useMessageListPagination.tsx +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -60,7 +60,7 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => { * Loads older messages (before the oldest loaded message). */ const loadMore = useStableCallback(async () => { - if (!paginator.hasMoreTail || loadingMore || loadingMoreRecent) { + if (!paginator.hasMoreTail || paginator.isLoading) { return; } setLoadingMore(true); @@ -77,7 +77,7 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => { * Loads newer messages (after the most recent loaded message). */ const loadMoreRecent = useStableCallback(async () => { - if (!paginator.hasMoreHead || loadingMore || loadingMoreRecent) { + if (!paginator.hasMoreHead || paginator.isLoading) { return; } setLoadingMoreRecent(true); From fc09564c17c75869bd45b66ff6eb05bc61d4221b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Jul 2026 02:22:51 +0200 Subject: [PATCH 13/36] feat: move message sending to llc state updates --- package/src/components/Channel/Channel.tsx | 269 +++------------------ 1 file changed, 33 insertions(+), 236 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index cceafb3d56..4abd6928dc 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -11,7 +11,6 @@ import { DeleteMessageOptions, EventHandler, LocalMessage, - localMessageToNewMessagePayload, MessageResponse, Reaction, SendMessageAPIResponse, @@ -91,17 +90,9 @@ import { } from '../../state-store/channel-unread-state'; import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; -import { DefaultAttachmentData, FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; -import { compressedImageURI } from '../../utils/compressImage'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; -import { - getFileNameFromPath, - isBouncedMessage, - isLocalUrl, - MessageStatusTypes, - ReactionData, -} from '../../utils/utils'; +import { MessageStatusTypes, ReactionData } from '../../utils/utils'; import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardCompatibleView'; @@ -481,7 +472,6 @@ const ChannelWithContext = (props: PropsWithChildren) = threadList, threadMessages, topInset = 0, - isOnline, maximumMessageLimit, initializeOnMount = true, urlPreviewType = 'full', @@ -967,243 +957,50 @@ const ChannelWithContext = (props: PropsWithChildren) = }, ); - const replaceMessage = useStableCallback( - (oldMessage: LocalMessage, newMessage: MessageResponse) => { - if (channel) { - channel.state.removeMessage(oldMessage); - channel.state.addMessageSorted(newMessage, true); - const paginator = newMessage.parent_id - ? threadInstance?.messagePaginator - : channel.messagePaginator; - paginator?.removeItem({ id: oldMessage.id }); - paginator?.ingestItem(channel.state.formatMessage(newMessage)); - - if (thread && newMessage.parent_id) { - const threadMessages = channel.state.threads[newMessage.parent_id] || []; - setThreadMessages(threadMessages); - } - } - }, - ); - - const uploadPendingAttachments = useStableCallback(async (message: LocalMessage) => { - const updatedMessage = { ...message }; - if (!updatedMessage.attachments?.length || !channel?.cid) { - return updatedMessage; - } - - const uploadOne = async (attachment: NonNullable[number]) => { - if ( - (attachment.image_url && !isLocalUrl(attachment.image_url)) || - (attachment.asset_url && !isLocalUrl(attachment.asset_url)) - ) { - return; - } - - const originalFile = attachment.originalFile; - if (!originalFile?.uri) { - return; - } - - const localId = (attachment as DefaultAttachmentData).localId; - if (!localId) { - console.warn('uploadPendingAttachments: local attachment missing localId, skipping upload'); - return; - } - - let fileForUpload = originalFile; - if (attachment.type === FileTypes.Image && !doFileUploadRequest) { - const filename = originalFile.name ?? getFileNameFromPath(originalFile.uri); - const compressedUri = await compressedImageURI(originalFile, compressImageQuality); - fileForUpload = { ...originalFile, name: filename, uri: compressedUri }; + const sendMessage: InputMessageInputContextValue['sendMessage'] = useStableCallback( + async ({ localMessage, message, options }) => { + if (preSendMessageRequest) { + await preSendMessageRequest({ localMessage, message, options }); } - const response = await ( - client as typeof client & { - uploadManager: { - upload(args: { - channelCid: string; - file: { - name?: string; - type?: string; - uri: string; - }; - id: string; - }): Promise<{ file: string; thumb_url?: string }>; - }; - } - ).uploadManager.upload({ - channelCid: channel.cid, - file: fileForUpload, - id: localId, - }); - - if (attachment.type === FileTypes.Image) { - attachment.image_url = response.file; - } else { - attachment.asset_url = response.file; - if (response.thumb_url) { - attachment.thumb_url = response.thumb_url; - } + // Preserve RN's moderation slash-command patching ("/mute @user" -> "/mute @userId"). + const messageToSend = message + ? { + ...message, + text: patchMessageTextCommand(message.text ?? '', message.mentioned_users ?? []), + } + : message; + + // Thread replies: the RN reply list reads `threadMessages` (channel.state.threads), which the + // LLC thread paginator does not feed — so we optimistically reflect the reply there ourselves + // (and mark it failed on error). Channel messages need no such bridge: that list reads + // channel.messagePaginator, which the LLC ingests into directly. + const isThreadReply = !!localMessage.parent_id && !!threadInstance; + if (isThreadReply) { + updateMessage(localMessage); } - delete attachment.originalFile; - delete (attachment as DefaultAttachmentData).localId; - - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }), - { method: 'updateMessage' }, - ); - }; - - await Promise.all(updatedMessage.attachments.map((att) => uploadOne(att))); - - return updatedMessage; - }); - - const sendMessageRequest = useStableCallback( - async ({ - localMessage, - message, - options, - retrying, - }: { - localMessage: LocalMessage; - message: StreamMessage; - options?: SendMessageOptions; - retrying?: boolean; - }) => { - let failedMessageUpdated = false; - const handleFailedMessage = () => { - if (!failedMessageUpdated) { - const updatedMessage = { - ...localMessage, - cid: channel.cid, - status: MessageStatusTypes.FAILED, - }; - updateMessage(updatedMessage); - threadInstance?.upsertReplyLocally?.({ message: updatedMessage }); - optimisticallyUpdatedNewMessages.delete(localMessage.id); - - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: updatedMessage, - }), - { method: 'updateMessage' }, - ); - - failedMessageUpdated = true; - } - }; - + // The stream-chat message-operations engine owns the optimistic lifecycle (pending -> + // received/failed), offline-DB persistence and paginator ingest. It throws on failure, which + // the MessageInput send flow catches to surface a notification. try { - if (!isOnline) { - await handleFailedMessage(); - } - - const updatedLocalMessage = await uploadPendingAttachments(localMessage); - const { attachments } = updatedLocalMessage; - const { text, mentioned_users } = message; - if (!channel.id) { - return; - } - - const messageData = { - ...message, - attachments, - text: patchMessageTextCommand(text ?? '', mentioned_users ?? []), - // We cannot send an error message, so we convert it to a regular message. - type: message.type === 'error' ? 'regular' : message.type, - } as StreamMessage; - - let messageResponse = {} as SendMessageAPIResponse; - - if (doSendMessageRequest) { - messageResponse = await doSendMessageRequest(channel?.cid || '', messageData, options); - } else if (channel) { - messageResponse = await channel.sendMessage(messageData, options); - } - - if (messageResponse?.message) { - const newMessageResponse = { - ...messageResponse.message, - status: MessageStatusTypes.RECEIVED, - }; - - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...newMessageResponse, cid: channel.cid }, - }), - { method: 'updateMessage' }, - ); - - if (retrying) { - replaceMessage(localMessage, newMessageResponse); - } else { - updateMessage(newMessageResponse, {}, true); - } + await (threadInstance ?? channel).sendMessageWithLocalUpdate({ + localMessage, + message: messageToSend, + options, + }); + } catch (error) { + if (isThreadReply) { + updateMessage({ ...localMessage, status: MessageStatusTypes.FAILED }); } - } catch (err) { - console.log('Error sending message:', err); - await handleFailedMessage(); + throw error; } }, ); - const sendMessage: InputMessageInputContextValue['sendMessage'] = useStableCallback( - async ({ localMessage, message, options }) => { - if (channel?.state?.filterErrorMessages) { - channel.state.filterErrorMessages(); - } - - updateMessage(localMessage); - threadInstance?.upsertReplyLocally?.({ message: localMessage }); - optimisticallyUpdatedNewMessages.add(localMessage.id); - - // While sending a message, we add the message to local db with failed status, so that - // if app gets closed before message gets sent and next time user opens the app - // then user can see that message in failed state and can retry. - // If succesfull, it will be updated with received status. - client.offlineDb?.executeQuerySafely( - (db) => - db.upsertMessages({ - // @ts-ignore - messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }], - }), - { method: 'upsertMessages' }, - ); - - if (preSendMessageRequest) { - await preSendMessageRequest({ localMessage, message, options }); - } - await sendMessageRequest({ localMessage, message, options }); - }, - ); - const retrySendMessage: MessagesContextValue['retrySendMessage'] = useStableCallback( async (localMessage) => { - const statusPendingMessage = { - ...localMessage, - status: MessageStatusTypes.SENDING, - }; - - const messageWithoutReservedFields = localMessageToNewMessagePayload(statusPendingMessage); - - // For bounced messages, we don't need to update the message, instead always send a new message. - if (!isBouncedMessage(localMessage)) { - updateMessage(messageWithoutReservedFields as MessageResponse); - } - - await sendMessageRequest({ - localMessage, - message: messageWithoutReservedFields, - retrying: true, - }); + await (threadInstance ?? channel).retrySendMessageWithLocalUpdate({ localMessage }); }, ); From c45d7fa668e7dbfdc8b6cf407da0e4451458b825 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Jul 2026 03:39:31 +0200 Subject: [PATCH 14/36] fix: reaction optimistic updates --- package/src/components/Channel/Channel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 4abd6928dc..0e23f96b6c 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1067,7 +1067,7 @@ const ChannelWithContext = (props: PropsWithChildren) = (reactedMessage.parent_id ? threadInstance?.messagePaginator : channel.messagePaginator - )?.ingestItem(reactedMessage); + )?.ingestItem(channel.state.formatMessage(reactedMessage)); } } @@ -1129,7 +1129,7 @@ const ChannelWithContext = (props: PropsWithChildren) = (reactedMessage.parent_id ? threadInstance?.messagePaginator : channel.messagePaginator - )?.ingestItem(reactedMessage); + )?.ingestItem(channel.state.formatMessage(reactedMessage)); } } From 2b5d2c1515e039b46decee19f6ebffd93c0112b6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Jul 2026 17:15:36 +0200 Subject: [PATCH 15/36] feat: completely remove channel unread store and rely on LLC --- package/src/components/Channel/Channel.tsx | 51 ++--------- .../Channel/hooks/useCreateChannelContext.ts | 4 - .../MessageItemView/MessageWrapper.tsx | 84 +++++++++++++------ .../MessageList/MessageFlashList.tsx | 67 +++------------ .../components/MessageList/MessageList.tsx | 29 ++----- .../UnreadMessagesNotification.tsx | 27 +++--- .../Thread/__tests__/Thread.test.tsx | 21 ++--- .../channelContext/ChannelContext.tsx | 11 --- .../src/state-store/channel-unread-state.ts | 28 ------- package/src/utils/getChannelUnreadState.ts | 27 ++++++ 10 files changed, 128 insertions(+), 221 deletions(-) delete mode 100644 package/src/state-store/channel-unread-state.ts create mode 100644 package/src/utils/getChannelUnreadState.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 0e23f96b6c..3e424c280b 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -84,12 +84,9 @@ import { isImagePickerAvailable, NativeHandlers, } from '../../native'; -import { - ChannelUnreadStateStore, - ChannelUnreadStateStoreType, -} from '../../state-store/channel-unread-state'; import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; +import type { ChannelUnreadState } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; import { MessageStatusTypes, ReactionData } from '../../utils/utils'; @@ -283,7 +280,7 @@ export type ChannelPropsWithContext = Pick & */ doMarkReadRequest?: ( channel: ChannelType, - setChannelUnreadUiState?: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void, + setChannelUnreadUiState?: (data: ChannelUnreadState | undefined) => void, ) => void; /** * Overrides the Stream default send message request (Advanced usage only) @@ -492,41 +489,7 @@ const ChannelWithContext = (props: PropsWithChildren) = ); const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); - const [channelUnreadStateStore] = useState(() => new ChannelUnreadStateStore()); const [messageInputHeightStore] = useState(() => new MessageInputHeightStore()); - // TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere. - const setChannelUnreadState = useCallback( - (data: ChannelUnreadStateStoreType['channelUnreadState']) => { - channelUnreadStateStore.channelUnreadState = data; - }, - [channelUnreadStateStore], - ); - - // Unread state is owned by the LLC: channel.messagePaginator.unreadStateSnapshot is the single - // source of truth (the paginator updates it on seeding / message.new / notification.mark_unread / - // truncate). Mirror it into channelUnreadStateStore so existing consumers keep working. - useEffect(() => { - const snapshotStore = channel.messagePaginator.unreadStateSnapshot; - const applyUnreadSnapshot = (snapshot: { - firstUnreadMessageId: string | null; - lastReadAt: Date | null; - lastReadMessageId: string | null; - unreadCount: number; - }) => { - setChannelUnreadState( - snapshot.firstUnreadMessageId || snapshot.lastReadMessageId || snapshot.unreadCount - ? { - first_unread_message_id: snapshot.firstUnreadMessageId ?? undefined, - last_read: snapshot.lastReadAt ?? new Date(0), - last_read_message_id: snapshot.lastReadMessageId ?? undefined, - unread_messages: snapshot.unreadCount, - } - : undefined, - ); - }; - applyUnreadSnapshot(snapshotStore.getLatestValue()); - return snapshotStore.subscribe(applyUnreadSnapshot); - }, [channel, setChannelUnreadState]); const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); const syncingChannelRef = useRef(false); @@ -611,7 +574,7 @@ const ChannelWithContext = (props: PropsWithChildren) = } // notification.mark_unread + channel.truncated update channel.messagePaginator.unreadStateSnapshot - // in the LLC; that snapshot is mirrored into channelUnreadStateStore, so no manual handling here. + // in the LLC (the single source of truth for unread state), so no manual handling here. // The message list is backed reactively by channel.messagePaginator (channel._handleChannelEvent // ingests message.new/updated/deleted + reaction events), and read/typing/members come from @@ -653,8 +616,8 @@ const ChannelWithContext = (props: PropsWithChildren) = } } - // The paginator's unreadStateSnapshot is populated by the seed/reload above and mirrored into - // channelUnreadStateStore, so there's no manual initial read-state copy here. + // The paginator's unreadStateSnapshot is populated by the seed/reload above (the single source + // of truth for unread state), so there's no manual initial read-state copy here. if (messageId) { await loadChannelAroundMessage({ messageId, setTargetedMessage }); @@ -728,7 +691,7 @@ const ChannelWithContext = (props: PropsWithChildren) = // Read events disabled (e.g. livestreams): if the client opted into a local unread count, // reset it locally (dispatches message.read_locally) — no backend round trip. The paginator's - // unread snapshot updates from that, and is mirrored into channelUnreadStateStore. + // unread snapshot updates from that. if (!clientChannelConfig?.read_events) { if (client.options.isLocalUnreadCountEnabled) { channel.markReadLocally(); @@ -1258,7 +1221,6 @@ const ChannelWithContext = (props: PropsWithChildren) = const channelContext = useCreateChannelContext({ channel, - channelUnreadStateStore, disabled: !!channel?.data?.frozen, enableMessageGroupingByUser, enforceUniqueReaction, @@ -1275,7 +1237,6 @@ const ChannelWithContext = (props: PropsWithChildren) = maxTimeBetweenGroupedMessages, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, setTargetedMessage, hasPendingInitialTargetLoad, targetedMessage, diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 1525b8fbb5..78ddeedf8a 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -4,7 +4,6 @@ import type { ChannelContextValue } from '../../../contexts/channelContext/Chann export const useCreateChannelContext = ({ channel, - channelUnreadStateStore, disabled, enableMessageGroupingByUser, enforceUniqueReaction, @@ -21,7 +20,6 @@ export const useCreateChannelContext = ({ maximumMessageLimit, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, setTargetedMessage, hasPendingInitialTargetLoad, targetedMessage, @@ -33,7 +31,6 @@ export const useCreateChannelContext = ({ const channelContext: ChannelContextValue = useMemo( () => ({ channel, - channelUnreadStateStore, disabled, enableMessageGroupingByUser, enforceUniqueReaction, @@ -50,7 +47,6 @@ export const useCreateChannelContext = ({ maxTimeBetweenGroupedMessages, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, setTargetedMessage, hasPendingInitialTargetLoad, targetedMessage, diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index 2a86922a19..4402560c9b 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -1,8 +1,8 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import { LocalMessage } from 'stream-chat'; +import { LocalMessage, UnreadSnapshotState } from 'stream-chat'; import { useMessageDateSeparator } from '../../../components/MessageList/hooks/useMessageDateSeparator'; import { useMessageGroupStyles } from '../../../components/MessageList/hooks/useMessageGroupStyles'; @@ -14,16 +14,8 @@ import { useMessagesContext } from '../../../contexts/messagesContext/MessagesCo import { ThemeProvider, useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useStateStore } from '../../../hooks/useStateStore'; -import { ChannelUnreadStateStoreType } from '../../../state-store/channel-unread-state'; import { primitives } from '../../../theme'; -const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ - first_unread_message_id: state.channelUnreadState?.first_unread_message_id, - last_read_message_id: state.channelUnreadState?.last_read_message_id, - last_read_timestamp: state.channelUnreadState?.last_read?.getTime(), - unread_messages: state.channelUnreadState?.unread_messages, -}); - export type MessageWrapperProps = { message: LocalMessage; previousMessage?: LocalMessage; @@ -34,7 +26,6 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW const { message, previousMessage, nextMessage } = props; const { client } = useChatContext(); const { - channelUnreadStateStore, channel, hideDateSeparators, highlightedMessageId, @@ -52,7 +43,6 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW previousMessage, }); - const isNewestMessage = nextMessage === undefined; const groupStyles = useMessageGroupStyles({ dateSeparatorDate, getMessageGroupStyle, @@ -63,8 +53,61 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW noGroupByUser, }); - const { first_unread_message_id, last_read_timestamp, last_read_message_id, unread_messages } = - useStateStore(channelUnreadStateStore.state, channelUnreadStateSelector); + const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); + const messageIsOwn = message.user?.id === client.userID; + const nextMessageId = nextMessage?.id; + const nextMessageIsOwn = nextMessage?.user?.id === client.userID; + const nextMessageCreatedAt = nextMessage?.created_at + ? new Date(nextMessage.created_at).getTime() + : undefined; + + // The unread separator belongs above the first UNREAD message from another user — i.e. on the row + // whose newer neighbour is that first unread. We locate it per-message inside the selector so + // `useStateStore`'s per-key comparison keeps the flag referentially stable (`false === false`) for + // every non-boundary row: a mark-read changes the channel-wide unread fields but only re-renders + // the one or two boundary rows whose flag actually flips, not the whole list. + // + // We deliberately do NOT anchor on `lastReadMessageId`. It tracks the last read message from + // ANOTHER user and is not advanced by our own sends, so anchoring on it drops the separator in + // front of our own just-sent messages (read → us → new-unread would wrongly separate before "us"). + // Skipping our own messages (they are always read) places it correctly above the first incoming + // unread instead. + const showUnreadSeparatorSelector = useCallback( + (snapshot: UnreadSnapshotState) => { + let showUnreadSeparator: boolean; + if (snapshot.firstUnreadMessageId) { + // Frozen boundary (channel open / mark-unread): anchor directly to the known first-unread id. + showUnreadSeparator = nextMessageId === snapshot.firstUnreadMessageId; + } else if (snapshot.unreadCount) { + // Live boundary: this row is the last read/own message before the first unread-from-another. + const lastReadAtMs = snapshot.lastReadAt?.getTime() ?? 0; + const nextIsUnreadFromOther = + !!nextMessageId && + !nextMessageIsOwn && + nextMessageCreatedAt !== undefined && + nextMessageCreatedAt > lastReadAtMs; + const thisIsUnreadFromOther = + !messageIsOwn && !!createdAtTimestamp && createdAtTimestamp > lastReadAtMs; + showUnreadSeparator = nextIsUnreadFromOther && !thisIsUnreadFromOther; + } else { + showUnreadSeparator = false; + } + + return { + showUnreadSeparator, + // Only the boundary row needs the unread count (for its inline indicator). Gating it on + // `showUnreadSeparator` keeps this `undefined` for every other row, so the channel-wide + // count changing on each mark-read doesn't re-render the whole list. + unreadCount: showUnreadSeparator ? snapshot.unreadCount : undefined, + }; + }, + [createdAtTimestamp, messageIsOwn, nextMessageCreatedAt, nextMessageId, nextMessageIsOwn], + ); + const { showUnreadSeparator, unreadCount } = useStateStore( + channel.messagePaginator.unreadStateSnapshot, + showUnreadSeparatorSelector, + ); + const { theme: { messageList: { messageContainer }, @@ -75,17 +118,6 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW return null; } - const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); - const isLastReadMessage = - last_read_message_id === message.id || - (!unread_messages && createdAtTimestamp === last_read_timestamp); - - const showUnreadSeparator = - isLastReadMessage && - !isNewestMessage && - // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label - (!!first_unread_message_id || !!unread_messages); - const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; @@ -125,7 +157,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW )} {showUnreadUnderlay && ( - + )} diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index fd3764aaa8..499cd323fa 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -11,7 +11,7 @@ import { import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; -import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; +import type { Channel, Event, LocalMessage } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; @@ -58,6 +58,7 @@ import { MessageInputHeightState } from '../../state-store/message-input-height- import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { transitions } from '../../utils/animations/transitions'; +import { getChannelUnreadState } from '../../utils/getChannelUnreadState'; import { useMessageListPagination } from '../Channel/hooks/useMessageListPagination'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; import { excludeCanceledUploadNotifications } from '../Notifications/notificationFilters'; @@ -97,20 +98,6 @@ const hasReadLastMessage = (channel: Channel, userId: string) => { return latestMessageIdInChannel === lastReadMessageIdServer; }; -const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageResponse) => { - if (!newMessage) return; - let previousLastMessage; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (!msg?.id) break; - if (msg.id !== newMessage.id) { - previousLastMessage = msg; - break; - } - } - return previousLastMessage; -}; - const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ height: state.height, }); @@ -123,7 +110,6 @@ type MessageFlashListPropsWithContext = Pick< Pick< ChannelContextValue, | 'channel' - | 'channelUnreadStateStore' | 'disabled' | 'hideStickyDateHeader' | 'highlightedMessageId' @@ -132,7 +118,6 @@ type MessageFlashListPropsWithContext = Pick< | 'markRead' | 'reloadChannel' | 'scrollToFirstUnreadThreshold' - | 'setChannelUnreadState' | 'setTargetedMessage' | 'hasPendingInitialTargetLoad' | 'targetedMessage' @@ -321,7 +306,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => attachmentPickerStore, additionalFlashListProps, channel, - channelUnreadStateStore, client, closePicker, disabled, @@ -349,7 +333,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onListScroll, onThreadSelect, reloadChannel, - setChannelUnreadState, setFlatListRef, setTargetedMessage, hasPendingInitialTargetLoad, @@ -633,7 +616,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => */ useEffect(() => { const shouldMarkRead = () => { - const channelUnreadState = channelUnreadStateStore.channelUnreadState; + const channelUnreadState = getChannelUnreadState(channel); return ( !channelUnreadState?.first_unread_message_id && !scrollToBottomButtonVisible && @@ -643,26 +626,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }; const handleEvent = async (event: Event) => { + // The unread count is owned by channel.messagePaginator.unreadStateSnapshot (the LLC bumps it + // on message.new); we no longer manually bump it here. We only mark the channel read when the + // user is caught up at the bottom. const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; - const isMyOwnMessage = event.message?.user?.id === client.user?.id; - const channelUnreadState = channelUnreadStateStore.channelUnreadState; - // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState when its a received message. - if ( - (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && - !isMyOwnMessage - ) { - const previousUnreadCount = channelUnreadState?.unread_messages ?? 0; - const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); - setChannelUnreadState({ - ...channelUnreadState, - last_read: - channelUnreadState?.last_read ?? - (previousUnreadCount === 0 && previousLastMessage?.created_at - ? new Date(previousLastMessage.created_at) - : new Date(0)), // not having information about the last read message means the whole channel is unread, - unread_messages: previousUnreadCount + 1, - }); - } else if (mainChannelUpdated && shouldMarkRead()) { + if (mainChannelUpdated && shouldMarkRead()) { await markRead(); } }; @@ -672,15 +640,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return () => { listener?.unsubscribe(); }; - }, [ - channel, - channelUnreadStateStore, - client.user?.id, - markRead, - scrollToBottomButtonVisible, - setChannelUnreadState, - threadList, - ]); + }, [channel, client.user?.id, markRead, scrollToBottomButtonVisible, threadList]); const updateStickyHeaderDateIfNeeded = useStableCallback((viewableItems: ViewToken[]) => { if (!viewableItems.length) { @@ -715,7 +675,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => * This function should show or hide the unread indicator depending on the */ const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { - const channelUnreadState = channelUnreadStateStore.channelUnreadState; + const channelUnreadState = getChannelUnreadState(channel); // we need this check to make sure that regular list change do not trigger // the unread notification to appear (for example if the old last read messages // go out of the viewport). @@ -1204,10 +1164,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => {isUnreadNotificationOpen && !threadList ? ( - + ) : null} { const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); const { channel, - channelUnreadStateStore, disabled, enableMessageGroupingByUser, error, @@ -1302,7 +1258,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { maximumMessageLimit, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, setTargetedMessage, hasPendingInitialTargetLoad, targetedMessage, @@ -1327,7 +1282,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { allowSendBeforeAttachmentsUpload, attachmentPickerStore, channel, - channelUnreadStateStore, client, closePicker, disabled, @@ -1354,7 +1308,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { readEvents, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, setTargetedMessage, hasPendingInitialTargetLoad, shouldShowUnreadUnderlay, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 566dfb068a..d45281a20a 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -70,6 +70,7 @@ import { bumpOverlayLayoutRevision, useHasActiveId } from '../../state-store'; import { MessageInputHeightState } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; import { transitions } from '../../utils/animations/transitions'; +import { getChannelUnreadState } from '../../utils/getChannelUnreadState'; import { useIncomingMessageAnnouncements } from '../Accessibility/hooks/useIncomingMessageAnnouncements'; import { useMessageListPagination } from '../Channel/hooks/useMessageListPagination'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; @@ -195,7 +196,6 @@ type MessageListPropsWithContext = Pick< Pick< ChannelContextValue, | 'channel' - | 'channelUnreadStateStore' | 'disabled' | 'hideStickyDateHeader' | 'loadChannelAroundMessage' @@ -317,7 +317,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { attachmentPickerStore, additionalFlatListProps, channel, - channelUnreadStateStore, client, closePicker, disabled, @@ -536,7 +535,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * This function should show or hide the unread indicator depending on the */ const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { - const channelUnreadState = channelUnreadStateStore.channelUnreadState; + const channelUnreadState = getChannelUnreadState(channel); // we need this check to make sure that regular list change do not trigger // the unread notification to appear (for example if the old last read messages // go out of the viewport). @@ -646,7 +645,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ useEffect(() => { const shouldMarkRead = () => { - const channelUnreadState = channelUnreadStateStore.channelUnreadState; + const channelUnreadState = getChannelUnreadState(channel); return ( AppState.currentState === 'active' && !channelUnreadState?.first_unread_message_id && @@ -657,9 +656,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }; const handleEvent = async (event: Event) => { - // The unread count is owned by channel.messagePaginator.unreadStateSnapshot (mirrored into - // channelUnreadStateStore); we no longer manually bump it here. We only mark the channel read - // when the user is caught up at the bottom and the app is foregrounded. + // The unread count is owned by channel.messagePaginator.unreadStateSnapshot; we no longer + // manually bump it here. We only mark the channel read when the user is caught up at the + // bottom and the app is foregrounded. const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; if (mainChannelUpdated && shouldMarkRead()) { await markRead(); @@ -671,14 +670,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return () => { listener?.unsubscribe(); }; - }, [ - channel, - channelUnreadStateStore, - client.user?.id, - markRead, - scrollToBottomButtonVisible, - threadList, - ]); + }, [channel, client.user?.id, markRead, scrollToBottomButtonVisible, threadList]); useEffect(() => { /** @@ -1360,10 +1352,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { {isUnreadNotificationOpen && !threadList ? ( - + ) : null} { const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); const { channel, - channelUnreadStateStore, disabled, enableMessageGroupingByUser, error, @@ -1432,7 +1420,6 @@ export const MessageList = (props: MessageListProps) => { allowSendBeforeAttachmentsUpload, attachmentPickerStore, channel, - channelUnreadStateStore, client, closePicker, disabled, diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index a43555f71c..1b37407689 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -1,23 +1,18 @@ import React, { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import { - ChannelContextValue, - useChannelContext, -} from '../../contexts/channelContext/ChannelContext'; +import type { UnreadSnapshotState } from 'stream-chat'; + +import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { useStateStore } from '../../hooks/useStateStore'; import { ArrowUp } from '../../icons/arrow-up'; import { NewClose } from '../../icons/xmark'; -import { ChannelUnreadStateStoreType } from '../../state-store/channel-unread-state'; import { primitives } from '../../theme'; import { Button } from '../ui'; -export type UnreadMessagesNotificationProps = Pick< - ChannelContextValue, - 'channelUnreadStateStore' -> & { +export type UnreadMessagesNotificationProps = { /** * Callback to handle the close event */ @@ -32,18 +27,18 @@ export type UnreadMessagesNotificationProps = Pick< unreadCount?: number; }; -const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ - unread_messages: state.channelUnreadState?.unread_messages, +const unreadCountSelector = (snapshot: UnreadSnapshotState) => ({ + unread_messages: snapshot.unreadCount, }); export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProps) => { - const { onCloseHandler, onPressHandler, unreadCount, channelUnreadStateStore } = props; + const { onCloseHandler, onPressHandler, unreadCount } = props; const { t } = useTranslationContext(); - const { loadChannelAtFirstUnreadMessage, markRead, setChannelUnreadState, setTargetedMessage } = + const { channel, loadChannelAtFirstUnreadMessage, markRead, setTargetedMessage } = useChannelContext(); const { unread_messages } = useStateStore( - channelUnreadStateStore.state, - channelUnreadStateSelector, + channel.messagePaginator.unreadStateSnapshot, + unreadCountSelector, ); const count = unread_messages ?? unreadCount; @@ -53,8 +48,6 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp await onPressHandler(); } else { await loadChannelAtFirstUnreadMessage({ - channelUnreadState: channelUnreadStateStore.channelUnreadState, - setChannelUnreadState, setTargetedMessage, }); } diff --git a/package/src/components/Thread/__tests__/Thread.test.tsx b/package/src/components/Thread/__tests__/Thread.test.tsx index 56aad0705c..d4282e712b 100644 --- a/package/src/components/Thread/__tests__/Thread.test.tsx +++ b/package/src/components/Thread/__tests__/Thread.test.tsx @@ -5,7 +5,6 @@ import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-ch import { v5 as uuidv5 } from 'uuid'; import { AttachmentPickerProvider } from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; -import { ChannelContext } from '../../../contexts/channelContext/ChannelContext'; import { ChannelsStateProvider } from '../../../contexts/channelsStateContext/ChannelsStateContext'; import { ImageGalleryProvider } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -142,10 +141,6 @@ describe('Thread', () => { threadResponses as unknown as Parameters[0], ); - let setChannelUnreadState: - | React.ContextType['setChannelUnreadState'] - | undefined; - const { getByText, toJSON } = render( @@ -161,12 +156,7 @@ describe('Thread', () => { value={{} as React.ComponentProps['value']} > - - {(c) => { - setChannelUnreadState = c.setChannelUnreadState; - return ; - }} - + @@ -180,7 +170,14 @@ describe('Thread', () => { expect(getByText('Message6')).toBeTruthy(); }); - act(() => setChannelUnreadState!(undefined)); + act(() => { + channel.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: null, + lastReadMessageId: null, + unreadCount: 0, + }); + }); const snapshot = toJSON() as unknown as { children: Array<{ diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index 60f6e261d0..7999a443bd 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -3,10 +3,6 @@ import React, { PropsWithChildren, useContext } from 'react'; import type { Channel } from 'stream-chat'; import { MarkReadFunctionOptions } from '../../components/Channel/Channel'; -import { - ChannelUnreadStateStore, - ChannelUnreadStateStoreType, -} from '../../state-store/channel-unread-state'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -66,25 +62,19 @@ export type ChannelContextValue = { /** * Loads channel at first unread message. - * @param channelUnreadState - The unread state of the channel * @param limit - The number of messages to load around the first unread message - * @param setChannelUnreadState - Callback to set the channel unread state */ loadChannelAtFirstUnreadMessage: ({ - channelUnreadState, limit, setTargetedMessage, }: { - channelUnreadState?: ChannelUnreadStateStoreType['channelUnreadState']; limit?: number; - setChannelUnreadState?: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; setTargetedMessage?: (messageId: string) => void; }) => Promise; markRead: (options?: MarkReadFunctionOptions) => void; reloadChannel: () => Promise; scrollToFirstUnreadThreshold: number; - setChannelUnreadState: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; setTargetedMessage: (messageId?: string) => void; /** * Returns true when Channel is about to load an initial targeted message. @@ -97,7 +87,6 @@ export type ChannelContextValue = { * Its a map of filename and AbortController */ uploadAbortControllerRef: React.MutableRefObject>; - channelUnreadStateStore: ChannelUnreadStateStore; disabled?: boolean; enableMessageGroupingByUser?: boolean; /** diff --git a/package/src/state-store/channel-unread-state.ts b/package/src/state-store/channel-unread-state.ts deleted file mode 100644 index 2e938dec5b..0000000000 --- a/package/src/state-store/channel-unread-state.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { StateStore } from 'stream-chat'; - -import type { ChannelUnreadState as ChannelUnreadStateType } from '../types/types'; - -export type ChannelUnreadStateStoreType = { - channelUnreadState?: ChannelUnreadStateType; -}; - -const INITIAL_STATE: ChannelUnreadStateStoreType = { - channelUnreadState: undefined, -}; - -export class ChannelUnreadStateStore { - public state: StateStore; - - constructor() { - this.state = new StateStore(INITIAL_STATE); - } - - set channelUnreadState(data: ChannelUnreadStateStoreType['channelUnreadState']) { - this.state.next({ channelUnreadState: data }); - } - - get channelUnreadState() { - const { channelUnreadState } = this.state.getLatestValue(); - return channelUnreadState; - } -} diff --git a/package/src/utils/getChannelUnreadState.ts b/package/src/utils/getChannelUnreadState.ts new file mode 100644 index 0000000000..de98a96100 --- /dev/null +++ b/package/src/utils/getChannelUnreadState.ts @@ -0,0 +1,27 @@ +import type { Channel } from 'stream-chat'; + +import type { ChannelUnreadState } from '../types/types'; + +/** + * Maps the LLC's `channel.messagePaginator.unreadStateSnapshot` (the single source of truth for + * unread state) into the SDK's `ChannelUnreadState` shape used across the message list. + * + * Returns `undefined` when there is nothing unread to represent (no first-unread marker, no + * last-read message, no unread count) — mirroring the previous store's "empty means undefined" + * semantics so downstream `channelUnreadState?.x` checks behave identically. `last_read` falls back + * to the epoch (whole channel unread) exactly as before. + */ +export const getChannelUnreadState = (channel: Channel): ChannelUnreadState | undefined => { + const snapshot = channel.messagePaginator.unreadStateSnapshot.getLatestValue(); + + if (!snapshot.firstUnreadMessageId && !snapshot.lastReadMessageId && !snapshot.unreadCount) { + return undefined; + } + + return { + first_unread_message_id: snapshot.firstUnreadMessageId ?? undefined, + last_read: snapshot.lastReadAt ?? new Date(0), + last_read_message_id: snapshot.lastReadMessageId ?? undefined, + unread_messages: snapshot.unreadCount, + }; +}; From bd863822c6c279000f8c779cc81791922e49e8ee Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Jul 2026 20:17:12 +0200 Subject: [PATCH 16/36] chore: cleanup of old state --- .../Channel/__tests__/Channel.test.tsx | 35 ++-- .../Channel/hooks/useChannelDataState.ts | 165 ------------------ .../__tests__/MessageList.test.tsx | 20 ++- 3 files changed, 35 insertions(+), 185 deletions(-) delete mode 100644 package/src/components/Channel/hooks/useChannelDataState.ts diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 7051560965..7fa23a9601 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -1,7 +1,7 @@ import React, { type ComponentProps, useContext, useEffect } from 'react'; import { View } from 'react-native'; -import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react-native'; +import { act, cleanup, render, waitFor } from '@testing-library/react-native'; import type { Channel as ChannelType, StreamChat as StreamChatType } from 'stream-chat'; import { StreamChat } from 'stream-chat'; @@ -28,9 +28,25 @@ import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Attachment } from '../../Attachment/Attachment'; import { Chat } from '../../Chat/Chat'; import { Channel } from '../Channel'; -import { channelInitialState, useChannelMessageDataState } from '../hooks/useChannelDataState'; import * as MessageListPaginationHooks from '../hooks/useMessageListPagination'; +// Local test fixture (was previously imported from the now-removed useChannelDataState hook). +const channelInitialState = { + hasMore: true, + hasMoreNewer: false, + loading: false, + loadingMore: false, + loadingMoreRecent: false, + members: {}, + messages: [], + pinnedMessages: [], + read: {}, + targetedMessageId: undefined, + typing: {}, + watcherCount: 0, + watchers: {}, +}; + // This component is used for performing effects in a component that consumes ChannelContext, // i.e. making use of the callbacks & values provided by the Channel component. // the effect is called every time channelContext changes @@ -445,10 +461,7 @@ describe('Channel initial load useEffect', () => { renderComponent({ channel }); - const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); - await waitFor(() => expect(watchSpy).toHaveBeenCalled()); - await waitFor(() => expect(channelMessageState.current.state.messages!).toHaveLength(10)); // members now come reactively from channel.state.membersStore (via the shim getter). await waitFor(() => expect(Object.keys(channel.state.members)).toHaveLength(10)); }); @@ -496,16 +509,6 @@ describe('Channel initial load useEffect', () => { await waitFor(() => { expect(loadMessageIntoState).toHaveBeenCalledWith(messageToSearch.id, undefined, 25); }); - - const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); - await waitFor(() => expect(channelMessageState.current.state.messages!).toHaveLength(25)); - await waitFor(() => - expect( - channelMessageState.current.state.messages!.find( - (message) => message.id === messageToSearch.id, - ), - ).toBeTruthy(), - ); }); describe('initialScrollToFirstUnreadMessage', () => { @@ -522,10 +525,8 @@ describe('Channel initial load useEffect', () => { jest.spyOn(MessageListPaginationHooks, 'useMessageListPagination').mockImplementation( () => ({ - copyMessagesStateFromChannel: jest.fn(), loadChannelAroundMessage: jest.fn(), loadChannelAtFirstUnreadMessage: jest.fn(), - loadInitialMessagesStateFromChannel: jest.fn(), loadLatestMessages: jest.fn(), loadMore: jest.fn(), loadMoreRecent: jest.fn(), diff --git a/package/src/components/Channel/hooks/useChannelDataState.ts b/package/src/components/Channel/hooks/useChannelDataState.ts deleted file mode 100644 index b201242a02..0000000000 --- a/package/src/components/Channel/hooks/useChannelDataState.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { useCallback, useState } from 'react'; - -import { Channel, LocalMessage, ChannelState as StreamChannelState } from 'stream-chat'; - -export const channelInitialState = { - hasMore: true, - hasMoreNewer: false, - loading: false, - loadingMore: false, - loadingMoreRecent: false, - members: {}, - messages: [], - pinnedMessages: [], - read: {}, - targetedMessageId: undefined, - typing: {}, - watcherCount: 0, - watchers: {}, -}; - -/** - * The ChannelMessagesState object - */ -export type ChannelMessagesState = { - hasMore?: boolean; - hasMoreNewer?: boolean; - loading?: boolean; - loadingMore?: boolean; - loadingMoreRecent?: boolean; - messages?: StreamChannelState['messages']; - pinnedMessages?: StreamChannelState['pinnedMessages']; - targetedMessageId?: string; -}; - -/** - * The ChannelThreadState object - */ -export type ChannelThreadState = { - thread: LocalMessage | null; - threadHasMore?: boolean; - threadLoadingMore?: boolean; - threadMessages?: StreamChannelState['messages']; -}; - -/** - * The ChannelState object - */ -export type ChannelState = ChannelMessagesState & { - members?: StreamChannelState['members']; - read?: StreamChannelState['read']; - typing?: StreamChannelState['typing']; - watcherCount?: number; - watchers?: StreamChannelState['watchers']; -}; - -/** - * The useChannelMessageDataState hook that handles the state for the channel messages. - */ -export const useChannelMessageDataState = (channel: Channel) => { - const [state, setState] = useState({ - hasMore: true, - hasMoreNewer: false, - loading: false, - loadingMore: false, - loadingMoreRecent: false, - messages: channel?.state?.messages || [], - pinnedMessages: channel?.state?.pinnedMessages || [], - targetedMessageId: undefined, - }); - - const copyMessagesStateFromChannel = useCallback((channel: Channel) => { - setState((prev) => ({ - ...prev, - messages: [...channel.state.messages], - pinnedMessages: [...channel.state.pinnedMessages], - })); - }, []); - - const loadInitialMessagesStateFromChannel = useCallback((channel: Channel, hasMore: boolean) => { - setState((prev) => ({ - ...prev, - hasMore, - loading: false, - messages: [...channel.state.messages], - pinnedMessages: [...channel.state.pinnedMessages], - })); - }, []); - - const jumpToLatestMessage = useCallback(() => { - setState((prev) => ({ - ...prev, - hasMoreNewer: false, - loading: false, - targetedMessageId: undefined, - })); - }, []); - - const jumpToMessageFinished = useCallback((hasMoreNewer: boolean, targetedMessageId: string) => { - setState((prev) => ({ - ...prev, - hasMoreNewer, - loading: false, - targetedMessageId, - })); - }, []); - - const loadMoreFinished = useCallback((hasMore: boolean, messages: ChannelState['messages']) => { - setState((prev) => ({ - ...prev, - hasMore, - loadingMore: false, - messages, - })); - }, []); - - const setLoadingMore = useCallback((loadingMore: boolean) => { - setState((prev) => ({ - ...prev, - loadingMore, - })); - }, []); - - const setLoadingMoreRecent = useCallback((loadingMoreRecent: boolean) => { - setState((prev) => ({ - ...prev, - loadingMoreRecent, - })); - }, []); - - const setLoading = useCallback((loading: boolean) => { - setState((prev) => ({ - ...prev, - loading, - })); - }, []); - - const loadMoreRecentFinished = useCallback( - (hasMoreNewer: boolean, messages: ChannelState['messages']) => { - setState((prev) => ({ - ...prev, - hasMoreNewer, - loadingMoreRecent: false, - messages, - })); - }, - [], - ); - - return { - copyMessagesStateFromChannel, - jumpToLatestMessage, - jumpToMessageFinished, - loadInitialMessagesStateFromChannel, - loadMoreFinished, - loadMoreRecentFinished, - setLoading, - setLoadingMore, - setLoadingMoreRecent, - state, - }; -}; - -// Channel scalar state (members/read/typing/watchers) now comes reactively from -// stream-chat's channel.state StateStores (membersStore/readStore/typingStore/watcherStore), -// consumed directly via useStateStore — no React-state mirror needed. diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index bfdee3cb8d..6c949c71d4 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -16,13 +16,29 @@ import { generateMessage } from '../../../mock-builders/generator/message'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Channel } from '../../Channel/Channel'; -import { channelInitialState } from '../../Channel/hooks/useChannelDataState'; import * as MessageListPaginationHook from '../../Channel/hooks/useMessageListPagination'; import { Chat } from '../../Chat/Chat'; import { SCROLL_TO_BOTTOM_ACCESSIBILITY_ACTION_NAME } from '../hooks/useScrollToBottomAccessibilityAction'; import { MessageList } from '../MessageList'; +// Local test fixture (was previously imported from the now-removed useChannelDataState hook). +const channelInitialState = { + hasMore: true, + hasMoreNewer: false, + loading: false, + loadingMore: false, + loadingMoreRecent: false, + members: {}, + messages: [], + pinnedMessages: [], + read: {}, + targetedMessageId: undefined, + typing: {}, + watcherCount: 0, + watchers: {}, +}; + describe('MessageList', () => { afterEach(() => { cleanup(); @@ -511,10 +527,8 @@ describe('MessageList pagination', () => { return jest .spyOn(MessageListPaginationHook, 'useMessageListPagination') .mockImplementation(() => ({ - copyMessagesStateFromChannel: jest.fn(), loadChannelAroundMessage: jest.fn(), loadChannelAtFirstUnreadMessage: jest.fn(), - loadInitialMessagesStateFromChannel: jest.fn(), loadLatestMessages: jest.fn(), loadMore: jest.fn(), loadMoreRecent: jest.fn(), From 718fee6913700836fea94627c4de42b8d9f6e656 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Jul 2026 21:37:47 +0200 Subject: [PATCH 17/36] feat: highlighted messages --- package/src/components/Channel/Channel.tsx | 36 ++++-- .../useMessageListPagination.test.tsx | 14 +-- .../Channel/hooks/useCreateChannelContext.ts | 5 - .../Channel/hooks/useCreateMessagesContext.ts | 3 - .../hooks/useMessageListPagination.tsx | 14 +-- .../Channel/hooks/useTargetedMessage.ts | 44 ------- .../MessageList/MessageFlashList.tsx | 97 ++++++++------- .../components/MessageList/MessageList.tsx | 114 +++++++----------- .../UnreadMessagesNotification.tsx | 7 +- .../__tests__/MessageList.test.tsx | 68 +++-------- package/src/components/index.ts | 1 - .../channelContext/ChannelContext.tsx | 22 +--- .../messagesContext/MessagesContext.tsx | 2 - 13 files changed, 146 insertions(+), 281 deletions(-) delete mode 100644 package/src/components/Channel/hooks/useTargetedMessage.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 3e424c280b..bcebcd79f1 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -33,8 +33,10 @@ import { useCreateOwnCapabilitiesContext } from './hooks/useCreateOwnCapabilitie import { useCreateThreadContext } from './hooks/useCreateThreadContext'; -import { useMessageListPagination } from './hooks/useMessageListPagination'; -import { useTargetedMessage } from './hooks/useTargetedMessage'; +import { + DEFAULT_HIGHLIGHT_DURATION, + useMessageListPagination, +} from './hooks/useMessageListPagination'; import { AttachmentPickerContextValue, @@ -78,6 +80,7 @@ import { useStableCallback } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; import { useAttachmentPickerBottomSheet } from '../../hooks/useAttachmentPickerBottomSheet'; +import { useStateStore } from '../../hooks/useStateStore'; import { isDocumentPickerAvailable, isImageMediaLibraryAvailable, @@ -360,6 +363,12 @@ export type ChannelPropsWithContext = Pick & initializeOnMount?: boolean; }; +// The highlighted message id is derived from the paginator's messageFocusSignal (LLC), which is +// emitted by the jump fns and auto-cleared after its TTL — no separate targeted-message React state. +const messageFocusSignalSelector = (state: { signal: { messageId?: string } | null }) => ({ + highlightedMessageId: state.signal?.messageId, +}); + const ChannelWithContext = (props: PropsWithChildren) => { const { disableAttachmentPicker = !isImageMediaLibraryAvailable(), @@ -494,7 +503,10 @@ const ChannelWithContext = (props: PropsWithChildren) = const syncingChannelRef = useRef(false); - const { highlightedMessageId, setTargetedMessage, targetedMessage } = useTargetedMessage(); + const { highlightedMessageId } = useStateStore( + (threadInstance ?? channel).messagePaginator.messageFocusSignal, + messageFocusSignalSelector, + ); /** * This ref will hold the abort controllers for @@ -620,10 +632,10 @@ const ChannelWithContext = (props: PropsWithChildren) = // of truth for unread state), so there's no manual initial read-state copy here. if (messageId) { - await loadChannelAroundMessage({ messageId, setTargetedMessage }); + await loadChannelAroundMessage({ messageId }); } else if (shouldLoadAtFirstUnread) { // jumpToTheFirstUnreadMessage resolves the first-unread id from the paginator's snapshot. - await loadChannelAtFirstUnreadMessage({ setTargetedMessage }); + await loadChannelAtFirstUnreadMessage(); } if (unreadCount > 0 && markReadOnMount) { @@ -872,9 +884,13 @@ const ChannelWithContext = (props: PropsWithChildren) = await channel.state.loadMessageIntoState(messageIdToLoadAround, thread.id); setThreadLoadingMore(false); setThreadMessages(channel.state.threads[thread.id]); - if (setTargetedMessage) { - setTargetedMessage(messageIdToLoadAround); - } + // The reply list still reads channel.state.threads; emit the thread paginator's focus + // signal so the thread-aware highlight + scroll fire (mirrors the channel jump path). + threadInstance?.messagePaginator?.emitMessageFocusSignal({ + messageId: messageIdToLoadAround, + reason: 'jump-to-message', + ttlMs: DEFAULT_HIGHLIGHT_DURATION, + }); } catch (err) { if (err instanceof Error) { setError(err); @@ -886,7 +902,6 @@ const ChannelWithContext = (props: PropsWithChildren) = } else { await loadChannelAroundMessageFn({ messageId: messageIdToLoadAround, - setTargetedMessage, }); } } catch (err) { @@ -1237,9 +1252,7 @@ const ChannelWithContext = (props: PropsWithChildren) = maxTimeBetweenGroupedMessages, reloadChannel, scrollToFirstUnreadThreshold, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, threadList, uploadAbortControllerRef, }); @@ -1336,7 +1349,6 @@ const ChannelWithContext = (props: PropsWithChildren) = sendReaction, shouldShowUnreadUnderlay, supportedReactions, - targetedMessage, updateMessage, urlPreviewType, }); diff --git a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx b/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx index 245e3aeb07..a33c6173c4 100644 --- a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx +++ b/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx @@ -130,42 +130,36 @@ describe('useMessageListPagination', () => { expect(paginator.jumpToTheLatestMessage).toHaveBeenCalledTimes(1); }); - it('loadChannelAroundMessage jumps to the message and targets it', async () => { + it('loadChannelAroundMessage jumps to the message (emitting the focus signal)', async () => { const paginator = makePaginator({ hasMoreHead: true, hasMoreTail: true, isLoading: false, items: [], }); - const setTargetedMessage = jest.fn(); const { result } = renderHook(() => useMessageListPagination({ channel: makeChannel(paginator) }), ); await act(async () => { - await result.current.loadChannelAroundMessage({ messageId: 'm7', setTargetedMessage }); + await result.current.loadChannelAroundMessage({ messageId: 'm7' }); }); expect(paginator.jumpToMessage).toHaveBeenCalledWith( 'm7', expect.objectContaining({ focusReason: 'jump-to-message' }), ); - expect(setTargetedMessage).toHaveBeenCalledWith('m7'); }); - it('loadChannelAtFirstUnreadMessage jumps to first unread and targets the focused message', async () => { + it('loadChannelAtFirstUnreadMessage jumps to first unread (emitting the focus signal)', async () => { const paginator = makePaginator( { hasMoreHead: true, hasMoreTail: true, isLoading: false, items: [] }, 'm5', ); - const setTargetedMessage = jest.fn(); const { result } = renderHook(() => useMessageListPagination({ channel: makeChannel(paginator) }), ); await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({ setTargetedMessage } as Parameters< - typeof result.current.loadChannelAtFirstUnreadMessage - >[0]); + await result.current.loadChannelAtFirstUnreadMessage(); }); expect(paginator.jumpToTheFirstUnreadMessage).toHaveBeenCalledTimes(1); - expect(setTargetedMessage).toHaveBeenCalledWith('m5'); }); }); diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 78ddeedf8a..f2bb3ca818 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -20,9 +20,7 @@ export const useCreateChannelContext = ({ maximumMessageLimit, reloadChannel, scrollToFirstUnreadThreshold, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, threadList, uploadAbortControllerRef, }: ChannelContextValue) => { @@ -47,9 +45,7 @@ export const useCreateChannelContext = ({ maxTimeBetweenGroupedMessages, reloadChannel, scrollToFirstUnreadThreshold, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, threadList, uploadAbortControllerRef, }), @@ -61,7 +57,6 @@ export const useCreateChannelContext = ({ isChannelActive, highlightedMessageId, loading, - targetedMessage, threadList, maximumMessageLimit, ], diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index a237709a95..29482852de 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -52,7 +52,6 @@ export const useCreateMessagesContext = ({ sendReaction, shouldShowUnreadUnderlay, supportedReactions, - targetedMessage, updateMessage, urlPreviewType, }: MessagesContextValue & { @@ -116,7 +115,6 @@ export const useCreateMessagesContext = ({ sendReaction, shouldShowUnreadUnderlay, supportedReactions, - targetedMessage, updateMessage, urlPreviewType, }), @@ -132,7 +130,6 @@ export const useCreateMessagesContext = ({ messageOverlayTargetId, supportedReactionsLength, myMessageTheme, - targetedMessage, hasCreatePoll, ], ); diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx index a4c83ab804..77c1ca0626 100644 --- a/package/src/components/Channel/hooks/useMessageListPagination.tsx +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -7,7 +7,7 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran import { useStableCallback, useStateStore } from '../../../hooks'; import { useNotificationApi } from '../../Notifications'; -const DEFAULT_HIGHLIGHT_DURATION = 3000; +export const DEFAULT_HIGHLIGHT_DURATION = 3000; type MessagePaginatorState = { hasMoreHead: boolean; @@ -108,16 +108,17 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => { * @param messageId If undefined, no-op. */ const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = - useStableCallback(async ({ messageId: messageIdToLoadAround, setTargetedMessage }) => { + useStableCallback(async ({ messageId: messageIdToLoadAround }) => { if (!messageIdToLoadAround) { return; } try { + // jumpToMessage loads-around the target AND emits messageFocusSignal, which drives both the + // highlight and the scroll-to-target — no separate targeted-message React state needed. await paginator.jumpToMessage(messageIdToLoadAround, { focusReason: 'jump-to-message', focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, }); - setTargetedMessage?.(messageIdToLoadAround); } catch (error) { console.warn( 'Message pagination(fetching messages around a message id) request failed with error:', @@ -131,15 +132,12 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => { * from its unread snapshot / channel read state. */ const loadChannelAtFirstUnreadMessage: ChannelContextValue['loadChannelAtFirstUnreadMessage'] = - useStableCallback(async ({ setTargetedMessage }) => { + useStableCallback(async () => { try { + // jumpToTheFirstUnreadMessage emits messageFocusSignal, which drives the highlight + scroll. await paginator.jumpToTheFirstUnreadMessage({ focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, }); - const focusedMessageId = paginator.messageFocusSignal.getLatestValue().signal?.messageId; - if (focusedMessageId) { - setTargetedMessage?.(focusedMessageId); - } } catch (error) { notifyJumpToFirstUnreadError(error); } diff --git a/package/src/components/Channel/hooks/useTargetedMessage.ts b/package/src/components/Channel/hooks/useTargetedMessage.ts deleted file mode 100644 index a158d93776..0000000000 --- a/package/src/components/Channel/hooks/useTargetedMessage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -export const useTargetedMessage = (messageId?: string) => { - const clearTargetedMessageCall = useRef>(undefined); - const [targetedMessage, setTargetedMessage] = useState(messageId); - const [highlightedMessageId, setHighlightedMessageId] = useState(); - const prevTargetedMessageRef = useRef(undefined); - - useEffect(() => { - prevTargetedMessageRef.current = targetedMessage; - if (targetedMessage) { - setHighlightedMessageId(targetedMessage); - } - }, [targetedMessage]); - - useEffect(() => { - clearTargetedMessageCall.current = setTimeout(() => { - setTargetedMessage(undefined); - setHighlightedMessageId(undefined); - }, 3000); - - return () => { - clearTargetedMessageCall.current && clearTimeout(clearTargetedMessageCall.current); - }; - }, []); - - const setTargetedMessageTimeoutRef = useRef((messageId: string | undefined) => { - clearTargetedMessageCall.current && clearTimeout(clearTargetedMessageCall.current); - - clearTargetedMessageCall.current = setTimeout(() => { - setTargetedMessage(undefined); - setHighlightedMessageId(undefined); - }, 3000); - - setTargetedMessage(messageId); - }); - - return { - highlightedMessageId, - prevTargetedMessage: prevTargetedMessageRef.current, - setTargetedMessage: setTargetedMessageTimeoutRef.current, - targetedMessage, - }; -}; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 499cd323fa..e72f163c16 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -118,9 +118,7 @@ type MessageFlashListPropsWithContext = Pick< | 'markRead' | 'reloadChannel' | 'scrollToFirstUnreadThreshold' - | 'setTargetedMessage' | 'hasPendingInitialTargetLoad' - | 'targetedMessage' | 'threadList' | 'maximumMessageLimit' > & @@ -297,6 +295,13 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'generic-message'; }; +const messageFocusSelector = (state: { + signal: { messageId?: string; token?: number } | null; +}) => ({ + focusedMessageId: state.signal?.messageId, + focusToken: state.signal?.token, +}); + const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator @@ -334,9 +339,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onThreadSelect, reloadChannel, setFlatListRef, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, thread, threadInstance, threadList = false, @@ -466,66 +469,64 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }, [disabled]); + // Scroll-to-target is driven by the paginator's messageFocusSignal (thread-aware): a jump emits + // it, and the effect below scrolls to it. `token` re-fires the effect on every jump (even to the + // same id); see MessageList for the full rationale. + const focusPaginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator; + const { focusedMessageId, focusToken } = + useStateStore(focusPaginator?.messageFocusSignal, messageFocusSelector) ?? {}; + const lastFocusScrollTokenRef = useRef(undefined); + /** - * Check if a messageId needs to be scrolled to after list loads, and scroll to it - * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender + * Scrolls to the focused message (messageFocusSignal) once it's rendered. Re-attempts when the + * list updates while a focus is pending, and marks each focus token handled so unrelated list + * changes during the highlight window don't re-scroll. */ useEffect(() => { - if (!targetedMessage) { + if (!focusedMessageId || focusToken === lastFocusScrollTokenRef.current) { return; } const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === targetedMessage, + (message) => message?.id === focusedMessageId, ); - // the message we want to scroll to has not been loaded in the state yet + // Not in the rendered window yet (jumpToMessage already loaded-around it, so a later + // processedMessageList change re-runs this) — bail rather than re-jump (no jump↔effect loop). if (indexOfParentInMessageList === -1) { - loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); - } else { - scrollToDebounceTimeoutRef.current = setTimeout(async () => { - clearTimeout(scrollToDebounceTimeoutRef.current); + return; + } - const scrollToIndex = async () => { - const list = flashListRef.current; + lastFocusScrollTokenRef.current = focusToken; + scrollToDebounceTimeoutRef.current = setTimeout(async () => { + clearTimeout(scrollToDebounceTimeoutRef.current); - if (!list) { - return false; - } + const scrollToIndex = async () => { + const list = flashListRef.current; - await list.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, - }); + if (!list) { + return false; + } + + await list.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, + }); - return true; - }; + return true; + }; + await scrollToIndex(); + requestAnimationFrame(async () => { await scrollToIndex(); - requestAnimationFrame(async () => { - await scrollToIndex(); - setTargetedMessage(undefined); - }); - }, WAIT_FOR_SCROLL_TIMEOUT); - } - }, [loadChannelAroundMessage, processedMessageList, setTargetedMessage, targetedMessage]); + }); + }, WAIT_FOR_SCROLL_TIMEOUT); + }, [focusToken, focusedMessageId, processedMessageList]); const goToMessage = useStableCallback(async (messageId: string) => { - const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === messageId, - ); - - try { - if (indexOfParentInMessageList === -1) { - clearTimeout(scrollToDebounceTimeoutRef.current); - await loadChannelAroundMessage({ messageId, setTargetedMessage }); - } else { - setTargetedMessage(messageId); - } - } catch (e) { - console.warn('Error while scrolling to message', e); - } + // jumpToMessage loads-around + emits messageFocusSignal → the effect scrolls and highlights. + await loadChannelAroundMessage({ messageId }); }); useEffect(() => { @@ -1258,9 +1259,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { maximumMessageLimit, reloadChannel, scrollToFirstUnreadThreshold, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, threadList, } = useChannelContext(); const { client } = useChatContext(); @@ -1308,10 +1307,8 @@ export const MessageFlashList = (props: MessageFlashListProps) => { readEvents, reloadChannel, scrollToFirstUnreadThreshold, - setTargetedMessage, hasPendingInitialTargetLoad, shouldShowUnreadUnderlay, - targetedMessage, thread, threadInstance, threadList, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index d45281a20a..cb3533d949 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -203,8 +203,6 @@ type MessageListPropsWithContext = Pick< | 'markRead' | 'reloadChannel' | 'scrollToFirstUnreadThreshold' - | 'setTargetedMessage' - | 'targetedMessage' | 'threadList' | 'maximumMessageLimit' > & @@ -307,6 +305,13 @@ const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ * [ThreadContext](https://getstream.io/chat/docs/sdk/reactnative/contexts/thread-context/) * [TranslationContext](https://getstream.io/chat/docs/sdk/reactnative/contexts/translation-context/) */ +const messageFocusSelector = (state: { + signal: { messageId?: string; token?: number } | null; +}) => ({ + focusedMessageId: state.signal?.messageId, + focusToken: state.signal?.token, +}); + const MessageListWithContext = (props: MessageListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator @@ -346,8 +351,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { readEvents, reloadChannel, setFlatListRef, - setTargetedMessage, - targetedMessage, thread, threadInstance, threadList = false, @@ -486,11 +489,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ const onScrollEventTimeoutRef = useRef>(undefined); - /** - * Last messageID that was scrolled to after loading a new message list, - * this flag keeps track of it so that we dont scroll to it again on target message set - */ - const messageIdLastScrolledToRef = useRef(undefined); const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); @@ -787,73 +785,50 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { maximumMessageLimit, ]); + // Scroll-to-target is driven by the paginator's messageFocusSignal (thread-aware): a jump + // (jumpToMessage / jumpToTheFirstUnreadMessage / emitMessageFocusSignal) emits it, and the effect + // below scrolls to it. `token` re-fires the effect on every jump, even to the same message id. + const focusPaginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator; + const { focusedMessageId, focusToken } = + useStateStore(focusPaginator?.messageFocusSignal, messageFocusSelector) ?? {}; + const lastFocusScrollTokenRef = useRef(undefined); + const goToMessage = useStableCallback(async (messageId: string) => { - const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === messageId, - ); - try { - if (indexOfParentInMessageList === -1) { - await loadChannelAroundMessage({ messageId }); - return; - } else { - if (!flatListRef.current) { - return; - } - clearTimeout(failScrollTimeoutId.current); - scrollToIndexFailedRetryCountRef.current = 0; - // keep track of this messageId, so that we dont scroll to again in useEffect for targeted message change - messageIdLastScrolledToRef.current = messageId; - setTargetedMessage(messageId); - // now scroll to it with animated=true - flatListRef.current.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, // try to place message in the center of the screen - }); - return; - } - } catch (e) { - console.warn('Error while scrolling to message', e); - } + // jumpToMessage loads-around the target + emits messageFocusSignal → the effect scrolls and the + // message highlights (mirrors stream-chat-react — no bespoke scroll/highlight bookkeeping). + await loadChannelAroundMessage({ messageId }); }); /** - * Check if a messageId needs to be scrolled to after list loads, and scroll to it - * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender + * Scrolls to the focused message (messageFocusSignal) once it's rendered. Keyed on the focus + * token so it re-fires on every jump (even to the same id); also re-attempts when the list + * updates while a focus is pending, and marks each token handled so unrelated list changes during + * the highlight window don't re-scroll. */ useEffect(() => { - if (!targetedMessage) { + if (!focusedMessageId || focusToken === lastFocusScrollTokenRef.current) { return; } - scrollToDebounceTimeoutRef.current = setTimeout(async () => { + scrollToDebounceTimeoutRef.current = setTimeout(() => { const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === targetedMessage, + (message) => message?.id === focusedMessageId, ); - - // the message we want to scroll to has not been loaded in the state yet - if (indexOfParentInMessageList === -1) { - await loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); - } else { - if (!flatListRef.current) { - return; - } - // By a fresh scroll we should clear the retries for the previous failed scroll - clearTimeout(scrollToDebounceTimeoutRef.current); - clearTimeout(failScrollTimeoutId.current); - // reset the retry count - scrollToIndexFailedRetryCountRef.current = 0; - // now scroll to it - flatListRef.current.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, // try to place message in the center of the screen - }); - setTargetedMessage(undefined); + // Not in the rendered window yet (jumpToMessage already loaded-around it, so a later + // processedMessageList change re-runs this) — bail rather than re-jump (no jump↔effect loop). + if (indexOfParentInMessageList === -1 || !flatListRef.current) { + return; } + clearTimeout(scrollToDebounceTimeoutRef.current); + clearTimeout(failScrollTimeoutId.current); + scrollToIndexFailedRetryCountRef.current = 0; + lastFocusScrollTokenRef.current = focusToken; + flatListRef.current.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, // try to place message in the center of the screen + }); }, WAIT_FOR_SCROLL_TIMEOUT); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetedMessage]); + }, [focusToken, focusedMessageId, processedMessageList]); const setNativeScrollability = useStableCallback((value: boolean) => { if (flatListRef.current) { @@ -1075,11 +1050,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { index: info.index, viewPosition: 0.5, // try to place message in the center of the screen }); - if (messageIdLastScrolledToRef.current) { - // in case the target message was cleared out - // the state being set again will trigger the highlight again - setTargetedMessage(messageIdLastScrolledToRef.current); - } + // The highlight is driven by the live messageFocusSignal (persists for its TTL), so there's + // no targeted-message state to re-set across scroll-fail retries. scrollToIndexFailedRetryCountRef.current = 0; } catch (e) { if ( @@ -1396,8 +1368,6 @@ export const MessageList = (props: MessageListProps) => { markRead, reloadChannel, scrollToFirstUnreadThreshold, - setTargetedMessage, - targetedMessage, threadList, } = useChannelContext(); const { client } = useChatContext(); @@ -1443,9 +1413,7 @@ export const MessageList = (props: MessageListProps) => { readEvents, reloadChannel, scrollToFirstUnreadThreshold, - setTargetedMessage, shouldShowUnreadUnderlay, - targetedMessage, thread, threadInstance, threadList, diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index 1b37407689..31409b7d1c 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -34,8 +34,7 @@ const unreadCountSelector = (snapshot: UnreadSnapshotState) => ({ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProps) => { const { onCloseHandler, onPressHandler, unreadCount } = props; const { t } = useTranslationContext(); - const { channel, loadChannelAtFirstUnreadMessage, markRead, setTargetedMessage } = - useChannelContext(); + const { channel, loadChannelAtFirstUnreadMessage, markRead } = useChannelContext(); const { unread_messages } = useStateStore( channel.messagePaginator.unreadStateSnapshot, unreadCountSelector, @@ -47,9 +46,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp if (onPressHandler) { await onPressHandler(); } else { - await loadChannelAtFirstUnreadMessage({ - setTargetedMessage, - }); + await loadChannelAtFirstUnreadMessage(); } }; diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 6c949c71d4..9c03aec4fe 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -288,11 +288,17 @@ describe('MessageList', () => { const channel = chatClient.channel('messaging', mockedChannel.channel.id); await channel.watch(); + channel.messagePaginator.emitMessageFocusSignal({ + messageId: targetedMessageId, + reason: 'jump-to-message', + ttlMs: 3000, + }); + render( - + , @@ -444,11 +450,21 @@ describe('MessageList', () => { .spyOn(FlatList.prototype, 'scrollToIndex') .mockImplementation(() => {}); + // Targeting is driven by the paginator's messageFocusSignal now (not a prop): emitting it makes + // the list scroll to the focused message. NOTE: like the other full-list-render tests here, this + // is runtime-stale under the portal (it seeds channel.state.messages, not + // channel.messagePaginator.state.items) — the seed needs updating when the portal is removed. + channel.messagePaginator.emitMessageFocusSignal({ + messageId: targetedMessage, + reason: 'jump-to-message', + ttlMs: 3000, + }); + render( - + , @@ -462,54 +478,6 @@ describe('MessageList', () => { }); }); }); - - it("should load more messages around the message id if the targeted message isn't present in the list", async () => { - const user = generateUser(); - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user })], - }); - - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ id: `${i}`, text: `message-${i}` }), - ); - - const chatClient = await getTestClientWithUser({ id: user.id }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.channel.id); - await channel.watch(); - - const targetedMessage = '21'; - const setTargetedMessage = jest.fn(); - - channel.state = { - ...channelInitialState, - latestMessages: [], - messages, - } as unknown as typeof channel.state; - - const loadChannelAroundMessage = jest.fn(() => Promise.resolve()); - - render( - - - - - - - , - ); - - await waitFor(() => { - expect(loadChannelAroundMessage).toHaveBeenCalledWith({ - messageId: targetedMessage, - setTargetedMessage, - }); - }); - }); }); describe('MessageList pagination', () => { diff --git a/package/src/components/index.ts b/package/src/components/index.ts index b8dd9479b4..6e3fd978e9 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -35,7 +35,6 @@ export * from './Channel/hooks/useCreateChannelContext'; export * from './Channel/hooks/useCreateInputMessageInputContext'; export * from './Channel/hooks/useCreateMessagesContext'; export * from './Channel/hooks/useCreateThreadContext'; -export * from './Channel/hooks/useTargetedMessage'; /** Channel List exports*/ export * from './ChannelList/ChannelList'; diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index 7999a443bd..335ab0fcaa 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -45,37 +45,28 @@ export type ChannelContextValue = { hideDateSeparators: boolean; hideStickyDateHeader: boolean; /** - * Loads channel around a specific message + * Loads channel around a specific message. Emits `messageFocusSignal` on the paginator, which + * drives the highlight + scroll-to-target. * @param limit - The number of messages to load around the message * @param messageId - The message around which to load messages - * @param setTargetedMessage - Callback to set the targeted message */ loadChannelAroundMessage: ({ limit, messageId, - setTargetedMessage, }: { limit?: number; messageId?: string; - setTargetedMessage?: (messageId: string) => void; }) => Promise; /** - * Loads channel at first unread message. + * Loads channel at first unread message. Emits `messageFocusSignal` on the paginator. * @param limit - The number of messages to load around the first unread message */ - loadChannelAtFirstUnreadMessage: ({ - limit, - setTargetedMessage, - }: { - limit?: number; - setTargetedMessage?: (messageId: string) => void; - }) => Promise; + loadChannelAtFirstUnreadMessage: (options?: { limit?: number }) => Promise; markRead: (options?: MarkReadFunctionOptions) => void; reloadChannel: () => Promise; scrollToFirstUnreadThreshold: number; - setTargetedMessage: (messageId?: string) => void; /** * Returns true when Channel is about to load an initial targeted message. * @@ -106,11 +97,6 @@ export type ChannelContextValue = { * currently near them within the viewport. */ maximumMessageLimit?: number; - /** - * Id of message, around which Channel/MessageList gets loaded when opened. - * You will see a highlighted background for targetted message, when opened. - */ - targetedMessage?: string; threadList?: boolean; }; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index b4846b074b..9c557a083b 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -339,8 +339,6 @@ export type MessagesContextValue = Pick Date: Fri, 10 Jul 2026 22:08:28 +0200 Subject: [PATCH 18/36] feat: migrate marking as read --- package/src/components/Channel/Channel.tsx | 41 ++--------- .../Channel/__tests__/Channel.test.tsx | 2 - .../Channel/hooks/useCreateChannelContext.ts | 2 - .../MessageList/MessageFlashList.tsx | 11 ++- .../components/MessageList/MessageList.tsx | 11 ++- .../UnreadMessagesNotification.tsx | 12 +++- .../MessageList/hooks/useMarkRead.ts | 72 +++++++++++++++++++ .../channelContext/ChannelContext.tsx | 2 - 8 files changed, 101 insertions(+), 52 deletions(-) create mode 100644 package/src/components/MessageList/hooks/useMarkRead.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index bcebcd79f1..c8d0b02dc3 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -2,7 +2,6 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useS import { StyleSheet, Text, View } from 'react-native'; import debounce from 'lodash/debounce'; -import throttle from 'lodash/throttle'; import { Channel as ChannelClass, @@ -96,6 +95,7 @@ import { MessageStatusTypes, ReactionData } from '../../utils/utils'; import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardCompatibleView'; +import { useMarkRead } from '../MessageList/hooks/useMarkRead'; import { Emoji } from '../MessageMenu/EmojiPickerList'; import { emojis } from '../MessageMenu/emojis'; import { toUnicodeScalarString } from '../MessageMenu/utils/toUnicodeScalarString'; @@ -149,12 +149,7 @@ export const reactionData: ReactionData[] = [ */ const scrollToFirstUnreadThreshold = 0; -const defaultThrottleInterval = 500; const defaultDebounceInterval = 500; -const throttleOptions = { - leading: true, - trailing: true, -}; const debounceOptions = { leading: true, @@ -695,36 +690,9 @@ const ChannelWithContext = (props: PropsWithChildren) = /** * CHANNEL METHODS */ - const markReadInternal: ChannelContextValue['markRead'] = throttle( - async () => { - if (!channel || channel?.disconnected) { - return; - } - - // Read events disabled (e.g. livestreams): if the client opted into a local unread count, - // reset it locally (dispatches message.read_locally) — no backend round trip. The paginator's - // unread snapshot updates from that. - if (!clientChannelConfig?.read_events) { - if (client.options.isLocalUnreadCountEnabled) { - channel.markReadLocally(); - } - return; - } - - // channel.markRead() delegates to client.messageDeliveryReporter.markRead(this), which honors - // any custom markReadRequest handler registered in channel.configState (see - // useChannelRequestHandlers). Unread state updates in the LLC snapshot, mirrored into the store. - try { - await channel.markRead(); - } catch (err) { - console.log('Error marking channel as read:', err); - } - }, - defaultThrottleInterval, - throttleOptions, - ); - - const markRead = useStableCallback(markReadInternal); + // markRead is no longer placed on the ChannelContext; the message lists own their own throttled + // instance via useMarkRead(channel). Channel still needs it internally (mark-read-on-mount + resync). + const markRead = useMarkRead(channel); const reloadThread = useStableCallback(async () => { if (!channel || !thread?.id || !threadInstance) { @@ -1247,7 +1215,6 @@ const ChannelWithContext = (props: PropsWithChildren) = loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading: channelMessagesState.loading, - markRead, maximumMessageLimit, maxTimeBetweenGroupedMessages, reloadChannel, diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 7fa23a9601..9e4d525cf8 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -280,7 +280,6 @@ describe('Channel', () => { const mockContext = { channel, client: chatClient, - markRead: () => {}, }; render( @@ -299,7 +298,6 @@ describe('Channel', () => { const ctx = context as unknown as typeof mockContext; expect(ctx.channel).toBeInstanceOf(Object); expect(ctx.client).toBeInstanceOf(StreamChat); - expect(ctx.markRead).toBeInstanceOf(Function); }); }); }); diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index f2bb3ca818..5a05e81755 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -15,7 +15,6 @@ export const useCreateChannelContext = ({ loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading, - markRead, maxTimeBetweenGroupedMessages, maximumMessageLimit, reloadChannel, @@ -40,7 +39,6 @@ export const useCreateChannelContext = ({ loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading, - markRead, maximumMessageLimit, maxTimeBetweenGroupedMessages, reloadChannel, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index e72f163c16..212be0d718 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -13,6 +13,7 @@ import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage } from 'stream-chat'; +import { useMarkRead } from './hooks/useMarkRead'; import { useMessageList } from './hooks/useMessageList'; import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction'; @@ -59,6 +60,7 @@ import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { transitions } from '../../utils/animations/transitions'; import { getChannelUnreadState } from '../../utils/getChannelUnreadState'; +import { MarkReadFunctionOptions } from '../Channel/Channel'; import { useMessageListPagination } from '../Channel/hooks/useMessageListPagination'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; import { excludeCanceledUploadNotifications } from '../Notifications/notificationFilters'; @@ -115,7 +117,6 @@ type MessageFlashListPropsWithContext = Pick< | 'highlightedMessageId' | 'loadChannelAroundMessage' | 'loading' - | 'markRead' | 'reloadChannel' | 'scrollToFirstUnreadThreshold' | 'hasPendingInitialTargetLoad' @@ -129,6 +130,7 @@ type MessageFlashListPropsWithContext = Pick< > & { loadMore: () => Promise; loadMoreRecent: () => Promise; + markRead: (options?: MarkReadFunctionOptions) => void; loadingMore?: boolean; loadingMoreRecent?: boolean; } & Pick< @@ -1165,7 +1167,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => {isUnreadNotificationOpen && !threadList ? ( - + ) : null} { isChannelActive, loadChannelAroundMessage, loading, - markRead, maximumMessageLimit, reloadChannel, scrollToFirstUnreadThreshold, hasPendingInitialTargetLoad, threadList, } = useChannelContext(); + const markRead = useMarkRead(channel); const { client } = useChatContext(); const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index cb3533d949..bf7eb1d295 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -18,6 +18,7 @@ import debounce from 'lodash/debounce'; import type { Channel, Event, LocalMessage } from 'stream-chat'; +import { useMarkRead } from './hooks/useMarkRead'; import { useMessageList } from './hooks/useMessageList'; import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction'; @@ -72,6 +73,7 @@ import { primitives } from '../../theme'; import { transitions } from '../../utils/animations/transitions'; import { getChannelUnreadState } from '../../utils/getChannelUnreadState'; import { useIncomingMessageAnnouncements } from '../Accessibility/hooks/useIncomingMessageAnnouncements'; +import { MarkReadFunctionOptions } from '../Channel/Channel'; import { useMessageListPagination } from '../Channel/hooks/useMessageListPagination'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; import { excludeCanceledUploadNotifications } from '../Notifications/notificationFilters'; @@ -200,7 +202,6 @@ type MessageListPropsWithContext = Pick< | 'hideStickyDateHeader' | 'loadChannelAroundMessage' | 'loading' - | 'markRead' | 'reloadChannel' | 'scrollToFirstUnreadThreshold' | 'threadList' @@ -209,6 +210,7 @@ type MessageListPropsWithContext = Pick< Pick & { loadMore: () => Promise; loadMoreRecent: () => Promise; + markRead: (options?: MarkReadFunctionOptions) => void; hasMore?: boolean; loadingMore?: boolean; loadingMoreRecent?: boolean; @@ -1324,7 +1326,10 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { {isUnreadNotificationOpen && !threadList ? ( - + ) : null} { loadChannelAroundMessage, loading, maximumMessageLimit, - markRead, reloadChannel, scrollToFirstUnreadThreshold, threadList, } = useChannelContext(); + const markRead = useMarkRead(channel); const { client } = useChatContext(); const { readEvents } = useOwnCapabilitiesContext(); const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index 31409b7d1c..7ead782052 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -10,9 +10,15 @@ import { useStateStore } from '../../hooks/useStateStore'; import { ArrowUp } from '../../icons/arrow-up'; import { NewClose } from '../../icons/xmark'; import { primitives } from '../../theme'; +import { MarkReadFunctionOptions } from '../Channel/Channel'; import { Button } from '../ui'; export type UnreadMessagesNotificationProps = { + /** + * Marks the channel as read. Passed down from the message list so the notification shares the + * list's throttled `markRead` instance. + */ + markRead?: (options?: MarkReadFunctionOptions) => void; /** * Callback to handle the close event */ @@ -32,9 +38,9 @@ const unreadCountSelector = (snapshot: UnreadSnapshotState) => ({ }); export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProps) => { - const { onCloseHandler, onPressHandler, unreadCount } = props; + const { markRead, onCloseHandler, onPressHandler, unreadCount } = props; const { t } = useTranslationContext(); - const { channel, loadChannelAtFirstUnreadMessage, markRead } = useChannelContext(); + const { channel, loadChannelAtFirstUnreadMessage } = useChannelContext(); const { unread_messages } = useStateStore( channel.messagePaginator.unreadStateSnapshot, unreadCountSelector, @@ -54,7 +60,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp if (onCloseHandler) { await onCloseHandler(); } else { - await markRead(); + await markRead?.(); } }; diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts new file mode 100644 index 0000000000..247615d6b7 --- /dev/null +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -0,0 +1,72 @@ +import throttle from 'lodash/throttle'; + +import type { Channel } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useStableCallback } from '../../../hooks'; +import { MarkReadFunctionOptions } from '../../Channel/Channel'; + +const defaultThrottleInterval = 500; +const throttleOptions = { + leading: true, + trailing: true, +}; + +/** + * Returns a throttled `markRead` callback for the active channel. + * + * The behavior mirrors the previous `Channel`-level implementation exactly: it is a no-op when the + * channel is missing or disconnected, resets the local unread count when read events are disabled + * (and the client opted into a local unread count), and otherwise delegates to `channel.markRead()`. + * The returned function is stabilized so it can be used as a dependency without triggering rerenders, + * and accepts (but ignores) an options argument for backwards compatibility with call sites. + */ +export const useMarkRead = (channel: Channel) => { + const { client } = useChatContext(); + + // In case the channel is disconnected which may happen when channel is deleted, + // underlying js client throws an error. Following function ensures that we don't + // result in an error in such a case. + const getChannelConfigSafely = () => { + try { + return channel?.getConfig(); + } catch (_) { + return null; + } + }; + + const clientChannelConfig = getChannelConfigSafely(); + + const markReadInternal = throttle( + async () => { + if (!channel || channel?.disconnected) { + return; + } + + // Read events disabled (e.g. livestreams): if the client opted into a local unread count, + // reset it locally (dispatches message.read_locally) — no backend round trip. The paginator's + // unread snapshot updates from that. + if (!clientChannelConfig?.read_events) { + if (client.options.isLocalUnreadCountEnabled) { + channel.markReadLocally(); + } + return; + } + + // channel.markRead() delegates to client.messageDeliveryReporter.markRead(this), which honors + // any custom markReadRequest handler registered in channel.configState (see + // useChannelRequestHandlers). Unread state updates in the LLC snapshot, mirrored into the store. + try { + await channel.markRead(); + } catch (err) { + console.log('Error marking channel as read:', err); + } + }, + defaultThrottleInterval, + throttleOptions, + ); + + const markRead: (options?: MarkReadFunctionOptions) => void = useStableCallback(markReadInternal); + + return markRead; +}; diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index 335ab0fcaa..f40c1f1961 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -2,7 +2,6 @@ import React, { PropsWithChildren, useContext } from 'react'; import type { Channel } from 'stream-chat'; -import { MarkReadFunctionOptions } from '../../components/Channel/Channel'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -64,7 +63,6 @@ export type ChannelContextValue = { */ loadChannelAtFirstUnreadMessage: (options?: { limit?: number }) => Promise; - markRead: (options?: MarkReadFunctionOptions) => void; reloadChannel: () => Promise; scrollToFirstUnreadThreshold: number; /** From e880ccefccd4fec839fe0ce2abfbcf1b700d9146 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Jul 2026 23:45:31 +0200 Subject: [PATCH 19/36] feat: move thread to reactive state fully --- .../offline-support/optimistic-update.tsx | 68 ++-- package/src/components/Channel/Channel.tsx | 318 +++--------------- .../Channel/hooks/useCreateMessagesContext.ts | 12 - .../Channel/hooks/useCreateThreadContext.ts | 9 +- package/src/components/Message/Message.tsx | 16 +- .../Message/MessageItemView/MessageBounce.tsx | 9 +- .../Message/hooks/useMessageActionHandlers.ts | 6 +- .../Message/hooks/useMessageActions.tsx | 38 ++- .../Message/hooks/useMessageOperations.ts | 189 +++++++++++ .../Message/utils/messageActions.ts | 4 +- .../ChannelsStateContext.tsx | 6 +- .../channelsStateContext/useChannelState.ts | 72 +--- .../messagesContext/MessagesContext.tsx | 32 +- 13 files changed, 345 insertions(+), 434 deletions(-) create mode 100644 package/src/components/Message/hooks/useMessageOperations.ts diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index aa12e875be..a64e5357c5 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -16,7 +16,11 @@ import { v4 as uuidv4 } from 'uuid'; import { Channel as ChannelRaw } from '../../components/Channel/Channel'; import { Chat } from '../../components/Chat/Chat'; -import { MessageInputContext, MessagesContext } from '../../contexts'; +import { + MessageOperations, + useMessageOperations, +} from '../../components/Message/hooks/useMessageOperations'; +import { MessageInputContext } from '../../contexts'; import { deleteMessageApi } from '../../mock-builders/api/deleteMessage'; import { deleteReactionApi } from '../../mock-builders/api/deleteReaction'; import { erroredDeleteApi, erroredPostApi } from '../../mock-builders/api/error'; @@ -210,6 +214,33 @@ export const OptimisticUpdates = () => { return <>{children}; }; + // Same as CallbackEffectWithContext, but sources the message operations (delete/react/etc.) + // from the useMessageOperations hook instead of a context — they no longer live on MessagesContext. + const CallbackEffectWithMessageOperations = ({ + callback, + children, + }: { + callback: (ops: MessageOperations) => Promise | void; + children: React.ReactNode; + }) => { + const ops = useMessageOperations(); + const [ready, setReady] = useState(false); + useEffect(() => { + const call = async () => { + await callback(ops); + setReady(true); + }; + + call(); + }, [callback, ops]); + + if (!ready) { + return null; + } + + return <>{children}; + }; + describe('delete message', () => { it('pending task should exist if deleteMessage request fails', async () => { const message = generateMessage(); @@ -217,7 +248,7 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [erroredPostApi()]); try { @@ -226,10 +257,9 @@ export const OptimisticUpdates = () => { // do nothing } }} - context={MessagesContext} > - + , ); @@ -248,15 +278,14 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [deleteMessageApi(message)]); await deleteMessage(message); }} - context={MessagesContext} > - + , ); @@ -277,7 +306,7 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [erroredPostApi()]); try { @@ -286,10 +315,9 @@ export const OptimisticUpdates = () => { // do nothing } }} - context={MessagesContext} > - + , ); @@ -310,15 +338,14 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [sendReactionApi(targetMessage, reaction)]); await sendReaction(reaction.type, targetMessage.id); }} - context={MessagesContext} > - + , ); @@ -407,7 +434,7 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [erroredPostApi()]); try { @@ -416,10 +443,9 @@ export const OptimisticUpdates = () => { // do nothing } }} - context={MessagesContext} > - + , ); @@ -440,15 +466,14 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [deleteReactionApi(targetMessage, reaction)]); await deleteReaction(reaction.type, targetMessage.id); }} - context={MessagesContext} > - + , ); @@ -696,7 +721,7 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [erroredDeleteApi()]); try { @@ -712,10 +737,9 @@ export const OptimisticUpdates = () => { // do nothing } }} - context={MessagesContext} > - + , ); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index c8d0b02dc3..9ad2060d67 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1,17 +1,11 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import debounce from 'lodash/debounce'; - import { - Channel as ChannelClass, - ChannelState, Channel as ChannelType, - DeleteMessageOptions, EventHandler, LocalMessage, MessageResponse, - Reaction, SendMessageAPIResponse, SendMessageOptions, StreamChat, @@ -47,7 +41,6 @@ import { } from '../../contexts/audioPlayerContext/AudioPlayerContext'; import { ChannelContextValue, ChannelProvider } from '../../contexts/channelContext/ChannelContext'; -import type { UseChannelStateValue } from '../../contexts/channelsStateContext/useChannelState'; import { useChannelState } from '../../contexts/channelsStateContext/useChannelState'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; @@ -89,7 +82,6 @@ import { import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; import type { ChannelUnreadState } from '../../types/types'; -import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; import { MessageStatusTypes, ReactionData } from '../../utils/utils'; import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer'; @@ -149,13 +141,6 @@ export const reactionData: ReactionData[] = [ */ const scrollToFirstUnreadThreshold = 0; -const defaultDebounceInterval = 500; - -const debounceOptions = { - leading: true, - trailing: true, -}; - export type ChannelPropsWithContext = Pick & Partial< Pick< @@ -206,7 +191,6 @@ export type ChannelPropsWithContext = Pick & > > & Pick & - Pick & Partial< Pick< MessagesContextValue, @@ -464,14 +448,12 @@ const ChannelWithContext = (props: PropsWithChildren) = reactionListType = 'clustered', selectReaction, setInputRef, - setThreadMessages, shouldShowUnreadUnderlay = true, shouldSyncChannel, supportedReactions = reactionData, t, thread: threadFromProps, threadList, - threadMessages, topInset = 0, maximumMessageLimit, initializeOnMount = true, @@ -491,7 +473,7 @@ const ChannelWithContext = (props: PropsWithChildren) = const [threadInstance, setThreadInstance] = useState( threadInstanceFromProps ?? null, ); - const [threadHasMore, setThreadHasMore] = useState(true); + const [threadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); const [messageInputHeightStore] = useState(() => new MessageInputHeightStore()); const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); @@ -567,17 +549,6 @@ const ChannelWithContext = (props: PropsWithChildren) = // Typing state is sourced reactively from channel.state.typingStore; nothing to copy here. if (event.type === 'typing.start' || event.type === 'typing.stop') { return; - } else { - if (thread?.id) { - const updatedThreadMessages = - (thread.id && channel && channel.state.threads[thread.id]) || threadMessages; - setThreadMessages(updatedThreadMessages); - - if (channel && event.message?.id === thread.id && !threadInstance) { - const updatedThread = channel.state.formatMessage(event.message); - setThread(updatedThread); - } - } } // notification.mark_unread + channel.truncated update channel.messagePaginator.unreadStateSnapshot @@ -643,7 +614,6 @@ const ChannelWithContext = (props: PropsWithChildren) = initChannel(); return () => { - loadMoreThreadFinished.cancel(); listener?.unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -665,8 +635,14 @@ const ChannelWithContext = (props: PropsWithChildren) = useEffect(() => { if (threadProps && shouldSyncChannel) { setThread(threadProps); - if (channel && threadProps?.id) { - setThreadMessages(channel.state.threads?.[threadProps.id] || []); + // A thread supplied via props without its own Thread instance still needs one so the reply + // list is backed by thread.messagePaginator (mirrors openThread). + if (channel && threadProps?.id && !threadInstanceFromProps) { + const newThreadInstance = new Thread({ channel, client, parentMessage: threadProps }); + setThreadInstance(newThreadInstance); + newThreadInstance.messagePaginator + .reload() + .catch((err) => console.warn('Thread reply load failed with error:', err)); } } else { setThread(null); @@ -765,10 +741,14 @@ const ChannelWithContext = (props: PropsWithChildren) = } else { await reloadThread(); - const failedThreadMessages = thread ? getRecoverableFailedMessages(threadMessages) : []; + const currentThreadMessages = + threadInstance?.messagePaginator?.state.getLatestValue().items ?? []; + const failedThreadMessages = getRecoverableFailedMessages(currentThreadMessages); if (failedThreadMessages.length) { channel.state.addMessagesSorted(failedThreadMessages); - setThreadMessages([...channel.state.threads[thread.id]]); + failedThreadMessages.forEach((m) => + threadInstance?.messagePaginator?.ingestItem(channel.state.formatMessage(m)), + ); } } } catch (err) { @@ -849,16 +829,13 @@ const ChannelWithContext = (props: PropsWithChildren) = if (thread) { setThreadLoadingMore(true); try { - await channel.state.loadMessageIntoState(messageIdToLoadAround, thread.id); - setThreadLoadingMore(false); - setThreadMessages(channel.state.threads[thread.id]); - // The reply list still reads channel.state.threads; emit the thread paginator's focus - // signal so the thread-aware highlight + scroll fire (mirrors the channel jump path). - threadInstance?.messagePaginator?.emitMessageFocusSignal({ - messageId: messageIdToLoadAround, - reason: 'jump-to-message', - ttlMs: DEFAULT_HIGHLIGHT_DURATION, + // jumpToMessage loads the message range into thread.messagePaginator (which backs the + // reply list) and emits the focus signal driving the thread-aware highlight + scroll. + await threadInstance?.messagePaginator?.jumpToMessage(messageIdToLoadAround, { + focusReason: 'jump-to-message', + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, }); + setThreadLoadingMore(false); } catch (err) { if (err instanceof Error) { setError(err); @@ -880,29 +857,6 @@ const ChannelWithContext = (props: PropsWithChildren) = /** * MESSAGE METHODS */ - const updateMessage: MessagesContextValue['updateMessage'] = useStableCallback( - (updatedMessage, extraState = {}) => { - if (!channel) { - return; - } - - // Keep legacy channel.state in sync (many readers remain) and ingest into the reactive - // paginator that now backs the message list. - channel.state.addMessageSorted(updatedMessage, true); - const formatted = channel.state.formatMessage(updatedMessage); - if (updatedMessage.parent_id) { - threadInstance?.messagePaginator?.ingestItem(formatted); - } else { - channel.messagePaginator.ingestItem(formatted); - } - - if (thread && updatedMessage.parent_id) { - extraState.threadMessages = channel.state.threads[updatedMessage.parent_id] || []; - setThreadMessages(extraState.threadMessages); - } - }, - ); - const sendMessage: InputMessageInputContextValue['sendMessage'] = useStableCallback( async ({ localMessage, message, options }) => { if (preSendMessageRequest) { @@ -917,36 +871,16 @@ const ChannelWithContext = (props: PropsWithChildren) = } : message; - // Thread replies: the RN reply list reads `threadMessages` (channel.state.threads), which the - // LLC thread paginator does not feed — so we optimistically reflect the reply there ourselves - // (and mark it failed on error). Channel messages need no such bridge: that list reads - // channel.messagePaginator, which the LLC ingests into directly. - const isThreadReply = !!localMessage.parent_id && !!threadInstance; - if (isThreadReply) { - updateMessage(localMessage); - } - - // The stream-chat message-operations engine owns the optimistic lifecycle (pending -> - // received/failed), offline-DB persistence and paginator ingest. It throws on failure, which - // the MessageInput send flow catches to surface a notification. - try { - await (threadInstance ?? channel).sendMessageWithLocalUpdate({ - localMessage, - message: messageToSend, - options, - }); - } catch (error) { - if (isThreadReply) { - updateMessage({ ...localMessage, status: MessageStatusTypes.FAILED }); - } - throw error; - } - }, - ); - - const retrySendMessage: MessagesContextValue['retrySendMessage'] = useStableCallback( - async (localMessage) => { - await (threadInstance ?? channel).retrySendMessageWithLocalUpdate({ localMessage }); + // The stream-chat message-operations engine owns the full optimistic lifecycle (pending -> + // received/failed), offline-DB persistence and paginator ingest — for both channel messages + // (channel.messagePaginator) and thread replies (thread.messagePaginator, which the thread + // instance ingests into directly). It throws on failure, which the MessageInput send flow + // catches to surface a notification. + await (threadInstance ?? channel).sendMessageWithLocalUpdate({ + localMessage, + message: messageToSend, + options, + }); }, ); @@ -963,126 +897,6 @@ const ChannelWithContext = (props: PropsWithChildren) = }, ); - /** - * Removes the message from local state - */ - const removeMessage: MessagesContextValue['removeMessage'] = useStableCallback( - async (message) => { - if (channel) { - channel.state.removeMessage(message); - const paginator = message.parent_id - ? threadInstance?.messagePaginator - : channel.messagePaginator; - paginator?.removeItem({ id: message.id }); - - if (thread) { - setThreadMessages(channel.state.threads[thread.id] || []); - } - } - - if (client.offlineDb) { - await client.offlineDb.handleRemoveMessage({ messageId: message.id }); - } - }, - ); - - const sendReaction = useStableCallback(async (type: string, messageId: string) => { - if (!channel?.id || !client.user) { - throw new Error('Channel has not been initialized'); - } - - const payload: Parameters = [ - messageId, - { - type, - } as Reaction, - { enforce_unique: enforceUniqueReaction }, - ]; - - if (enableOfflineSupport) { - await addReactionToLocalState({ - channel, - enforceUniqueReaction, - messageId, - reactionType: type, - user: client.user, - }); - - const reactedMessage = channel.state.findMessage(messageId); - if (reactedMessage) { - (reactedMessage.parent_id - ? threadInstance?.messagePaginator - : channel.messagePaginator - )?.ingestItem(channel.state.formatMessage(reactedMessage)); - } - } - - const sendReactionResponse = await channel.sendReaction(...payload); - - if (sendReactionResponse?.message) { - threadInstance?.upsertReplyLocally?.({ message: sendReactionResponse.message }); - } - }); - - const deleteMessage: MessagesContextValue['deleteMessage'] = useStableCallback( - async (message, optionsOrHardDelete = false) => { - if (!channel?.id) { - throw new Error('Channel has not been initialized yet'); - } - - // A failed (never-sent) message exists only locally — remove it without a server delete. - if (message.status === MessageStatusTypes.FAILED) { - await removeMessage(message); - return; - } - - let options: DeleteMessageOptions = {}; - if (typeof optionsOrHardDelete === 'boolean') { - options = optionsOrHardDelete ? { hardDelete: true } : {}; - } else if (optionsOrHardDelete?.deleteForMe) { - options = { deleteForMe: true }; - } else if (optionsOrHardDelete?.hardDelete) { - options = { hardDelete: true }; - } - - // The LLC performs the delete request (honoring any configState delete handler) and ingests - // the deleted message into the paginator. Thread deletes route through the thread instance. - await (threadInstance ?? channel).deleteMessageWithLocalUpdate({ - localMessage: message, - options, - }); - }, - ); - - const deleteReaction: MessagesContextValue['deleteReaction'] = useStableCallback( - async (type: string, messageId: string) => { - if (!channel?.id || !client.user) { - throw new Error('Channel has not been initialized'); - } - - const payload: Parameters = [messageId, type]; - - if (enableOfflineSupport) { - channel.state.removeReaction({ - created_at: '', - message_id: messageId, - type, - updated_at: '', - }); - - const reactedMessage = channel.state.findMessage(messageId); - if (reactedMessage) { - (reactedMessage.parent_id - ? threadInstance?.messagePaginator - : channel.messagePaginator - )?.ingestItem(channel.state.formatMessage(reactedMessage)); - } - } - - await channel.deleteReaction(...payload); - }, - ); - /** * THREAD METHODS */ @@ -1100,8 +914,12 @@ const ChannelWithContext = (props: PropsWithChildren) = if (channel.initialized) { // Mark the thread read on open. A freshly-constructed minimal thread has ownUnreadCount 0, // so threadInstance.markRead() would no-op; channel.markRead({thread_id}) marks it reliably - // (and still delegates to the messageDeliveryReporter). - channel.markRead({ thread_id: message.id }); + // (and still delegates to the messageDeliveryReporter). Opening a reply-less parent has no + // server-side thread yet, so this rejects with "thread not found" — a benign no-op we swallow + // (otherwise it surfaces as an unhandled promise rejection / dev redbox). + channel + .markRead({ thread_id: message.id }) + .catch((err) => console.warn('Marking thread as read on open failed with error:', err)); } }, [channel, client], @@ -1110,51 +928,18 @@ const ChannelWithContext = (props: PropsWithChildren) = const closeThread: ThreadContextValue['closeThread'] = useCallback(() => { setThread(null); setThreadInstance(null); - setThreadMessages([]); - }, [setThreadMessages]); - - // hard limit to prevent you from scrolling faster than 1 page per 2 seconds - const loadMoreThreadFinished = useRef( - debounce( - (newThreadHasMore: boolean, updatedThreadMessages: ChannelState['threads'][string]) => { - setThreadHasMore(newThreadHasMore); - setThreadLoadingMore(false); - setThreadMessages(updatedThreadMessages); - }, - defaultDebounceInterval, - debounceOptions, - ), - ).current; + }, []); const loadMoreThread: ThreadContextValue['loadMoreThread'] = useStableCallback(async () => { - if (threadLoadingMore || !thread?.id) { + // Older replies are paginated via the thread's messagePaginator, which backs the reply list. + // (useCreateThreadContext also wires loadMoreThread to the paginator when a thread instance + // exists — this keeps the ThreadContext shape stable and the behavior identical; the paginator + // guards against concurrent loads and tracks hasMore/loading reactively.) + if (!threadInstance) { return; } - setThreadLoadingMore(true); - try { - if (channel) { - const parentID = thread.id; - - /** - * In the channel is re-initializing, then threads may get wiped out during the process - * (check `addMessagesSorted` method on channel.state). In those cases, we still want to - * preserve the messages on active thread, so lets simply copy messages from UI state to - * `channel.state`. - */ - channel.state.threads[parentID] = threadMessages; - const oldestMessageID = threadMessages?.[0]?.id; - - const limit = 50; - const queryResponse = await channel.getReplies(parentID, { - id_lt: oldestMessageID, - limit, - }); - - const updatedHasMore = queryResponse.messages.length === limit; - const updatedThreadMessages = channel.state.threads[parentID] || []; - loadMoreThreadFinished(updatedHasMore, updatedThreadMessages); - } + await threadInstance.messagePaginator.toTail(); } catch (err) { console.warn('Message pagination request failed with error', err); if (err instanceof Error) { @@ -1162,7 +947,6 @@ const ChannelWithContext = (props: PropsWithChildren) = } else { setError(true); } - setThreadLoadingMore(false); throw err; } }); @@ -1269,8 +1053,6 @@ const ChannelWithContext = (props: PropsWithChildren) = additionalPressableProps, channelId, customMessageSwipeAction, - deleteMessage, - deleteReaction, disableTypingIndicator, dismissKeyboardOnMessageTouch, enableMessageGroupingByUser, @@ -1310,13 +1092,9 @@ const ChannelWithContext = (props: PropsWithChildren) = onPressMessage, reactionListPosition, reactionListType, - removeMessage, - retrySendMessage, selectReaction, - sendReaction, shouldShowUnreadUnderlay, supportedReactions, - updateMessage, urlPreviewType, }); @@ -1332,7 +1110,6 @@ const ChannelWithContext = (props: PropsWithChildren) = threadHasMore, threadInstance, threadLoadingMore, - threadMessages, }); const audioPlayerContext = useMemo( @@ -1425,10 +1202,7 @@ export const Channel = (props: PropsWithChildren) => { const shouldSyncChannel = threadMessage?.id ? !!props.threadList : true; - const { setThreadMessages, threadMessages } = useChannelState( - props.channel, - props.threadList ? threadMessage?.id : undefined, - ); + useChannelState(props.channel); const channelWithContext = ( ) => { {...{ isMessageAIGenerated, isOnline, - setThreadMessages, thread, - threadMessages, }} /> ); diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 29482852de..aed765164a 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -6,8 +6,6 @@ export const useCreateMessagesContext = ({ additionalPressableProps, channelId, customMessageSwipeAction, - deleteMessage, - deleteReaction, disableTypingIndicator, dismissKeyboardOnMessageTouch, enableMessageGroupingByUser, @@ -46,13 +44,9 @@ export const useCreateMessagesContext = ({ onPressMessage, reactionListPosition, reactionListType, - removeMessage, - retrySendMessage, selectReaction, - sendReaction, shouldShowUnreadUnderlay, supportedReactions, - updateMessage, urlPreviewType, }: MessagesContextValue & { /** @@ -69,8 +63,6 @@ export const useCreateMessagesContext = ({ () => ({ additionalPressableProps, customMessageSwipeAction, - deleteMessage, - deleteReaction, disableTypingIndicator, dismissKeyboardOnMessageTouch, enableMessageGroupingByUser, @@ -109,13 +101,9 @@ export const useCreateMessagesContext = ({ onPressMessage, reactionListPosition, reactionListType, - removeMessage, - retrySendMessage, selectReaction, - sendReaction, shouldShowUnreadUnderlay, supportedReactions, - updateMessage, urlPreviewType, }), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/package/src/components/Channel/hooks/useCreateThreadContext.ts b/package/src/components/Channel/hooks/useCreateThreadContext.ts index 427180df8c..2ac26b46d5 100644 --- a/package/src/components/Channel/hooks/useCreateThreadContext.ts +++ b/package/src/components/Channel/hooks/useCreateThreadContext.ts @@ -31,8 +31,7 @@ export const useCreateThreadContext = ({ threadHasMore, threadInstance, threadLoadingMore, - threadMessages, -}: ThreadContextValue) => { +}: Omit) => { const { hasMore, isLoading, messages } = useStateStore(threadInstance?.messagePaginator?.state, selector) ?? {}; @@ -47,7 +46,6 @@ export const useCreateThreadContext = ({ threadHasMore: hasMore ?? false, threadInstance, threadLoadingMore: !!isLoading, - threadMessages: messages ?? [], } : {}; @@ -62,7 +60,10 @@ export const useCreateThreadContext = ({ thread, threadHasMore, threadLoadingMore, - threadMessages, + // The reply list is sourced solely from the thread's messagePaginator (optimistic thread ops + // write there). Empty when no thread is open (threadInstance null); the list only reads this + // when threadList is true, i.e. a thread is open and threadInstance is set. + threadMessages: messages ?? [], ...contextAdapter, }; }; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 65e626309f..be8002b34c 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -23,6 +23,7 @@ import { useCreateMessageContext } from './hooks/useCreateMessageContext'; import { useMessageActionHandlers } from './hooks/useMessageActionHandlers'; import { useMessageActions } from './hooks/useMessageActions'; import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; +import { MessageOperations, useMessageOperations } from './hooks/useMessageOperations'; import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from './messageOverlayConstants'; @@ -210,9 +211,16 @@ export type MessagePropsWithContext = Pick< 'groupStyles' | 'message' | 'isMessageAIGenerated' | 'readBy' | 'deliveredToCount' > & Pick< - MessagesContextValue, + MessageOperations, | 'sendReaction' | 'deleteMessage' + | 'removeMessage' + | 'deleteReaction' + | 'retrySendMessage' + | 'updateMessage' + > & + Pick< + MessagesContextValue, | 'dismissKeyboardOnMessageTouch' | 'forceAlignMessages' | 'handleBan' @@ -236,12 +244,8 @@ export type MessagePropsWithContext = Pick< | 'onLongPressMessage' | 'onPressInMessage' | 'onPressMessage' - | 'removeMessage' - | 'deleteReaction' - | 'retrySendMessage' | 'selectReaction' | 'supportedReactions' - | 'updateMessage' > & Pick & Pick & { @@ -1154,6 +1158,7 @@ export const Message = (props: MessageProps) => { const chatContext = useChatContext(); const { dismissKeyboard } = useKeyboardContext(); const messagesContext = useMessagesContext(); + const messageOperations = useMessageOperations(); const { openThread } = useThreadContext(); const { t } = useTranslationContext(); const readBy = useMessageReadData({ message }); @@ -1163,6 +1168,7 @@ export const Message = (props: MessageProps) => { return ( & Pick & { @@ -95,7 +92,7 @@ export type MessageBounceProps = Partial & { export const MessageBounce = (props: MessageBounceProps) => { const { message } = useMessageContext(); - const { removeMessage, retrySendMessage } = useMessagesContext(); + const { removeMessage, retrySendMessage } = useMessageOperations(); const { setEditingState } = useMessageComposerAPIContext(); return ( & + Pick & Pick & Pick & Pick & diff --git a/package/src/components/Message/hooks/useMessageActions.tsx b/package/src/components/Message/hooks/useMessageActions.tsx index a1451d4b65..0e5ed2e596 100644 --- a/package/src/components/Message/hooks/useMessageActions.tsx +++ b/package/src/components/Message/hooks/useMessageActions.tsx @@ -4,6 +4,7 @@ import { Alert } from 'react-native'; import { LocalMessage } from 'stream-chat'; import { useMessageActionHandlers } from './useMessageActionHandlers'; +import { MessageOperations } from './useMessageOperations'; import { useUserMuteActive } from './useUserMuteActive'; @@ -39,30 +40,33 @@ import { MessageStatusTypes } from '../../../utils/utils'; import type { MessageActionType } from '../../MessageMenu/MessageActionListItem'; export type MessageActionsHookProps = Pick< - MessagesContextValue, + MessageOperations, | 'deleteMessage' | 'sendReaction' - | 'handleBan' - | 'handleCopy' - | 'handleDelete' - | 'handleDeleteForMe' - | 'handleEdit' - | 'handleFlag' - | 'handleQuotedReply' - | 'handleMarkUnread' - | 'handleMute' - | 'handlePinMessage' - | 'handleRetry' - | 'handleReaction' - | 'handleThreadReply' - | 'handleBlockUser' | 'removeMessage' | 'deleteReaction' | 'retrySendMessage' - | 'selectReaction' - | 'supportedReactions' | 'updateMessage' > & + Pick< + MessagesContextValue, + | 'handleBan' + | 'handleCopy' + | 'handleDelete' + | 'handleDeleteForMe' + | 'handleEdit' + | 'handleFlag' + | 'handleQuotedReply' + | 'handleMarkUnread' + | 'handleMute' + | 'handlePinMessage' + | 'handleRetry' + | 'handleReaction' + | 'handleThreadReply' + | 'handleBlockUser' + | 'selectReaction' + | 'supportedReactions' + > & Pick & Pick & Pick & diff --git a/package/src/components/Message/hooks/useMessageOperations.ts b/package/src/components/Message/hooks/useMessageOperations.ts new file mode 100644 index 0000000000..2464acae32 --- /dev/null +++ b/package/src/components/Message/hooks/useMessageOperations.ts @@ -0,0 +1,189 @@ +import { + Channel as ChannelClass, + DeleteMessageOptions, + LocalMessage, + MessageResponse, + Reaction, +} from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; +import { useStableCallback } from '../../../hooks'; +import { addReactionToLocalState } from '../../../utils/addReactionToLocalState'; +import { MessageStatusTypes } from '../../../utils/utils'; + +export type MessageOperations = { + // FIXME: Remove the signature with optionsOrHardDelete boolean with the next major release + deleteMessage: ( + message: LocalMessage, + optionsOrHardDelete?: boolean | DeleteMessageOptions, + ) => Promise; + deleteReaction: (type: string, messageId: string) => Promise; + removeMessage: (message: { id: string; parent_id?: string }) => Promise; + retrySendMessage: (message: LocalMessage) => Promise; + sendReaction: (type: string, messageId: string) => Promise; + updateMessage: (updatedMessage: MessageResponse | LocalMessage) => void; +}; + +/** + * Message operations (delete/remove/retry/react/update) resolved from the channel and — when a + * thread is open — the thread instance. These are thin wrappers over the stream-chat message + * operations engine (`*WithLocalUpdate`, `messagePaginator.ingestItem/removeItem`), which owns the + * optimistic lifecycle, offline-DB persistence and paginator ingest. Previously these lived on the + * MessagesContext; they now resolve per-message (mirroring stream-chat-react, which has no shared + * action context) so the operations stay co-located with the LLC state they drive. + */ +export const useMessageOperations = (): MessageOperations => { + const { client, enableOfflineSupport } = useChatContext(); + const { channel, enforceUniqueReaction } = useChannelContext(); + const { threadInstance } = useThreadContext(); + + const updateMessage: MessageOperations['updateMessage'] = useStableCallback((updatedMessage) => { + if (!channel) { + return; + } + + // Keep legacy channel.state in sync (many readers remain) and ingest into the reactive + // paginator that now backs the list — the channel's for channel messages, the thread's for + // replies (the reply list is sourced solely from thread.messagePaginator). + channel.state.addMessageSorted(updatedMessage, true); + const formatted = channel.state.formatMessage(updatedMessage); + if (updatedMessage.parent_id) { + threadInstance?.messagePaginator?.ingestItem(formatted); + } else { + channel.messagePaginator.ingestItem(formatted); + } + }); + + /** + * Removes the message from local state + */ + const removeMessage: MessageOperations['removeMessage'] = useStableCallback(async (message) => { + if (channel) { + channel.state.removeMessage(message); + const paginator = message.parent_id + ? threadInstance?.messagePaginator + : channel.messagePaginator; + paginator?.removeItem({ id: message.id }); + } + + if (client.offlineDb) { + await client.offlineDb.handleRemoveMessage({ messageId: message.id }); + } + }); + + const retrySendMessage: MessageOperations['retrySendMessage'] = useStableCallback( + async (localMessage) => { + await (threadInstance ?? channel).retrySendMessageWithLocalUpdate({ localMessage }); + }, + ); + + const sendReaction: MessageOperations['sendReaction'] = useStableCallback( + async (type, messageId) => { + if (!channel?.id || !client.user) { + throw new Error('Channel has not been initialized'); + } + + const payload: Parameters = [ + messageId, + { + type, + } as Reaction, + { enforce_unique: enforceUniqueReaction }, + ]; + + if (enableOfflineSupport) { + await addReactionToLocalState({ + channel, + enforceUniqueReaction, + messageId, + reactionType: type, + user: client.user, + }); + + const reactedMessage = channel.state.findMessage(messageId); + if (reactedMessage) { + (reactedMessage.parent_id + ? threadInstance?.messagePaginator + : channel.messagePaginator + )?.ingestItem(channel.state.formatMessage(reactedMessage)); + } + } + + const sendReactionResponse = await channel.sendReaction(...payload); + + if (sendReactionResponse?.message) { + threadInstance?.upsertReplyLocally?.({ message: sendReactionResponse.message }); + } + }, + ); + + const deleteReaction: MessageOperations['deleteReaction'] = useStableCallback( + async (type, messageId) => { + if (!channel?.id || !client.user) { + throw new Error('Channel has not been initialized'); + } + + const payload: Parameters = [messageId, type]; + + if (enableOfflineSupport) { + channel.state.removeReaction({ + created_at: '', + message_id: messageId, + type, + updated_at: '', + }); + + const reactedMessage = channel.state.findMessage(messageId); + if (reactedMessage) { + (reactedMessage.parent_id + ? threadInstance?.messagePaginator + : channel.messagePaginator + )?.ingestItem(channel.state.formatMessage(reactedMessage)); + } + } + + await channel.deleteReaction(...payload); + }, + ); + + const deleteMessage: MessageOperations['deleteMessage'] = useStableCallback( + async (message, optionsOrHardDelete = false) => { + if (!channel?.id) { + throw new Error('Channel has not been initialized yet'); + } + + // A failed (never-sent) message exists only locally — remove it without a server delete. + if (message.status === MessageStatusTypes.FAILED) { + await removeMessage(message); + return; + } + + let options: DeleteMessageOptions = {}; + if (typeof optionsOrHardDelete === 'boolean') { + options = optionsOrHardDelete ? { hardDelete: true } : {}; + } else if (optionsOrHardDelete?.deleteForMe) { + options = { deleteForMe: true }; + } else if (optionsOrHardDelete?.hardDelete) { + options = { hardDelete: true }; + } + + // The LLC performs the delete request (honoring any configState delete handler) and ingests + // the deleted message into the paginator. Thread deletes route through the thread instance. + await (threadInstance ?? channel).deleteMessageWithLocalUpdate({ + localMessage: message, + options, + }); + }, + ); + + return { + deleteMessage, + deleteReaction, + removeMessage, + retrySendMessage, + sendReaction, + updateMessage, + }; +}; diff --git a/package/src/components/Message/utils/messageActions.ts b/package/src/components/Message/utils/messageActions.ts index 7c98b2c155..6566a4914c 100644 --- a/package/src/components/Message/utils/messageActions.ts +++ b/package/src/components/Message/utils/messageActions.ts @@ -1,10 +1,10 @@ import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; -import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import type { OwnCapabilitiesContextValue } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { isClipboardAvailable } from '../../../native'; import { FileTypes } from '../../../types/types'; import type { MessageActionType } from '../../MessageMenu/MessageActionListItem'; +import type { MessageOperations } from '../hooks/useMessageOperations'; export type MessageActionsParams = { banUser: MessageActionType; @@ -27,7 +27,7 @@ export type MessageActionsParams = { // Optional Actions deleteForMeMessage?: MessageActionType; } & Pick & - Pick; + Pick; export type MessageActionsProp = (param: MessageActionsParams) => MessageActionType[]; diff --git a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx index ac9d493f10..772bb7549d 100644 --- a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx +++ b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx @@ -10,13 +10,15 @@ import React, { import { ActiveChannelsProvider } from '../activeChannelsRefContext/ActiveChannelsRefContext'; -import type { ThreadContextValue } from '../threadContext/ThreadContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; +// Per-channel state is no longer stored here — message/thread state lives in the LLC paginators. +// This context now only tracks which channels are mounted ("active"), which the ChannelList uses +// to skip re-initializing their state on a reconnect query (see useChannelState / usePaginatedChannels). export type ChannelState = { - threadMessages: ThreadContextValue['threadMessages']; + active: boolean; }; type ChannelsState = { diff --git a/package/src/contexts/channelsStateContext/useChannelState.ts b/package/src/contexts/channelsStateContext/useChannelState.ts index 8500a90f18..21f8c953ca 100644 --- a/package/src/contexts/channelsStateContext/useChannelState.ts +++ b/package/src/contexts/channelsStateContext/useChannelState.ts @@ -1,66 +1,22 @@ -import { useCallback, useMemo } from 'react'; +import { useEffect } from 'react'; import type { Channel as ChannelType } from 'stream-chat'; import { useChannelsStateContext } from './ChannelsStateContext'; -import type { ChannelsStateContextValue, ChannelState, Keys } from './ChannelsStateContext'; - -type StateManagerParams = ChannelsStateContextValue & { - cid: string; - key: Key; -}; - -/* - This hook takes care of creating a useState-like interface which can be used later to call - updates to the ChannelsStateContext reducer. It receives the cid and key which it wants to update - and perform the state updates. Also supports a initialState. -*/ -export function useStateManager( - { cid, key, setState, state }: StateManagerParams, - initialValue?: ChannelState[Key], -) { - // eslint-disable-next-line react-hooks/exhaustive-deps - const memoizedInitialValue = useMemo(() => initialValue, []); - const value = state[cid]?.[key] || (memoizedInitialValue as ChannelState[Key]); - - const setValue = useCallback( - (value: ChannelState[Key]) => setState({ cid, key, value }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [cid, key], - ); - - return [value, setValue] as const; -} - -export type UseChannelStateValue = { - setThreadMessages: (value: ChannelState['threadMessages']) => void; - threadMessages: ChannelState['threadMessages']; -}; - -export function useChannelState( - channel: ChannelType | undefined, - threadId?: string, -): UseChannelStateValue { +/** + * Registers the channel as "active" in the ChannelsStateContext while it is mounted, so the + * ChannelList's `queryChannels` call can skip re-initializing (and thereby clearing) the state + * of channels that are currently open — preventing a reconnect race between ChannelList and + * Channel/Thread. Message and thread-reply state itself now lives in the LLC paginators + * (`channel.messagePaginator` / `thread.messagePaginator`), not in this context. + */ +export function useChannelState(channel: ChannelType | undefined): void { const cid = channel?.id || 'id'; // in case channel is not initialized, use generic id string for indexing - const { setState, state } = useChannelsStateContext(); + const { setState } = useChannelsStateContext(); - const [threadMessages, setThreadMessagesInternal] = useStateManager( - { - cid, - key: 'threadMessages', - setState, - state, - }, - (threadId && channel?.state?.threads?.[threadId]) || [], - ); - const setThreadMessages = useCallback( - (value: ChannelState['threadMessages']) => setThreadMessagesInternal([...value]), - [setThreadMessagesInternal], - ); - - return { - setThreadMessages, - threadMessages, - }; + useEffect(() => { + setState({ cid, key: 'active', value: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cid]); } diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 9c557a083b..e92622fee8 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -2,15 +2,7 @@ import React, { PropsWithChildren, useContext } from 'react'; import { PressableProps, ViewProps } from 'react-native'; -import type { - Attachment, - Channel, - ChannelState, - CommandSuggestion, - DeleteMessageOptions, - LocalMessage, - MessageResponse, -} from 'stream-chat'; +import type { Attachment, Channel, LocalMessage } from 'stream-chat'; import type { MessagePressableHandlerPayload } from '../../components/Message/Message'; import type { MarkdownRules } from '../../components/Message/MessageItemView/utils/renderText'; @@ -45,13 +37,6 @@ export type MessageLocationProps = { }; export type MessagesContextValue = Pick & { - // FIXME: Remove the signature with optionsOrHardDelete boolean with the next major release - deleteMessage: ( - message: LocalMessage, - optionsOrHardDelete?: boolean | DeleteMessageOptions, - ) => Promise; - deleteReaction: (type: string, messageId: string) => Promise; - /** Should keyboard be dismissed when messaged is touched */ dismissKeyboardOnMessageTouch: boolean; @@ -80,21 +65,6 @@ export type MessagesContextValue = Pick Promise; - /** - * Override the api request for retry message functionality. - */ - retrySendMessage: (message: LocalMessage) => Promise; - sendReaction: (type: string, messageId: string) => Promise; - updateMessage: ( - updatedMessage: MessageResponse | LocalMessage, - extraState?: { - commands?: CommandSuggestion[]; - messageInput?: string; - threadMessages?: ChannelState['threads'][string]; - }, - throttled?: boolean, - ) => void; /** * Provide any additional props for `Pressable` which wraps inner MessageContent component here. * Please check docs for Pressable for supported props - https://reactnative.dev/docs/pressable#props From 484fb493d0b8b5980ec6457ec401abd16a81ec3f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 11 Jul 2026 14:51:55 +0200 Subject: [PATCH 20/36] perf: do not load too many messages --- package/src/components/Channel/Channel.tsx | 13 +++++++++++++ package/src/components/ui/Avatar/ChannelAvatar.tsx | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 9ad2060d67..0789e3892f 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -141,6 +141,15 @@ export const reactionData: ReactionData[] = [ */ const scrollToFirstUnreadThreshold = 0; +/** + * Initial message-list page size. stream-chat's `MessagePaginator` defaults to 100 + * (`DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE`). On native that makes the initial load — and therefore + * every subsequent message-list commit, whose cost scales with the number of loaded messages — several + * times heavier than necessary. We keep it to a screenful-plus-buffer; older messages load on demand + * via pagination. Mirrors the SDK's historical initial-load size. + */ +const DEFAULT_MESSAGE_LIST_PAGE_SIZE = 25; + export type ChannelPropsWithContext = Pick & Partial< Pick< @@ -574,6 +583,10 @@ const ChannelWithContext = (props: PropsWithChildren) = } let errored = false; + // Keep the message-list page light: the list's per-update commit cost scales with the number + // of loaded messages, and the paginator otherwise defaults to a 100-message page. + channel.messagePaginator.pageSize = DEFAULT_MESSAGE_LIST_PAGE_SIZE; + if ((!channel.initialized || !channel.state.isUpToDate) && initializeOnMount) { try { await channel?.watch(); diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index 1048410650..7a361e445f 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -61,7 +61,7 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { const members = useChannelMembersState(channel); const usersForGroup = useMemo( - () => Object.values(members).map((member) => member.user as UserResponse), + () => Object.values(members ?? {}).map((member) => member.user as UserResponse), [members], ); const usersWithoutSelf = useMemo( From 4afa591100fb7364d687ba266b156e826359ec25 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 11 Jul 2026 15:32:32 +0200 Subject: [PATCH 21/36] fix: messagelist snapping on send --- package/src/components/Message/Message.tsx | 7 +++++-- package/src/components/Message/hooks/useMessageData.ts | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index be8002b34c..507f626c4f 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -859,9 +859,12 @@ const MessageWithContext = (props: MessagePropsWithContext) => { groupStyles?.[0] === 'bottom' ? groupStyles[0] : undefined; + + const lastPaginatorMessageId = channel?.messagePaginator?.state + .getLatestValue() + .items?.slice(-1)[0]?.id; const isVeryLastBubble = - messagesContext.enableMessageGroupingByUser && - channel?.state.messages[channel.state.messages.length - 1]?.id === message.id; + messagesContext.enableMessageGroupingByUser && lastPaginatorMessageId === message.id; const styles = useStyles({ groupKey, highlightedMessage: (isTargetedMessage || message.pinned) && !isMessageTypeDeleted, diff --git a/package/src/components/Message/hooks/useMessageData.ts b/package/src/components/Message/hooks/useMessageData.ts index d4f501a15f..ef9be449b5 100644 --- a/package/src/components/Message/hooks/useMessageData.ts +++ b/package/src/components/Message/hooks/useMessageData.ts @@ -27,8 +27,9 @@ export const useMessageData = ({ const isMyMessage = propIsMyMessage || contextIsMyMessage; const message = propMessage || contextMessage; + const paginatorItems = channel?.messagePaginator?.state.getLatestValue().items; const isVeryLastMessage = - channel?.state.messages[channel?.state.messages.length - 1]?.id === message.id; + !!paginatorItems?.length && paginatorItems[paginatorItems.length - 1]?.id === message.id; const messageGroupedSingle = groupStyles.includes('single'); const messageGroupedBottom = groupStyles.includes('bottom'); From 469ae4dc7dd0ce9dfc0505fa46c1ad4f479ee144 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sun, 12 Jul 2026 00:03:19 +0200 Subject: [PATCH 22/36] perf: introduce delivery count hooks --- package/src/components/Message/Message.tsx | 12 +++---- .../hooks/useMessageDeliveredToCount.ts | 34 +++++++++++++++++++ .../Message/hooks/useMessageReadCount.ts | 31 +++++++++++++++++ package/src/components/index.ts | 4 +++ 4 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 package/src/components/Message/hooks/useMessageDeliveredToCount.ts create mode 100644 package/src/components/Message/hooks/useMessageReadCount.ts diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 507f626c4f..836c33e3e2 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -22,9 +22,9 @@ import type { import { useCreateMessageContext } from './hooks/useCreateMessageContext'; import { useMessageActionHandlers } from './hooks/useMessageActionHandlers'; import { useMessageActions } from './hooks/useMessageActions'; -import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; +import { useMessageDeliveredToCount } from './hooks/useMessageDeliveredToCount'; import { MessageOperations, useMessageOperations } from './hooks/useMessageOperations'; -import { useMessageReadData } from './hooks/useMessageReadData'; +import { useMessageReadCount } from './hooks/useMessageReadCount'; import { useProcessReactions } from './hooks/useProcessReactions'; import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from './messageOverlayConstants'; import { MessageOverlayWrapper } from './MessageOverlayWrapper'; @@ -1164,8 +1164,8 @@ export const Message = (props: MessageProps) => { const messageOperations = useMessageOperations(); const { openThread } = useThreadContext(); const { t } = useTranslationContext(); - const readBy = useMessageReadData({ message }); - const deliveredTo = useMessageDeliveredData({ message }); + const readByCount = useMessageReadCount({ message }); + const deliveredToCount = useMessageDeliveredToCount({ message }); const { setQuotedMessage, setEditingState } = useMessageComposerAPIContext(); return ( @@ -1175,13 +1175,13 @@ export const Message = (props: MessageProps) => { {...{ channel, chatContext, - deliveredToCount: deliveredTo.length, + deliveredToCount, dismissKeyboard, enforceUniqueReaction, members, messagesContext, openThread, - readBy: readBy.length, + readBy: readByCount, setEditingState, setQuotedMessage, t, diff --git a/package/src/components/Message/hooks/useMessageDeliveredToCount.ts b/package/src/components/Message/hooks/useMessageDeliveredToCount.ts new file mode 100644 index 0000000000..a5ec721b36 --- /dev/null +++ b/package/src/components/Message/hooks/useMessageDeliveredToCount.ts @@ -0,0 +1,34 @@ +import { useCallback } from 'react'; + +import type { LocalMessage, MessageReceiptsSnapshot } from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +/** + * Returns the number of users the given message has been delivered to, sourced reactively from + * the channel's `messageReceiptsTracker.snapshotStore`. + * + * Selecting the count (a number, compared by value) instead of the delivered-to array (compared + * by reference) means a row only re-renders when the count actually changes — not on every + * receipts emit that reallocates the array. Prefer this over `useMessageDeliveredData` whenever + * you only need the count; use `useMessageDeliveredData` when you need the actual users. + */ +export const useMessageDeliveredToCount = ({ message }: { message?: LocalMessage }) => { + const { channel } = useChannelContext(); + const messageId = message?.id ?? ''; + + const selector = useCallback( + (snapshot: MessageReceiptsSnapshot) => ({ + deliveredToCount: snapshot.deliveredByMessageId[messageId]?.length ?? 0, + }), + [messageId], + ); + + const { deliveredToCount } = useStateStore( + channel.messageReceiptsTracker.snapshotStore, + selector, + ); + + return deliveredToCount; +}; diff --git a/package/src/components/Message/hooks/useMessageReadCount.ts b/package/src/components/Message/hooks/useMessageReadCount.ts new file mode 100644 index 0000000000..ec56016a01 --- /dev/null +++ b/package/src/components/Message/hooks/useMessageReadCount.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; + +import type { LocalMessage, MessageReceiptsSnapshot } from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +/** + * Returns the number of users who have read the given message, sourced reactively from the + * channel's `messageReceiptsTracker.snapshotStore`. + * + * Selecting the count (a number, compared by value) instead of the readers array (compared by + * reference) means a row only re-renders when the count actually changes — not on every receipts + * emit that reallocates the array. Prefer this over `useMessageReadData` whenever you only need + * the count (e.g. read indicators); use `useMessageReadData` when you need the actual users. + */ +export const useMessageReadCount = ({ message }: { message?: LocalMessage }) => { + const { channel } = useChannelContext(); + const messageId = message?.id ?? ''; + + const selector = useCallback( + (snapshot: MessageReceiptsSnapshot) => ({ + readCount: snapshot.readersByMessageId[messageId]?.length ?? 0, + }), + [messageId], + ); + + const { readCount } = useStateStore(channel.messageReceiptsTracker.snapshotStore, selector); + + return readCount; +}; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 6e3fd978e9..a1cb75f4f9 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -88,7 +88,9 @@ export * from './Message/hooks/useCreateMessageContext'; export * from './Message/hooks/useMessageActions'; export * from './Message/hooks/useMessageActionHandlers'; export * from './Message/hooks/useStreamingMessage'; +export * from './Message/hooks/useMessageDeliveredToCount'; export * from './Message/hooks/useMessageDeliveryData'; +export * from './Message/hooks/useMessageReadCount'; export * from './Message/hooks/useMessageReadData'; export * from './Message/Message'; export * from './Message/MessageOverlayWrapper'; @@ -161,7 +163,9 @@ export * from './MessageList/TypingIndicator'; export * from './MessageList/TypingIndicatorContainer'; export * from './MessageList/utils/getGroupStyles'; export * from './MessageList/utils/getLastReceivedMessage'; +export * from './Message/hooks/useMessageDeliveredToCount'; export * from './Message/hooks/useMessageDeliveryData'; +export * from './Message/hooks/useMessageReadCount'; export * from './Message/hooks/useMessageReadData'; export * from './MessageList/hooks/useMessageDateSeparator'; export * from './MessageList/hooks/useMessageGroupStyles'; From 05142b5da4ad8f3bcf8714f53d739eb9e27da2e9 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sun, 12 Jul 2026 01:50:40 +0200 Subject: [PATCH 23/36] feat: fully migrate thread to thread instances --- package/src/components/Channel/Channel.tsx | 24 ++++---- package/src/components/Thread/Thread.tsx | 68 +++++++++++++++++++++- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 0789e3892f..dd20bc8b03 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -651,11 +651,13 @@ const ChannelWithContext = (props: PropsWithChildren) = // A thread supplied via props without its own Thread instance still needs one so the reply // list is backed by thread.messagePaginator (mirrors openThread). if (channel && threadProps?.id && !threadInstanceFromProps) { - const newThreadInstance = new Thread({ channel, client, parentMessage: threadProps }); + // Mirror stream-chat-react: reuse the ThreadManager's instance when present, else construct + // one. Loading + live updates are handled by the Thread component's effects (reload + adopt + // into client.threads) — not a direct messagePaginator.reload() here. + const newThreadInstance = + client.threads.threadsById[threadProps.id] ?? + new Thread({ channel, client, parentMessage: threadProps }); setThreadInstance(newThreadInstance); - newThreadInstance.messagePaginator - .reload() - .catch((err) => console.warn('Thread reply load failed with error:', err)); } } else { setThread(null); @@ -915,15 +917,15 @@ const ChannelWithContext = (props: PropsWithChildren) = */ const openThread: ThreadContextValue['openThread'] = useCallback( (message) => { - // Construct a stream-chat Thread instance for the opened parent message; its - // messagePaginator drives the reply list + optimistic reply ops. - const newThreadInstance = new Thread({ channel, client, parentMessage: message }); + // Mirror stream-chat-react: reuse the ThreadManager's instance when it already exists + // (it's registered + self-updating), otherwise construct one. The reply list is loaded by + // the Thread component (reload for metadata + loadMoreThread for the first page) and kept + // live by the manager once the instance is adopted — so we do NOT reload the paginator here. + const newThreadInstance = + client.threads.threadsById[message.id] ?? + new Thread({ channel, client, parentMessage: message }); setThread(message); setThreadInstance(newThreadInstance); - // Seed the reply paginator so replies render on open. - newThreadInstance.messagePaginator - .reload() - .catch((err) => console.warn('Thread reply load failed with error:', err)); if (channel.initialized) { // Mark the thread read on open. A freshly-constructed minimal thread has ownUnreadCount 0, // so threadInstance.markRead() would no-op; channel.markRead({thread_id}) marks it reliably diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index fef622b043..0e6c5649b3 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useMemo } from 'react'; +import type { LocalMessage, Thread as StreamThread } from 'stream-chat'; + import { ThreadFooterComponent } from './components/ThreadFooterComponent'; import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; @@ -7,6 +9,8 @@ import { ChatContextValue, useChatContext } from '../../contexts/chatContext/Cha import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; +import { useStateStore } from '../../hooks/useStateStore'; + import type { MessageComposerProps } from '../MessageInput/MessageComposer'; import { MessageFlashList, MessageFlashListProps } from '../MessageList/MessageFlashList'; import { MessageListProps } from '../MessageList/MessageList'; @@ -62,8 +66,29 @@ type ThreadPropsWithContext = Pick & shouldUseFlashList?: boolean; }; +type ThreadReplyPaginatorState = { + isLoading: boolean; + items?: LocalMessage[]; + lastQueryError?: Error; +}; + +const paginatorSelector = (state: ThreadReplyPaginatorState) => ({ + isLoading: state.isLoading, + items: state.items, + lastQueryError: state.lastQueryError, +}); + +const threadStaleSelector = (state: { isStateStale: boolean }) => ({ + isStateStale: state.isStateStale, +}); + +const threadManagerSelector = (state: { threads: StreamThread[] }) => ({ + threads: state.threads, +}); + const ThreadWithContext = (props: ThreadPropsWithContext) => { const { + client, additionalMessageComposerProps, additionalMessageListProps, additionalMessageFlashListProps, @@ -81,6 +106,44 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { } = props; const { MessageList, ThreadMessageComposer: MessageComposer } = useComponentsContext(); + const { isLoading, items, lastQueryError } = + useStateStore(threadInstance?.messagePaginator?.state, paginatorSelector) ?? {}; + const { isStateStale } = useStateStore(threadInstance?.state, threadStaleSelector) ?? {}; + const { threads } = useStateStore(client.threads.state, threadManagerSelector) ?? { + threads: client.threads.state.getLatestValue().threads, + }; + const isThreadManaged = threadInstance?.id + ? threads.some((managedThread) => managedThread.id === threadInstance.id) + : false; + + // Mirror stream-chat-react: an unmanaged thread whose reply paginator hasn't loaded yet gets a + // metadata reload (parent message, read state, participants) — not a paginator reload. + useEffect(() => { + if (!threadInstance || isThreadManaged) return; + if (items !== undefined || isLoading) return; + void threadInstance.reload().catch((err) => console.warn('Thread reload failed', err)); + }, [isThreadManaged, threadInstance, isLoading, items]); + + // Reload when the thread's state goes stale (e.g. user stopped then resumed watching the channel). + useEffect(() => { + if (threadInstance && isStateStale) { + void threadInstance.reload().catch((err) => console.warn('Thread reload failed', err)); + } + }, [isStateStale, threadInstance]); + + // Once the reply paginator has loaded, adopt the instance into the ThreadManager. The manager + // registers the thread's subscriptions on adoption, which keeps the reply list live (incoming + // replies, read state, thread.updated) — mirrors stream-chat-react. + useEffect(() => { + if (!threadInstance || isThreadManaged) return; + if (isLoading || lastQueryError || items === undefined) return; + client.threads.state.next((current) => + current.threads.some((managedThread) => managedThread.id === threadInstance.id) + ? current + : { ...current, threads: [threadInstance, ...current.threads] }, + ); + }, [client.threads.state, isThreadManaged, threadInstance, isLoading, items, lastQueryError]); + useEffect(() => { if (threadInstance?.activate) { threadInstance.activate(); @@ -89,7 +152,10 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { await loadMoreThread(); }; - if (thread?.id && thread.reply_count) { + // Load the reply paginator's first page even for a reply-less thread (returns []), so `items` + // becomes defined and the adopt effect can register the thread with the manager. Mirrors + // stream-chat-react, whose MessageList always loads the thread paginator. + if (thread?.id) { loadMoreThreadAsync(); } From 6236684ac0474ae11e750ce42fb017a0f0cf6513 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sun, 12 Jul 2026 04:16:19 +0200 Subject: [PATCH 24/36] feat: always rely on shared thread instance --- package/src/components/Channel/Channel.tsx | 77 +++++-------------- .../Channel/__tests__/Channel.test.tsx | 43 ++--------- .../Channel/hooks/useCreateThreadContext.ts | 4 - package/src/components/Message/Message.tsx | 9 --- .../Message/hooks/useMessageActions.tsx | 6 -- package/src/components/Thread/Thread.tsx | 73 ++++++++++-------- .../contexts/threadContext/ThreadContext.tsx | 2 - 7 files changed, 69 insertions(+), 145 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index dd20bc8b03..efad087130 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -478,10 +478,25 @@ const ChannelWithContext = (props: PropsWithChildren) = const [deleted, setDeleted] = useState(false); const [error, setError] = useState(false); const lastReadRef = useRef(undefined); - const [thread, setThread] = useState(threadProps || null); - const [threadInstance, setThreadInstance] = useState( - threadInstanceFromProps ?? null, - ); + // The active thread is fully prop-driven: derive it synchronously during render so the reply + // data is present on the first frame (no setState round-trip / one-frame gap). Opening a thread + // is the integrator's job via `onThreadSelect` (they render a Channel with the `thread` prop). + const thread = threadProps ?? null; + const threadInstance = useMemo(() => { + if (threadInstanceFromProps) { + return threadInstanceFromProps; + } + if (!threadProps?.id || !channel) { + return null; + } + return ( + client.threads.threadsById[threadProps.id] ?? + new Thread({ channel, client, parentMessage: threadProps }) + ); + // Keyed on threadProps.id (stable) rather than the threadProps object so an unmanaged thread's + // constructed instance isn't recreated (losing paginator state) on unrelated re-renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [threadInstanceFromProps, threadProps?.id, channel, client]); const [threadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); const [messageInputHeightStore] = useState(() => new MessageInputHeightStore()); @@ -643,28 +658,6 @@ const ChannelWithContext = (props: PropsWithChildren) = return unsubscribe; }, [channel?.cid, client]); - const threadPropsExists = !!threadProps; - - useEffect(() => { - if (threadProps && shouldSyncChannel) { - setThread(threadProps); - // A thread supplied via props without its own Thread instance still needs one so the reply - // list is backed by thread.messagePaginator (mirrors openThread). - if (channel && threadProps?.id && !threadInstanceFromProps) { - // Mirror stream-chat-react: reuse the ThreadManager's instance when present, else construct - // one. Loading + live updates are handled by the Thread component's effects (reload + adopt - // into client.threads) — not a direct messagePaginator.reload() here. - const newThreadInstance = - client.threads.threadsById[threadProps.id] ?? - new Thread({ channel, client, parentMessage: threadProps }); - setThreadInstance(newThreadInstance); - } - } else { - setThread(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [threadPropsExists, shouldSyncChannel]); - const handleAppBackground = useCallback(() => { const channelData = channel.data; if (channelData?.own_capabilities?.includes('send-typing-events')) { @@ -915,36 +908,6 @@ const ChannelWithContext = (props: PropsWithChildren) = /** * THREAD METHODS */ - const openThread: ThreadContextValue['openThread'] = useCallback( - (message) => { - // Mirror stream-chat-react: reuse the ThreadManager's instance when it already exists - // (it's registered + self-updating), otherwise construct one. The reply list is loaded by - // the Thread component (reload for metadata + loadMoreThread for the first page) and kept - // live by the manager once the instance is adopted — so we do NOT reload the paginator here. - const newThreadInstance = - client.threads.threadsById[message.id] ?? - new Thread({ channel, client, parentMessage: message }); - setThread(message); - setThreadInstance(newThreadInstance); - if (channel.initialized) { - // Mark the thread read on open. A freshly-constructed minimal thread has ownUnreadCount 0, - // so threadInstance.markRead() would no-op; channel.markRead({thread_id}) marks it reliably - // (and still delegates to the messageDeliveryReporter). Opening a reply-less parent has no - // server-side thread yet, so this rejects with "thread not found" — a benign no-op we swallow - // (otherwise it surfaces as an unhandled promise rejection / dev redbox). - channel - .markRead({ thread_id: message.id }) - .catch((err) => console.warn('Marking thread as read on open failed with error:', err)); - } - }, - [channel, client], - ); - - const closeThread: ThreadContextValue['closeThread'] = useCallback(() => { - setThread(null); - setThreadInstance(null); - }, []); - const loadMoreThread: ThreadContextValue['loadMoreThread'] = useStableCallback(async () => { // Older replies are paginated via the thread's messagePaginator, which backs the reply list. // (useCreateThreadContext also wires loadMoreThread to the paginator when a thread instance @@ -1116,9 +1079,7 @@ const ChannelWithContext = (props: PropsWithChildren) = const threadContext = useCreateThreadContext({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - closeThread, loadMoreThread, - openThread, reloadThread, setThreadLoadingMore, thread, diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 9e4d525cf8..f5f2e1fbb4 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -188,49 +188,22 @@ describe('Channel', () => { await waitFor(() => expect(channelOnSpy).toHaveBeenCalledWith(expect.any(Function))); }); - it('should be able to open threads', async () => { + it('exposes a thread provided via props through the thread context', async () => { const threadMessage = messages[0]; const hasThread = jest.fn(); - // this renders Channel, calls openThread from a child context consumer with a message, - // and then calls hasThread with the thread id if it was set. - const { rerender } = renderComponent( - { channel }, + // Threads are now fully prop-driven: passing `thread` to Channel exposes it through + // ThreadContext synchronously (no openThread state setter). + renderComponent( + { channel, thread: threadMessage, threadList: true }, (ctx) => { - const { openThread, thread } = ctx as { - openThread: (m: unknown) => void; - thread: { id: string } | null; - }; - if (!thread) { - openThread(threadMessage); - } else { + const { thread } = ctx as { thread: { id: string } | null }; + if (thread) { hasThread(thread.id); } }, ThreadContext as React.Context, ); - rerender( - - - - { - const { openThread, thread } = ctx as { - openThread: (m: unknown) => void; - thread: { id: string } | null; - }; - if (!thread) { - openThread(threadMessage); - } else { - hasThread(thread.id); - } - }} - context={ThreadContext as React.Context} - /> - - - , - ); await waitFor(() => expect(hasThread).toHaveBeenCalledWith(threadMessage.id)); }); @@ -360,7 +333,6 @@ describe('Channel', () => { let context: ThreadContextValue | undefined; const mockContext = { - openThread: () => {}, thread: {}, threadHasMore: true, threadLoadingMore: false, @@ -379,7 +351,6 @@ describe('Channel', () => { await waitFor(() => { expect(context).toBeInstanceOf(Object); - expect(context!.openThread).toBeInstanceOf(Function); expect(context!.thread).toBeInstanceOf(Object); expect(context!.threadHasMore).toBe(true); expect(context!.threadLoadingMore).toBe(false); diff --git a/package/src/components/Channel/hooks/useCreateThreadContext.ts b/package/src/components/Channel/hooks/useCreateThreadContext.ts index 2ac26b46d5..369c56bbac 100644 --- a/package/src/components/Channel/hooks/useCreateThreadContext.ts +++ b/package/src/components/Channel/hooks/useCreateThreadContext.ts @@ -22,9 +22,7 @@ const selector = (state: ThreadMessagePaginatorState) => ({ export const useCreateThreadContext = ({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - closeThread, loadMoreThread, - openThread, reloadThread, setThreadLoadingMore, thread, @@ -52,9 +50,7 @@ export const useCreateThreadContext = ({ return { allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - closeThread, loadMoreThread, - openThread, reloadThread, setThreadLoadingMore, thread, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 836c33e3e2..ccb76b0da2 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -57,7 +57,6 @@ import { } from '../../contexts/messagesContext/MessagesContext'; import { useOwnCapabilitiesContext } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { TranslationContextValue, useTranslationContext, @@ -247,7 +246,6 @@ export type MessagePropsWithContext = Pick< | 'selectReaction' | 'supportedReactions' > & - Pick & Pick & { chatContext: ChatContextValue; messagesContext: MessagesContextValue; @@ -314,7 +312,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { onPressInMessage: onPressInMessageProp, onPressMessage: onPressMessageProp, onThreadSelect, - openThread, preventPress, removeMessage, retrySendMessage, @@ -563,9 +560,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { if (onThreadSelect) { onThreadSelect(message); } - if (openThread) { - openThread(message); - } }; const { existingReactions, hasReactions } = useProcessReactions({ @@ -644,7 +638,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { handleBlockUser, message, onThreadSelect, - openThread, removeMessage, retrySendMessage, selectReaction, @@ -1162,7 +1155,6 @@ export const Message = (props: MessageProps) => { const { dismissKeyboard } = useKeyboardContext(); const messagesContext = useMessagesContext(); const messageOperations = useMessageOperations(); - const { openThread } = useThreadContext(); const { t } = useTranslationContext(); const readByCount = useMessageReadCount({ message }); const deliveredToCount = useMessageDeliveredToCount({ message }); @@ -1180,7 +1172,6 @@ export const Message = (props: MessageProps) => { enforceUniqueReaction, members, messagesContext, - openThread, readBy: readByCount, setEditingState, setQuotedMessage, diff --git a/package/src/components/Message/hooks/useMessageActions.tsx b/package/src/components/Message/hooks/useMessageActions.tsx index 0e5ed2e596..e3b5ab4f77 100644 --- a/package/src/components/Message/hooks/useMessageActions.tsx +++ b/package/src/components/Message/hooks/useMessageActions.tsx @@ -14,7 +14,6 @@ import { MessageComposerAPIContextValue } from '../../../contexts/messageCompose import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; -import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; import { useStableCallback } from '../../../hooks'; import { @@ -69,7 +68,6 @@ export type MessageActionsHookProps = Pick< > & Pick & Pick & - Pick & Pick & Pick & { onThreadSelect?: (message: LocalMessage) => void; @@ -97,7 +95,6 @@ export const useMessageActions = ({ handleBlockUser, message, onThreadSelect, - openThread, retrySendMessage, selectReaction, sendReaction, @@ -143,9 +140,6 @@ export const useMessageActions = ({ if (onThreadSelect) { onThreadSelect(message); } - if (openThread) { - openThread(message); - } }); const onBanUser = useStableCallback(async () => { diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 0e6c5649b3..9339c584ab 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import type { LocalMessage, Thread as StreamThread } from 'stream-chat'; @@ -28,12 +28,7 @@ try { type ThreadPropsWithContext = Pick & Pick< ThreadContextValue, - | 'closeThread' - | 'loadMoreThread' - | 'parentMessagePreventPress' - | 'reloadThread' - | 'thread' - | 'threadInstance' + 'loadMoreThread' | 'parentMessagePreventPress' | 'reloadThread' | 'thread' | 'threadInstance' > & { /** * Additional props for underlying MessageComposer component. @@ -93,8 +88,6 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { additionalMessageListProps, additionalMessageFlashListProps, autoFocus = false, - closeThread, - closeThreadOnDismount = true, disabled, loadMoreThread, onThreadDismount, @@ -144,34 +137,55 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { ); }, [client.threads.state, isThreadManaged, threadInstance, isLoading, items, lastQueryError]); + // Activate the thread instance once it is available. `threadInstance` can resolve asynchronously + // (Channel adopts it from the ThreadManager after this component mounts), so keying on it — rather + // than running on mount alone — ensures activation isn't missed when the instance arrives late. useEffect(() => { - if (threadInstance?.activate) { - threadInstance.activate(); + threadInstance?.activate?.(); + }, [threadInstance]); + + // Mark the thread read on open. Mirrors the pre-refactor openThread behavior: channel.markRead with + // a thread_id marks reliably even when the thread instance's own unread count is 0 (so it can't + // rely on the LLC's active-thread auto-read); a reply-less parent has no server-side thread yet and + // rejects with "thread not found", which we swallow. + useEffect(() => { + const channel = threadInstance?.channel; + if (!threadInstance?.id || !channel?.initialized) { + return; } - const loadMoreThreadAsync = async () => { - await loadMoreThread(); - }; + channel + .markRead({ thread_id: threadInstance.id }) + .catch((err) => console.warn('Marking thread as read on open failed with error:', err)); + }, [threadInstance]); - // Load the reply paginator's first page even for a reply-less thread (returns []), so `items` - // becomes defined and the adopt effect can register the thread with the manager. Mirrors - // stream-chat-react, whose MessageList always loads the thread paginator. - if (thread?.id) { - loadMoreThreadAsync(); + // Load the first reply page, but only when the paginator hasn't already been seeded from the + // thread's `latest_replies` (managed/queried threads seed on construction) or loaded/loading. A + // seeded paginator already holds its first page, so we skip the fetch and let scroll-up load older + // replies — mirroring stream-chat-react, whose thread list has no mount-time fetch. Reactive on + // `threadInstance`/`items` because the instance can arrive after mount; the `items === undefined` + // guard makes this fire at most once (an unseeded thread fetches; the fetch defines `items`, which + // also lets the adopt effect register it with the manager). + useEffect(() => { + if (!threadInstance || isLoading || items !== undefined) { + return; } + void loadMoreThread(); + }, [threadInstance, items, isLoading, loadMoreThread]); - return () => { - if (threadInstance?.deactivate) { - threadInstance.deactivate(); - } - if (closeThreadOnDismount) { - closeThread(); - } + // Tear down on unmount. Use a ref so we deactivate whichever instance is current at unmount, not + // the (possibly null) one captured when this effect first ran. + const threadInstanceRef = useRef(threadInstance); + threadInstanceRef.current = threadInstance; + useEffect( + () => () => { + threadInstanceRef.current?.deactivate?.(); if (onThreadDismount) { onThreadDismount(); } - }; + }, // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + [], + ); const MemoizedThreadFooterComponent = useCallback( () => , @@ -232,7 +246,7 @@ export type ThreadProps = Partial; export const Thread = (props: ThreadProps) => { const { client } = useChatContext(); const { threadList } = useChannelContext(); - const { closeThread, loadMoreThread, reloadThread, thread, threadInstance } = useThreadContext(); + const { loadMoreThread, reloadThread, thread, threadInstance } = useThreadContext(); if (thread?.id && !threadList) { throw new Error( @@ -244,7 +258,6 @@ export const Thread = (props: ThreadProps) => { void; loadMoreThread: () => Promise; - openThread: (message: LocalMessage) => void; reloadThread: () => void; setThreadLoadingMore: React.Dispatch>; thread: LocalMessage | null; From 77e859221d802cb36ac6d7e28042a014f1556ce6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 13 Jul 2026 11:12:55 +0200 Subject: [PATCH 25/36] fix: also replied to channel message operations --- .../Message/hooks/useMessageOperations.ts | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/package/src/components/Message/hooks/useMessageOperations.ts b/package/src/components/Message/hooks/useMessageOperations.ts index 2464acae32..49130eb39a 100644 --- a/package/src/components/Message/hooks/useMessageOperations.ts +++ b/package/src/components/Message/hooks/useMessageOperations.ts @@ -49,10 +49,11 @@ export const useMessageOperations = (): MessageOperations => { // replies (the reply list is sourced solely from thread.messagePaginator). channel.state.addMessageSorted(updatedMessage, true); const formatted = channel.state.formatMessage(updatedMessage); + if (!updatedMessage.parent_id || updatedMessage.show_in_channel) { + channel.messagePaginator.ingestItem(formatted); + } if (updatedMessage.parent_id) { threadInstance?.messagePaginator?.ingestItem(formatted); - } else { - channel.messagePaginator.ingestItem(formatted); } }); @@ -62,10 +63,13 @@ export const useMessageOperations = (): MessageOperations => { const removeMessage: MessageOperations['removeMessage'] = useStableCallback(async (message) => { if (channel) { channel.state.removeMessage(message); - const paginator = message.parent_id - ? threadInstance?.messagePaginator - : channel.messagePaginator; - paginator?.removeItem({ id: message.id }); + // removeItem is a no-op when the item isn't in that paginator, so removing from the channel + // unconditionally is safe (a hidden reply simply isn't there) and covers replies shown in + // the channel; replies additionally live in the open thread's paginator. + channel.messagePaginator.removeItem({ id: message.id }); + if (message.parent_id) { + threadInstance?.messagePaginator?.removeItem({ id: message.id }); + } } if (client.offlineDb) { @@ -104,10 +108,13 @@ export const useMessageOperations = (): MessageOperations => { const reactedMessage = channel.state.findMessage(messageId); if (reactedMessage) { - (reactedMessage.parent_id - ? threadInstance?.messagePaginator - : channel.messagePaginator - )?.ingestItem(channel.state.formatMessage(reactedMessage)); + const formatted = channel.state.formatMessage(reactedMessage); + if (!reactedMessage.parent_id || reactedMessage.show_in_channel) { + channel.messagePaginator.ingestItem(formatted); + } + if (reactedMessage.parent_id) { + threadInstance?.messagePaginator?.ingestItem(formatted); + } } } @@ -137,10 +144,13 @@ export const useMessageOperations = (): MessageOperations => { const reactedMessage = channel.state.findMessage(messageId); if (reactedMessage) { - (reactedMessage.parent_id - ? threadInstance?.messagePaginator - : channel.messagePaginator - )?.ingestItem(channel.state.formatMessage(reactedMessage)); + const formatted = channel.state.formatMessage(reactedMessage); + if (!reactedMessage.parent_id || reactedMessage.show_in_channel) { + channel.messagePaginator.ingestItem(formatted); + } + if (reactedMessage.parent_id) { + threadInstance?.messagePaginator?.ingestItem(formatted); + } } } From 262e1c159e0c9412b6594c7f77b9bb1d3bde1536 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 13 Jul 2026 17:33:39 +0200 Subject: [PATCH 26/36] fix: thread connection recovery mechanism --- package/src/components/Channel/Channel.tsx | 8 ++++---- package/src/components/MessageInput/MessageComposer.tsx | 8 +++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index efad087130..1ef7128abd 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -746,16 +746,16 @@ const ChannelWithContext = (props: PropsWithChildren) = } await markRead(); channel.state.setIsUpToDate(true); - } else { - await reloadThread(); + } else if (threadInstance) { + await threadInstance.messagePaginator.refresh(); const currentThreadMessages = - threadInstance?.messagePaginator?.state.getLatestValue().items ?? []; + threadInstance.messagePaginator.state.getLatestValue().items ?? []; const failedThreadMessages = getRecoverableFailedMessages(currentThreadMessages); if (failedThreadMessages.length) { channel.state.addMessagesSorted(failedThreadMessages); failedThreadMessages.forEach((m) => - threadInstance?.messagePaginator?.ingestItem(channel.state.formatMessage(m)), + threadInstance.messagePaginator.ingestItem(channel.state.formatMessage(m)), ); } } diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index 1107be5701..7d5527f536 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -659,8 +659,14 @@ export const MessageComposer = (props: MessageComposerProps) => { /** * Disable the message input if the channel is frozen, or the user doesn't have the capability to send a message. * Enable it in frozen mode, if it the input has editing state. + * */ - if (!ownCapabilities.sendMessage && !editing && SendMessageDisallowedIndicator) { + if ( + channel.initialized && + !ownCapabilities.sendMessage && + !editing && + SendMessageDisallowedIndicator + ) { return ; } From 7a7ee701c45aadb902318ce69b99f1ebcf2492e6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 15 Jul 2026 13:44:21 +0200 Subject: [PATCH 27/36] fix: inline merge of message lists --- package/src/components/Channel/Channel.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 1ef7128abd..935733af28 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -725,10 +725,10 @@ const ChannelWithContext = (props: PropsWithChildren) = .map(parseMessage); try { + let watchResponse; if (channelMessagesState?.messages) { - await channel?.watch({ + watchResponse = await channel?.watch({ messages: { - // Do we want to reduce this to the default as well ? limit: channelMessagesState.messages.length, }, }); @@ -737,17 +737,25 @@ const ChannelWithContext = (props: PropsWithChildren) = if (!thread) { const failedMessages = getRecoverableFailedMessages(channelMessagesState.messages); - await channel.messagePaginator.reload(); + const newestWindow = (watchResponse?.messages ?? []).map((message) => + channel.state.formatMessage(message), + ); + channel.messagePaginator.mergeNewestPage(newestWindow); if (failedMessages?.length) { channel.state.addMessagesSorted(failedMessages); failedMessages.forEach((m) => channel.messagePaginator.ingestItem(channel.state.formatMessage(m)), ); } - await markRead(); - channel.state.setIsUpToDate(true); + // The merge no-ops if the user had jumped away from the head; only claim up-to-date / mark + // read when it actually left us at the newest, otherwise keep their position untouched. + const atLatest = !channel.messagePaginator.hasMoreHead; + if (atLatest) { + await markRead(); + } + channel.state.setIsUpToDate(atLatest); } else if (threadInstance) { - await threadInstance.messagePaginator.refresh(); + await threadInstance.reload(); const currentThreadMessages = threadInstance.messagePaginator.state.getLatestValue().items ?? []; From 18905dd921765ca812c16acc9a77e98e7198c8e1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 15 Jul 2026 17:32:55 +0200 Subject: [PATCH 28/36] fix: scrollto edge cases --- package/src/components/MessageList/MessageFlashList.tsx | 6 +++--- package/src/components/MessageList/MessageList.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 212be0d718..a6c6e3f9c4 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -582,7 +582,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return; } - const notLatestSet = channel.state.messages !== channel.state.latestMessages; + const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (notLatestSet) { latestNonCurrentMessageBeforeUpdateRef.current = channel.state.latestMessages[channel.state.latestMessages.length - 1]; @@ -924,7 +924,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const isScrollAtStart = contentLength - visibleLength - offset < messageInputHeight; - const notLatestSet = channel.state.messages !== channel.state.latestMessages; + const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; const showScrollToBottomButton = messageListHasMessages && ((!threadList && notLatestSet) || !isScrollAtStart); @@ -943,7 +943,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }); const goToNewMessages = useStableCallback(async () => { - const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; + const isNotLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (isNotLatestSet) { resetPaginationTrackersRef.current(); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index bf7eb1d295..d485c1860e 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -737,7 +737,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return; } - const notLatestSet = channel.state.messages !== channel.state.latestMessages; + const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (notLatestSet) { latestNonCurrentMessageBeforeUpdateRef.current = channel.state.latestMessages[channel.state.latestMessages.length - 1]; @@ -979,7 +979,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const offset = event.nativeEvent.contentOffset.y; const isScrollAtBottom = offset <= messageInputHeight; - const notLatestSet = channel.state.messages !== channel.state.latestMessages; + const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; const showScrollToBottomButton = messageListHasMessages && ((!threadList && notLatestSet) || !isScrollAtBottom); @@ -998,7 +998,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }); const goToNewMessages = useStableCallback(async () => { - const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; + const isNotLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (isNotLatestSet) { resetPaginationTrackersRef.current(); From 34549a03988781f63410fca4e0de73efcc16e2b7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 15 Jul 2026 23:00:02 +0200 Subject: [PATCH 29/36] fix: unread resolution --- package/src/components/Channel/Channel.tsx | 13 +++++-- .../MessageList/MessageFlashList.tsx | 20 ++++++++--- .../components/MessageList/MessageList.tsx | 20 ++++++++--- .../MessageList/hooks/useMarkRead.ts | 34 ++++++++++++++----- 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 935733af28..96eefcdb39 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -622,8 +622,12 @@ const ChannelWithContext = (props: PropsWithChildren) = } } - // The paginator's unreadStateSnapshot is populated by the seed/reload above (the single source - // of truth for unread state), so there's no manual initial read-state copy here. + // Re-seed the unread snapshot from the CURRENT read state on every open. The paginator is + // usually reused from cache (the reload above is skipped), and hydrateActiveChannels merges + // rather than re-seeds, so without this the snapshot's boundary/count/first-unread stay frozen + // at the very first open — making the separator, the "N new" banner and the jump-to-first-unread + // target all go stale on reopen. Mirrors stream-chat-react, which re-seeds by re-querying on open. + channel.messagePaginator.seedUnreadSnapshot(); if (messageId) { await loadChannelAroundMessage({ messageId }); @@ -633,7 +637,10 @@ const ChannelWithContext = (props: PropsWithChildren) = } if (unreadCount > 0 && markReadOnMount) { - await markRead(); + // Keep the original unread UI (separator frozen at the boundary, "N new" banner) when + // opening a channel with unreads — don't reset the snapshot here. It clears once the user + // catches up (a subsequent markRead with the default updateChannelUnreadState: true). + await markRead({ updateChannelUnreadState: false }); } listener = channel.on(handleEvent); diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index a6c6e3f9c4..efe20fa49e 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -628,13 +628,23 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ); }; - const handleEvent = async (event: Event) => { - // The unread count is owned by channel.messagePaginator.unreadStateSnapshot (the LLC bumps it - // on message.new); we no longer manually bump it here. We only mark the channel read when the - // user is caught up at the bottom. + const handleEvent = (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; if (mainChannelUpdated && shouldMarkRead()) { - await markRead(); + // We're caught up at the bottom and reading this message in real time. Clear the unread + // snapshot SYNCHRONOUSLY — before the throttled, async markRead below — so the "N new + // messages" banner / unread separator never flash for a message we're already looking at. + // The LLC bumps unreadCount on this same event; resetting it here (a later-registered + // listener in the same synchronous dispatch) means the bumped value is never rendered. + channel.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: new Date(), + lastReadMessageId: + event.message?.id ?? + channel.messagePaginator.unreadStateSnapshot.getLatestValue().lastReadMessageId, + unreadCount: 0, + }); + markRead(); } }; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index d485c1860e..5f303f1fa6 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -655,13 +655,23 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); }; - const handleEvent = async (event: Event) => { - // The unread count is owned by channel.messagePaginator.unreadStateSnapshot; we no longer - // manually bump it here. We only mark the channel read when the user is caught up at the - // bottom and the app is foregrounded. + const handleEvent = (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; if (mainChannelUpdated && shouldMarkRead()) { - await markRead(); + // We're caught up at the bottom and reading this message in real time. Clear the unread + // snapshot SYNCHRONOUSLY — before the throttled, async markRead below — so the "N new + // messages" banner / unread separator never flash for a message we're already looking at. + // The LLC bumps unreadCount on this same event; resetting it here (a later-registered + // listener in the same synchronous dispatch) means the bumped value is never rendered. + channel.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: new Date(), + lastReadMessageId: + event.message?.id ?? + channel.messagePaginator.unreadStateSnapshot.getLatestValue().lastReadMessageId, + unreadCount: 0, + }); + markRead(); } }; diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts index 247615d6b7..e201b3aa1b 100644 --- a/package/src/components/MessageList/hooks/useMarkRead.ts +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -15,11 +15,11 @@ const throttleOptions = { /** * Returns a throttled `markRead` callback for the active channel. * - * The behavior mirrors the previous `Channel`-level implementation exactly: it is a no-op when the - * channel is missing or disconnected, resets the local unread count when read events are disabled - * (and the client opted into a local unread count), and otherwise delegates to `channel.markRead()`. - * The returned function is stabilized so it can be used as a dependency without triggering rerenders, - * and accepts (but ignores) an options argument for backwards compatibility with call sites. + * The behavior mirrors the previous `Channel`-level implementation: it is a no-op when the channel + * is missing or disconnected, resets the local unread count when read events are disabled (and the + * client opted into a local unread count), and otherwise delegates to `channel.markRead()` and then + * resets the paginator's unread snapshot (unless `updateChannelUnreadState` is `false`). + * The returned function is stabilized so it can be used as a dependency without triggering rerenders. */ export const useMarkRead = (channel: Channel) => { const { client } = useChatContext(); @@ -38,7 +38,7 @@ export const useMarkRead = (channel: Channel) => { const clientChannelConfig = getChannelConfigSafely(); const markReadInternal = throttle( - async () => { + async (options?: MarkReadFunctionOptions) => { if (!channel || channel?.disconnected) { return; } @@ -55,9 +55,27 @@ export const useMarkRead = (channel: Channel) => { // channel.markRead() delegates to client.messageDeliveryReporter.markRead(this), which honors // any custom markReadRequest handler registered in channel.configState (see - // useChannelRequestHandlers). Unread state updates in the LLC snapshot, mirrored into the store. + // useChannelRequestHandlers). try { - await channel.markRead(); + const response = await channel.markRead(); + + // Reset the paginator's unread snapshot so the "N new messages" banner and the unread + // separator clear once the channel is caught up. The LLC bumps `unreadCount` on every + // incoming `message.new` but never clears it on `message.read`, so without this the banner + // latches on and can't be dismissed. Mirrors stream-chat-react's `markChannelRead`. + // + // Gated on `updateChannelUnreadState` (default true): the mark-read-on-mount call passes + // `false` so opening a channel with unreads keeps its original unread UI (separator frozen + // at the boundary) until the user actually catches up. + const { updateChannelUnreadState = true } = options ?? {}; + if (updateChannelUnreadState && response?.event) { + channel.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: new Date(), + lastReadMessageId: response.event.last_read_message_id ?? null, + unreadCount: 0, + }); + } } catch (err) { console.log('Error marking channel as read:', err); } From 6ce7903959a499a7b11367b8656c4c55fb729ad9 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 16 Jul 2026 22:50:11 +0200 Subject: [PATCH 30/36] fix: unified unread count state --- .../src/components/UnreadCountBadge.tsx | 37 +++++---- .../MessageItemView/MessageWrapper.tsx | 17 ++-- .../MessageList/MessageFlashList.tsx | 82 +++++++++++++++---- .../components/MessageList/MessageList.tsx | 74 ++++++++++++----- .../MessageList/ScrollToBottomButton.tsx | 23 +++++- .../__tests__/ScrollToBottomButton.test.tsx | 33 +++++++- 6 files changed, 201 insertions(+), 65 deletions(-) diff --git a/examples/SampleApp/src/components/UnreadCountBadge.tsx b/examples/SampleApp/src/components/UnreadCountBadge.tsx index 6092ae3b55..38cdab4ae4 100644 --- a/examples/SampleApp/src/components/UnreadCountBadge.tsx +++ b/examples/SampleApp/src/components/UnreadCountBadge.tsx @@ -23,28 +23,31 @@ export const ChannelsUnreadCountBadge: React.FC = () => { const { chatClient } = useAppContext(); const [unreadCount, setUnreadCount] = useState(0); /** - * Listen to changes in unread counts and update the badge count + * Listen to changes in unread counts and update the badge count. + * + * We deliberately derive the total from each channel's local `countUnread()` (a sum over the + * loaded/active channels) rather than the server-sent `event.total_unread_count`. The local count + * respects the "viewing live" gate — the channel the user is currently reading at the bottom stays + * at 0 — so the badge does NOT flicker when a message arrives in the active channel. The server + * total, by contrast, briefly counts that message until the mark-read round-trips. */ useEffect(() => { - const listener = chatClient?.on((e) => { - const event = e.me ?? e; - if (event.total_unread_count !== undefined) { - setUnreadCount(event.total_unread_count); - } else { - if (Object.keys(chatClient?.activeChannels).length > 0) { - const countUnread = Object.values(chatClient.activeChannels).reduce( - (count, channel) => count + channel.countUnread(), - 0, - ); - setUnreadCount(countUnread); - } - } + if (!chatClient) { + return; + } + const computeUnreadCount = () => + Object.values(chatClient.activeChannels).reduce( + (count, channel) => count + (channel?.countUnread() ?? 0), + 0, + ); + + setUnreadCount(computeUnreadCount()); + const listener = chatClient.on(() => { + setUnreadCount(computeUnreadCount()); }); return () => { - if (listener) { - listener.unsubscribe(); - } + listener.unsubscribe(); }; }, [chatClient]); diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index 4402560c9b..282cc45446 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -54,7 +54,6 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW }); const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); - const messageIsOwn = message.user?.id === client.userID; const nextMessageId = nextMessage?.id; const nextMessageIsOwn = nextMessage?.user?.id === client.userID; const nextMessageCreatedAt = nextMessage?.created_at @@ -79,16 +78,22 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW // Frozen boundary (channel open / mark-unread): anchor directly to the known first-unread id. showUnreadSeparator = nextMessageId === snapshot.firstUnreadMessageId; } else if (snapshot.unreadCount) { - // Live boundary: this row is the last read/own message before the first unread-from-another. + // Live boundary: the separator sits above the FIRST unread-from-another message — i.e. on + // the last genuinely-read row (created at/before the read boundary) whose newer neighbour is + // that first unread. Gating on "this row is read" (rather than "this row isn't itself an + // unread-from-another") keeps it to a SINGLE separator even when our own messages are + // interleaved among the unreads: an own message sent after the boundary is not "read" by + // this test, so `own → unread-from-another` transitions further down never start a second + // separator. Chronological ordering guarantees exactly one read→unread transition. const lastReadAtMs = snapshot.lastReadAt?.getTime() ?? 0; const nextIsUnreadFromOther = !!nextMessageId && !nextMessageIsOwn && nextMessageCreatedAt !== undefined && nextMessageCreatedAt > lastReadAtMs; - const thisIsUnreadFromOther = - !messageIsOwn && !!createdAtTimestamp && createdAtTimestamp > lastReadAtMs; - showUnreadSeparator = nextIsUnreadFromOther && !thisIsUnreadFromOther; + const thisIsRead = + typeof createdAtTimestamp === 'number' && createdAtTimestamp <= lastReadAtMs; + showUnreadSeparator = nextIsUnreadFromOther && thisIsRead; } else { showUnreadSeparator = false; } @@ -101,7 +106,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW unreadCount: showUnreadSeparator ? snapshot.unreadCount : undefined, }; }, - [createdAtTimestamp, messageIsOwn, nextMessageCreatedAt, nextMessageId, nextMessageIsOwn], + [createdAtTimestamp, nextMessageCreatedAt, nextMessageId, nextMessageIsOwn], ); const { showUnreadSeparator, unreadCount } = useStateStore( channel.messagePaginator.unreadStateSnapshot, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index efe20fa49e..24e40474ca 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -1,5 +1,6 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + AppState, LayoutChangeEvent, ScrollViewProps, StyleSheet, @@ -367,6 +368,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); + const [isAppActive, setIsAppActive] = useState(() => AppState.currentState === 'active'); + const isNewestMessageVisibleRef = useRef(false); const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const [stickyHeaderDate, setStickyHeaderDate] = useState(); const [scrollEnabled, setScrollEnabled] = useState(true); @@ -479,6 +482,15 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => useStateStore(focusPaginator?.messageFocusSignal, messageFocusSelector) ?? {}; const lastFocusScrollTokenRef = useRef(undefined); + // Clear the focus/highlight signal on unmount (or when switching channel/thread). The signal + // lives on the LLC paginator, which outlives this component — without this, a highlight still + // active when you navigate away re-fires the scroll+highlight on return (the scroll-token ref + // resets on remount). Mirrors the old React-state highlight that cleaned up on unmount. + useEffect(() => { + const paginator = focusPaginator; + return () => paginator?.clearMessageFocusSignal(); + }, [focusPaginator]); + /** * Scrolls to the focused message (messageFocusSignal) once it's rendered. Re-attempts when the * list updates while a focus is pending, and marks each focus token handled so unrelated list @@ -523,8 +535,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => requestAnimationFrame(async () => { await scrollToIndex(); }); + // Start the highlight's auto-dismiss countdown now that the message is scrolled into view. + // The LLC deliberately does NOT start it on emit (the message may not be visible yet), so + // without this the highlight would persist forever. + focusPaginator?.scheduleMessageFocusSignalClear({ token: focusToken }); }, WAIT_FOR_SCROLL_TIMEOUT); - }, [focusToken, focusedMessageId, processedMessageList]); + }, [focusPaginator, focusToken, focusedMessageId, processedMessageList]); const goToMessage = useStableCallback(async (messageId: string) => { // jumpToMessage loads-around + emits messageFocusSignal → the effect scrolls and highlights. @@ -615,14 +631,38 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }, [channel, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, threadList]); /** - * Effect to mark the channel as read when the user scrolls to the bottom of the message list. + * Track app foreground/background. Combined with viewability (above) it decides whether we are + * "viewing live"; the LLC skips the unread bump while live (see `messagePaginator.isViewingLive`). + */ + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => + setIsAppActive(nextAppState === 'active'), + ); + return () => subscription.remove(); + }, []); + + /** + * Push the "viewing live" signal when the app foreground state changes (viewability pushes it on + * scroll). Reset to false on unmount / channel switch so a backgrounded or torn-down list never + * suppresses unread counting. + */ + useEffect(() => { + const { messagePaginator } = channel; + messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); + return () => messagePaginator.setViewingLive(false); + }, [channel, isAppActive]); + + /** + * Mark the channel read when a message arrives while the user is viewing the latest messages. + * The LLC skips the unread bump while live, so — unlike before — no synchronous snapshot reset is + * needed here (that was the fragile bump-then-undo); we just tell the server. */ useEffect(() => { const shouldMarkRead = () => { const channelUnreadState = getChannelUnreadState(channel); return ( + channel.messagePaginator.isViewingLive.getLatestValue().isViewingLive && !channelUnreadState?.first_unread_message_id && - !scrollToBottomButtonVisible && client.user?.id && !hasReadLastMessage(channel, client.user?.id) ); @@ -631,19 +671,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const handleEvent = (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; if (mainChannelUpdated && shouldMarkRead()) { - // We're caught up at the bottom and reading this message in real time. Clear the unread - // snapshot SYNCHRONOUSLY — before the throttled, async markRead below — so the "N new - // messages" banner / unread separator never flash for a message we're already looking at. - // The LLC bumps unreadCount on this same event; resetting it here (a later-registered - // listener in the same synchronous dispatch) means the bumped value is never rendered. - channel.messagePaginator.unreadStateSnapshot.next({ - firstUnreadMessageId: null, - lastReadAt: new Date(), - lastReadMessageId: - event.message?.id ?? - channel.messagePaginator.unreadStateSnapshot.getLatestValue().lastReadMessageId, - unreadCount: 0, - }); markRead(); } }; @@ -653,7 +680,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return () => { listener?.unsubscribe(); }; - }, [channel, client.user?.id, markRead, scrollToBottomButtonVisible, threadList]); + }, [channel, client.user?.id, markRead]); const updateStickyHeaderDateIfNeeded = useStableCallback((viewableItems: ViewToken[]) => { if (!viewableItems.length) { @@ -764,6 +791,24 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => updateStickyHeaderDateIfNeeded(viewableItems); } updateStickyUnreadIndicator(viewableItems); + + // Report whether the user is viewing the latest messages (the newest channel message is on + // screen) so the LLC can skip the unread bump while live (see `messagePaginator.isViewingLive`). + // Viewability reflects the real layout, so this is correct even at mount — a channel opened at + // its first unread has the newest message off-screen and therefore reports `false`. + // The newest message comes from the paginator (what the list actually renders), not + // channel.state.latestMessages. The last loaded item is the true newest only when the head is + // loaded (`!hasMoreHead`) — if newer messages exist beyond the loaded window we're not live even + // when the last loaded item is visible. + const paginatorState = channel.messagePaginator.state.getLatestValue(); + const loadedItems = paginatorState.items ?? []; + const newestMessageId = paginatorState.hasMoreHead + ? undefined + : loadedItems[loadedItems.length - 1]?.id; + isNewestMessageVisibleRef.current = + !!newestMessageId && + viewableItems.some((viewable) => viewable.item.message?.id === newestMessageId); + channel.messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); }; const onViewableItemsChanged = useRef(unstableOnViewableItemsChanged); @@ -974,6 +1019,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }); }); + // Non-reactive read for the accessibility action label only (the button owns its own reactive + // count). Refreshes on the list's normal re-renders (e.g. scroll), which is sufficient for a11y. const scrollToBottomUnreadCount = scrollToBottomButtonVisible && !threadList ? channel?.countUnread() : undefined; const { @@ -1171,7 +1218,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 5f303f1fa6..109ba4073b 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -494,6 +494,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); + const [isAppActive, setIsAppActive] = useState(() => AppState.currentState === 'active'); + const isNewestMessageVisibleRef = useRef(false); const [stickyHeaderDate, setStickyHeaderDate] = useState(); const stickyHeaderDateRef = useRef(undefined); @@ -614,6 +616,16 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { updateStickyHeaderDateIfNeeded(viewableItems); } updateStickyUnreadIndicator(viewableItems); + + const paginatorState = channel.messagePaginator.state.getLatestValue(); + const loadedItems = paginatorState.items ?? []; + const newestMessageId = paginatorState.hasMoreHead + ? undefined + : loadedItems[loadedItems.length - 1]?.id; + isNewestMessageVisibleRef.current = + !!newestMessageId && + viewableItems.some((viewable) => viewable.item.message?.id === newestMessageId); + channel.messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); }; const onViewableItemsChanged = useRef(unstableOnViewableItemsChanged); @@ -641,15 +653,38 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }, [disabled]); /** - * Effect to mark the channel as read when the user scrolls to the bottom of the message list. + * Track app foreground/background. Combined with viewability (above) it decides whether we are + * "viewing live"; the LLC skips the unread bump while live (see `messagePaginator.isViewingLive`). + */ + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => + setIsAppActive(nextAppState === 'active'), + ); + return () => subscription.remove(); + }, []); + + /** + * Push the "viewing live" signal when the app foreground state changes (viewability pushes it on + * scroll). Reset to false on unmount / channel switch so a backgrounded or torn-down list never + * suppresses unread counting. + */ + useEffect(() => { + const { messagePaginator } = channel; + messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); + return () => messagePaginator.setViewingLive(false); + }, [channel, isAppActive]); + + /** + * Mark the channel read when a message arrives while the user is viewing the latest messages. + * The LLC skips the unread bump while live, so — unlike before — no synchronous snapshot reset is + * needed here (that was the fragile bump-then-undo); we just tell the server. */ useEffect(() => { const shouldMarkRead = () => { const channelUnreadState = getChannelUnreadState(channel); return ( - AppState.currentState === 'active' && + channel.messagePaginator.isViewingLive.getLatestValue().isViewingLive && !channelUnreadState?.first_unread_message_id && - !scrollToBottomButtonVisible && client.user?.id && !hasReadLastMessage(channel, client.user?.id) ); @@ -658,19 +693,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const handleEvent = (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; if (mainChannelUpdated && shouldMarkRead()) { - // We're caught up at the bottom and reading this message in real time. Clear the unread - // snapshot SYNCHRONOUSLY — before the throttled, async markRead below — so the "N new - // messages" banner / unread separator never flash for a message we're already looking at. - // The LLC bumps unreadCount on this same event; resetting it here (a later-registered - // listener in the same synchronous dispatch) means the bumped value is never rendered. - channel.messagePaginator.unreadStateSnapshot.next({ - firstUnreadMessageId: null, - lastReadAt: new Date(), - lastReadMessageId: - event.message?.id ?? - channel.messagePaginator.unreadStateSnapshot.getLatestValue().lastReadMessageId, - unreadCount: 0, - }); markRead(); } }; @@ -680,7 +702,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return () => { listener?.unsubscribe(); }; - }, [channel, client.user?.id, markRead, scrollToBottomButtonVisible, threadList]); + }, [channel, client.user?.id, markRead]); useEffect(() => { /** @@ -805,6 +827,15 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { useStateStore(focusPaginator?.messageFocusSignal, messageFocusSelector) ?? {}; const lastFocusScrollTokenRef = useRef(undefined); + // Clear the focus/highlight signal on unmount (or when switching channel/thread). The signal + // lives on the LLC paginator, which outlives this component — without this, a highlight still + // active when you navigate away re-fires the scroll+highlight on return (the scroll-token ref + // resets on remount). Mirrors the old React-state highlight that cleaned up on unmount. + useEffect(() => { + const paginator = focusPaginator; + return () => paginator?.clearMessageFocusSignal(); + }, [focusPaginator]); + const goToMessage = useStableCallback(async (messageId: string) => { // jumpToMessage loads-around the target + emits messageFocusSignal → the effect scrolls and the // message highlights (mirrors stream-chat-react — no bespoke scroll/highlight bookkeeping). @@ -839,8 +870,12 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { index: indexOfParentInMessageList, viewPosition: 0.5, // try to place message in the center of the screen }); + // Start the highlight's auto-dismiss countdown now that the message is scrolled into view. + // The LLC deliberately does NOT start it on emit (the message may not be visible yet), so + // without this the highlight would persist forever. + focusPaginator?.scheduleMessageFocusSignalClear({ token: focusToken }); }, WAIT_FOR_SCROLL_TIMEOUT); - }, [focusToken, focusedMessageId, processedMessageList]); + }, [focusPaginator, focusToken, focusedMessageId, processedMessageList]); const setNativeScrollability = useStableCallback((value: boolean) => { if (flatListRef.current) { @@ -1030,6 +1065,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }); }); + // Non-reactive read for the accessibility action label only (the button owns its own reactive + // count). Refreshes on the list's normal re-renders (e.g. scroll), which is sufficient for a11y. const scrollToBottomUnreadCount = scrollToBottomButtonVisible && !threadList ? channel?.countUnread() : undefined; const { @@ -1328,7 +1365,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ) : null} diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index 40e89d862d..f102955c30 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -1,9 +1,14 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; +import type { ReadState } from 'stream-chat'; + +import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; +import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; import { Down } from '../../icons/arrow-up'; import { primitives } from '../../theme'; import { BadgeNotification } from '../ui'; @@ -18,15 +23,25 @@ export type ScrollToBottomButtonProps = { onPress: () => void; /** If we should show the notification or not */ showNotification?: boolean; - unreadCount?: number; }; export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { - const { onPress, showNotification = true, unreadCount } = props; + const { onPress, showNotification = true } = props; + const { channel, threadList } = useChannelContext(); + const { client } = useChatContext(); const { theme: { semantics }, } = useTheme(); - const accessibilityLabelParams = React.useMemo( + const userId = client?.userID; + const ownUnreadSelector = useCallback( + (state: ReadState) => ({ + unreadCount: userId ? (state.read[userId]?.unread_messages ?? 0) : 0, + }), + [userId], + ); + const ownRead = useStateStore(channel?.state?.readStore, ownUnreadSelector); + const unreadCount = threadList ? undefined : ownRead?.unreadCount; + const accessibilityLabelParams = useMemo( () => (unreadCount ? { count: unreadCount } : undefined), [unreadCount], ); diff --git a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx index 1445742812..74b48ef37d 100644 --- a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx +++ b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx @@ -2,6 +2,10 @@ import React from 'react'; import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; +import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; +import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; +import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; +import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; @@ -67,10 +71,37 @@ describe('ScrollToBottomButton', () => { const t = jest.fn((key: string) => key); const i18nInstance = new Streami18n(); const translators = await i18nInstance.getTranslators(); + // The button reads OUR user's count from the channel read store (see ScrollToBottomButton), + // keyed by the chat client's userID. Provide a minimal fake client + channel read store + // reporting 3 unread for that user. Hand-rolled to avoid a runtime `stream-chat` import (which + // breaks jest under the local portal). + const readState = { + read: { + me: { + last_read: new Date(), + unread_messages: 3, + user: { id: 'me' }, + }, + }, + }; + const channel = { + state: { + readStore: { + getLatestValue: () => readState, + subscribeWithSelector: () => () => {}, + }, + }, + }; const { getByTestId, getByText } = render( - null} showNotification={true} unreadCount={3} /> + + + null} showNotification={true} /> + + , ); From fa1bf4b7a3d6bb9af8d84855f1306ac0ef6373c7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 17 Jul 2026 14:21:50 +0200 Subject: [PATCH 31/36] fix: use throttled mark read version --- .../MessageList/hooks/useMarkRead.ts | 106 ++++++++---------- 1 file changed, 46 insertions(+), 60 deletions(-) diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts index e201b3aa1b..8a29228c08 100644 --- a/package/src/components/MessageList/hooks/useMarkRead.ts +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -1,25 +1,20 @@ -import throttle from 'lodash/throttle'; - import type { Channel } from 'stream-chat'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useStableCallback } from '../../../hooks'; import { MarkReadFunctionOptions } from '../../Channel/Channel'; -const defaultThrottleInterval = 500; -const throttleOptions = { - leading: true, - trailing: true, -}; - /** - * Returns a throttled `markRead` callback for the active channel. + * Returns a `markRead` callback for the active channel. * - * The behavior mirrors the previous `Channel`-level implementation: it is a no-op when the channel - * is missing or disconnected, resets the local unread count when read events are disabled (and the - * client opted into a local unread count), and otherwise delegates to `channel.markRead()` and then - * resets the paginator's unread snapshot (unless `updateChannelUnreadState` is `false`). - * The returned function is stabilized so it can be used as a dependency without triggering rerenders. + * Marks the channel read through the client's `messageDeliveryReporter.throttledMarkRead` — the + * canonical v10 read-reporting path, which the reporter throttles + coordinates — rather than calling + * `channel.markRead()` behind our own throttle. It is a no-op when the channel is missing or + * disconnected; when read events are disabled it resets the local unread count via `markReadLocally()` + * (dispatches `message.read_locally`) if the client opted into a local count; otherwise it reports the + * read and resets the paginator's unread snapshot (unless `updateChannelUnreadState` is `false`) so the + * "N new messages" banner + unread separator clear once caught up. The returned function is stabilized + * so it can be used as a dependency without triggering rerenders. */ export const useMarkRead = (channel: Channel) => { const { client } = useChatContext(); @@ -35,56 +30,47 @@ export const useMarkRead = (channel: Channel) => { } }; - const clientChannelConfig = getChannelConfigSafely(); - - const markReadInternal = throttle( - async (options?: MarkReadFunctionOptions) => { - if (!channel || channel?.disconnected) { - return; - } + const markRead = useStableCallback((options?: MarkReadFunctionOptions) => { + if (!channel || channel?.disconnected) { + return; + } - // Read events disabled (e.g. livestreams): if the client opted into a local unread count, - // reset it locally (dispatches message.read_locally) — no backend round trip. The paginator's - // unread snapshot updates from that. - if (!clientChannelConfig?.read_events) { - if (client.options.isLocalUnreadCountEnabled) { - channel.markReadLocally(); - } - return; + // Read events disabled (e.g. livestreams): if the client opted into a local unread count, reset + // it locally (dispatches message.read_locally) — no backend round trip. The paginator's unread + // snapshot updates from that. + if (!getChannelConfigSafely()?.read_events) { + if (client.options.isLocalUnreadCountEnabled) { + channel.markReadLocally(); } + return; + } - // channel.markRead() delegates to client.messageDeliveryReporter.markRead(this), which honors - // any custom markReadRequest handler registered in channel.configState (see - // useChannelRequestHandlers). - try { - const response = await channel.markRead(); + // Canonical v10 read reporting: the reporter throttles + coordinates the `/read` calls and honors + // any custom markReadRequest handler registered in channel.configState (see + // useChannelRequestHandlers). + client.messageDeliveryReporter.throttledMarkRead(channel); - // Reset the paginator's unread snapshot so the "N new messages" banner and the unread - // separator clear once the channel is caught up. The LLC bumps `unreadCount` on every - // incoming `message.new` but never clears it on `message.read`, so without this the banner - // latches on and can't be dismissed. Mirrors stream-chat-react's `markChannelRead`. - // - // Gated on `updateChannelUnreadState` (default true): the mark-read-on-mount call passes - // `false` so opening a channel with unreads keeps its original unread UI (separator frozen - // at the boundary) until the user actually catches up. - const { updateChannelUnreadState = true } = options ?? {}; - if (updateChannelUnreadState && response?.event) { - channel.messagePaginator.unreadStateSnapshot.next({ - firstUnreadMessageId: null, - lastReadAt: new Date(), - lastReadMessageId: response.event.last_read_message_id ?? null, - unreadCount: 0, - }); - } - } catch (err) { - console.log('Error marking channel as read:', err); - } - }, - defaultThrottleInterval, - throttleOptions, - ); - - const markRead: (options?: MarkReadFunctionOptions) => void = useStableCallback(markReadInternal); + // Reset the paginator's unread snapshot so the "N new messages" banner and the unread separator + // clear once the channel is caught up. The LLC bumps `unreadCount` on every incoming + // `message.new` but never clears it on `message.read`, so without this the banner latches on and + // can't be dismissed. `throttledMarkRead` is fire-and-forget (no response to read), so advance + // the boundary to the latest loaded message. + // + // Gated on `updateChannelUnreadState` (default true): the mark-read-on-mount call passes `false` + // so opening a channel with unreads keeps its original unread UI (separator frozen at the + // boundary) until the user actually catches up. + const { updateChannelUnreadState = true } = options ?? {}; + if (updateChannelUnreadState) { + const loadedItems = channel.messagePaginator.state.getLatestValue().items ?? []; + const previous = channel.messagePaginator.unreadStateSnapshot.getLatestValue(); + channel.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: new Date(), + lastReadMessageId: loadedItems[loadedItems.length - 1]?.id ?? previous.lastReadMessageId, + unreadCount: 0, + }); + } + }); return markRead; }; From 4a34bf3a4fc96275dc52b6a95b56f9a858085318 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 17 Jul 2026 17:21:56 +0200 Subject: [PATCH 32/36] chore: renaming --- package/src/components/MessageList/MessageFlashList.tsx | 2 +- package/src/components/MessageList/MessageList.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 24e40474ca..7f26b2a675 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -661,7 +661,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const shouldMarkRead = () => { const channelUnreadState = getChannelUnreadState(channel); return ( - channel.messagePaginator.isViewingLive.getLatestValue().isViewingLive && + channel.messagePaginator.isViewingLive && !channelUnreadState?.first_unread_message_id && client.user?.id && !hasReadLastMessage(channel, client.user?.id) diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 109ba4073b..4e2405ccbb 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -683,7 +683,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const shouldMarkRead = () => { const channelUnreadState = getChannelUnreadState(channel); return ( - channel.messagePaginator.isViewingLive.getLatestValue().isViewingLive && + channel.messagePaginator.isViewingLive && !channelUnreadState?.first_unread_message_id && client.user?.id && !hasReadLastMessage(channel, client.user?.id) From e0cda4900e5230fbb8223c6075405f4c4f38e5bd Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 17 Jul 2026 23:14:20 +0200 Subject: [PATCH 33/36] chore: cleanup of old thread apis --- package/src/components/Channel/Channel.tsx | 57 +------------- .../Channel/__tests__/Channel.test.tsx | 6 +- .../Channel/hooks/useCreateThreadContext.ts | 74 +++++-------------- ...InlineLoadingMoreRecentThreadIndicator.tsx | 9 ++- .../MessageList/MessageFlashList.tsx | 19 ++--- .../components/MessageList/MessageList.tsx | 25 ++----- .../MessageList/hooks/useMessageList.ts | 12 +-- package/src/components/Thread/Thread.tsx | 14 +--- .../components/ThreadFooterComponent.tsx | 12 +-- .../contexts/threadContext/ThreadContext.tsx | 10 +-- 10 files changed, 57 insertions(+), 181 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 96eefcdb39..ccfe6872cf 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -497,8 +497,6 @@ const ChannelWithContext = (props: PropsWithChildren) = // constructed instance isn't recreated (losing paginator state) on unrelated re-renders. // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadInstanceFromProps, threadProps?.id, channel, client]); - const [threadHasMore] = useState(true); - const [threadLoadingMore, setThreadLoadingMore] = useState(false); const [messageInputHeightStore] = useState(() => new MessageInputHeightStore()); const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); @@ -685,28 +683,6 @@ const ChannelWithContext = (props: PropsWithChildren) = // instance via useMarkRead(channel). Channel still needs it internally (mark-read-on-mount + resync). const markRead = useMarkRead(channel); - const reloadThread = useStableCallback(async () => { - if (!channel || !thread?.id || !threadInstance) { - return; - } - setThreadLoadingMore(true); - try { - // Rehydrate the thread instance (parent + read state) and reload its reply paginator. - await threadInstance.reload(); - await threadInstance.messagePaginator.reload(); - setThreadLoadingMore(false); - } catch (err) { - console.warn('Thread loading request failed with error', err); - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - setThreadLoadingMore(false); - throw err; - } - }); - const resyncChannel = useStableCallback(async () => { if (!channel || syncingChannelRef.current || (!channel.initialized && !channel.offlineMode)) { return; @@ -850,22 +826,20 @@ const ChannelWithContext = (props: PropsWithChildren) = } try { if (thread) { - setThreadLoadingMore(true); try { // jumpToMessage loads the message range into thread.messagePaginator (which backs the // reply list) and emits the focus signal driving the thread-aware highlight + scroll. + // The reply-list loading spinner is driven off the paginator's own isLoading flag. await threadInstance?.messagePaginator?.jumpToMessage(messageIdToLoadAround, { focusReason: 'jump-to-message', focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, }); - setThreadLoadingMore(false); } catch (err) { if (err instanceof Error) { setError(err); } else { setError(true); } - setThreadLoadingMore(false); } } else { await loadChannelAroundMessageFn({ @@ -920,30 +894,6 @@ const ChannelWithContext = (props: PropsWithChildren) = }, ); - /** - * THREAD METHODS - */ - const loadMoreThread: ThreadContextValue['loadMoreThread'] = useStableCallback(async () => { - // Older replies are paginated via the thread's messagePaginator, which backs the reply list. - // (useCreateThreadContext also wires loadMoreThread to the paginator when a thread instance - // exists — this keeps the ThreadContext shape stable and the behavior identical; the paginator - // guards against concurrent loads and tracks hasMore/loading reactively.) - if (!threadInstance) { - return; - } - try { - await threadInstance.messagePaginator.toTail(); - } catch (err) { - console.warn('Message pagination request failed with error', err); - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - throw err; - } - }); - const handleClosePicker = useStableCallback(() => closePicker(bottomSheetRef)); const handleOpenPicker = useStableCallback(() => openPicker(bottomSheetRef)); @@ -1094,13 +1044,8 @@ const ChannelWithContext = (props: PropsWithChildren) = const threadContext = useCreateThreadContext({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - loadMoreThread, - reloadThread, - setThreadLoadingMore, thread, - threadHasMore, threadInstance, - threadLoadingMore, }); const audioPlayerContext = useMemo( diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index f5f2e1fbb4..6f8d2ab316 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -333,9 +333,8 @@ describe('Channel', () => { let context: ThreadContextValue | undefined; const mockContext = { + allowThreadMessagesInChannel: true, thread: {}, - threadHasMore: true, - threadLoadingMore: false, }; render( @@ -352,8 +351,7 @@ describe('Channel', () => { await waitFor(() => { expect(context).toBeInstanceOf(Object); expect(context!.thread).toBeInstanceOf(Object); - expect(context!.threadHasMore).toBe(true); - expect(context!.threadLoadingMore).toBe(false); + expect(context!.allowThreadMessagesInChannel).toBe(true); }); }); }); diff --git a/package/src/components/Channel/hooks/useCreateThreadContext.ts b/package/src/components/Channel/hooks/useCreateThreadContext.ts index 369c56bbac..7ba51a9a93 100644 --- a/package/src/components/Channel/hooks/useCreateThreadContext.ts +++ b/package/src/components/Channel/hooks/useCreateThreadContext.ts @@ -1,65 +1,25 @@ -import { LocalMessage } from 'stream-chat'; +import { useMemo } from 'react'; import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; -import { useStateStore } from '../../../hooks'; - -type ThreadMessagePaginatorState = { - hasMoreHead: boolean; - hasMoreTail: boolean; - isLoading: boolean; - items?: LocalMessage[]; -}; - -// Reply list is sourced from the thread's messagePaginator (optimistic thread ops write there, -// not to the legacy state.replies). tailward === older replies (loadMoreThread); headward === -// newer replies (loadMoreRecentThread). -const selector = (state: ThreadMessagePaginatorState) => ({ - hasMore: state.hasMoreTail, - isLoading: state.isLoading, - messages: state.items, -}); +// The ThreadContext now carries only the Thread instance (+ a few UI-config props). Reply data, +// pagination and loading state are read by consumers straight off `threadInstance.messagePaginator` +// / `threadInstance.state` via useStateStore — there is no derived state to assemble here. export const useCreateThreadContext = ({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - loadMoreThread, - reloadThread, - setThreadLoadingMore, thread, - threadHasMore, threadInstance, - threadLoadingMore, -}: Omit) => { - const { hasMore, isLoading, messages } = - useStateStore(threadInstance?.messagePaginator?.state, selector) ?? {}; - - const contextAdapter = threadInstance - ? { - loadMoreRecentThread: async () => { - await threadInstance.messagePaginator.toHead(); - }, - loadMoreThread: async () => { - await threadInstance.messagePaginator.toTail(); - }, - threadHasMore: hasMore ?? false, - threadInstance, - threadLoadingMore: !!isLoading, - } - : {}; - - return { - allowThreadMessagesInChannel, - onAlsoSentToChannelHeaderPress, - loadMoreThread, - reloadThread, - setThreadLoadingMore, - thread, - threadHasMore, - threadLoadingMore, - // The reply list is sourced solely from the thread's messagePaginator (optimistic thread ops - // write there). Empty when no thread is open (threadInstance null); the list only reads this - // when threadList is true, i.e. a thread is open and threadInstance is set. - threadMessages: messages ?? [], - ...contextAdapter, - }; -}; +}: Pick< + ThreadContextValue, + 'allowThreadMessagesInChannel' | 'onAlsoSentToChannelHeaderPress' | 'thread' | 'threadInstance' +>): ThreadContextValue => + useMemo( + () => ({ + allowThreadMessagesInChannel, + onAlsoSentToChannelHeaderPress, + thread, + threadInstance, + }), + [allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, thread, threadInstance], + ); diff --git a/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx b/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx index 73e8a97701..74408a5945 100644 --- a/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx +++ b/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { useThreadContext } from '../../contexts'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; @@ -57,7 +56,9 @@ const MemoizedInlineLoadingMoreRecentIndicator = React.memo( export const InlineLoadingMoreRecentThreadIndicator = ( _props: InlineLoadingMoreRecentThreadIndicatorPropsWithContext, ) => { - const { threadLoadingMoreRecent } = useThreadContext(); - - return ; + // The reply paginator exposes a single `isLoading` flag with no head/tail direction, so a + // dedicated "loading newer replies" state isn't available yet; keep this indicator dormant (it + // was previously driven by `threadLoadingMoreRecent`, which was never actually produced). + // TODO: wire to a directional loading flag on MessagePaginator once one exists. + return ; }; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 7f26b2a675..fe8edca462 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -138,10 +138,7 @@ type MessageFlashListPropsWithContext = Pick< MessagesContextValue, 'disableTypingIndicator' | 'FlatList' | 'myMessageTheme' | 'shouldShowUnreadUnderlay' > & - Pick< - ThreadContextValue, - 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' - > & { + Pick & { /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want * to attach some additional props to underlying FlatList, you can add it to following prop. @@ -329,8 +326,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => loadingMoreRecent, loadMore, loadMoreRecent, - loadMoreRecentThread, - loadMoreThread, markRead, maximumMessageLimit, messageInputFloating, @@ -891,9 +886,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => await onEndReachedInPromise.current; } onStartReachedInPromise.current = ( - threadList && !!threadInstance && loadMoreRecentThread - ? loadMoreRecentThread({}) - : loadMoreRecent() + threadList && threadInstance ? threadInstance.messagePaginator.toHead() : loadMoreRecent() ) .then(callback) .catch(onError); @@ -932,7 +925,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => await onStartReachedInPromise.current; } - onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) + onEndReachedInPromise.current = ( + threadList ? (threadInstance?.messagePaginator.toTail() ?? Promise.resolve()) : loadMore() + ) .then(callback) .catch(onError); }); @@ -1331,7 +1326,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { loadMoreRecent, state: { loadingMore, loadingMoreRecent }, } = useMessageListPagination({ channel }); - const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); + const { thread, threadInstance } = useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = useMessageInputContext(); @@ -1358,8 +1353,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { loadMoreRecent, loadingMore, loadingMoreRecent, - loadMoreRecentThread, - loadMoreThread, markRead, maximumMessageLimit, messageInputFloating, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 4e2405ccbb..6f4ad36da3 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -222,10 +222,7 @@ type MessageListPropsWithContext = Pick< MessageInputContextValue, 'allowSendBeforeAttachmentsUpload' | 'messageInputFloating' | 'messageInputHeightStore' > & - Pick< - ThreadContextValue, - 'loadMoreRecentThread' | 'loadMoreThread' | 'threadHasMore' | 'thread' | 'threadInstance' - > & { + Pick & { /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want * to attach some additional props to underlying FlatList, you can add it to following prop. @@ -340,8 +337,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { loadingMoreRecent, loadMore, loadMoreRecent, - loadMoreRecentThread, - loadMoreThread, markRead, maximumMessageLimit, messageInputFloating, @@ -357,7 +352,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { threadInstance, threadList = false, hasMore, - threadHasMore, } = props; const { EmptyStateIndicator, @@ -948,9 +942,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { await onEndReachedInPromise.current; } onStartReachedInPromise.current = ( - threadList && !!threadInstance && loadMoreRecentThread - ? loadMoreRecentThread({}) - : loadMoreRecent() + threadList && threadInstance ? threadInstance.messagePaginator.toHead() : loadMoreRecent() ) .then(callback) .catch(onError); @@ -962,7 +954,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * 3. If the call to `loadMoreRecent` is in progress, we wait for it to finish to make sure scroll doesn't jump. */ const maybeCallOnEndReached = useStableCallback(async () => { - const shouldQuery = (threadList && threadHasMore) || (!threadList && hasMore); + const shouldQuery = + (threadList && !!threadInstance?.messagePaginator.hasMoreTail) || (!threadList && hasMore); // If onEndReached has already been called for given messageList length, then ignore. if ( (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) || @@ -991,7 +984,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onStartReachedInPromise.current) { await onStartReachedInPromise.current; } - onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) + onEndReachedInPromise.current = ( + threadList ? (threadInstance?.messagePaginator.toTail() ?? Promise.resolve()) : loadMore() + ) .then(callback) .catch(onError); }); @@ -1432,8 +1427,7 @@ export const MessageList = (props: MessageListProps) => { loadMoreRecent, state: { hasMore, loadingMore, loadingMoreRecent }, } = useMessageListPagination({ channel }); - const { loadMoreRecentThread, loadMoreThread, threadHasMore, thread, threadInstance } = - useThreadContext(); + const { thread, threadInstance } = useThreadContext(); return ( { loading, loadMore, loadMoreRecent, - loadMoreRecentThread, - loadMoreThread, markRead, maximumMessageLimit, messageInputFloating, @@ -1471,7 +1463,6 @@ export const MessageList = (props: MessageListProps) => { hasMore, loadingMore, loadingMoreRecent, - threadHasMore, }} {...props} noGroupByUser={!enableMessageGroupingByUser || props.noGroupByUser} diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index ef49c163f5..dd1713fb8e 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -31,16 +31,16 @@ const messageListSelector = (state: { items?: LocalMessage[] }) => ({ messages: export const useMessageList = (params: UseMessageListParams) => { const { threadList, isLiveStreaming, isFlashList = false, maximumMessageLimit } = params; const { channel } = useChannelContext(); - // The channel message list is sourced reactively from channel.messagePaginator (channel-specific, - // NOT the thread-aware useMessagePaginator — so the main list keeps showing channel messages while - // a thread is open). Thread reply lists read threadMessages from the ThreadContext instead. - const { messages } = useStateStore(channel.messagePaginator.state, messageListSelector) ?? {}; + const { threadInstance } = useThreadContext(); + const messagePaginatorState = threadList + ? threadInstance?.messagePaginator?.state + : channel.messagePaginator.state; + const { messages } = useStateStore(messagePaginatorState, messageListSelector) ?? {}; const { viewabilityChangedCallback } = usePrunableMessageList({ maximumMessageLimit, setMessages: () => {}, }); - const { threadMessages } = useThreadContext(); - const messageList = (threadList ? threadMessages : messages) ?? EMPTY_MESSAGES; + const messageList = messages ?? EMPTY_MESSAGES; const processedMessageList = useMemo(() => { const newMessageList: LocalMessage[] = []; diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 9339c584ab..0120792a5a 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -26,10 +26,7 @@ try { } type ThreadPropsWithContext = Pick & - Pick< - ThreadContextValue, - 'loadMoreThread' | 'parentMessagePreventPress' | 'reloadThread' | 'thread' | 'threadInstance' - > & { + Pick & { /** * Additional props for underlying MessageComposer component. * Available props - https://getstream.io/chat/docs/sdk/reactnative/ui-components/message-input/#props @@ -89,7 +86,6 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { additionalMessageFlashListProps, autoFocus = false, disabled, - loadMoreThread, onThreadDismount, notificationHostId: notificationHostIdProp, parentMessagePreventPress = true, @@ -169,8 +165,8 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { if (!threadInstance || isLoading || items !== undefined) { return; } - void loadMoreThread(); - }, [threadInstance, items, isLoading, loadMoreThread]); + void threadInstance.messagePaginator.toTail(); + }, [threadInstance, items, isLoading]); // Tear down on unmount. Use a ref so we deactivate whichever instance is current at unmount, not // the (possibly null) one captured when this effect first ran. @@ -246,7 +242,7 @@ export type ThreadProps = Partial; export const Thread = (props: ThreadProps) => { const { client } = useChatContext(); const { threadList } = useChannelContext(); - const { loadMoreThread, reloadThread, thread, threadInstance } = useThreadContext(); + const { thread, threadInstance } = useThreadContext(); if (thread?.id && !threadList) { throw new Error( @@ -258,8 +254,6 @@ export const Thread = (props: ThreadProps) => { ; +const loadingSelector = (state: { isLoading: boolean }) => ({ isLoading: state.isLoading }); + export const InlineLoadingMoreThreadIndicator = () => { - const { threadLoadingMore } = useThreadContext(); + const { threadInstance } = useThreadContext(); const { theme: { semantics }, } = useTheme(); const styles = useStyles(); + const { isLoading } = + useStateStore(threadInstance?.messagePaginator?.state, loadingSelector) ?? {}; - if (!threadLoadingMore) { + if (!isLoading) { return null; } @@ -136,8 +140,7 @@ export type ThreadFooterComponentProps = Partial< >; export const ThreadFooterComponent = (props: ThreadFooterComponentProps) => { - const { parentMessagePreventPress, thread, threadInstance, threadLoadingMore } = - useThreadContext(); + const { parentMessagePreventPress, thread, threadInstance } = useThreadContext(); return ( { parentMessagePreventPress, thread, threadInstance, - threadLoadingMore, }} {...props} /> diff --git a/package/src/contexts/threadContext/ThreadContext.tsx b/package/src/contexts/threadContext/ThreadContext.tsx index 735fd64de5..79a835de96 100644 --- a/package/src/contexts/threadContext/ThreadContext.tsx +++ b/package/src/contexts/threadContext/ThreadContext.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import { ChannelState, LocalMessage, Thread } from 'stream-chat'; +import { LocalMessage, Thread } from 'stream-chat'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -14,20 +14,12 @@ export type AlsoSentToChannelHeaderPressPayload = { export type ThreadContextValue = { allowThreadMessagesInChannel: boolean; - loadMoreThread: () => Promise; - reloadThread: () => void; - setThreadLoadingMore: React.Dispatch>; thread: LocalMessage | null; - threadHasMore: boolean; - threadMessages: ChannelState['threads'][string]; - loadMoreRecentThread?: (opts: { limit?: number }) => Promise; /** * Boolean to enable/disable parent message press */ parentMessagePreventPress?: boolean; threadInstance?: Thread | null; - threadLoadingMore?: boolean; - threadLoadingMoreRecent?: boolean; /** * Function to handle press on the "Also sent to channel" header action. * @param payload - Navigation payload with optional parent thread message and targeted message id From 5741b51592ecafd89814b8343a4780fe3db9dfae Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 18 Jul 2026 00:05:39 +0200 Subject: [PATCH 34/36] chore: cleanup thread composer semantics --- .../ExpoMessaging/app/channel/[cid]/index.tsx | 3 +- examples/ExpoMessaging/context/AppContext.tsx | 9 ++- .../src/context/StreamChatContext.tsx | 9 ++- .../SampleApp/src/screens/ChannelScreen.tsx | 3 +- examples/TypeScriptMessaging/App.tsx | 9 ++- package/src/components/Channel/Channel.tsx | 5 +- .../Channel/__tests__/Channel.test.tsx | 2 - .../Channel/hooks/useCreateThreadContext.ts | 10 ++-- .../MessageList/MessageFlashList.tsx | 10 ++-- .../components/MessageList/MessageList.tsx | 14 ++--- .../MessageList/TypingIndicatorContainer.tsx | 24 ++++---- .../MessageList/hooks/useTypingUsers.ts | 7 ++- .../MessageList/utils/filterTypingUsers.ts | 12 ++-- package/src/components/Thread/Thread.tsx | 15 +++-- .../Thread/__tests__/Thread.test.tsx | 5 +- .../components/ThreadFooterComponent.tsx | 60 ++++++------------- .../MessageComposerContext.tsx | 3 +- .../MessageInputContext.tsx | 4 +- .../hooks/useCreateMessageComposer.ts | 45 ++------------ .../hooks/useCreateMessageInputContext.ts | 6 +- .../contexts/threadContext/ThreadContext.tsx | 1 - 21 files changed, 91 insertions(+), 165 deletions(-) diff --git a/examples/ExpoMessaging/app/channel/[cid]/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/index.tsx index 7db3afd0c3..8d78a92c80 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/index.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/index.tsx @@ -10,7 +10,6 @@ import { ChannelAvatar, MessageComposer, MessageList, - ThreadContextValue, useChannelPreviewDisplayName, useChatContext, } from 'stream-chat-expo'; @@ -110,7 +109,7 @@ export default function ChannelScreen() { messageId={messageId} > { + onThreadSelect={(thread) => { setThread(thread); router.push(`/channel/${channel.cid}/thread/${thread?.cid ?? ''}`); }} diff --git a/examples/ExpoMessaging/context/AppContext.tsx b/examples/ExpoMessaging/context/AppContext.tsx index f69d4a2171..aeba3e7cf3 100644 --- a/examples/ExpoMessaging/context/AppContext.tsx +++ b/examples/ExpoMessaging/context/AppContext.tsx @@ -1,13 +1,12 @@ import React, { PropsWithChildren, createContext, useState } from 'react'; -import { Channel as ChannelType } from 'stream-chat'; -import { ThreadContextValue } from 'stream-chat-expo'; +import { Channel as ChannelType, LocalMessage } from 'stream-chat'; export type AppContextType = { channel: ChannelType | undefined; setChannel: React.Dispatch>; - setThread: React.Dispatch>; - thread: ThreadContextValue['thread'] | undefined; + setThread: React.Dispatch>; + thread: LocalMessage | null | undefined; }; export const AppContext = createContext({ @@ -19,7 +18,7 @@ export const AppContext = createContext({ export const AppProvider = ({ children }: PropsWithChildren) => { const [channel, setChannel] = useState(undefined); - const [thread, setThread] = useState(undefined); + const [thread, setThread] = useState(undefined); return ( diff --git a/examples/SampleApp/src/context/StreamChatContext.tsx b/examples/SampleApp/src/context/StreamChatContext.tsx index 58fd0627c5..9b968a732a 100644 --- a/examples/SampleApp/src/context/StreamChatContext.tsx +++ b/examples/SampleApp/src/context/StreamChatContext.tsx @@ -1,14 +1,13 @@ import React from 'react'; import { PropsWithChildren, createContext, useState } from 'react'; -import { Channel as ChannelType } from 'stream-chat'; -import { ThreadContextValue } from 'stream-chat-react-native'; +import { Channel as ChannelType, LocalMessage } from 'stream-chat'; export type StreamChatContextType = { channel: ChannelType | undefined; setChannel: React.Dispatch>; - setThread: React.Dispatch>; - thread: ThreadContextValue['thread'] | undefined; + setThread: React.Dispatch>; + thread: LocalMessage | null | undefined; }; export const StreamChatContext = createContext({ @@ -20,7 +19,7 @@ export const StreamChatContext = createContext({ export const StreamChatProvider = ({ children }: PropsWithChildren) => { const [channel, setChannel] = useState(undefined); - const [thread, setThread] = useState(undefined); + const [thread, setThread] = useState(undefined); return ( diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 7b47278b1c..ea938da0ad 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -11,7 +11,6 @@ import { MessageComposer, MessageList, MessageFlashList, - ThreadContextValue, useAttachmentPickerContext, useChannelPreviewDisplayName, useChatContext, @@ -130,7 +129,7 @@ export const ChannelScreen: React.FC = ({ navigation, route const [channel, setChannel] = useState(channelFromProp); - const [selectedThread, setSelectedThread] = useState(); + const [selectedThread, setSelectedThread] = useState(); useEffect(() => { const initChannel = async () => { diff --git a/examples/TypeScriptMessaging/App.tsx b/examples/TypeScriptMessaging/App.tsx index 99714a0e3c..4f12001690 100644 --- a/examples/TypeScriptMessaging/App.tsx +++ b/examples/TypeScriptMessaging/App.tsx @@ -8,7 +8,7 @@ import { useHeaderHeight } from '@react-navigation/elements'; import { DarkTheme, DefaultTheme, NavigationContainer, RouteProp } from '@react-navigation/native'; import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'; -import { Channel as ChannelType, ChannelSort } from 'stream-chat'; +import { Channel as ChannelType, ChannelSort, LocalMessage } from 'stream-chat'; import { Channel, ChannelList, @@ -19,7 +19,6 @@ import { SqliteClient, Streami18n, Thread, - ThreadContextValue, useCreateChatClient, useOverlayContext, } from 'stream-chat-react-native'; @@ -187,8 +186,8 @@ const Stack = createStackNavigator(); type AppContextType = { channel: ChannelType | undefined; setChannel: React.Dispatch>; - setThread: React.Dispatch>; - thread: ThreadContextValue['thread'] | undefined; + setThread: React.Dispatch>; + thread: LocalMessage | null | undefined; }; const AppContext = React.createContext({} as AppContextType); @@ -268,7 +267,7 @@ const App = () => { export default () => { const [channel, setChannel] = useState(); - const [thread, setThread] = useState(); + const [thread, setThread] = useState(); const theme = useStreamChatTheme(); const colorScheme = useColorScheme(); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index ccfe6872cf..f9536eddf7 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1044,7 +1044,6 @@ const ChannelWithContext = (props: PropsWithChildren) = const threadContext = useCreateThreadContext({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - thread, threadInstance, }); @@ -1054,8 +1053,8 @@ const ChannelWithContext = (props: PropsWithChildren) = ); const messageComposerContext = useMemo( - () => ({ channel, thread, threadInstance }), - [channel, thread, threadInstance], + () => ({ channel, threadInstance }), + [channel, threadInstance], ); // TODO: replace the null view with appropriate message. Currently this is waiting a design decision. diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 6f8d2ab316..c9f791e1d3 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -334,7 +334,6 @@ describe('Channel', () => { const mockContext = { allowThreadMessagesInChannel: true, - thread: {}, }; render( @@ -350,7 +349,6 @@ describe('Channel', () => { await waitFor(() => { expect(context).toBeInstanceOf(Object); - expect(context!.thread).toBeInstanceOf(Object); expect(context!.allowThreadMessagesInChannel).toBe(true); }); }); diff --git a/package/src/components/Channel/hooks/useCreateThreadContext.ts b/package/src/components/Channel/hooks/useCreateThreadContext.ts index 7ba51a9a93..1b00cfc390 100644 --- a/package/src/components/Channel/hooks/useCreateThreadContext.ts +++ b/package/src/components/Channel/hooks/useCreateThreadContext.ts @@ -3,23 +3,21 @@ import { useMemo } from 'react'; import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; // The ThreadContext now carries only the Thread instance (+ a few UI-config props). Reply data, -// pagination and loading state are read by consumers straight off `threadInstance.messagePaginator` -// / `threadInstance.state` via useStateStore — there is no derived state to assemble here. +// pagination and loading state — and the parent message — are read by consumers straight off +// `threadInstance.messagePaginator` / `threadInstance.state` via useStateStore. export const useCreateThreadContext = ({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - thread, threadInstance, }: Pick< ThreadContextValue, - 'allowThreadMessagesInChannel' | 'onAlsoSentToChannelHeaderPress' | 'thread' | 'threadInstance' + 'allowThreadMessagesInChannel' | 'onAlsoSentToChannelHeaderPress' | 'threadInstance' >): ThreadContextValue => useMemo( () => ({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - thread, threadInstance, }), - [allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, thread, threadInstance], + [allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, threadInstance], ); diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index fe8edca462..572dbe2e82 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -138,7 +138,7 @@ type MessageFlashListPropsWithContext = Pick< MessagesContextValue, 'disableTypingIndicator' | 'FlatList' | 'myMessageTheme' | 'shouldShowUnreadUnderlay' > & - Pick & { + Pick & { /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want * to attach some additional props to underlying FlatList, you can add it to following prop. @@ -182,7 +182,7 @@ type MessageFlashListPropsWithContext = Pick< * * @param message A message object to open the thread upon. */ - onThreadSelect?: (message: ThreadContextValue['thread']) => void; + onThreadSelect?: (message: LocalMessage | null) => void; /** * Use `setFlatListRef` to get access to ref to inner FlatList. * @@ -338,7 +338,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => reloadChannel, setFlatListRef, hasPendingInitialTargetLoad, - thread, threadInstance, threadList = false, } = props; @@ -1153,7 +1152,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return ( - {processedMessageList.length === 0 && !thread ? ( + {processedMessageList.length === 0 && !threadInstance ? ( {EmptyStateIndicator ? : null} @@ -1326,7 +1325,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { loadMoreRecent, state: { loadingMore, loadingMoreRecent }, } = useMessageListPagination({ channel }); - const { thread, threadInstance } = useThreadContext(); + const { threadInstance } = useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = useMessageInputContext(); @@ -1363,7 +1362,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { scrollToFirstUnreadThreshold, hasPendingInitialTargetLoad, shouldShowUnreadUnderlay, - thread, threadInstance, threadList, }} diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 6f4ad36da3..5e0b3ea767 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -222,7 +222,7 @@ type MessageListPropsWithContext = Pick< MessageInputContextValue, 'allowSendBeforeAttachmentsUpload' | 'messageInputFloating' | 'messageInputHeightStore' > & - Pick & { + Pick & { /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want * to attach some additional props to underlying FlatList, you can add it to following prop. @@ -266,7 +266,7 @@ type MessageListPropsWithContext = Pick< * * @param message A message object to open the thread upon. */ - onThreadSelect?: (message: ThreadContextValue['thread']) => void; + onThreadSelect?: (message: LocalMessage | null) => void; /** * Use `setFlatListRef` to get access to ref to inner FlatList. * @@ -348,7 +348,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { readEvents, reloadChannel, setFlatListRef, - thread, threadInstance, threadList = false, hasMore, @@ -374,7 +373,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); useIncomingMessageAnnouncements({ - activeThreadId: thread?.id, + activeThreadId: threadInstance?.id, channel, ownUserId: client.user?.id, threadList, @@ -1158,7 +1157,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } if (debugRef.current.setSendEventParams) { debugRef.current.setSendEventParams({ - action: thread ? 'ThreadList' : 'Messages', + action: threadInstance ? 'ThreadList' : 'Messages', data: processedMessageList, }); } @@ -1285,7 +1284,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return ( {/* Don't show the empty list indicator for Thread messages */} - {processedMessageList.length === 0 && !thread ? ( + {processedMessageList.length === 0 && !threadInstance ? ( {EmptyStateIndicator ? : null} @@ -1427,7 +1426,7 @@ export const MessageList = (props: MessageListProps) => { loadMoreRecent, state: { hasMore, loadingMore, loadingMoreRecent }, } = useMessageListPagination({ channel }); - const { thread, threadInstance } = useThreadContext(); + const { threadInstance } = useThreadContext(); return ( { reloadChannel, scrollToFirstUnreadThreshold, shouldShowUnreadUnderlay, - thread, threadInstance, threadList, hasMore, diff --git a/package/src/components/MessageList/TypingIndicatorContainer.tsx b/package/src/components/MessageList/TypingIndicatorContainer.tsx index 6115cfd215..44e3f3cbcb 100644 --- a/package/src/components/MessageList/TypingIndicatorContainer.tsx +++ b/package/src/components/MessageList/TypingIndicatorContainer.tsx @@ -8,7 +8,7 @@ import { filterTypingUsers } from './utils/filterTypingUsers'; import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; +import { useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; @@ -22,23 +22,22 @@ const styles = StyleSheet.create({ const typingSelector = (state: TypingUsersState) => ({ typing: state.typing }); -type TypingIndicatorContainerPropsWithContext = { typing: TypingUsersState['typing'] } & Pick< - ChatContextValue, - 'client' -> & - Pick; +type TypingIndicatorContainerPropsWithContext = { + threadId?: string; + typing: TypingUsersState['typing']; +} & Pick; const TypingIndicatorContainerWithContext = ( props: PropsWithChildren, ) => { - const { children, client, thread, typing } = props; + const { children, client, threadId, typing } = props; const { theme: { messageList: { typingIndicatorContainer }, }, } = useTheme(); - const typingUsers = filterTypingUsers({ client, thread, typing }); + const typingUsers = filterTypingUsers({ client, threadId, typing }); if (!typingUsers.length) { return null; @@ -58,10 +57,15 @@ export type TypingIndicatorContainerProps = PropsWithChildren< export const TypingIndicatorContainer = (props: TypingIndicatorContainerProps) => { const { channel } = useChannelContext(); const { client } = useChatContext(); - const { thread } = useThreadContext(); + const { threadInstance } = useThreadContext(); const { typing } = useStateStore(channel.state.typingStore, typingSelector) ?? { typing: {} }; - return ; + return ( + + ); }; TypingIndicatorContainer.displayName = diff --git a/package/src/components/MessageList/hooks/useTypingUsers.ts b/package/src/components/MessageList/hooks/useTypingUsers.ts index c86ae04e05..64d13286be 100644 --- a/package/src/components/MessageList/hooks/useTypingUsers.ts +++ b/package/src/components/MessageList/hooks/useTypingUsers.ts @@ -11,8 +11,11 @@ const selector = (state: TypingUsersState) => ({ typing: state.typing }); export const useTypingUsers = () => { const { client } = useChatContext(); const { channel } = useChannelContext(); - const { thread } = useThreadContext(); + const { threadInstance } = useThreadContext(); const { typing } = useStateStore(channel.state.typingStore, selector) ?? { typing: {} }; - return useMemo(() => filterTypingUsers({ client, thread, typing }), [client, thread, typing]); + return useMemo( + () => filterTypingUsers({ client, threadId: threadInstance?.id, typing }), + [client, threadInstance, typing], + ); }; diff --git a/package/src/components/MessageList/utils/filterTypingUsers.ts b/package/src/components/MessageList/utils/filterTypingUsers.ts index ae2cfc3614..b889e4b084 100644 --- a/package/src/components/MessageList/utils/filterTypingUsers.ts +++ b/package/src/components/MessageList/utils/filterTypingUsers.ts @@ -1,15 +1,13 @@ import { TypingUsersState, UserResponse } from 'stream-chat'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; -import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; -type FilterTypingUsersParams = { typing: TypingUsersState['typing'] } & Pick< +type FilterTypingUsersParams = { threadId?: string; typing: TypingUsersState['typing'] } & Pick< ChatContextValue, 'client' -> & - Pick; +>; -export const filterTypingUsers = ({ client, thread, typing }: FilterTypingUsersParams) => { +export const filterTypingUsers = ({ client, threadId, typing }: FilterTypingUsersParams) => { const nonSelfUsers: UserResponse[] = []; if (!client || !client.user || !typing) { @@ -28,8 +26,8 @@ export const filterTypingUsers = ({ client, thread, typing }: FilterTypingUsersP return; } - const isRegularEvent = !typing[typingKey].parent_id && !thread?.id; - const isCurrentThreadEvent = typing[typingKey].parent_id === thread?.id; + const isRegularEvent = !typing[typingKey].parent_id && !threadId; + const isCurrentThreadEvent = typing[typingKey].parent_id === threadId; /** filters different threads events */ if (!isRegularEvent && !isCurrentThreadEvent) { diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 0120792a5a..16266f28e8 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -26,7 +26,7 @@ try { } type ThreadPropsWithContext = Pick & - Pick & { + Pick & { /** * Additional props for underlying MessageComposer component. * Available props - https://getstream.io/chat/docs/sdk/reactnative/ui-components/message-input/#props @@ -89,7 +89,6 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { onThreadDismount, notificationHostId: notificationHostIdProp, parentMessagePreventPress = true, - thread, threadInstance, shouldUseFlashList = false, } = props; @@ -196,14 +195,15 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { [disabled, autoFocus], ); - if (!thread?.id) { + const threadId = threadInstance?.id; + if (!threadId) { return null; } - const notificationHostId = notificationHostIdProp ?? getThreadNotificationHostId(thread.id); + const notificationHostId = notificationHostIdProp ?? getThreadNotificationHostId(threadId); return ( - + {FlashList && shouldUseFlashList ? ( ; export const Thread = (props: ThreadProps) => { const { client } = useChatContext(); const { threadList } = useChannelContext(); - const { thread, threadInstance } = useThreadContext(); + const { threadInstance } = useThreadContext(); - if (thread?.id && !threadList) { + if (threadInstance?.id && !threadList) { throw new Error( 'Please add a threadList prop to your Channel component when rendering a thread list. Check our Channel documentation for more info: https://getstream.io/chat/docs/sdk/reactnative/core-components/channel/#threadlist', ); @@ -254,7 +254,6 @@ export const Thread = (props: ThreadProps) => { { const cid = 'messaging:test-channel'; const thread = generateMessage({ cid, text: 'Thread Message Text' }); const parent_id = thread.id; - const props = { - thread, - }; const threadResponses = [ generateMessage({ cid, parent_id, text: 'Response Message Text' }), @@ -78,7 +75,7 @@ describe('Thread', () => { threadResponses as unknown as Parameters[0], ); - renderComponent({ channel, chatClient, props, thread }); + renderComponent({ channel, chatClient, thread }); const { getAllByText, getByText, queryByText } = screen; diff --git a/package/src/components/Thread/components/ThreadFooterComponent.tsx b/package/src/components/Thread/components/ThreadFooterComponent.tsx index 016fa880ad..7ebd2c3248 100644 --- a/package/src/components/Thread/components/ThreadFooterComponent.tsx +++ b/package/src/components/Thread/components/ThreadFooterComponent.tsx @@ -15,7 +15,7 @@ import { primitives } from '../../../theme'; type ThreadFooterComponentPropsWithContext = Pick< ThreadContextValue, - 'parentMessagePreventPress' | 'thread' | 'threadInstance' + 'parentMessagePreventPress' | 'threadInstance' >; const loadingSelector = (state: { isLoading: boolean }) => ({ isLoading: state.isLoading }); @@ -42,19 +42,23 @@ export const InlineLoadingMoreThreadIndicator = () => { const selector = (nextValue: ThreadState) => ({ + parentMessage: nextValue.parentMessage, replyCount: nextValue.replyCount, }) as const; const ThreadFooterComponentWithContext = (props: ThreadFooterComponentPropsWithContext) => { - const { parentMessagePreventPress, thread, threadInstance } = props; + const { parentMessagePreventPress, threadInstance } = props; const { Message } = useComponentsContext(); const { t } = useTranslationContext(); const styles = useStyles(); - const { replyCount = thread?.reply_count } = useStateStore(threadInstance?.state, selector) ?? {}; + // The parent message is read reactively from the thread instance so the header updates live when + // it is edited / reacted to / its reply_count changes. + const { parentMessage, replyCount = parentMessage?.reply_count } = + useStateStore(threadInstance?.state, selector) ?? {}; - if (!thread) { + if (!parentMessage) { return null; } @@ -62,7 +66,7 @@ const ThreadFooterComponentWithContext = (props: ThreadFooterComponentPropsWithC { + // The parent message + reply count are read reactively from `threadInstance.state` inside the + // component (via useStateStore), so this memo only needs to gate parent-driven re-renders on the + // remaining props: the prevent-press flag and the thread instance identity. const { parentMessagePreventPress: prevParentMessagePreventPress, - thread: prevThread, threadInstance: prevThreadInstance, } = prevProps; const { parentMessagePreventPress: nextParentMessagePreventPress, - thread: nextThread, threadInstance: nextThreadInstance, } = nextProps; - if (prevParentMessagePreventPress !== nextParentMessagePreventPress) { - return false; - } - - const threadEqual = - prevThread?.id === nextThread?.id && - prevThread?.text === nextThread?.text && - prevThread?.reply_count === nextThread?.reply_count; - - if (!threadEqual) { - return false; - } - - if (prevThreadInstance !== nextThreadInstance) { - return false; - } - - const latestReactionsEqual = - prevThread && - nextThread && - Array.isArray(prevThread.latest_reactions) && - Array.isArray(nextThread.latest_reactions) - ? prevThread.latest_reactions.length === nextThread.latest_reactions.length && - prevThread.latest_reactions.every( - ({ type }, index) => type === nextThread.latest_reactions?.[index].type, - ) - : prevThread?.latest_reactions === nextThread?.latest_reactions; - if (!latestReactionsEqual) { - return false; - } - - return true; + return ( + prevParentMessagePreventPress === nextParentMessagePreventPress && + prevThreadInstance === nextThreadInstance + ); }; const MemoizedThreadFooter = React.memo( @@ -136,17 +113,16 @@ const MemoizedThreadFooter = React.memo( ) as typeof ThreadFooterComponentWithContext; export type ThreadFooterComponentProps = Partial< - Pick + Pick >; export const ThreadFooterComponent = (props: ThreadFooterComponentProps) => { - const { parentMessagePreventPress, thread, threadInstance } = useThreadContext(); + const { parentMessagePreventPress, threadInstance } = useThreadContext(); return ( ; + value: Pick; }>; export const MessageComposerProvider = ({ children, value }: Props) => { diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index db2ecd8675..6402d6b796 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -227,7 +227,7 @@ export const MessageInputProvider = ({ const { uploadAbortControllerRef } = useChannelContext(); const { clearEditingState } = useMessageComposerAPIContext(); - const { thread } = useThreadContext(); + const { threadInstance } = useThreadContext(); const { t } = useTranslationContext(); const { addNotification } = useNotificationApi(); const inputBoxRef = useRef(null); @@ -559,7 +559,7 @@ export const MessageInputProvider = ({ pickFile, setInputBoxRef, takeAndUploadImage, - thread, + threadInstance, uploadNewFile, ...value, closePollCreationDialog, diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts index fd1a908d7f..e1e61cc625 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts @@ -7,10 +7,9 @@ import { MessageComposerContextValue } from '../../messageComposerContext/Messag export const useCreateMessageComposer = ({ editing: editedMessage, - thread: parentMessage, threadInstance, channel, -}: Pick) => { +}: Pick) => { const { client } = useChatContext(); const { messageComposerCache: queueCache } = client; @@ -21,17 +20,8 @@ export const useCreateMessageComposer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [editedMessage?.id]); - const cachedParentMessage = useMemo(() => { - if (!parentMessage) return undefined; - - return parentMessage; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [parentMessage?.id]); - - // composer hierarchy - // edited message (always new) -> thread instance (own) -> thread message (always new) -> channel (own) - // editedMessage ?? thread ?? parentMessage ?? channel; - + // composer hierarchy: edited message (always new) -> thread instance (own) -> channel (own) + // editedMessage ?? threadInstance ?? channel const messageComposer = useMemo(() => { if (cachedEditedMessage) { const tag = MessageComposer.constructTag(cachedEditedMessage); @@ -49,38 +39,13 @@ export const useCreateMessageComposer = ({ }); } else if (threadInstance) { return threadInstance.messageComposer; - } else if (cachedParentMessage) { - const compositionContext = { - ...cachedParentMessage, - legacyThreadId: cachedParentMessage.id, - }; - - const tag = MessageComposer.constructTag(compositionContext); - - const cachedComposer = queueCache.get(tag); - if (cachedComposer) return cachedComposer; - - // legacy thread will receive new composer - return new MessageComposer({ - client, - compositionContext, - }); } else { return channel.messageComposer; } - }, [ - cachedEditedMessage, - cachedParentMessage, - channel.messageComposer, - client, - queueCache, - threadInstance, - ]); + }, [cachedEditedMessage, channel.messageComposer, client, queueCache, threadInstance]); if ( - (['legacy_thread', 'message'] as MessageComposer['contextType'][]).includes( - messageComposer.contextType, - ) && + (['message'] as MessageComposer['contextType'][]).includes(messageComposer.contextType) && !queueCache.peek(messageComposer.tag) ) { queueCache.add(messageComposer.tag, messageComposer); diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 83cdd1bef4..7b1c34996d 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -42,9 +42,9 @@ export const useCreateMessageInputContext = ({ deleteVoiceRecording, uploadVoiceRecording, stopVoiceRecording, - thread, -}: MessageInputContextValue & Pick) => { - const threadId = thread?.id; + threadInstance, +}: MessageInputContextValue & Pick) => { + const threadId = threadInstance?.id; const messageInputContext: MessageInputContextValue = useMemo( () => ({ diff --git a/package/src/contexts/threadContext/ThreadContext.tsx b/package/src/contexts/threadContext/ThreadContext.tsx index 79a835de96..2741b87032 100644 --- a/package/src/contexts/threadContext/ThreadContext.tsx +++ b/package/src/contexts/threadContext/ThreadContext.tsx @@ -14,7 +14,6 @@ export type AlsoSentToChannelHeaderPressPayload = { export type ThreadContextValue = { allowThreadMessagesInChannel: boolean; - thread: LocalMessage | null; /** * Boolean to enable/disable parent message press */ From 36904be8cd10bbc1350ced6f4e3a30eaacff84b1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 20 Jul 2026 12:13:02 +0200 Subject: [PATCH 35/36] fix: rely on paginator state and not replies --- package/src/components/ThreadList/ThreadListItem.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx index 20e6ff5e60..fad85c0039 100644 --- a/package/src/components/ThreadList/ThreadListItem.tsx +++ b/package/src/components/ThreadList/ThreadListItem.tsx @@ -46,6 +46,13 @@ const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({ attachments: state.attachments, }); +type ThreadReplyPaginatorState = { items?: LocalMessage[] }; + +// The messagePaginator is the sole reply source (Thread.state.replies was removed). +const lastReplySelector = (state: ThreadReplyPaginatorState) => ({ + lastReply: state.items?.at(-1), +}); + export const ThreadListItemComponent = () => { const { channel, @@ -153,7 +160,6 @@ export const ThreadListItem = (props: ThreadListItemProps) => { ({ channel: nextValue.channel, deletedAt: nextValue.deletedAt, - lastReply: nextValue.replies.at(-1), ownUnreadMessageCount: (client.userID && nextValue.read[client.userID]?.unreadMessageCount) || 0, parentMessage: nextValue.parentMessage, @@ -161,11 +167,13 @@ export const ThreadListItem = (props: ThreadListItemProps) => { [client], ); - const { channel, deletedAt, lastReply, ownUnreadMessageCount, parentMessage } = useStateStore( + const { channel, deletedAt, ownUnreadMessageCount, parentMessage } = useStateStore( thread.state, selector, ); + const { lastReply } = useStateStore(thread.messagePaginator.state, lastReplySelector) ?? {}; + const timestamp = lastReply?.created_at; useEffect(() => { From 8e14da14f4ea3dfa03f4745ebac636b9a36cd5e4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 20 Jul 2026 16:56:23 +0200 Subject: [PATCH 36/36] fix: remove more remnants of the old channel state/ --- package/src/components/Channel/Channel.tsx | 2 - .../MessageItemView/MessageItemView.tsx | 4 +- .../Message/hooks/useMessageOperations.ts | 8 +-- .../MessageList/MessageFlashList.tsx | 5 +- .../components/MessageList/MessageList.tsx | 4 +- package/src/hooks/usePrunableMessageList.ts | 7 +-- .../src/utils/removeReactionFromLocalState.ts | 49 ------------------- 7 files changed, 15 insertions(+), 64 deletions(-) delete mode 100644 package/src/utils/removeReactionFromLocalState.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index f9536eddf7..519a67fb79 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -725,7 +725,6 @@ const ChannelWithContext = (props: PropsWithChildren) = ); channel.messagePaginator.mergeNewestPage(newestWindow); if (failedMessages?.length) { - channel.state.addMessagesSorted(failedMessages); failedMessages.forEach((m) => channel.messagePaginator.ingestItem(channel.state.formatMessage(m)), ); @@ -744,7 +743,6 @@ const ChannelWithContext = (props: PropsWithChildren) = threadInstance.messagePaginator.state.getLatestValue().items ?? []; const failedThreadMessages = getRecoverableFailedMessages(currentThreadMessages); if (failedThreadMessages.length) { - channel.state.addMessagesSorted(failedThreadMessages); failedThreadMessages.forEach((m) => threadInstance.messagePaginator.ingestItem(channel.state.formatMessage(m)), ); diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index 28e7719d3a..5a2f707303 100644 --- a/package/src/components/Message/MessageItemView/MessageItemView.tsx +++ b/package/src/components/Message/MessageItemView/MessageItemView.tsx @@ -330,7 +330,9 @@ const areEqual = ( return false; } - const channelEqual = prevChannel?.state.messages.length === nextChannel?.state.messages.length; + const channelEqual = + prevChannel?.messagePaginator.state.getLatestValue().items?.length === + nextChannel?.messagePaginator.state.getLatestValue().items?.length; if (!channelEqual) { return false; } diff --git a/package/src/components/Message/hooks/useMessageOperations.ts b/package/src/components/Message/hooks/useMessageOperations.ts index 49130eb39a..ff8bfd8b43 100644 --- a/package/src/components/Message/hooks/useMessageOperations.ts +++ b/package/src/components/Message/hooks/useMessageOperations.ts @@ -44,10 +44,6 @@ export const useMessageOperations = (): MessageOperations => { return; } - // Keep legacy channel.state in sync (many readers remain) and ingest into the reactive - // paginator that now backs the list — the channel's for channel messages, the thread's for - // replies (the reply list is sourced solely from thread.messagePaginator). - channel.state.addMessageSorted(updatedMessage, true); const formatted = channel.state.formatMessage(updatedMessage); if (!updatedMessage.parent_id || updatedMessage.show_in_channel) { channel.messagePaginator.ingestItem(formatted); @@ -62,10 +58,10 @@ export const useMessageOperations = (): MessageOperations => { */ const removeMessage: MessageOperations['removeMessage'] = useStableCallback(async (message) => { if (channel) { - channel.state.removeMessage(message); // removeItem is a no-op when the item isn't in that paginator, so removing from the channel // unconditionally is safe (a hidden reply simply isn't there) and covers replies shown in - // the channel; replies additionally live in the open thread's paginator. + // the channel; replies additionally live in the open thread's paginator. No channel.state + // write (that store is being removed). channel.messagePaginator.removeItem({ id: message.id }); if (message.parent_id) { threadInstance?.messagePaginator?.removeItem({ id: message.id }); diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 572dbe2e82..51f746069d 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -95,8 +95,9 @@ const flatListViewabilityConfig: ViewabilityConfig = { }; const hasReadLastMessage = (channel: Channel, userId: string) => { - const latestMessageIdInChannel = - channel.state.latestMessages[channel.state.latestMessages.length - 1]?.id; + const latestMessageIdInChannel = channel.messagePaginator.state + .getLatestValue() + .items?.at(-1)?.id; const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id; return latestMessageIdInChannel === lastReadMessageIdServer; }; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 5e0b3ea767..ad6d93954c 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -185,7 +185,9 @@ const flatListViewabilityConfig: ViewabilityConfig = { }; const hasReadLastMessage = (channel: Channel, userId: string) => { - const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id; + const latestMessageIdInChannel = channel.messagePaginator.state + .getLatestValue() + .items?.at(-1)?.id; const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id; return latestMessageIdInChannel === lastReadMessageIdServer; }; diff --git a/package/src/hooks/usePrunableMessageList.ts b/package/src/hooks/usePrunableMessageList.ts index 4973ec74f1..e67b5b0d02 100644 --- a/package/src/hooks/usePrunableMessageList.ts +++ b/package/src/hooks/usePrunableMessageList.ts @@ -63,15 +63,16 @@ export function usePrunableMessageList({ if ( maximumMessageLimit == null || - channel.state.messages.length <= maximumMessageLimit || + (channel.messagePaginator.state.getLatestValue().items?.length ?? 0) <= maximumMessageLimit || isNearEnd({ maximumMessageLimit, rangeConfig }) ) { rawSetMessages(channel); return; } - channel.state.pruneOldest(maximumMessageLimit); - + // TODO(#6): reimplement pruning over channel.messagePaginator — it has no prune API yet, and + // channel.state.pruneOldest is being removed. This path is currently dead (setMessages is wired + // to a no-op in useMessageList), so no window-cap is enforced today. rawSetMessages(channel); }); diff --git a/package/src/utils/removeReactionFromLocalState.ts b/package/src/utils/removeReactionFromLocalState.ts deleted file mode 100644 index 2caf00be1a..0000000000 --- a/package/src/utils/removeReactionFromLocalState.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Channel, UserResponse } from 'stream-chat'; - -export const removeReactionFromLocalState = ({ - channel, - messageId, - reactionType, - user, -}: { - channel: Channel; - messageId: string; - reactionType: string; - user: UserResponse; -}) => { - const message = channel.state.messages.find(({ id }) => id === messageId); - if (!message || !channel?.id || !user?.id) { - return; - } - - message.own_reactions = message.own_reactions?.filter( - (reaction) => reaction.type !== reactionType, - ); - message.latest_reactions = message.latest_reactions?.filter( - (r) => !(r.user_id === user?.id && r.type === reactionType), - ); - - if (message.reaction_groups?.[reactionType]) { - if ( - message.reaction_groups[reactionType].count > 0 || - message.reaction_groups[reactionType].sum_scores > 0 - ) { - message.reaction_groups[reactionType].count = Math.max( - 0, - message.reaction_groups[reactionType].count - 1, - ); - message.reaction_groups[reactionType].sum_scores = Math.max( - 0, - message.reaction_groups[reactionType].sum_scores - 1, - ); - if ( - message.reaction_groups[reactionType].count === 0 || - message.reaction_groups[reactionType].sum_scores === 0 - ) { - delete message.reaction_groups[reactionType]; - } - } else { - delete message.reaction_groups[reactionType]; - } - } -};