stream-chat-react-native-core 10.0.0-rc.3 → 10.0.0-rc.5

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 (191) hide show
  1. package/lib/commonjs/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.js +4 -1
  2. package/lib/commonjs/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.js.map +1 -1
  3. package/lib/commonjs/components/Channel/Channel.js +1 -0
  4. package/lib/commonjs/components/Channel/Channel.js.map +1 -1
  5. package/lib/commonjs/components/Channel/ChannelShell.js +8 -1
  6. package/lib/commonjs/components/Channel/ChannelShell.js.map +1 -1
  7. package/lib/commonjs/components/Channel/hooks/useChannelLifecycle.js +2 -2
  8. package/lib/commonjs/components/Channel/hooks/useChannelLifecycle.js.map +1 -1
  9. package/lib/commonjs/components/Channel/hooks/useJumpToLatestOnOpen.js +26 -0
  10. package/lib/commonjs/components/Channel/hooks/useJumpToLatestOnOpen.js.map +1 -0
  11. package/lib/commonjs/components/ChannelList/hooks/usePaginatedChannels.js +1 -1
  12. package/lib/commonjs/components/ChannelList/hooks/usePaginatedChannels.js.map +1 -1
  13. package/lib/commonjs/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.js +7 -0
  14. package/lib/commonjs/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.js.map +1 -1
  15. package/lib/commonjs/components/ChannelPreview/ChannelPreviewMessage.js +4 -5
  16. package/lib/commonjs/components/ChannelPreview/ChannelPreviewMessage.js.map +1 -1
  17. package/lib/commonjs/components/Message/Message.js.map +1 -1
  18. package/lib/commonjs/components/Message/MessageItemView/Headers/SentToChannelHeader.js +6 -32
  19. package/lib/commonjs/components/Message/MessageItemView/Headers/SentToChannelHeader.js.map +1 -1
  20. package/lib/commonjs/components/Message/MessageItemView/MessageContent.js +2 -2
  21. package/lib/commonjs/components/Message/MessageItemView/MessageContent.js.map +1 -1
  22. package/lib/commonjs/components/Message/MessageItemView/MessageStatus.js +29 -9
  23. package/lib/commonjs/components/Message/MessageItemView/MessageStatus.js.map +1 -1
  24. package/lib/commonjs/components/Message/hooks/useOpenThreadAtMessage.js +57 -0
  25. package/lib/commonjs/components/Message/hooks/useOpenThreadAtMessage.js.map +1 -0
  26. package/lib/commonjs/components/Message/utils/messageActions.js +1 -1
  27. package/lib/commonjs/components/Message/utils/messageActions.js.map +1 -1
  28. package/lib/commonjs/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.js +12 -8
  29. package/lib/commonjs/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.js.map +1 -1
  30. package/lib/commonjs/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.js +3 -2
  31. package/lib/commonjs/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.js.map +1 -1
  32. package/lib/commonjs/components/MessageInput/hooks/useAudioRecorder.js +2 -2
  33. package/lib/commonjs/components/MessageInput/hooks/useAudioRecorder.js.map +1 -1
  34. package/lib/commonjs/components/MessageList/InlineUnreadIndicator.js +2 -2
  35. package/lib/commonjs/components/MessageList/InlineUnreadIndicator.js.map +1 -1
  36. package/lib/commonjs/components/MessageList/MessageList.js +1 -4
  37. package/lib/commonjs/components/MessageList/MessageList.js.map +1 -1
  38. package/lib/commonjs/components/MessageList/hooks/useMessageListFocus.js +18 -2
  39. package/lib/commonjs/components/MessageList/hooks/useMessageListFocus.js.map +1 -1
  40. package/lib/commonjs/components/Thread/components/ThreadFooterComponent.js +1 -2
  41. package/lib/commonjs/components/Thread/components/ThreadFooterComponent.js.map +1 -1
  42. package/lib/commonjs/components/index.js +12 -0
  43. package/lib/commonjs/components/index.js.map +1 -1
  44. package/lib/commonjs/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.js +0 -15
  45. package/lib/commonjs/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.js.map +1 -1
  46. package/lib/commonjs/contexts/messageContext/MessageContext.js.map +1 -1
  47. package/lib/commonjs/contexts/messageListItemContext/MessageListItemContext.js.map +1 -1
  48. package/lib/commonjs/index.js +0 -12
  49. package/lib/commonjs/index.js.map +1 -1
  50. package/lib/commonjs/types/types.js.map +1 -1
  51. package/lib/commonjs/version.json +1 -1
  52. package/lib/module/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.js +4 -1
  53. package/lib/module/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.js.map +1 -1
  54. package/lib/module/components/Channel/Channel.js +1 -0
  55. package/lib/module/components/Channel/Channel.js.map +1 -1
  56. package/lib/module/components/Channel/ChannelShell.js +8 -1
  57. package/lib/module/components/Channel/ChannelShell.js.map +1 -1
  58. package/lib/module/components/Channel/hooks/useChannelLifecycle.js +2 -2
  59. package/lib/module/components/Channel/hooks/useChannelLifecycle.js.map +1 -1
  60. package/lib/module/components/Channel/hooks/useJumpToLatestOnOpen.js +26 -0
  61. package/lib/module/components/Channel/hooks/useJumpToLatestOnOpen.js.map +1 -0
  62. package/lib/module/components/ChannelList/hooks/usePaginatedChannels.js +1 -1
  63. package/lib/module/components/ChannelList/hooks/usePaginatedChannels.js.map +1 -1
  64. package/lib/module/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.js +7 -0
  65. package/lib/module/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.js.map +1 -1
  66. package/lib/module/components/ChannelPreview/ChannelPreviewMessage.js +4 -5
  67. package/lib/module/components/ChannelPreview/ChannelPreviewMessage.js.map +1 -1
  68. package/lib/module/components/Message/Message.js.map +1 -1
  69. package/lib/module/components/Message/MessageItemView/Headers/SentToChannelHeader.js +6 -32
  70. package/lib/module/components/Message/MessageItemView/Headers/SentToChannelHeader.js.map +1 -1
  71. package/lib/module/components/Message/MessageItemView/MessageContent.js +2 -2
  72. package/lib/module/components/Message/MessageItemView/MessageContent.js.map +1 -1
  73. package/lib/module/components/Message/MessageItemView/MessageStatus.js +29 -9
  74. package/lib/module/components/Message/MessageItemView/MessageStatus.js.map +1 -1
  75. package/lib/module/components/Message/hooks/useOpenThreadAtMessage.js +57 -0
  76. package/lib/module/components/Message/hooks/useOpenThreadAtMessage.js.map +1 -0
  77. package/lib/module/components/Message/utils/messageActions.js +1 -1
  78. package/lib/module/components/Message/utils/messageActions.js.map +1 -1
  79. package/lib/module/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.js +12 -8
  80. package/lib/module/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.js.map +1 -1
  81. package/lib/module/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.js +3 -2
  82. package/lib/module/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.js.map +1 -1
  83. package/lib/module/components/MessageInput/hooks/useAudioRecorder.js +2 -2
  84. package/lib/module/components/MessageInput/hooks/useAudioRecorder.js.map +1 -1
  85. package/lib/module/components/MessageList/InlineUnreadIndicator.js +2 -2
  86. package/lib/module/components/MessageList/InlineUnreadIndicator.js.map +1 -1
  87. package/lib/module/components/MessageList/MessageList.js +1 -4
  88. package/lib/module/components/MessageList/MessageList.js.map +1 -1
  89. package/lib/module/components/MessageList/hooks/useMessageListFocus.js +18 -2
  90. package/lib/module/components/MessageList/hooks/useMessageListFocus.js.map +1 -1
  91. package/lib/module/components/Thread/components/ThreadFooterComponent.js +1 -2
  92. package/lib/module/components/Thread/components/ThreadFooterComponent.js.map +1 -1
  93. package/lib/module/components/index.js +12 -0
  94. package/lib/module/components/index.js.map +1 -1
  95. package/lib/module/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.js +0 -15
  96. package/lib/module/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.js.map +1 -1
  97. package/lib/module/contexts/messageContext/MessageContext.js.map +1 -1
  98. package/lib/module/contexts/messageListItemContext/MessageListItemContext.js.map +1 -1
  99. package/lib/module/index.js +0 -12
  100. package/lib/module/index.js.map +1 -1
  101. package/lib/module/types/types.js.map +1 -1
  102. package/lib/module/version.json +1 -1
  103. package/lib/typescript/ai-components/services/media-picker-service/index.d.ts +1 -1
  104. package/lib/typescript/components/Channel/Channel.d.ts.map +1 -1
  105. package/lib/typescript/components/Channel/ChannelShell.d.ts +2 -0
  106. package/lib/typescript/components/Channel/ChannelShell.d.ts.map +1 -1
  107. package/lib/typescript/components/Channel/hooks/useChannelLifecycle.d.ts.map +1 -1
  108. package/lib/typescript/components/Channel/hooks/useJumpToLatestOnOpen.d.ts +15 -0
  109. package/lib/typescript/components/Channel/hooks/useJumpToLatestOnOpen.d.ts.map +1 -0
  110. package/lib/typescript/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.d.ts.map +1 -1
  111. package/lib/typescript/components/ChannelPreview/ChannelPreviewMessage.d.ts.map +1 -1
  112. package/lib/typescript/components/Message/Message.d.ts +2 -1
  113. package/lib/typescript/components/Message/Message.d.ts.map +1 -1
  114. package/lib/typescript/components/Message/MessageItemView/Headers/SentToChannelHeader.d.ts.map +1 -1
  115. package/lib/typescript/components/Message/MessageItemView/MessageContent.d.ts.map +1 -1
  116. package/lib/typescript/components/Message/MessageItemView/MessageStatus.d.ts +6 -0
  117. package/lib/typescript/components/Message/MessageItemView/MessageStatus.d.ts.map +1 -1
  118. package/lib/typescript/components/Message/hooks/useOpenThreadAtMessage.d.ts +14 -0
  119. package/lib/typescript/components/Message/hooks/useOpenThreadAtMessage.d.ts.map +1 -0
  120. package/lib/typescript/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.d.ts.map +1 -1
  121. package/lib/typescript/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.d.ts.map +1 -1
  122. package/lib/typescript/components/MessageList/MessageList.d.ts.map +1 -1
  123. package/lib/typescript/components/MessageList/hooks/useMessageListFocus.d.ts +5 -2
  124. package/lib/typescript/components/MessageList/hooks/useMessageListFocus.d.ts.map +1 -1
  125. package/lib/typescript/components/Thread/components/ThreadFooterComponent.d.ts.map +1 -1
  126. package/lib/typescript/components/index.d.ts +1 -0
  127. package/lib/typescript/components/index.d.ts.map +1 -1
  128. package/lib/typescript/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.d.ts.map +1 -1
  129. package/lib/typescript/contexts/messageContext/MessageContext.d.ts +2 -1
  130. package/lib/typescript/contexts/messageContext/MessageContext.d.ts.map +1 -1
  131. package/lib/typescript/contexts/messageListItemContext/MessageListItemContext.d.ts +10 -6
  132. package/lib/typescript/contexts/messageListItemContext/MessageListItemContext.d.ts.map +1 -1
  133. package/lib/typescript/i18n/keys.d.ts +2 -2
  134. package/lib/typescript/i18n/keys.d.ts.map +1 -1
  135. package/lib/typescript/index.d.ts +0 -1
  136. package/lib/typescript/index.d.ts.map +1 -1
  137. package/lib/typescript/types/types.d.ts +0 -4
  138. package/lib/typescript/types/types.d.ts.map +1 -1
  139. package/package.json +2 -2
  140. package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx +1 -1
  141. package/src/components/AttachmentPicker/components/AttachmentMediaPicker/__tests__/AttachmentPickerItem.test.tsx +29 -1
  142. package/src/components/Channel/Channel.tsx +1 -0
  143. package/src/components/Channel/ChannelShell.tsx +12 -0
  144. package/src/components/Channel/__tests__/Channel.test.tsx +142 -7
  145. package/src/components/Channel/hooks/useChannelLifecycle.ts +8 -4
  146. package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts +1 -1
  147. package/src/components/Channel/hooks/useJumpToLatestOnOpen.ts +42 -0
  148. package/src/components/ChannelList/__tests__/ChannelList.test.tsx +28 -0
  149. package/src/components/ChannelList/hooks/usePaginatedChannels.ts +1 -1
  150. package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx +8 -0
  151. package/src/components/ChannelPreview/ChannelPreviewMessage.tsx +2 -7
  152. package/src/components/ChannelPreview/__tests__/ChannelPreviewView.test.tsx +41 -0
  153. package/src/components/Message/Message.tsx +5 -2
  154. package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx +4 -32
  155. package/src/components/Message/MessageItemView/MessageContent.tsx +3 -2
  156. package/src/components/Message/MessageItemView/MessageStatus.tsx +43 -4
  157. package/src/components/Message/MessageItemView/__tests__/MessageContent.test.tsx +68 -1
  158. package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx +57 -0
  159. package/src/components/Message/hooks/useOpenThreadAtMessage.ts +50 -0
  160. package/src/components/Message/utils/__tests__/messageActions.test.ts +57 -0
  161. package/src/components/Message/utils/messageActions.ts +1 -1
  162. package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx +34 -6
  163. package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.tsx +24 -3
  164. package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.tsx +3 -6
  165. package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx +19 -8
  166. package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +2 -5
  167. package/src/components/MessageInput/hooks/useAudioRecorder.tsx +2 -2
  168. package/src/components/MessageList/InlineUnreadIndicator.tsx +2 -2
  169. package/src/components/MessageList/MessageList.tsx +1 -5
  170. package/src/components/MessageList/__tests__/InlineUnreadIndicator.test.tsx +2 -2
  171. package/src/components/MessageList/__tests__/MessageList.test.tsx +252 -6
  172. package/src/components/MessageList/hooks/useMessageListFocus.ts +22 -2
  173. package/src/components/Thread/__tests__/Thread.test.tsx +131 -1
  174. package/src/components/Thread/components/ThreadFooterComponent.tsx +0 -1
  175. package/src/components/index.ts +1 -0
  176. package/src/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.tsx +5 -14
  177. package/src/contexts/messageContext/MessageContext.tsx +2 -1
  178. package/src/contexts/messageListItemContext/MessageListItemContext.tsx +15 -6
  179. package/src/i18n/__tests__/catalog.fixture.json +2 -2
  180. package/src/i18n/keys.ts +2 -2
  181. package/src/index.ts +0 -1
  182. package/src/types/stream-chat-common-custom-data.d.ts +0 -3
  183. package/src/types/types.ts +0 -5
  184. package/src/version.json +1 -1
  185. package/lib/commonjs/utils/setupCommandUIMiddlewares.js +0 -34
  186. package/lib/commonjs/utils/setupCommandUIMiddlewares.js.map +0 -1
  187. package/lib/module/utils/setupCommandUIMiddlewares.js +0 -34
  188. package/lib/module/utils/setupCommandUIMiddlewares.js.map +0 -1
  189. package/lib/typescript/utils/setupCommandUIMiddlewares.d.ts +0 -3
  190. package/lib/typescript/utils/setupCommandUIMiddlewares.d.ts.map +0 -1
  191. package/src/utils/setupCommandUIMiddlewares.ts +0 -31
@@ -147,12 +147,12 @@ describe('Channel', () => {
147
147
  await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1));
148
148
  });
149
149
 
150
- it('does not watch or load a draft channel, which has no id and is marked initialized', async () => {
150
+ it('does not watch, query or load a channel built from members', async () => {
151
151
  const draft = chatClient.channel('messaging', {
152
152
  members: [{ user_id: user.id }, { user_id: 'other' }],
153
153
  } as never);
154
- draft.initialized = true;
155
154
  const watchSpy = jest.spyOn(draft, 'watch');
155
+ const querySpy = jest.spyOn(draft, 'query');
156
156
  const seedSpy = jest.spyOn(draft.messagePaginator, 'reload');
157
157
 
158
158
  renderComponent({ channel: draft });
@@ -162,7 +162,9 @@ describe('Channel', () => {
162
162
  });
163
163
 
164
164
  expect(draft.isProvisional).toBe(true);
165
+ expect(draft.initialized).toBe(false);
165
166
  expect(watchSpy).not.toHaveBeenCalled();
167
+ expect(querySpy).not.toHaveBeenCalled();
166
168
  expect(seedSpy).not.toHaveBeenCalled();
167
169
  });
168
170
 
@@ -223,6 +225,37 @@ describe('Channel', () => {
223
225
  );
224
226
  });
225
227
 
228
+ describe('connection.recovered', () => {
229
+ const renderAndRecover = async () => {
230
+ const markReadSpy = jest
231
+ .spyOn(chatClient.messageDeliveryReporter, 'throttledMarkRead')
232
+ .mockImplementation(() => undefined);
233
+ renderComponent({ channel });
234
+ await waitFor(() => expect(channel.initialized).toBe(true));
235
+ markReadSpy.mockClear();
236
+
237
+ act(() => {
238
+ chatClient.dispatchEvent({ type: 'connection.recovered' });
239
+ });
240
+
241
+ return markReadSpy;
242
+ };
243
+
244
+ it('marks the channel read after a recovery', async () => {
245
+ const markReadSpy = await renderAndRecover();
246
+
247
+ expect(markReadSpy).toHaveBeenCalledWith(channel);
248
+ });
249
+
250
+ it('does not mark the channel read when recovery is disabled', async () => {
251
+ chatClient.config.set({ client: { connectionRecovery: { enabled: false } } });
252
+
253
+ const markReadSpy = await renderAndRecover();
254
+
255
+ expect(markReadSpy).not.toHaveBeenCalled();
256
+ });
257
+ });
258
+
226
259
  const queryChannelWithNewMessages = (newMessages: ReturnType<typeof generateMessage>[]) =>
227
260
  // generate new channel mock from existing channel with new messages added
228
261
  getOrCreateChannelApi(
@@ -400,8 +433,6 @@ describe('Channel', () => {
400
433
  members: [{ user_id: user.id }, { user_id: otherUser.id }],
401
434
  });
402
435
  const stored = chatClient.channel('messaging', '!members-xyz');
403
- // the mount's first-page load would query (and so supersede) the draft before the test does
404
- jest.spyOn(draft.messagePaginator, 'reload').mockResolvedValue(undefined);
405
436
  useMockedApis(chatClient, [
406
437
  getOrCreateChannelApi(
407
438
  generateChannelResponse({
@@ -610,12 +641,14 @@ describe('Channel', () => {
610
641
  });
611
642
  const rendersBefore = renderSpy.mock.calls.length;
612
643
  const capabilitiesBefore = channel.state.getLatestValue().ownCapabilities;
644
+ const dataBefore = channel.data;
613
645
 
614
646
  act(() => dispatchChannelUpdatedEvent(chatClient, { ...channel.data }));
615
647
 
616
- // Guard against a vacuous pass: the update was applied and republished the capabilities.
617
- expect(channel.state.getLatestValue().ownCapabilities).not.toBe(capabilitiesBefore);
618
- expect(channel.state.getLatestValue().ownCapabilities).toEqual(capabilitiesBefore);
648
+ // Guard against a vacuous pass: the update was applied. The capabilities did not change, so
649
+ // the client keeps their array.
650
+ expect(channel.data).not.toBe(dataBefore);
651
+ expect(channel.state.getLatestValue().ownCapabilities).toBe(capabilitiesBefore);
619
652
  expect(renderSpy.mock.calls.length).toBe(rendersBefore);
620
653
  });
621
654
 
@@ -796,6 +829,108 @@ describe('Channel initial load useEffect', () => {
796
829
  });
797
830
  });
798
831
 
832
+ describe('reopening a channel left on an older page', () => {
833
+ const OLDER_PAGE_START = new Date('2020-01-01T00:00:00.000Z').getTime();
834
+
835
+ // A watched channel whose newest messages are loaded, but whose list shows an older page, as it
836
+ // does after a jump to a quoted, pinned or searched message.
837
+ const setUpChannelOnOlderPage = async () => {
838
+ const newestMessages = Array.from({ length: 10 }, (_, i) =>
839
+ generateMessage({ id: `newest-${i}` }),
840
+ );
841
+ const mockedChannel = generateChannelResponse({ messages: newestMessages });
842
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
843
+ const channel = chatClient.channel('messaging', mockedChannel.channel.id);
844
+ await channel.watch();
845
+
846
+ const olderMessages = Array.from({ length: 5 }, (_, i) =>
847
+ generateMessage({ id: `older-${i}`, timestamp: OLDER_PAGE_START + i * 1000 }),
848
+ );
849
+ channel.messagePaginator.ingestPage({
850
+ page: olderMessages,
851
+ policy: 'strict-overlap-only',
852
+ setActive: true,
853
+ });
854
+ expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false);
855
+
856
+ return channel;
857
+ };
858
+
859
+ it('shows the newest messages again', async () => {
860
+ const channel = await setUpChannelOnOlderPage();
861
+
862
+ renderComponent({ channel, markReadOnMount: false });
863
+
864
+ await waitFor(() => expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(true));
865
+ expect(channel.messagePaginator.items?.map((message) => message.id)).toContain('newest-9');
866
+ });
867
+
868
+ it('keeps a jump the integrator already made to a loaded message', async () => {
869
+ const channel = await setUpChannelOnOlderPage();
870
+ // A deep link from the screen: the target is loaded, so the jump lands at once.
871
+ await channel.messagePaginator.jumpToMessage('older-2', { focusReason: 'jump-to-message' });
872
+ const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot');
873
+
874
+ renderComponent({ channel, markReadOnMount: false });
875
+
876
+ await waitFor(() => expect(seedSpy).toHaveBeenCalled());
877
+ expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false);
878
+ expect(channel.messagePaginator.messageFocusSignal.getLatestValue().signal?.messageId).toBe(
879
+ 'older-2',
880
+ );
881
+ });
882
+
883
+ it('leaves a jump that is still in flight alone', async () => {
884
+ const channel = await setUpChannelOnOlderPage();
885
+ // A deep link to a message that is not loaded: its query is in flight.
886
+ jest.spyOn(channel.messagePaginator, 'isLoading', 'get').mockReturnValue(true);
887
+ const jumpToTheLatestMessageSpy = jest.spyOn(
888
+ channel.messagePaginator,
889
+ 'jumpToTheLatestMessage',
890
+ );
891
+ const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot');
892
+
893
+ renderComponent({ channel, markReadOnMount: false });
894
+
895
+ await waitFor(() => expect(seedSpy).toHaveBeenCalled());
896
+ expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
897
+ });
898
+
899
+ it('leaves the list alone when the app places its pages itself', async () => {
900
+ const channel = await setUpChannelOnOlderPage();
901
+ const jumpToTheLatestMessageSpy = jest.spyOn(
902
+ channel.messagePaginator,
903
+ 'jumpToTheLatestMessage',
904
+ );
905
+
906
+ renderComponent({ channel, initializeOnMount: false, markReadOnMount: false });
907
+
908
+ expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
909
+ expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false);
910
+ });
911
+
912
+ it('leaves the jump to the first unread message to the initial load', async () => {
913
+ const channel = await setUpChannelOnOlderPage();
914
+ jest.spyOn(channel, 'countUnread').mockImplementation(() => 3);
915
+ const jumpToTheFirstUnreadMessageSpy = jest
916
+ .spyOn(channel.messagePaginator, 'jumpToTheFirstUnreadMessage')
917
+ .mockResolvedValue(undefined as never);
918
+ const jumpToTheLatestMessageSpy = jest.spyOn(
919
+ channel.messagePaginator,
920
+ 'jumpToTheLatestMessage',
921
+ );
922
+
923
+ renderComponent({
924
+ channel,
925
+ initialScrollToFirstUnreadMessage: true,
926
+ markReadOnMount: false,
927
+ });
928
+
929
+ await waitFor(() => expect(jumpToTheFirstUnreadMessageSpy).toHaveBeenCalledTimes(1));
930
+ expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
931
+ });
932
+ });
933
+
799
934
  describe('message page size', () => {
800
935
  const mountChannel = async () => {
801
936
  const mockedChannel = generateChannelResponse({ messages: [] });
@@ -97,8 +97,9 @@ export const useChannelLifecycle = ({
97
97
  return;
98
98
  }
99
99
 
100
- // A draft has no ID and is marked initialized, and querying it would create it on the server.
101
- if (channel.isProvisional && channel.initialized) {
100
+ // A channel built from members has no ID yet, and querying it would create it on the server. It
101
+ // is the app's responsibility to create it before trying to interact.
102
+ if (channel.isProvisional) {
102
103
  return;
103
104
  }
104
105
 
@@ -155,9 +156,12 @@ export const useChannelLifecycle = ({
155
156
  useEffect(() => {
156
157
  // Waits for `connection.recovered`, which fires after the reloads land, so `hasMoreHead`
157
158
  // reflects the refreshed window. Marks read only when that window is at the newest message,
158
- // leaving the read state alone when the user has paginated up into older history.
159
+ // leaving the read state alone when the user has paginated up into older history. With
160
+ // recovery disabled the event fires on every reconnect without a reload, so the messages sent
161
+ // during the drop were never shown and are not marked read. A manual `recover()` dispatches the
162
+ // same event, so it does not mark read either.
159
163
  const { unsubscribe } = client.on('connection.recovered', () => {
160
- if (channel.messagePaginator.hasMoreHead) {
164
+ if (!client.connectionRecovery.config.enabled || channel.messagePaginator.hasMoreHead) {
161
165
  return;
162
166
  }
163
167
  markRead();
@@ -22,7 +22,7 @@ export const useCreateOwnCapabilitiesContext = ({
22
22
  channel: Channel;
23
23
  overrideCapabilities?: Partial<OwnCapabilitiesContextValue>;
24
24
  }) => {
25
- // Updated by the client on watch, query and `capabilities.changed`.
25
+ // Updated by the client on watch, query and `channel.updated`.
26
26
  const { ownCapabilities } = useStateStore(channel.state, selector);
27
27
 
28
28
  const overrideCapabilitiesKey = overrideCapabilities
@@ -0,0 +1,42 @@
1
+ import { useLayoutEffect } from 'react';
2
+
3
+ import { useChannelContext } from '../../../contexts/channelContext/ChannelContext';
4
+ import { useThreadContext } from '../../../contexts/threadContext/ThreadContext';
5
+ import { useMessagePaginator } from '../../../hooks/useMessagePaginator';
6
+
7
+ /**
8
+ * Shows the newest messages when a channel or thread is opened on an older page. Its paginator
9
+ * outlives the screen, so it keeps the page it was left on, such as one after a jump to a quoted,
10
+ * pinned or searched message. Runs before the first paint, so the old page never flashes.
11
+ *
12
+ * Stays out of the way of a jump that already landed or is in flight (a deep link from the screen)
13
+ * and of the jump to the first unread message. Runs once per channel or thread: its dependencies
14
+ * are stable, so nothing that changes while it is open moves the list.
15
+ *
16
+ * `<Channel>` and `<Thread>` already run it, unless `<Channel initializeOnMount={false}>`. Call it
17
+ * from a component inside `<Channel>` or `<Thread>` only when the app opted out that way and still
18
+ * wants the newest messages shown on open.
19
+ */
20
+ export const useJumpToLatestOnOpen = () => {
21
+ const { channel, hasPendingInitialTargetLoad } = useChannelContext();
22
+ const { thread } = useThreadContext();
23
+ const paginator = useMessagePaginator();
24
+
25
+ useLayoutEffect(() => {
26
+ // A channel that is not loaded yet, or a draft, gets its first page from `<Channel>` itself.
27
+ const isChannelLoading = !thread && (!channel.initialized || channel.isProvisional);
28
+ if (
29
+ isChannelLoading ||
30
+ !paginator.items?.length ||
31
+ paginator.isActiveIntervalAtHead ||
32
+ paginator.isLoading ||
33
+ hasPendingInitialTargetLoad?.()
34
+ ) {
35
+ return;
36
+ }
37
+
38
+ paginator
39
+ .jumpToTheLatestMessage()
40
+ .catch((error: unknown) => console.warn('Jumping to the latest message failed:', error));
41
+ }, [channel, hasPendingInitialTargetLoad, paginator, thread]);
42
+ };
@@ -1154,6 +1154,34 @@ describe('ChannelList', () => {
1154
1154
  expect(querySpy).not.toHaveBeenCalled();
1155
1155
  });
1156
1156
 
1157
+ it('loads the first page when an event put a channel in the list before it was ever queried', async () => {
1158
+ const seededChannel = chatClient.channel(testChannel1.channel.type, testChannel1.channel.id);
1159
+ // What `message.new` / `member.updated` do for a channel matching a list nobody queried yet.
1160
+ chatClient.channelManager.ingestChannel(seededChannel);
1161
+ expect(props.paginator.items?.map((channel) => channel.id)).toEqual([
1162
+ testChannel1.channel.id,
1163
+ ]);
1164
+ expect(props.paginator.isInitialized).toBe(false);
1165
+
1166
+ useMockedApis(chatClient, [queryChannelsApi([testChannel2, testChannel1])]);
1167
+ const querySpy = jest.spyOn(chatClient, 'queryChannels');
1168
+ render(
1169
+ <Chat client={chatClient}>
1170
+ <WithComponents overrides={{ ChannelPreview: ChannelPreviewComponent }}>
1171
+ <ChannelList {...props} />
1172
+ </WithComponents>
1173
+ </Chat>,
1174
+ );
1175
+
1176
+ // The seeded channel does not count as a loaded first page, so the list queries on mount.
1177
+ await act(async () => {
1178
+ await new Promise((resolve) => setTimeout(resolve, 0));
1179
+ });
1180
+ expect(querySpy).toHaveBeenCalledTimes(1);
1181
+ await waitFor(() => expect(screen.getByTestId(testChannel2.channel.id)).toBeTruthy());
1182
+ expect(props.paginator.isInitialized).toBe(true);
1183
+ });
1184
+
1157
1185
  it('leaves the paginator registered and loaded when it unmounts', async () => {
1158
1186
  useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
1159
1187
 
@@ -95,7 +95,7 @@ export const usePaginatedChannels = ({ paginator }: { paginator: ChannelPaginato
95
95
  // new paginator, and after `client.disconnectUser` resets it. A paginator that already has
96
96
  // channels is shown as is, and a load in flight is left alone.
97
97
  const userId = useClientUserId(client);
98
- const isUnloaded = channels === undefined;
98
+ const isUnloaded = channels === undefined || !paginator.isInitialized;
99
99
  useEffect(() => {
100
100
  if (!isUnloaded || !userId || paginator.isLoading) return;
101
101
  reloadList();
@@ -103,6 +103,14 @@ export const ChannelMessagePreviewDeliveryStatus = ({
103
103
  );
104
104
  }
105
105
 
106
+ if (message.type === 'deleted') {
107
+ return (
108
+ <Text style={styles.text} testID='channel-preview-sender'>
109
+ {t('common.you.label', 'You')}:
110
+ </Text>
111
+ );
112
+ }
113
+
106
114
  return (
107
115
  <CompositeAccessibilityProbe label={statusLabel}>
108
116
  <View style={styles.container}>
@@ -55,7 +55,6 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => {
55
55
  const lastMessageStatus = lastMessage && 'status' in lastMessage ? lastMessage.status : undefined;
56
56
  const isFailedMessage =
57
57
  lastMessageStatus === MessageStatusTypes.FAILED || lastMessage?.type === 'error';
58
- const showMessageDeliveryStatus = !isMessageDeleted;
59
58
 
60
59
  if (usersTyping.length > 0) {
61
60
  return <ChannelPreviewTypingIndicator channel={channel} usersTyping={usersTyping} />;
@@ -103,18 +102,14 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => {
103
102
  if (channel.data?.custom?.name || membersWithoutSelf.length > 1) {
104
103
  return (
105
104
  <View style={styles.container}>
106
- {showMessageDeliveryStatus ? (
107
- <ChannelPreviewMessageDeliveryStatus channel={channel} message={lastMessage} />
108
- ) : null}
105
+ <ChannelPreviewMessageDeliveryStatus channel={channel} message={lastMessage} />
109
106
  <ChannelPreviewLastMessage message={lastMessage} />
110
107
  </View>
111
108
  );
112
109
  } else {
113
110
  return (
114
111
  <View style={styles.container}>
115
- {showMessageDeliveryStatus ? (
116
- <ChannelPreviewMessageDeliveryStatus channel={channel} message={lastMessage} />
117
- ) : null}
112
+ <ChannelPreviewMessageDeliveryStatus channel={channel} message={lastMessage} />
118
113
  <ChannelPreviewLastMessage message={lastMessage} />
119
114
  </View>
120
115
  );
@@ -126,6 +126,47 @@ describe('ChannelPreviewView', () => {
126
126
  await waitFor(() => screen.queryByText(expectedDisplayName));
127
127
  });
128
128
 
129
+ describe('the sender of the latest message', () => {
130
+ const otherUser = generateUser({ name: 'Count Dooku' });
131
+
132
+ const renderWithLatestMessage = async (latestMessage: ReturnType<typeof generateMessage>) => {
133
+ await initializeChannel(
134
+ generateChannelResponse({
135
+ channel: { custom: { name: 'group' } },
136
+ members: [generateMember({ user: clientUser }), generateMember({ user: otherUser })],
137
+ messages: [latestMessage],
138
+ }),
139
+ );
140
+ render(
141
+ <OverlayProvider accessibility={{ enabled: true }}>
142
+ <Chat client={chatClient}>
143
+ <ChannelPreviewView
144
+ channel={channel as ChannelType}
145
+ lastMessage={latestMessage}
146
+ onSelect={jest.fn()}
147
+ />
148
+ </Chat>
149
+ </OverlayProvider>,
150
+ );
151
+ };
152
+
153
+ it.each([
154
+ ['an own message', clientUser, 'regular', 'You:', 1],
155
+ ['an own deleted message', clientUser, 'deleted', 'You:', 0],
156
+ ['a deleted message from another member', otherUser, 'deleted', 'Count Dooku:', 0],
157
+ ] as const)('names the sender of %s', async (_, user, type, prefix, statusLabels) => {
158
+ await renderWithLatestMessage(generateMessage({ status: 'received', type, user }));
159
+
160
+ // An own message's status hides its sender text from screen readers, so hidden elements count.
161
+ const sender = () =>
162
+ screen.getByTestId('channel-preview-sender', { includeHiddenElements: true });
163
+ await waitFor(() => expect(sender()).toBeTruthy());
164
+ expect(sender().props.children.join('')).toBe(prefix);
165
+ // The delivery status announces itself with "Sent by you" or "Read, sent by you".
166
+ expect(screen.queryAllByLabelText(/sent by you/i)).toHaveLength(statusLabels);
167
+ });
168
+ });
169
+
129
170
  it('should render latest message, truncated to length given by latestMessageLength', async () => {
130
171
  const message = generateMessage();
131
172
  await initializeChannel(generateChannelResponse());
@@ -47,7 +47,10 @@ import {
47
47
  MessageOverlayRuntimeProvider,
48
48
  MessageProvider,
49
49
  } from '../../contexts/messageContext/MessageContext';
50
- import { useMessageListItemContext } from '../../contexts/messageListItemContext/MessageListItemContext';
50
+ import {
51
+ GoToMessage,
52
+ useMessageListItemContext,
53
+ } from '../../contexts/messageListItemContext/MessageListItemContext';
51
54
  import {
52
55
  MessagesContextValue,
53
56
  useMessagesContext,
@@ -223,7 +226,7 @@ export type MessagePropsWithContext = Pick<
223
226
  messagesContext: MessagesContextValue;
224
227
  /** Whether long pressing a message is enabled. Defaults to true. */
225
228
  enableLongPress?: boolean;
226
- goToMessage?: (messageId: string) => void;
229
+ goToMessage?: GoToMessage;
227
230
  isTargetedMessage?: boolean;
228
231
  /**
229
232
  * Called with the message's thread when its replies button is pressed.
@@ -2,8 +2,6 @@ import React, { useMemo } from 'react';
2
2
 
3
3
  import { Pressable, StyleSheet, Text, View } from 'react-native';
4
4
 
5
- import { useChannelContext } from '../../../../contexts/channelContext/ChannelContext';
6
- import { useChatContext } from '../../../../contexts/chatContext/ChatContext';
7
5
  import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext';
8
6
  import { useMessageContext } from '../../../../contexts/messageContext/MessageContext';
9
7
  import { useMessagesContext } from '../../../../contexts/messagesContext/MessagesContext';
@@ -15,6 +13,7 @@ import {
15
13
  import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
16
14
  import { useStableCallback } from '../../../../hooks';
17
15
  import { primitives } from '../../../../theme';
16
+ import { useOpenThreadAtMessage } from '../../hooks/useOpenThreadAtMessage';
18
17
  import { useShouldUseOverlayStyles } from '../../hooks/useShouldUseOverlayStyles';
19
18
 
20
19
  type SentToChannelHeaderPropsWithContext = Pick<ThreadContextValue, 'thread'> & {
@@ -67,10 +66,9 @@ export type SentToChannelHeaderProps = Partial<SentToChannelHeaderPropsWithConte
67
66
 
68
67
  export const SentToChannelHeader = (props: SentToChannelHeaderProps) => {
69
68
  const { onAlsoSentToChannelHeaderPress } = useMessagesContext();
70
- const { client } = useChatContext();
71
- const { channel } = useChannelContext();
72
69
  const { thread } = useThreadContext();
73
70
  const { message } = useMessageContext();
71
+ const openThreadAtMessage = useOpenThreadAtMessage();
74
72
 
75
73
  const handleOnPress = useStableCallback(async () => {
76
74
  if (thread) {
@@ -78,34 +76,8 @@ export const SentToChannelHeader = (props: SentToChannelHeaderProps) => {
78
76
  return;
79
77
  }
80
78
 
81
- const parentId = message.parent_id;
82
- if (!parentId) {
83
- return;
84
- }
85
-
86
- // The parent is only fetched when neither its thread nor the message itself is loaded. Opening
87
- // the thread loads whatever else it is missing.
88
- const registeredThread = client.threads.get(parentId);
89
- if (registeredThread) {
90
- onAlsoSentToChannelHeaderPress?.({ targetedMessageId: message.id, thread: registeredThread });
91
- return;
92
- }
93
-
94
- try {
95
- const parentMessage =
96
- client.messageStore.get(parentId) ??
97
- (await channel.getMessagesById([parentId])).messages?.[0];
98
-
99
- if (!parentMessage) {
100
- return;
101
- }
102
-
103
- onAlsoSentToChannelHeaderPress?.({
104
- targetedMessageId: message.id,
105
- thread: client.threads.ensure({ channel, parentMessage }),
106
- });
107
- } catch (error) {
108
- console.error('Error querying parent message:', error);
79
+ if (message.parent_id) {
80
+ await openThreadAtMessage({ parentId: message.parent_id, targetedMessageId: message.id });
109
81
  }
110
82
  });
111
83
 
@@ -254,14 +254,15 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
254
254
 
255
255
  if (onPress) {
256
256
  onPress({
257
- defaultHandler: () => goToMessage(message.quoted_message!.id),
257
+ defaultHandler: () =>
258
+ goToMessage(message.quoted_message!.id, message.quoted_message!),
258
259
  emitter: 'messageContent',
259
260
  event,
260
261
  });
261
262
  return;
262
263
  }
263
264
 
264
- goToMessage(message.quoted_message.id);
265
+ goToMessage(message.quoted_message.id, message.quoted_message);
265
266
  }}
266
267
  onPressIn={(event) => {
267
268
  if (onPressIn) {
@@ -1,13 +1,17 @@
1
1
  import React, { useMemo } from 'react';
2
2
  import { StyleSheet, View } from 'react-native';
3
3
 
4
+ import type { ChannelConfig } from 'stream-chat';
5
+
4
6
  import { useA11yLabel } from '../../../a11y/hooks/useA11yLabel';
7
+ import { useChannelContext } from '../../../contexts/channelContext/ChannelContext';
5
8
  import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext';
6
9
  import {
7
10
  MessageContextValue,
8
11
  useMessageContext,
9
12
  } from '../../../contexts/messageContext/MessageContext';
10
13
  import { useTheme } from '../../../contexts/themeContext/ThemeContext';
14
+ import { useStateStore } from '../../../hooks/useStateStore';
11
15
  import { primitives } from '../../../theme';
12
16
  import { MessageStatusTypes } from '../../../utils/utils';
13
17
  import { HiddenA11yText } from '../../Accessibility/HiddenA11yText';
@@ -15,15 +19,27 @@ import { useIsMessageDeliveredToOthers } from '../hooks/useIsMessageDeliveredToO
15
19
  import { useIsMessageReadByOthers } from '../hooks/useIsMessageReadByOthers';
16
20
  import { useShouldUseOverlayStyles } from '../hooks/useShouldUseOverlayStyles';
17
21
 
22
+ const readEventsSelector = ({ readEvents }: ChannelConfig) => ({
23
+ readEventsEnabled: readEvents.enabled,
24
+ });
25
+
18
26
  export type MessageStatusPropsWithContext = Pick<MessageContextValue, 'message'> & {
19
27
  /** Whether the message reached at least one other member. */
20
28
  delivered: boolean;
21
29
  /** Whether at least one other member read the message. */
22
30
  read: boolean;
31
+ /**
32
+ * Whether the channel has read events. Without them only the sending status shows, as in the
33
+ * channel preview.
34
+ * @default true
35
+ */
36
+ readEventsEnabled?: boolean;
23
37
  };
24
38
 
25
39
  const MessageStatusWithContext = (props: MessageStatusPropsWithContext) => {
26
- const { delivered, message, read } = props;
40
+ const { message, readEventsEnabled = true } = props;
41
+ const delivered = readEventsEnabled && props.delivered;
42
+ const read = readEventsEnabled && props.read;
27
43
 
28
44
  const styles = useStyles();
29
45
  const { icons } = useComponentsContext();
@@ -38,6 +54,7 @@ const MessageStatusWithContext = (props: MessageStatusPropsWithContext) => {
38
54
 
39
55
  const sending = message.status === MessageStatusTypes.SENDING;
40
56
  const sent =
57
+ readEventsEnabled &&
41
58
  message.status === MessageStatusTypes.RECEIVED &&
42
59
  !delivered &&
43
60
  !read &&
@@ -55,7 +72,11 @@ const MessageStatusWithContext = (props: MessageStatusPropsWithContext) => {
55
72
  : '',
56
73
  );
57
74
 
58
- if (message.status === MessageStatusTypes.FAILED || message.type === 'error') {
75
+ if (
76
+ message.status === MessageStatusTypes.FAILED ||
77
+ message.type === 'error' ||
78
+ message.type === 'deleted'
79
+ ) {
59
80
  return null;
60
81
  }
61
82
 
@@ -109,8 +130,23 @@ const areEqual = (
109
130
  prevProps: MessageStatusPropsWithContext,
110
131
  nextProps: MessageStatusPropsWithContext,
111
132
  ) => {
112
- const { delivered: prevDelivered, message: prevMessage, read: prevRead } = prevProps;
113
- const { delivered: nextDelivered, message: nextMessage, read: nextRead } = nextProps;
133
+ const {
134
+ delivered: prevDelivered,
135
+ message: prevMessage,
136
+ read: prevRead,
137
+ readEventsEnabled: prevReadEventsEnabled,
138
+ } = prevProps;
139
+ const {
140
+ delivered: nextDelivered,
141
+ message: nextMessage,
142
+ read: nextRead,
143
+ readEventsEnabled: nextReadEventsEnabled,
144
+ } = nextProps;
145
+
146
+ const readEventsEnabledEqual = prevReadEventsEnabled === nextReadEventsEnabled;
147
+ if (!readEventsEnabledEqual) {
148
+ return false;
149
+ }
114
150
 
115
151
  const deliveredEqual = prevDelivered === nextDelivered;
116
152
  if (!deliveredEqual) {
@@ -141,14 +177,17 @@ export type MessageStatusProps = Partial<MessageStatusPropsWithContext>;
141
177
  export const MessageStatus = (props: MessageStatusProps) => {
142
178
  const { message: contextMessage } = useMessageContext();
143
179
  const message = props.message ?? contextMessage;
180
+ const { channel } = useChannelContext();
144
181
  const readByOthers = useIsMessageReadByOthers({ message });
145
182
  const deliveredToOthers = useIsMessageDeliveredToOthers({ message });
183
+ const { readEventsEnabled } = useStateStore(channel?.configState, readEventsSelector) ?? {};
146
184
 
147
185
  return (
148
186
  <MemoizedMessageStatus
149
187
  delivered={props.delivered ?? deliveredToOthers}
150
188
  message={message}
151
189
  read={props.read ?? readByOthers}
190
+ readEventsEnabled={props.readEventsEnabled ?? readEventsEnabled}
152
191
  />
153
192
  );
154
193
  };