From 9347a6df2eb0ddfbf7363a28f14031ac3dd5b372 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 22 Jul 2026 19:21:01 +0200 Subject: [PATCH] feat: remove deprecated channel storage --- examples/SampleApp/ios/Podfile.lock | 10 +- .../offline-support/offline-feature.tsx | 8 +- .../offline-support/optimistic-update.tsx | 40 ++++--- package/src/components/Channel/Channel.tsx | 10 +- .../Channel/__tests__/Channel.test.tsx | 14 +-- .../__tests__/ChannelList.test.tsx | 4 +- .../__tests__/ChannelPreview.test.tsx | 3 +- .../hooks/useChannelPreviewData.ts | 93 +++++---------- .../Message/hooks/useMessageOperations.ts | 37 +++--- .../MessageList/MessageFlashList.tsx | 9 +- .../components/MessageList/MessageList.tsx | 6 +- .../__tests__/MessageList.test.tsx | 23 ++-- .../Thread/__tests__/Thread.test.tsx | 30 +++-- .../src/mock-builders/api/channelMocks.tsx | 2 +- package/src/utils/addReactionToLocalState.ts | 111 ++++++++++++++++-- .../src/utils/removeReactionFromLocalState.ts | 83 +++++++++++++ package/src/utils/utils.ts | 11 +- 17 files changed, 319 insertions(+), 175 deletions(-) create mode 100644 package/src/utils/removeReactionFromLocalState.ts diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 1fa8bb9bf7..5368b1461c 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2821,7 +2821,7 @@ PODS: - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - stream-chat-react-native (9.6.1): + - stream-chat-react-native (9.7.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3252,7 +3252,7 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 57270ccc2b77472d7e85c4cbe45972564eff78bb GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - hermes-engine: 26897e4ae07f28dde638ffab1d25a07f556f7e3e + hermes-engine: d8f4d6c98c2ea2858468ad58fbf6b3a0b33e4da6 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -3270,7 +3270,7 @@ SPEC CHECKSUMS: React: e2dc35338068bbd299c66f043ae0d7f25de8499e React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48 React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0 - React-Core-prebuilt: 81cf85599e5cd4ab9bbbbdf4db08ab654f52e760 + React-Core-prebuilt: 07da8c06ca047a4047eb44266cd74e1f422d7655 React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146 React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716 React-debug: 9af1e96a6069c996e3d9f1e493603e74bc9f1593 @@ -3342,7 +3342,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2 ReactCodegen: 7016a2114079361a2f1536b3c91a15ceb8eb7ca4 ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f - ReactNativeDependencies: 08dba4ab0913ea4d2255e4fc63538742aa3e7ac5 + ReactNativeDependencies: b494f9d4ef665da25093dffcdd0b1a110475f404 RNCClipboard: 7a7d4557bfd3370b35c99dfecd92ae7b9fc4948a RNFastImage: 14580cef91660b889645fb9e87f58a53621db993 RNFBApp: 3b942e786ca88524ba17df665a1a360fb3eee525 @@ -3359,7 +3359,7 @@ SPEC CHECKSUMS: SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 - stream-chat-react-native: fdb9929071f737e85eee5b8dbdb6bc4e120ac5c4 + stream-chat-react-native: b8f70a8fb5382c4a6a6a6dc16d52dd7243bab3af Teleport: 58dccc8594d74a77cdc2a2191b60656f9aaac743 Yoga: e240fec777ff1f21ef42ccebbb44b6d262125855 diff --git a/package/src/__tests__/offline-support/offline-feature.tsx b/package/src/__tests__/offline-support/offline-feature.tsx index 4851e25661..be12a63edf 100644 --- a/package/src/__tests__/offline-support/offline-feature.tsx +++ b/package/src/__tests__/offline-support/offline-feature.tsx @@ -81,7 +81,7 @@ import { BetterSqlite } from '../../test-utils/BetterSqlite'; const ChannelPreviewComponent = ({ channel }: { channel: ChannelLLC }) => ( {channel.data?.name} - {channel.state?.messages?.[0]?.text} + {channel.messagePaginator?.headItems?.[0]?.text} ); @@ -731,7 +731,7 @@ export const Generic = () => { expect(matchingRows.length).toBe(1); expect(matchingRows[0].hidden).toBeTruthy(); expect(matchingMessagesRows.length).toBe( - chatClient.activeChannels[hiddenChannel.cid].state.messages.length, + chatClient.activeChannels[hiddenChannel.cid].messagePaginator.headItems.length, ); }); }); @@ -766,7 +766,7 @@ export const Generic = () => { expect(matchingRows.length).toBe(1); expect(matchingRows[0].hidden).toBeTruthy(); expect(matchingMessagesRows.length).toBe( - chatClient.activeChannels[hiddenChannel.cid].state.messages.length, + chatClient.activeChannels[hiddenChannel.cid].messagePaginator.headItems.length, ); }); @@ -792,7 +792,7 @@ export const Generic = () => { expect(matchingRows.length).toBe(1); expect(matchingRows[0].hidden).toBeFalsy(); expect(matchingMessagesRows.length).toBe( - chatClient.activeChannels[hiddenChannel.cid].state.messages.length, + chatClient.activeChannels[hiddenChannel.cid].messagePaginator.headItems.length, ); }); }); diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index a64e5357c5..33a7d9d213 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -301,7 +301,7 @@ export const OptimisticUpdates = () => { describe('send reaction', () => { it('pending task should exist if sendReaction request fails', async () => { const reaction = generateReaction(); - const targetMessage = channel.state.messages[0]; + const targetMessage = channel.messagePaginator.headItems[0]; render( @@ -333,7 +333,7 @@ export const OptimisticUpdates = () => { it('pending task should be cleared if sendReaction request is successful', async () => { const reaction = generateReaction(); - const targetMessage = channel.state.messages[0]; + const targetMessage = channel.messagePaginator.headItems[0]; render( @@ -429,7 +429,7 @@ export const OptimisticUpdates = () => { describe('delete reaction', () => { it('pending task should exist if deleteReaction request fails', async () => { const reaction = generateReaction(); - const targetMessage = channel.state.messages[0]; + const targetMessage = channel.messagePaginator.headItems[0]; render( @@ -461,7 +461,7 @@ export const OptimisticUpdates = () => { it('pending task should be cleared if deleteReaction request is successful', async () => { const reaction = generateReaction(); - const targetMessage = channel.state.messages[0]; + const targetMessage = channel.messagePaginator.headItems[0]; render( @@ -488,7 +488,7 @@ export const OptimisticUpdates = () => { describe('edit message', () => { it('should keep the optimistic edit in state and DB if the LLC queues the edit', async () => { - const message = channel.state.messages[0]; + const message = channel.messagePaginator.headItems[0]; const editedText = 'edited while offline'; render( @@ -536,7 +536,7 @@ export const OptimisticUpdates = () => { await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); await waitFor(async () => { - const updatedMessage = channel.state.findMessage(message.id); + const updatedMessage = channel.messagePaginator.getItem(message.id); const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); const dbMessages = await BetterSqlite.selectFromTable('messages'); const dbMessage = dbMessages.find((row) => row.id === message.id); @@ -551,7 +551,7 @@ export const OptimisticUpdates = () => { }); it('should keep the optimistic edit if the request fails', async () => { - const message = channel.state.messages[0]; + const message = channel.messagePaginator.headItems[0]; const editedText = 'should stay optimistic'; render( @@ -588,7 +588,7 @@ export const OptimisticUpdates = () => { await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); await waitFor(async () => { - const updatedMessage = channel.state.findMessage(message.id); + const updatedMessage = channel.messagePaginator.getItem(message.id); const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); const dbMessages = await BetterSqlite.selectFromTable('messages'); const dbMessage = dbMessages.find((row) => row.id === message.id); @@ -600,7 +600,7 @@ export const OptimisticUpdates = () => { }); it('should not set message_text_updated_at during optimistic edit of a failed message', async () => { - const message = channel.state.messages[0]; + const message = channel.messagePaginator.headItems[0]; const optimisticStateSpy = jest.fn(); render( @@ -609,7 +609,7 @@ export const OptimisticUpdates = () => { channel={channel} doUpdateMessageRequest={ (() => { - const optimisticMessage = channel.state.findMessage(message.id); + const optimisticMessage = channel.messagePaginator.getItem(message.id); optimisticStateSpy(optimisticMessage); return { @@ -649,7 +649,7 @@ export const OptimisticUpdates = () => { }); it('should keep the optimistic edit for attachment updates without auto-queueing', async () => { - const message = channel.state.messages[0]; + const message = channel.messagePaginator.headItems[0]; const editedText = 'edited attachment message'; const localUri = 'file://edited-attachment.png'; @@ -698,7 +698,7 @@ export const OptimisticUpdates = () => { await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); await waitFor(async () => { - const updatedMessage = channel.state.findMessage(message.id); + const updatedMessage = channel.messagePaginator.getItem(message.id); const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); const dbMessages = await BetterSqlite.selectFromTable('messages'); const dbMessage = dbMessages.find((row) => row.id === message.id); @@ -715,7 +715,7 @@ export const OptimisticUpdates = () => { describe('pending task execution', () => { it('pending task should be executed after connection is recovered', async () => { - const message = channel.state.messages[0]; + const message = channel.messagePaginator.headItems[0]; const reaction = generateReaction(); render( @@ -855,14 +855,16 @@ export const OptimisticUpdates = () => { pendingTask = pendingTasks[0]; }); - expect(channel.state.messages.some((message) => message.id === localMessage.id)).toBe(true); + expect( + channel.messagePaginator.headItems.some((message) => message.id === localMessage.id), + ).toBe(true); jest .spyOn(channel, 'watch') .mockResolvedValue({} as Awaited>); - channel.state.removeMessage(localMessage); - channel.state.addMessageSorted(serverMessage, true); + channel.messagePaginator.removeItem({ id: localMessage.id }); + channel.messagePaginator.ingestItem(channel.state.formatMessage(serverMessage)); await getOfflineDb(chatClient).deletePendingTask({ id: pendingTask!.id }); await act(async () => { @@ -870,7 +872,7 @@ export const OptimisticUpdates = () => { }); await waitFor(() => { - const matchingMessages = channel.state.messages.filter( + const matchingMessages = channel.messagePaginator.headItems.filter( (message) => message.text === localMessage.text, ); @@ -922,7 +924,7 @@ export const OptimisticUpdates = () => { .spyOn(channel, 'watch') .mockResolvedValue({} as Awaited>); - channel.state.removeMessage(localMessage); + channel.messagePaginator.removeItem({ id: localMessage.id }); await getOfflineDb(chatClient).deletePendingTask({ id: pendingTask!.id }); await act(async () => { @@ -930,7 +932,7 @@ export const OptimisticUpdates = () => { }); await waitFor(() => { - const matchingMessages = channel.state.messages.filter( + const matchingMessages = channel.messagePaginator.headItems.filter( (message) => message.id === localMessage.id, ); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 7def775dd5..aaf61a7e56 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -600,7 +600,10 @@ const ChannelWithContext = (props: PropsWithChildren) = // 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) { + if ( + (!channel.initialized || !channel.messagePaginator.isActiveIntervalAtHead) && + initializeOnMount + ) { try { await channel?.watch(); } catch (err) { @@ -703,7 +706,9 @@ const ChannelWithContext = (props: PropsWithChildren) = .filter( (message) => message.status === MessageStatusTypes.FAILED && - !channel.state.findMessage(message.id, message.parent_id), + !(message.parent_id + ? threadInstance?.messagePaginator.getItem(message.id) + : channel.messagePaginator.getItem(message.id)), ) .map(parseMessage); @@ -735,7 +740,6 @@ const ChannelWithContext = (props: PropsWithChildren) = if (atLatest) { await markRead(); } - channel.state.setIsUpToDate(atLatest); } else if (threadInstance) { await threadInstance.reload(); diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index c9f791e1d3..395e54bd0c 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -456,7 +456,9 @@ describe('Channel initial load useEffect', () => { const loadMessageIntoState = jest.fn(() => { const newMessages = getElementsAround(messages, 'id', messageToSearch.id); - channel.state.messages = newMessages; + newMessages.forEach((m) => + channel.messagePaginator.ingestItem(channel.state.formatMessage(m)), + ); }); channel.state = { @@ -619,11 +621,9 @@ describe('Channel initial load useEffect', () => { }); await waitFor(() => { - channel.state.addMessagesSorted( - Array.from({ length: 10 }, (_, i) => - generateMessage({ status: 'failed', text: `message-${i}` }), - ), - ); + Array.from({ length: 10 }, (_, i) => + generateMessage({ status: 'failed', text: `message-${i}` }), + ).forEach((m) => channel.messagePaginator.ingestItem(channel.state.formatMessage(m))); }); await waitFor(() => { @@ -631,7 +631,7 @@ describe('Channel initial load useEffect', () => { }); await waitFor(() => { - expect(channel.state.messages.length).toBe(20); + expect(channel.messagePaginator.headItems.length).toBe(20); }); }); }); diff --git a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx index 95cc1da263..bc839e185d 100644 --- a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -54,7 +54,7 @@ jest.mock('../../ChannelPreview/ChannelSwipableWrapper', () => ({ const ChannelPreviewComponent = ({ channel }: { channel: ChannelType }) => ( {(channel.data as { name?: string } | undefined)?.name} - {channel.state.messages[0]?.text} + {channel.messagePaginator.headItems[0]?.text} ); @@ -261,7 +261,7 @@ describe('ChannelList', () => { const channel = generateChannel({ data: { name: id }, id, - state: { latestMessages: [], members: {}, messages: [], setIsUpToDate: jest.fn() }, + state: { members: {} }, } as unknown as Parameters[0]) as unknown as { countUnread: () => number; messageComposer: { registerDraftEventSubscriptions: () => () => void }; diff --git a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx index 011e1237d0..1492676271 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx @@ -66,9 +66,8 @@ const initChannelFromData = async ( channel.countUnread = jest.fn().mockReturnValue(0); channel.initialized = true; - channel.lastMessage = jest.fn().mockReturnValue(generateMessage()); channel.muteStatus = jest.fn().mockReturnValue({ muted: false }); - channel.state.messages = [generateMessage()]; + channel.messagePaginator.ingestItem(channel.state.formatMessage(generateMessage())); return channel; }; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index da37a0b050..34d96a8daa 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -1,52 +1,49 @@ -import { type SetStateAction, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import throttle from 'lodash/throttle'; -import type { Channel, Event, LocalMessage, MessageResponse, StreamChat } from 'stream-chat'; +import type { + Channel, + Event, + LocalMessage, + MessagePaginatorAggregateState, + MessageResponse, + StreamChat, +} from 'stream-chat'; import { useIsChannelMuted } from './useIsChannelMuted'; import { useIsChannelPinned } from './useIsChannelPinned'; import { useChannelsContext } from '../../../contexts'; -import { useStableCallback } from '../../../hooks'; - -const setLastMessageThrottleTimeout = 500; -const setLastMessageThrottleOptions = { leading: true, trailing: true }; +import { useStateStore } from '../../../hooks'; const refreshUnreadCountThrottleTimeout = 400; -const refreshUnreadCountThrottleOptions = setLastMessageThrottleOptions; +const refreshUnreadCountThrottleOptions = { leading: true, trailing: true }; export type LastMessageType = LocalMessage | MessageResponse; +// The last message is sourced reactively from the paginator's `aggregateState` — a store kept +// separate from pagination `state` precisely so it stays reactive when a new message lands in the +// head interval while an older window is active (a `state`-derived latest would go stale off-window). +// The preview re-renders whenever the newest message changes (new/edited/deleted) with no manual +// event plumbing. +const lastMessageSelector = (state: MessagePaginatorAggregateState) => ({ + lastMessage: state.lastMessage ?? undefined, +}); + export const useChannelPreviewData = ( channel: Channel, client: StreamChat, forceUpdateOverride?: number, ) => { - const [forceUpdate, setForceUpdate] = useState(0); - const [lastMessage, setLastMessageInner] = useState( - () => channel.state.latestMessages[channel.state.latestMessages.length - 1], - ); - const throttledSetLastMessage = useMemo( - () => - throttle( - (newLastMessage: SetStateAction) => setLastMessageInner(newLastMessage), - setLastMessageThrottleTimeout, - setLastMessageThrottleOptions, - ), - [], - ); - const setLastMessage = useStableCallback((newLastMessage: SetStateAction) => - throttledSetLastMessage(newLastMessage), - ); + const [, setForceUpdate] = useState(0); + const { lastMessage } = + useStateStore(channel.messagePaginator.aggregateState, lastMessageSelector) ?? {}; const [unread, setUnread] = useState(channel.countUnread()); const { muted } = useIsChannelMuted(channel); const pinned = useIsChannelPinned(channel); const { forceUpdate: contextForceUpdate } = useChannelsContext(); const channelListForceUpdate = forceUpdateOverride ?? contextForceUpdate; - const channelLastMessage = channel.state.latestMessages[channel.state.latestMessages.length - 1]; - const channelLastMessageString = `${channelLastMessage?.id}${channelLastMessage?.updated_at}`; - const refreshUnreadCount = useMemo( () => throttle( @@ -75,23 +72,6 @@ export const useChannelPreviewData = ( return unsubscribe; }, [client, refreshUnreadCount]); - useEffect(() => { - setLastMessage((prevLastMessage) => - channelLastMessage && - (channelLastMessage.id !== prevLastMessage?.id || - channelLastMessage.updated_at !== prevLastMessage?.updated_at) - ? channelLastMessage - : prevLastMessage, - ); - refreshUnreadCount(); - }, [ - channelLastMessage, - channelLastMessageString, - channelListForceUpdate, - setLastMessage, - refreshUnreadCount, - ]); - /** * This effect listens for the `notification.mark_read` event and sets the unread count to 0 */ @@ -140,43 +120,24 @@ export const useChannelPreviewData = ( }, [client, channel]); /** - * This effect listens for the `message.new`, `message.updated`, `message.deleted`, `message.undeleted`, and `channel.truncated` events + * Keep the unread count in sync with events that can change it. The last message itself is sourced + * reactively from the paginator (see `lastMessageSelector`), so these listeners only refresh unread. */ useEffect(() => { refreshUnreadCount(); const handleEvent = () => { - setLastMessage(channel.state.latestMessages[channel.state.latestMessages.length - 1]); refreshUnreadCount(); }; - const handleNewMessageEvent = (event: Event) => { - const message = event.message; - if (message && (!message.parent_id || message.show_in_channel)) { - setLastMessage(message); - refreshUnreadCount(); - } - }; - - const handleUpdatedOrDeletedMessage = (event: Event) => { - setLastMessage((prevLastMessage) => { - if (prevLastMessage?.id === event.message?.id) { - return event.message; - } - return prevLastMessage; - }); - }; - const listeners = [ - channel.on('message.new', handleNewMessageEvent), - channel.on('message.updated', handleUpdatedOrDeletedMessage), - channel.on('message.deleted', handleUpdatedOrDeletedMessage), + channel.on('message.new', handleEvent), channel.on('message.undeleted', handleEvent), channel.on('channel.truncated', handleEvent), ]; return () => listeners.forEach((l) => l.unsubscribe()); - }, [channel, refreshUnreadCount, forceUpdate, channelListForceUpdate, setLastMessage]); + }, [channel, refreshUnreadCount, channelListForceUpdate]); return { lastMessage, muted, pinned, unread }; }; diff --git a/package/src/components/Message/hooks/useMessageOperations.ts b/package/src/components/Message/hooks/useMessageOperations.ts index ff8bfd8b43..275ce6bd48 100644 --- a/package/src/components/Message/hooks/useMessageOperations.ts +++ b/package/src/components/Message/hooks/useMessageOperations.ts @@ -11,6 +11,7 @@ import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../../hooks'; import { addReactionToLocalState } from '../../../utils/addReactionToLocalState'; +import { removeReactionFromLocalState } from '../../../utils/removeReactionFromLocalState'; import { MessageStatusTypes } from '../../../utils/utils'; export type MessageOperations = { @@ -94,7 +95,7 @@ export const useMessageOperations = (): MessageOperations => { ]; if (enableOfflineSupport) { - await addReactionToLocalState({ + const messageWithReaction = await addReactionToLocalState({ channel, enforceUniqueReaction, messageId, @@ -102,14 +103,12 @@ export const useMessageOperations = (): MessageOperations => { user: client.user, }); - const reactedMessage = channel.state.findMessage(messageId); - if (reactedMessage) { - const formatted = channel.state.formatMessage(reactedMessage); - if (!reactedMessage.parent_id || reactedMessage.show_in_channel) { - channel.messagePaginator.ingestItem(formatted); + if (messageWithReaction) { + if (!messageWithReaction.parent_id || messageWithReaction.show_in_channel) { + channel.messagePaginator.ingestItem(messageWithReaction); } - if (reactedMessage.parent_id) { - threadInstance?.messagePaginator?.ingestItem(formatted); + if (messageWithReaction.parent_id) { + threadInstance?.messagePaginator?.ingestItem(messageWithReaction); } } } @@ -131,21 +130,19 @@ export const useMessageOperations = (): MessageOperations => { const payload: Parameters = [messageId, type]; if (enableOfflineSupport) { - channel.state.removeReaction({ - created_at: '', - message_id: messageId, - type, - updated_at: '', + const messageWithoutReaction = removeReactionFromLocalState({ + channel, + messageId, + reactionType: type, + user: client.user, }); - const reactedMessage = channel.state.findMessage(messageId); - if (reactedMessage) { - const formatted = channel.state.formatMessage(reactedMessage); - if (!reactedMessage.parent_id || reactedMessage.show_in_channel) { - channel.messagePaginator.ingestItem(formatted); + if (messageWithoutReaction) { + if (!messageWithoutReaction.parent_id || messageWithoutReaction.show_in_channel) { + channel.messagePaginator.ingestItem(messageWithoutReaction); } - if (reactedMessage.parent_id) { - threadInstance?.messagePaginator?.ingestItem(formatted); + if (messageWithoutReaction.parent_id) { + threadInstance?.messagePaginator?.ingestItem(messageWithoutReaction); } } } diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 51f746069d..9207f85b40 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -596,7 +596,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (notLatestSet) { latestNonCurrentMessageBeforeUpdateRef.current = - channel.state.latestMessages[channel.state.latestMessages.length - 1]; + channel.messagePaginator.lastMessage ?? undefined; setAutoscrollToRecent(false); setScrollToBottomButtonVisible(true); return; @@ -686,10 +686,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (!lastItem) return; - if ( - !channel.state.messagePagination.hasPrev && - processedMessageList[0].id === lastItem.item.id - ) { + if (!channel.messagePaginator.hasMoreTail && processedMessageList[0].id === lastItem.item.id) { setStickyHeaderDate(undefined); return; } @@ -743,7 +740,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const lastItemDate = lastItemCreatedAt.getTime(); if ( - !channel.state.messagePagination.hasPrev && + !channel.messagePaginator.hasMoreTail && processedMessageList[0].id === lastItemMessage.id ) { setIsUnreadNotificationOpen(false); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index ad6d93954c..f7e3cb7623 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -508,7 +508,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (lastMessage) { if ( - !channel.state.messagePagination.hasPrev && + !channel.messagePaginator.hasMoreTail && processedMessageList[processedMessageList.length - 1].id === lastMessage.id ) { setStickyHeaderDate(undefined); @@ -566,7 +566,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const lastItemDate = lastItemCreatedAt.getTime(); if ( - !channel.state.messagePagination.hasPrev && + !channel.messagePaginator.hasMoreTail && processedMessageList[processedMessageList.length - 1].id === lastItemMessage.id ) { setIsUnreadNotificationOpen(false); @@ -767,7 +767,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (notLatestSet) { latestNonCurrentMessageBeforeUpdateRef.current = - channel.state.latestMessages[channel.state.latestMessages.length - 1]; + channel.messagePaginator.lastMessage ?? undefined; setAutoscrollToRecent(false); setScrollToBottomButtonVisible(true); return; diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 9c03aec4fe..bf96cbde2a 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -334,10 +334,9 @@ describe('MessageList', () => { channel.state = { ...channelInitialState, - latestMessages: [], - messages, read: read_data, } as unknown as typeof channel.state; + channel.messagePaginator.state.partialNext({ items: messages }); const { queryByLabelText } = render( @@ -371,9 +370,8 @@ describe('MessageList', () => { channel.state = { ...channelInitialState, - latestMessages: [], - messages, } as unknown as typeof channel.state; + channel.messagePaginator.state.partialNext({ items: messages }); const { queryByLabelText } = render( @@ -442,18 +440,15 @@ describe('MessageList', () => { channel.state = { ...channelInitialState, - latestMessages: [], - messages, } as unknown as typeof channel.state; + channel.messagePaginator.state.partialNext({ items: messages }); const flatListRefMock = jest .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. + // the list scroll to the focused message. channel.messagePaginator.emitMessageFocusSignal({ messageId: targetedMessage, reason: 'jump-to-message', @@ -528,13 +523,14 @@ describe('MessageList pagination', () => { if (staleChannelState) { channel.state = { ...channelInitialState, - latestMessages: [], members: Object.fromEntries( Array.from({ length: 10 }, (_, i) => [i, generateMember({ user_id: String(i) })]), ), - messages: Array.from({ length: 10 }, (_, i) => generateMessage({ id: String(i) })), messageSets: [{ isCurrent: true, isLatest: true }], } as unknown as typeof channel.state; + channel.messagePaginator.state.partialNext({ + items: Array.from({ length: 10 }, (_, i) => generateMessage({ id: String(i) })), + }); } return render( @@ -649,13 +645,14 @@ describe('MessageList pagination', () => { channel.state = { ...channelInitialState, - latestMessages: [], members: Object.fromEntries( Array.from({ length: 10 }, (_, i) => [i, generateMember({ user_id: String(i) })]), ), - messages: Array.from({ length: 10 }, (_, i) => generateMessage({ id: String(i) })), messageSets: [{ isCurrent: true, isLatest: true }], } as unknown as typeof channel.state; + channel.messagePaginator.state.partialNext({ + items: Array.from({ length: 10 }, (_, i) => generateMessage({ id: String(i) })), + }); const loadLatestMessages = jest.fn(() => Promise.resolve()); mockedHook({ loadLatestMessages }); diff --git a/package/src/components/Thread/__tests__/Thread.test.tsx b/package/src/components/Thread/__tests__/Thread.test.tsx index 4febef6c6c..050058a262 100644 --- a/package/src/components/Thread/__tests__/Thread.test.tsx +++ b/package/src/components/Thread/__tests__/Thread.test.tsx @@ -1,7 +1,13 @@ import React from 'react'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; -import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat'; +import type { + Channel as ChannelType, + LocalMessage, + MessageResponse, + StreamChat, +} from 'stream-chat'; +import { Thread as ThreadClass } from 'stream-chat'; import { v5 as uuidv5 } from 'uuid'; import { AttachmentPickerProvider } from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; @@ -32,7 +38,7 @@ const renderComponent = ({ channel: ChannelType; chatClient: StreamChat; props?: Partial>; - thread: LocalMessage; + thread: LocalMessage | { thread: LocalMessage; threadInstance: ThreadClass }; }) => { return render( @@ -71,11 +77,16 @@ describe('Thread', () => { generateMessage({ cid, parent_id }), ]; - channel.state.addMessagesSorted( - threadResponses as unknown as Parameters[0], + // Replies are sourced from the thread instance's paginator now, so seed a Thread with the + // replies and pass it through instead of writing to the removed channel.state message store. + const threadInstance = new ThreadClass({ channel, client: chatClient, parentMessage: thread }); + threadResponses.forEach((reply) => + threadInstance.messagePaginator.ingestItem( + channel.state.formatMessage(reply as unknown as MessageResponse), + ), ); - renderComponent({ channel, chatClient, thread }); + renderComponent({ channel, chatClient, thread: { thread, threadInstance } }); const { getAllByText, getByText, queryByText } = screen; @@ -134,8 +145,11 @@ describe('Thread', () => { const channel = chatClient.channel('messaging', mockedChannel.channel.id); await channel.query(); - channel.state.addMessagesSorted( - threadResponses as unknown as Parameters[0], + const threadInstance = new ThreadClass({ channel, client: chatClient, parentMessage: thread }); + threadResponses.forEach((reply) => + threadInstance.messagePaginator.ingestItem( + channel.state.formatMessage(reply as unknown as MessageResponse), + ), ); const { getByText, toJSON } = render( @@ -152,7 +166,7 @@ describe('Thread', () => { ['value']} > - + diff --git a/package/src/mock-builders/api/channelMocks.tsx b/package/src/mock-builders/api/channelMocks.tsx index 74f9fb069f..81d0ab524c 100644 --- a/package/src/mock-builders/api/channelMocks.tsx +++ b/package/src/mock-builders/api/channelMocks.tsx @@ -19,7 +19,7 @@ const mockUser = (data: Partial) => fromPartial(data const channelName = 'okechukwu'; const CHANNEL = fromPartial({ data: { name: channelName }, - state: { messages: [] }, + state: {}, }); const CHANNEL_WITH_MESSAGES_TEXT = { diff --git a/package/src/utils/addReactionToLocalState.ts b/package/src/utils/addReactionToLocalState.ts index bf99946fd2..a8d924c42e 100644 --- a/package/src/utils/addReactionToLocalState.ts +++ b/package/src/utils/addReactionToLocalState.ts @@ -1,7 +1,99 @@ -import type { Channel, ReactionResponse, UserResponse } from 'stream-chat'; +import type { + Channel, + LocalMessage, + ReactionGroupResponse, + ReactionResponse, + UserResponse, +} from 'stream-chat'; import { insertReaction, updateReaction } from '../store/apis'; +/** + * Optimistically applies a reaction to a locally-cached message, mirroring the reaction math the + * (now removed) `channel.state.addReaction` used to perform. Returns a NEW message object with the + * updated `reaction_groups` / `own_reactions` / `latest_reactions` — the input is never mutated, so + * ingesting the result keeps the paginator update reactive. In this offline path the reactor is + * always the current user. + * + * TODO(reactions): delete this local re-implementation once the client exposes optimistic reaction + * support (its `messagePaginator.reflectReaction` is WS-shaped — it expects server-computed + * `reaction_groups` — so it cannot back an optimistic update yet). + */ +const getMessageWithReaction = ({ + enforceUniqueReaction, + message, + reaction, + userId, +}: { + enforceUniqueReaction: boolean; + message: LocalMessage; + reaction: ReactionResponse; + userId?: string; +}): LocalMessage => { + const score = reaction.score ?? 1; + const reactionGroups: Record = { + ...(message.reaction_groups ?? {}), + }; + let ownReactions = message.own_reactions ? [...message.own_reactions] : []; + let latestReactions = message.latest_reactions ? [...message.latest_reactions] : []; + + // 1. When enforcing uniqueness, first back the current user's existing reactions out of the groups. + if (enforceUniqueReaction) { + for (const ownReaction of ownReactions) { + const group = reactionGroups[ownReaction.type]; + if (!group) { + continue; + } + const next = { + ...group, + count: group.count - 1, + sum_scores: group.sum_scores - (ownReaction.score ?? 1), + }; + if (next.count < 1) { + delete reactionGroups[ownReaction.type]; + } else { + reactionGroups[ownReaction.type] = next; + } + } + } + + // 2. Add the new reaction to its group. + const existingGroup = reactionGroups[reaction.type]; + reactionGroups[reaction.type] = existingGroup + ? { + ...existingGroup, + count: existingGroup.count + 1, + last_reaction_at: reaction.created_at, + sum_scores: existingGroup.sum_scores + score, + } + : { + count: 1, + first_reaction_at: reaction.created_at, + last_reaction_at: reaction.created_at, + sum_scores: score, + }; + + // 3. Update own_reactions (the reactor is always the current user in this path). + ownReactions = enforceUniqueReaction + ? [] + : ownReactions.filter((r) => r.user_id !== reaction.user_id || r.type !== reaction.type); + if (userId === reaction.user_id) { + ownReactions.push(reaction); + } + + // 4. Update latest_reactions, respecting uniqueness. + latestReactions = enforceUniqueReaction + ? [...latestReactions.filter((r) => r.user_id !== userId), reaction] + : [...latestReactions, reaction]; + + return { + ...message, + latest_reactions: latestReactions, + own_reactions: ownReactions, + reaction_groups: reactionGroups, + }; +}; + export const addReactionToLocalState = async ({ channel, enforceUniqueReaction, @@ -14,8 +106,8 @@ export const addReactionToLocalState = async ({ messageId: string; reactionType: string; user: UserResponse; -}) => { - const message = channel.state.messages.find(({ id }) => id === messageId); +}): Promise => { + const message = channel.messagePaginator.getItem(messageId); if (!message) { return; @@ -32,11 +124,12 @@ export const addReactionToLocalState = async ({ const hasOwnReaction = message.own_reactions && message.own_reactions.length > 0; - const messageWithReaction = channel.state.addReaction(reaction, undefined, enforceUniqueReaction); - - if (!messageWithReaction) { - return; - } + const messageWithReaction = getMessageWithReaction({ + enforceUniqueReaction, + message, + reaction, + userId: user?.id, + }); if (enforceUniqueReaction && hasOwnReaction) { await updateReaction({ @@ -49,4 +142,6 @@ export const addReactionToLocalState = async ({ reaction, }); } + + return messageWithReaction; }; diff --git a/package/src/utils/removeReactionFromLocalState.ts b/package/src/utils/removeReactionFromLocalState.ts new file mode 100644 index 0000000000..8f1e75bd47 --- /dev/null +++ b/package/src/utils/removeReactionFromLocalState.ts @@ -0,0 +1,83 @@ +import type { + Channel, + LocalMessage, + ReactionGroupResponse, + ReactionResponse, + UserResponse, +} from 'stream-chat'; + +/** + * Optimistically removes the current user's reaction of `reactionType` from a locally-cached + * message, mirroring the (now removed) `channel.state.removeReaction`. Returns a NEW message object + * (the input is never mutated). Unlike the add path this does not persist to SQLite — the client's + * `channel.deleteReaction` handles offline removal persistence, matching the pre-migration behavior. + * + * TODO(reactions): delete this local re-implementation once the client exposes optimistic reaction + * support. + */ +const getMessageWithoutReaction = ({ + message, + reaction, + userId, +}: { + message: LocalMessage; + reaction: ReactionResponse; + userId?: string; +}): LocalMessage => { + const reactionGroups: Record = { + ...(message.reaction_groups ?? {}), + }; + const reactionToRemove = message.own_reactions?.find((r) => r.type === reaction.type); + + if (reactionToRemove && reactionGroups[reactionToRemove.type]) { + const group = reactionGroups[reactionToRemove.type]; + const next = { + ...group, + count: group.count - 1, + sum_scores: group.sum_scores - (reactionToRemove.score ?? 1), + }; + if (next.count < 1) { + delete reactionGroups[reactionToRemove.type]; + } else { + reactionGroups[reactionToRemove.type] = next; + } + } + + return { + ...message, + latest_reactions: message.latest_reactions?.filter( + (r) => !(r.user_id === userId && r.type === reaction.type), + ), + own_reactions: message.own_reactions?.filter((r) => r.type !== reaction.type), + reaction_groups: reactionGroups, + }; +}; + +export const removeReactionFromLocalState = ({ + channel, + messageId, + reactionType, + user, +}: { + channel: Channel; + messageId: string; + reactionType: string; + user: UserResponse; +}): LocalMessage | undefined => { + const message = channel.messagePaginator.getItem(messageId); + + if (!message) { + return; + } + + const reaction: ReactionResponse = { + created_at: '', + message_id: messageId, + type: reactionType, + updated_at: '', + user, + user_id: user?.id, + }; + + return getMessageWithoutReaction({ message, reaction, userId: user?.id }); +}; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 282a72dcb6..1e5deb0292 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -2,12 +2,7 @@ import type React from 'react'; import dayjs from 'dayjs'; import EmojiRegex from 'emoji-regex'; -import type { - AttachmentLoadingState, - ChannelState, - LocalMessage, - MessageResponse, -} from 'stream-chat'; +import type { AttachmentLoadingState, LocalMessage, MessageResponse } from 'stream-chat'; import { IconProps } from '../../src/icons/utils/base'; import { ValueOf } from '../types/types'; @@ -270,7 +265,7 @@ export function escapeRegExp(text: string) { * @param targetId * @returns number */ -export const findInMessagesById = (messages: ChannelState['messages'], targetId: string) => { +export const findInMessagesById = (messages: LocalMessage[], targetId: string) => { const idx = messages.findIndex((message) => message.id === targetId); return idx; }; @@ -282,7 +277,7 @@ export const findInMessagesById = (messages: ChannelState['messages'], targetId: * @returns an object with the index and the message object */ export const findInMessagesByDate = ( - messages: MessageResponse[] | ChannelState['messages'], + messages: MessageResponse[] | LocalMessage[], targetDate: Date, ) => { // Binary search