stream-chat-react-native-core 9.9.0-beta.1 → 9.9.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/lib/commonjs/components/Channel/Channel.js +3 -0
  2. package/lib/commonjs/components/Channel/Channel.js.map +1 -1
  3. package/lib/commonjs/components/Channel/hooks/useCreateChannelContext.js +2 -0
  4. package/lib/commonjs/components/Channel/hooks/useCreateChannelContext.js.map +1 -1
  5. package/lib/commonjs/components/Message/MessageItemView/MessageWrapper.js +6 -3
  6. package/lib/commonjs/components/Message/MessageItemView/MessageWrapper.js.map +1 -1
  7. package/lib/commonjs/contexts/channelContext/ChannelContext.js.map +1 -1
  8. package/lib/commonjs/version.json +1 -1
  9. package/lib/module/components/Channel/Channel.js +3 -0
  10. package/lib/module/components/Channel/Channel.js.map +1 -1
  11. package/lib/module/components/Channel/hooks/useCreateChannelContext.js +2 -0
  12. package/lib/module/components/Channel/hooks/useCreateChannelContext.js.map +1 -1
  13. package/lib/module/components/Message/MessageItemView/MessageWrapper.js +6 -3
  14. package/lib/module/components/Message/MessageItemView/MessageWrapper.js.map +1 -1
  15. package/lib/module/contexts/channelContext/ChannelContext.js.map +1 -1
  16. package/lib/module/version.json +1 -1
  17. package/lib/typescript/components/Channel/Channel.d.ts +1 -1
  18. package/lib/typescript/components/Channel/Channel.d.ts.map +1 -1
  19. package/lib/typescript/components/Channel/hooks/useCreateChannelContext.d.ts +1 -1
  20. package/lib/typescript/components/Channel/hooks/useCreateChannelContext.d.ts.map +1 -1
  21. package/lib/typescript/components/Channel/hooks/useMessageListPagination.d.ts.map +1 -1
  22. package/lib/typescript/components/Message/MessageItemView/MessageWrapper.d.ts.map +1 -1
  23. package/lib/typescript/contexts/channelContext/ChannelContext.d.ts +6 -0
  24. package/lib/typescript/contexts/channelContext/ChannelContext.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/components/Channel/Channel.tsx +3 -0
  27. package/src/components/Channel/hooks/useCreateChannelContext.ts +2 -0
  28. package/src/components/Message/MessageItemView/MessageWrapper.tsx +5 -1
  29. package/src/components/MessageList/__tests__/dateSeparatorSystemMessages.test.tsx +373 -0
  30. package/src/contexts/channelContext/ChannelContext.tsx +6 -0
  31. package/src/version.json +1 -1
@@ -9,6 +9,7 @@ export const useCreateChannelContext = ({
9
9
  enableMessageGroupingByUser,
10
10
  enforceUniqueReaction,
11
11
  error,
12
+ allowDateSeparatorForSystemMessages,
12
13
  hideDateSeparators,
13
14
  hideStickyDateHeader,
14
15
  highlightedMessageId,
@@ -49,6 +50,7 @@ export const useCreateChannelContext = ({
49
50
  enableMessageGroupingByUser,
50
51
  enforceUniqueReaction,
51
52
  error,
53
+ allowDateSeparatorForSystemMessages,
52
54
  hideDateSeparators,
53
55
  hideStickyDateHeader,
54
56
  highlightedMessageId,
@@ -34,6 +34,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW
34
34
  const { message, previousMessage, nextMessage } = props;
35
35
  const { client } = useChatContext();
36
36
  const {
37
+ allowDateSeparatorForSystemMessages,
37
38
  channelUnreadStateStore,
38
39
  channel,
39
40
  hideDateSeparators,
@@ -111,7 +112,10 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW
111
112
  return (
112
113
  <View testID={`message-list-item-${message.id}`}>
113
114
  {message.type === 'system' ? (
114
- <MessageSystem message={message} style={messageContainer} />
115
+ <>
116
+ {allowDateSeparatorForSystemMessages ? renderDateSeperator : null}
117
+ <MessageSystem message={message} style={messageContainer} />
118
+ </>
115
119
  ) : wrapMessageInTheme ? (
116
120
  <ThemeProvider mergedStyle={modifiedTheme}>
117
121
  {renderDateSeperator}
@@ -0,0 +1,373 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { cleanup, render, waitFor } from '@testing-library/react-native';
5
+
6
+ import { Channel as ChannelType, LocalMessage } from 'stream-chat';
7
+
8
+ import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext';
9
+ import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
10
+ import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext';
11
+ import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel';
12
+ import { useMockedApis } from '../../../mock-builders/api/useMockedApis';
13
+ import { generateChannelResponse } from '../../../mock-builders/generator/channel';
14
+ import { generateMember } from '../../../mock-builders/generator/member';
15
+ import { generateMessage } from '../../../mock-builders/generator/message';
16
+ import { generateUser } from '../../../mock-builders/generator/user';
17
+ import { getTestClientWithUser } from '../../../mock-builders/mock';
18
+ import { Channel } from '../../Channel/Channel';
19
+ import { Chat } from '../../Chat/Chat';
20
+ import { InlineDateSeparator, InlineDateSeparatorProps } from '../InlineDateSeparator';
21
+ import { MessageList } from '../MessageList';
22
+
23
+ const user = generateUser();
24
+ const at = (day: number, hour: number) => new Date(Date.UTC(2026, 0, day, hour, 0, 0));
25
+
26
+ const message = (day: number, hour: number) =>
27
+ generateMessage({ text: `message d${day} ${hour}h`, timestamp: at(day, hour), user });
28
+
29
+ const systemMessage = (day: number, hour: number) =>
30
+ generateMessage({
31
+ text: `system d${day} ${hour}h`,
32
+ timestamp: at(day, hour),
33
+ type: 'system',
34
+ user,
35
+ });
36
+
37
+ type TestMessage = ReturnType<typeof message>;
38
+
39
+ const renderMessageList = async (
40
+ messages: TestMessage[],
41
+ channelProps: Partial<React.ComponentProps<typeof Channel>> = {},
42
+ components:
43
+ | React.ComponentProps<typeof WithComponents>['overrides']
44
+ | ((channel: ChannelType) => React.ComponentProps<typeof WithComponents>['overrides']) = {},
45
+ { hasPrev }: { hasPrev?: boolean } = {},
46
+ ) => {
47
+ const mockedChannel = generateChannelResponse({
48
+ members: [generateMember({ user })],
49
+ messages,
50
+ });
51
+ const chatClient = await getTestClientWithUser({ id: user.id });
52
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
53
+ const channel = chatClient.channel('messaging', mockedChannel.channel.id);
54
+ await channel.watch();
55
+
56
+ if (hasPrev !== undefined) {
57
+ const currentSet = channel.state.messageSets.find((set) => set.isCurrent);
58
+ if (currentSet) {
59
+ currentSet.pagination = { hasNext: false, hasPrev };
60
+ }
61
+ }
62
+
63
+ const overrides = typeof components === 'function' ? components(channel) : components;
64
+
65
+ const result = render(
66
+ <OverlayProvider>
67
+ <Chat client={chatClient}>
68
+ <WithComponents overrides={overrides}>
69
+ <Channel channel={channel} {...channelProps}>
70
+ <MessageList />
71
+ </Channel>
72
+ </WithComponents>
73
+ </Chat>
74
+ </OverlayProvider>,
75
+ );
76
+
77
+ await waitFor(() => {
78
+ expect(result.queryAllByTestId(/^message-list-item-/).length).toBe(messages.length);
79
+ });
80
+
81
+ return result;
82
+ };
83
+
84
+ /** The rendered list, oldest first, with separators interleaved where they appear. */
85
+ const readRows = (
86
+ queryAllByTestId: Awaited<ReturnType<typeof renderMessageList>>['queryAllByTestId'],
87
+ messages: TestMessage[],
88
+ ) =>
89
+ queryAllByTestId(/^(date-separator|message-list-item-)/)
90
+ .map((node) => {
91
+ const testID = node.props.testID as string;
92
+ if (testID === 'date-separator') {
93
+ return `--- ${node.props.children?.props?.accessibilityLabel} ---`;
94
+ }
95
+ const id = testID.replace('message-list-item-', '');
96
+ return messages.find((item) => item.id === id)?.text as string;
97
+ })
98
+ // the list is inverted, so tree order is newest first
99
+ .reverse();
100
+
101
+ describe('allowDateSeparatorForSystemMessages', () => {
102
+ afterEach(cleanup);
103
+
104
+ it('leaves a day opened by a system message undated when the flag is off', async () => {
105
+ // Default behaviour: the system message consumes the day boundary and renders nothing, and
106
+ // the regular message below shares its day, so neither gets a separator.
107
+ const messages = [message(1, 9), systemMessage(2, 8), message(2, 9)];
108
+
109
+ const { queryAllByTestId } = await renderMessageList(messages);
110
+
111
+ expect(readRows(queryAllByTestId, messages)).toEqual([
112
+ '--- January 1, 2026 ---',
113
+ 'message d1 9h',
114
+ 'system d2 8h',
115
+ 'message d2 9h',
116
+ ]);
117
+ });
118
+
119
+ it('dates that day when the flag is on, above the system message', async () => {
120
+ const messages = [message(1, 9), systemMessage(2, 8), message(2, 9)];
121
+
122
+ const { queryAllByTestId } = await renderMessageList(messages, {
123
+ allowDateSeparatorForSystemMessages: true,
124
+ });
125
+
126
+ expect(readRows(queryAllByTestId, messages)).toEqual([
127
+ '--- January 1, 2026 ---',
128
+ 'message d1 9h',
129
+ '--- January 2, 2026 ---',
130
+ 'system d2 8h',
131
+ 'message d2 9h',
132
+ ]);
133
+ });
134
+
135
+ it('dates a channel whose every day opens with a system message', async () => {
136
+ // With the flag off this renders zero separators across the whole channel, because the
137
+ // oldest row is a system message too, so even the start-of-history separator is consumed.
138
+ const messages = [
139
+ systemMessage(1, 8),
140
+ message(1, 10),
141
+ systemMessage(2, 8),
142
+ message(2, 10),
143
+ systemMessage(3, 8),
144
+ message(3, 10),
145
+ ];
146
+
147
+ const off = await renderMessageList(messages);
148
+ expect(off.queryAllByTestId('date-separator')).toHaveLength(0);
149
+ cleanup();
150
+
151
+ const on = await renderMessageList(messages, { allowDateSeparatorForSystemMessages: true });
152
+ expect(on.queryAllByTestId('date-separator')).toHaveLength(3);
153
+ });
154
+
155
+ it('does not date a day twice when a system message sits mid-day', async () => {
156
+ const messages = [message(1, 9), systemMessage(1, 12), message(1, 15)];
157
+
158
+ const { queryAllByTestId } = await renderMessageList(messages, {
159
+ allowDateSeparatorForSystemMessages: true,
160
+ });
161
+
162
+ expect(readRows(queryAllByTestId, messages)).toEqual([
163
+ '--- January 1, 2026 ---',
164
+ 'message d1 9h',
165
+ 'system d1 12h',
166
+ 'message d1 15h',
167
+ ]);
168
+ });
169
+
170
+ it('stays off when separators are hidden entirely', async () => {
171
+ const { queryAllByTestId } = await renderMessageList(
172
+ [message(1, 9), systemMessage(2, 8), message(2, 9)],
173
+ { allowDateSeparatorForSystemMessages: true, hideDateSeparators: true },
174
+ );
175
+
176
+ expect(queryAllByTestId('date-separator')).toHaveLength(0);
177
+ });
178
+ });
179
+
180
+ describe('a channel that writes a system message at every session boundary', () => {
181
+ afterEach(cleanup);
182
+
183
+ /**
184
+ * Some integrations write a system message whenever a session starts, and use those markers as
185
+ * their own day anchors. They want a separator only on days that hold a real message: a session
186
+ * opened and abandoned leaves a marker behind, and dating that day gives a header with nothing
187
+ * meaningful under it.
188
+ *
189
+ * The flag makes those days reachable - the separator is rendered on the marker, so the
190
+ * component override is invoked and can decide. The list is read from
191
+ * `PaginatedMessageListContext` rather than `channel.state` because that context value is
192
+ * memoized on a digest of the whole list, so the separator re-evaluates whenever the list
193
+ * changes, including for rows whose own neighbours did not.
194
+ */
195
+ const SessionAwareDateSeparator = ({ date }: InlineDateSeparatorProps) => {
196
+ const { hasMore, messages } = usePaginatedMessageListContext();
197
+
198
+ if (!date) {
199
+ return null;
200
+ }
201
+
202
+ const day = date.toDateString();
203
+ const hasRealMessage = messages.some(
204
+ (item: LocalMessage) => item.type !== 'system' && item.created_at.toDateString() === day,
205
+ );
206
+ // A day we have not finished loading may still gain a real message - keep it for now, or the
207
+ // separator pops in mid-scroll once `loadMore` brings that day's messages in.
208
+ const dayIsPartiallyLoaded = day === messages[0]?.created_at.toDateString() && hasMore;
209
+
210
+ if (!hasRealMessage && !dayIsPartiallyLoaded) {
211
+ return null;
212
+ }
213
+
214
+ // The SDK's wrapper padding is zeroed through the theme in this setup, so it is re-added here
215
+ // and only applies to separators that actually render.
216
+ return (
217
+ <View style={{ paddingVertical: 8 }}>
218
+ <InlineDateSeparator date={date} />
219
+ </View>
220
+ );
221
+ };
222
+
223
+ const renderSessionChannel = (messages: TestMessage[]) =>
224
+ renderMessageList(
225
+ messages,
226
+ { allowDateSeparatorForSystemMessages: true },
227
+ { InlineDateSeparator: SessionAwareDateSeparator },
228
+ );
229
+
230
+ it('dates every day with a real message and skips the abandoned session', async () => {
231
+ const messages = [
232
+ systemMessage(1, 9),
233
+ message(1, 10),
234
+ message(1, 11),
235
+ systemMessage(2, 14), // session opened and abandoned - no messages that day
236
+ systemMessage(3, 8),
237
+ message(3, 9),
238
+ systemMessage(4, 11),
239
+ message(4, 12),
240
+ ];
241
+
242
+ const { queryAllByTestId } = await renderSessionChannel(messages);
243
+
244
+ expect(readRows(queryAllByTestId, messages)).toEqual([
245
+ '--- January 1, 2026 ---',
246
+ 'system d1 9h',
247
+ 'message d1 10h',
248
+ 'message d1 11h',
249
+ 'system d2 14h',
250
+ '--- January 3, 2026 ---',
251
+ 'system d3 8h',
252
+ 'message d3 9h',
253
+ '--- January 4, 2026 ---',
254
+ 'system d4 11h',
255
+ 'message d4 12h',
256
+ ]);
257
+ });
258
+
259
+ it('does not date a day made up of nothing but abandoned sessions', async () => {
260
+ const messages = [
261
+ message(1, 9),
262
+ systemMessage(2, 8),
263
+ systemMessage(2, 15),
264
+ systemMessage(2, 20),
265
+ message(3, 9),
266
+ ];
267
+
268
+ const { queryAllByTestId } = await renderSessionChannel(messages);
269
+
270
+ expect(
271
+ queryAllByTestId('date-separator')
272
+ .map((node) => node.props.children?.props?.accessibilityLabel)
273
+ .reverse(),
274
+ ).toEqual(['January 1, 2026', 'January 3, 2026']);
275
+ });
276
+
277
+ it('dates a day once when sessions restart during it', async () => {
278
+ const messages = [
279
+ systemMessage(1, 9),
280
+ message(1, 10),
281
+ systemMessage(1, 14), // second session, same day
282
+ message(1, 15),
283
+ ];
284
+
285
+ const { queryAllByTestId } = await renderSessionChannel(messages);
286
+
287
+ expect(queryAllByTestId('date-separator')).toHaveLength(1);
288
+ });
289
+ });
290
+
291
+ describe('reading the list from the channel instead of context', () => {
292
+ afterEach(cleanup);
293
+
294
+ /**
295
+ * The same rule, written against the `channel` the integration already holds rather than the
296
+ * message list context. `hasMore` on `PaginatedMessageListContext` is set from
297
+ * `channel.state.messagePagination.hasPrev` at every call site, so the two are equivalent.
298
+ */
299
+ const channelBackedSeparator =
300
+ (channel: ChannelType) =>
301
+ ({ date }: InlineDateSeparatorProps) => {
302
+ if (!date) {
303
+ return null;
304
+ }
305
+
306
+ const { messages } = channel.state;
307
+ const day = date.toDateString();
308
+ const hasRealMessage = messages.some(
309
+ (item) => item.type !== 'system' && item.created_at.toDateString() === day,
310
+ );
311
+ const dayIsPartiallyLoaded =
312
+ day === messages[0]?.created_at.toDateString() && channel.state.messagePagination.hasPrev;
313
+
314
+ if (!hasRealMessage && !dayIsPartiallyLoaded) {
315
+ return null;
316
+ }
317
+
318
+ return <InlineDateSeparator date={date} />;
319
+ };
320
+
321
+ const labels = (
322
+ queryAllByTestId: Awaited<ReturnType<typeof renderMessageList>>['queryAllByTestId'],
323
+ ) =>
324
+ queryAllByTestId('date-separator')
325
+ .map((node) => node.props.children?.props?.accessibilityLabel)
326
+ .reverse();
327
+
328
+ it('suppresses the abandoned session day, same as the context-backed version', async () => {
329
+ const messages = [
330
+ systemMessage(1, 9),
331
+ message(1, 10),
332
+ systemMessage(2, 14), // opened and abandoned
333
+ systemMessage(3, 8),
334
+ message(3, 9),
335
+ ];
336
+
337
+ const { queryAllByTestId } = await renderMessageList(
338
+ messages,
339
+ { allowDateSeparatorForSystemMessages: true },
340
+ (channel) => ({ InlineDateSeparator: channelBackedSeparator(channel) }),
341
+ );
342
+
343
+ expect(labels(queryAllByTestId)).toEqual(['January 1, 2026', 'January 3, 2026']);
344
+ });
345
+
346
+ it('keeps the oldest loaded day while older pages remain', async () => {
347
+ // day 1 holds only a marker so far - suppressing it now would mean inserting the separator
348
+ // mid-scroll once loadMore brings that day's messages in
349
+ const messages = [systemMessage(1, 9), systemMessage(2, 8), message(2, 9)];
350
+
351
+ const { queryAllByTestId } = await renderMessageList(
352
+ messages,
353
+ { allowDateSeparatorForSystemMessages: true },
354
+ (channel) => ({ InlineDateSeparator: channelBackedSeparator(channel) }),
355
+ { hasPrev: true },
356
+ );
357
+
358
+ expect(labels(queryAllByTestId)).toEqual(['January 1, 2026', 'January 2, 2026']);
359
+ });
360
+
361
+ it('suppresses that same day once the history is fully loaded', async () => {
362
+ const messages = [systemMessage(1, 9), systemMessage(2, 8), message(2, 9)];
363
+
364
+ const { queryAllByTestId } = await renderMessageList(
365
+ messages,
366
+ { allowDateSeparatorForSystemMessages: true },
367
+ (channel) => ({ InlineDateSeparator: channelBackedSeparator(channel) }),
368
+ { hasPrev: false },
369
+ );
370
+
371
+ expect(labels(queryAllByTestId)).toEqual(['January 2, 2026']);
372
+ });
373
+ });
@@ -43,6 +43,12 @@ export type ChannelContextValue = {
43
43
  */
44
44
  enforceUniqueReaction: boolean;
45
45
  error: boolean | Error;
46
+ /**
47
+ * Render the inline date separator above a system message when that message is the first of
48
+ * its day. Off by default: without it a system message on a day boundary consumes the boundary
49
+ * and renders nothing, so that day gets no separator at all.
50
+ */
51
+ allowDateSeparatorForSystemMessages: boolean;
46
52
  /**
47
53
  * Hide inline date separators on channel
48
54
  */
package/src/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "9.9.0-beta.1"
2
+ "version": "9.9.1-beta.1"
3
3
  }