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