stream-chat-react-native-core 10.0.0-rc.3 → 10.0.0-rc.4
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.
- package/lib/commonjs/components/Channel/Channel.js +1 -0
- package/lib/commonjs/components/Channel/Channel.js.map +1 -1
- package/lib/commonjs/components/Channel/ChannelShell.js +8 -1
- package/lib/commonjs/components/Channel/ChannelShell.js.map +1 -1
- package/lib/commonjs/components/Channel/hooks/useJumpToLatestOnOpen.js +26 -0
- package/lib/commonjs/components/Channel/hooks/useJumpToLatestOnOpen.js.map +1 -0
- package/lib/commonjs/components/ChannelList/hooks/usePaginatedChannels.js +1 -1
- package/lib/commonjs/components/ChannelList/hooks/usePaginatedChannels.js.map +1 -1
- package/lib/commonjs/components/index.js +12 -0
- package/lib/commonjs/components/index.js.map +1 -1
- package/lib/commonjs/utils/setupCommandUIMiddlewares.js +6 -3
- package/lib/commonjs/utils/setupCommandUIMiddlewares.js.map +1 -1
- package/lib/commonjs/version.json +1 -1
- package/lib/module/components/Channel/Channel.js +1 -0
- package/lib/module/components/Channel/Channel.js.map +1 -1
- package/lib/module/components/Channel/ChannelShell.js +8 -1
- package/lib/module/components/Channel/ChannelShell.js.map +1 -1
- package/lib/module/components/Channel/hooks/useJumpToLatestOnOpen.js +26 -0
- package/lib/module/components/Channel/hooks/useJumpToLatestOnOpen.js.map +1 -0
- package/lib/module/components/ChannelList/hooks/usePaginatedChannels.js +1 -1
- package/lib/module/components/ChannelList/hooks/usePaginatedChannels.js.map +1 -1
- package/lib/module/components/index.js +12 -0
- package/lib/module/components/index.js.map +1 -1
- package/lib/module/utils/setupCommandUIMiddlewares.js +6 -3
- package/lib/module/utils/setupCommandUIMiddlewares.js.map +1 -1
- package/lib/module/version.json +1 -1
- package/lib/typescript/components/Channel/Channel.d.ts.map +1 -1
- package/lib/typescript/components/Channel/ChannelShell.d.ts +2 -0
- package/lib/typescript/components/Channel/ChannelShell.d.ts.map +1 -1
- package/lib/typescript/components/Channel/hooks/useJumpToLatestOnOpen.d.ts +15 -0
- package/lib/typescript/components/Channel/hooks/useJumpToLatestOnOpen.d.ts.map +1 -0
- package/lib/typescript/components/index.d.ts +1 -0
- package/lib/typescript/components/index.d.ts.map +1 -1
- package/lib/typescript/utils/setupCommandUIMiddlewares.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/Channel/Channel.tsx +1 -0
- package/src/components/Channel/ChannelShell.tsx +12 -0
- package/src/components/Channel/__tests__/Channel.test.tsx +102 -0
- package/src/components/Channel/hooks/useJumpToLatestOnOpen.ts +42 -0
- package/src/components/ChannelList/__tests__/ChannelList.test.tsx +28 -0
- package/src/components/ChannelList/hooks/usePaginatedChannels.ts +1 -1
- package/src/components/MessageList/__tests__/MessageList.test.tsx +89 -6
- package/src/components/Thread/__tests__/Thread.test.tsx +85 -0
- package/src/components/index.ts +1 -0
- package/src/utils/__tests__/setupCommandUIMiddlewares.test.ts +49 -0
- package/src/utils/setupCommandUIMiddlewares.ts +3 -0
- package/src/version.json +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stream-chat-react-native-core",
|
|
3
3
|
"description": "The official React Native and Expo components for Stream Chat, a service for building chat applications",
|
|
4
|
-
"version": "10.0.0-rc.
|
|
4
|
+
"version": "10.0.0-rc.4",
|
|
5
5
|
"author": {
|
|
6
6
|
"company": "Stream.io Inc",
|
|
7
7
|
"name": "Stream.io Inc"
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"react-native-markdown-package": "1.8.2",
|
|
86
86
|
"react-native-url-polyfill": "^2.0.0",
|
|
87
87
|
"react-syntax-highlighter": "^16.1.0",
|
|
88
|
-
"stream-chat": "^10.0.0-rc.
|
|
88
|
+
"stream-chat": "^10.0.0-rc.30",
|
|
89
89
|
"use-sync-external-store": "^1.7.0",
|
|
90
90
|
"zod": "^4.1.12"
|
|
91
91
|
},
|
|
@@ -104,6 +104,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
|
|
|
104
104
|
{...shellProps}
|
|
105
105
|
channel={channel}
|
|
106
106
|
hasPendingInitialTargetLoad={hasPendingInitialTargetLoad}
|
|
107
|
+
initializeOnMount={initializeOnMount}
|
|
107
108
|
>
|
|
108
109
|
{children}
|
|
109
110
|
</ChannelShell>
|
|
@@ -15,6 +15,7 @@ import { useCreateChannelContext } from './hooks/useCreateChannelContext';
|
|
|
15
15
|
import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessageInputContext';
|
|
16
16
|
import { useCreateMessagesContext } from './hooks/useCreateMessagesContext';
|
|
17
17
|
import { useCreateOwnCapabilitiesContext } from './hooks/useCreateOwnCapabilitiesContext';
|
|
18
|
+
import { useJumpToLatestOnOpen } from './hooks/useJumpToLatestOnOpen';
|
|
18
19
|
|
|
19
20
|
import {
|
|
20
21
|
AttachmentPickerContextValue,
|
|
@@ -208,6 +209,8 @@ export type ChannelConfigProps = Partial<
|
|
|
208
209
|
type ChannelShellProps = ChannelConfigProps & {
|
|
209
210
|
channel: Channel;
|
|
210
211
|
hasPendingInitialTargetLoad: () => boolean;
|
|
212
|
+
/** `<Channel>`'s `initializeOnMount`: when false, the app places the list's pages itself. */
|
|
213
|
+
initializeOnMount?: boolean;
|
|
211
214
|
/** Set by `<Thread>`: the list and composer below show this thread's replies. */
|
|
212
215
|
thread?: Thread;
|
|
213
216
|
};
|
|
@@ -228,6 +231,13 @@ const channelStatusSelector = (state: ChannelDataState & ChannelLifecycleState)
|
|
|
228
231
|
pendingDisposal: state.pendingDisposal,
|
|
229
232
|
});
|
|
230
233
|
|
|
234
|
+
// Renders nothing. It sits under the providers below so its hook can read the
|
|
235
|
+
// channel and thread context this component provides.
|
|
236
|
+
const JumpToLatestOnOpen = () => {
|
|
237
|
+
useJumpToLatestOnOpen();
|
|
238
|
+
return null;
|
|
239
|
+
};
|
|
240
|
+
|
|
231
241
|
/**
|
|
232
242
|
* The providers a message list and composer need, shared by `<Channel>` and `<Thread>`, which each
|
|
233
243
|
* own their lifecycle (watching, loading, marking read) and render this around their children.
|
|
@@ -288,6 +298,7 @@ export const ChannelShell = (props: PropsWithChildren<ChannelShellProps>) => {
|
|
|
288
298
|
hasFilePicker = isDocumentPickerAvailable(),
|
|
289
299
|
hasImagePicker = isImagePickerAvailable() || isImageMediaLibraryAvailable(),
|
|
290
300
|
hasPendingInitialTargetLoad,
|
|
301
|
+
initializeOnMount = true,
|
|
291
302
|
allowDateSeparatorForSystemMessages = false,
|
|
292
303
|
hideDateSeparators = false,
|
|
293
304
|
hideStickyDateHeader = false,
|
|
@@ -508,6 +519,7 @@ export const ChannelShell = (props: PropsWithChildren<ChannelShellProps>) => {
|
|
|
508
519
|
<MessageInputProvider value={inputMessageInputContext}>
|
|
509
520
|
<AudioPlayerProvider value={audioPlayerContext}>
|
|
510
521
|
<NotificationAnnouncer />
|
|
522
|
+
{initializeOnMount ? <JumpToLatestOnOpen /> : null}
|
|
511
523
|
<View style={{ height: '100%' }}>{children}</View>
|
|
512
524
|
{shouldRenderAttachmentPicker ? <AttachmentPicker /> : null}
|
|
513
525
|
</AudioPlayerProvider>
|
|
@@ -796,6 +796,108 @@ describe('Channel initial load useEffect', () => {
|
|
|
796
796
|
});
|
|
797
797
|
});
|
|
798
798
|
|
|
799
|
+
describe('reopening a channel left on an older page', () => {
|
|
800
|
+
const OLDER_PAGE_START = new Date('2020-01-01T00:00:00.000Z').getTime();
|
|
801
|
+
|
|
802
|
+
// A watched channel whose newest messages are loaded, but whose list shows an older page, as it
|
|
803
|
+
// does after a jump to a quoted, pinned or searched message.
|
|
804
|
+
const setUpChannelOnOlderPage = async () => {
|
|
805
|
+
const newestMessages = Array.from({ length: 10 }, (_, i) =>
|
|
806
|
+
generateMessage({ id: `newest-${i}` }),
|
|
807
|
+
);
|
|
808
|
+
const mockedChannel = generateChannelResponse({ messages: newestMessages });
|
|
809
|
+
useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
|
|
810
|
+
const channel = chatClient.channel('messaging', mockedChannel.channel.id);
|
|
811
|
+
await channel.watch();
|
|
812
|
+
|
|
813
|
+
const olderMessages = Array.from({ length: 5 }, (_, i) =>
|
|
814
|
+
generateMessage({ id: `older-${i}`, timestamp: OLDER_PAGE_START + i * 1000 }),
|
|
815
|
+
);
|
|
816
|
+
channel.messagePaginator.ingestPage({
|
|
817
|
+
page: olderMessages,
|
|
818
|
+
policy: 'strict-overlap-only',
|
|
819
|
+
setActive: true,
|
|
820
|
+
});
|
|
821
|
+
expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false);
|
|
822
|
+
|
|
823
|
+
return channel;
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
it('shows the newest messages again', async () => {
|
|
827
|
+
const channel = await setUpChannelOnOlderPage();
|
|
828
|
+
|
|
829
|
+
renderComponent({ channel, markReadOnMount: false });
|
|
830
|
+
|
|
831
|
+
await waitFor(() => expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(true));
|
|
832
|
+
expect(channel.messagePaginator.items?.map((message) => message.id)).toContain('newest-9');
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it('keeps a jump the integrator already made to a loaded message', async () => {
|
|
836
|
+
const channel = await setUpChannelOnOlderPage();
|
|
837
|
+
// A deep link from the screen: the target is loaded, so the jump lands at once.
|
|
838
|
+
await channel.messagePaginator.jumpToMessage('older-2', { focusReason: 'jump-to-message' });
|
|
839
|
+
const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot');
|
|
840
|
+
|
|
841
|
+
renderComponent({ channel, markReadOnMount: false });
|
|
842
|
+
|
|
843
|
+
await waitFor(() => expect(seedSpy).toHaveBeenCalled());
|
|
844
|
+
expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false);
|
|
845
|
+
expect(channel.messagePaginator.messageFocusSignal.getLatestValue().signal?.messageId).toBe(
|
|
846
|
+
'older-2',
|
|
847
|
+
);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('leaves a jump that is still in flight alone', async () => {
|
|
851
|
+
const channel = await setUpChannelOnOlderPage();
|
|
852
|
+
// A deep link to a message that is not loaded: its query is in flight.
|
|
853
|
+
jest.spyOn(channel.messagePaginator, 'isLoading', 'get').mockReturnValue(true);
|
|
854
|
+
const jumpToTheLatestMessageSpy = jest.spyOn(
|
|
855
|
+
channel.messagePaginator,
|
|
856
|
+
'jumpToTheLatestMessage',
|
|
857
|
+
);
|
|
858
|
+
const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot');
|
|
859
|
+
|
|
860
|
+
renderComponent({ channel, markReadOnMount: false });
|
|
861
|
+
|
|
862
|
+
await waitFor(() => expect(seedSpy).toHaveBeenCalled());
|
|
863
|
+
expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('leaves the list alone when the app places its pages itself', async () => {
|
|
867
|
+
const channel = await setUpChannelOnOlderPage();
|
|
868
|
+
const jumpToTheLatestMessageSpy = jest.spyOn(
|
|
869
|
+
channel.messagePaginator,
|
|
870
|
+
'jumpToTheLatestMessage',
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
renderComponent({ channel, initializeOnMount: false, markReadOnMount: false });
|
|
874
|
+
|
|
875
|
+
expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
|
|
876
|
+
expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('leaves the jump to the first unread message to the initial load', async () => {
|
|
880
|
+
const channel = await setUpChannelOnOlderPage();
|
|
881
|
+
jest.spyOn(channel, 'countUnread').mockImplementation(() => 3);
|
|
882
|
+
const jumpToTheFirstUnreadMessageSpy = jest
|
|
883
|
+
.spyOn(channel.messagePaginator, 'jumpToTheFirstUnreadMessage')
|
|
884
|
+
.mockResolvedValue(undefined as never);
|
|
885
|
+
const jumpToTheLatestMessageSpy = jest.spyOn(
|
|
886
|
+
channel.messagePaginator,
|
|
887
|
+
'jumpToTheLatestMessage',
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
renderComponent({
|
|
891
|
+
channel,
|
|
892
|
+
initialScrollToFirstUnreadMessage: true,
|
|
893
|
+
markReadOnMount: false,
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
await waitFor(() => expect(jumpToTheFirstUnreadMessageSpy).toHaveBeenCalledTimes(1));
|
|
897
|
+
expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
|
|
799
901
|
describe('message page size', () => {
|
|
800
902
|
const mountChannel = async () => {
|
|
801
903
|
const mockedChannel = generateChannelResponse({ messages: [] });
|
|
@@ -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();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
2
|
|
|
3
3
|
import { FlatList, StyleSheet } from 'react-native';
|
|
4
4
|
|
|
@@ -8,6 +8,9 @@ import type { LocalMessage, StreamChat as StreamChatClient, UserResponse } from
|
|
|
8
8
|
import { msToNs, Thread } from 'stream-chat';
|
|
9
9
|
|
|
10
10
|
import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext';
|
|
11
|
+
import { useMessageComposerAPIContext } from '../../../contexts/messageComposerContext/MessageComposerAPIContext';
|
|
12
|
+
import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer';
|
|
13
|
+
import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext';
|
|
11
14
|
import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
|
|
12
15
|
import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel';
|
|
13
16
|
|
|
@@ -22,6 +25,7 @@ import { getTestClientWithUser } from '../../../mock-builders/mock';
|
|
|
22
25
|
import { primitives } from '../../../theme';
|
|
23
26
|
import { Channel } from '../../Channel/Channel';
|
|
24
27
|
import { Chat } from '../../Chat/Chat';
|
|
28
|
+
import { MessageComposer } from '../../MessageInput/MessageComposer';
|
|
25
29
|
|
|
26
30
|
import { Thread as ThreadComponent } from '../../Thread/Thread';
|
|
27
31
|
import { SCROLL_TO_BOTTOM_ACCESSIBILITY_ACTION_NAME } from '../hooks/useScrollToBottomAccessibilityAction';
|
|
@@ -1290,12 +1294,12 @@ describe('MessageList in a thread', () => {
|
|
|
1290
1294
|
generateMessage({ parent_id: parentMessage.id, text: `reply-${i}` }),
|
|
1291
1295
|
),
|
|
1292
1296
|
) as LocalMessage[];
|
|
1297
|
+
// Seeded as the thread seeds `latest_replies`: one complete page, which is the newest one.
|
|
1293
1298
|
act(() =>
|
|
1294
|
-
threadInstance.messagePaginator.
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
items: replies,
|
|
1299
|
+
threadInstance.messagePaginator.setItems({
|
|
1300
|
+
valueOrFactory: replies,
|
|
1301
|
+
isFirstPage: true,
|
|
1302
|
+
isLastPage: true,
|
|
1299
1303
|
}),
|
|
1300
1304
|
);
|
|
1301
1305
|
|
|
@@ -1413,3 +1417,82 @@ describe('MessageList in a thread', () => {
|
|
|
1413
1417
|
expect(queryAllByLabelText('Inline unread indicator')).toHaveLength(0);
|
|
1414
1418
|
});
|
|
1415
1419
|
});
|
|
1420
|
+
|
|
1421
|
+
describe('MessageList optimistic edit', () => {
|
|
1422
|
+
afterEach(() => {
|
|
1423
|
+
cleanup();
|
|
1424
|
+
jest.restoreAllMocks();
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
// A UI renders `i18n.<language>_text` in place of `text`, so an optimistic edit that kept the
|
|
1428
|
+
// translations of the old text showed the "Edited" label over the old message until the server
|
|
1429
|
+
// responded (or, offline, until the edit was replayed).
|
|
1430
|
+
it('shows an edit of an auto-translated message before the request resolves', async () => {
|
|
1431
|
+
const me = generateUser({ id: 'testID' });
|
|
1432
|
+
const mine = generateMessage({
|
|
1433
|
+
i18n: { en_text: 'before edit', es_text: 'antes de editar', language: 'en' },
|
|
1434
|
+
text: 'before edit',
|
|
1435
|
+
user: me,
|
|
1436
|
+
});
|
|
1437
|
+
const mockedChannel = generateChannelResponse({
|
|
1438
|
+
members: [generateMember({ user: me })],
|
|
1439
|
+
messages: [mine],
|
|
1440
|
+
});
|
|
1441
|
+
mine.cid = mockedChannel.channel.cid;
|
|
1442
|
+
|
|
1443
|
+
const chatClient = await getTestClientWithUser(me);
|
|
1444
|
+
useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
|
|
1445
|
+
const channel = chatClient.channel('messaging', mockedChannel.channel.id);
|
|
1446
|
+
await channel.watch();
|
|
1447
|
+
// Never settles, so whatever is on screen is the optimistic state.
|
|
1448
|
+
const updateMessage = jest
|
|
1449
|
+
.spyOn(chatClient, 'updateMessage')
|
|
1450
|
+
.mockImplementation(() => new Promise(() => undefined));
|
|
1451
|
+
|
|
1452
|
+
let startEditing: (message: LocalMessage) => void = () => undefined;
|
|
1453
|
+
let submit: () => Promise<void> = () => Promise.resolve();
|
|
1454
|
+
let composer: ReturnType<typeof useMessageComposer> | undefined;
|
|
1455
|
+
const EditDriver = () => {
|
|
1456
|
+
const { setEditingState } = useMessageComposerAPIContext();
|
|
1457
|
+
const { sendMessage } = useMessageInputContext();
|
|
1458
|
+
const messageComposer = useMessageComposer();
|
|
1459
|
+
useEffect(() => {
|
|
1460
|
+
startEditing = setEditingState;
|
|
1461
|
+
submit = sendMessage;
|
|
1462
|
+
composer = messageComposer;
|
|
1463
|
+
});
|
|
1464
|
+
return null;
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
const { getByTestId } = render(
|
|
1468
|
+
<OverlayProvider>
|
|
1469
|
+
<Chat client={chatClient}>
|
|
1470
|
+
<Channel channel={channel}>
|
|
1471
|
+
<MessageList />
|
|
1472
|
+
<MessageComposer />
|
|
1473
|
+
<EditDriver />
|
|
1474
|
+
</Channel>
|
|
1475
|
+
</Chat>
|
|
1476
|
+
</OverlayProvider>,
|
|
1477
|
+
);
|
|
1478
|
+
await waitFor(() =>
|
|
1479
|
+
expect(
|
|
1480
|
+
within(getByTestId(`message-list-item-${mine.id}`)).getByText('before edit'),
|
|
1481
|
+
).toBeTruthy(),
|
|
1482
|
+
);
|
|
1483
|
+
|
|
1484
|
+
act(() => startEditing(channel.messagePaginator.getItem(mine.id) as LocalMessage));
|
|
1485
|
+
await waitFor(() => expect(composer?.editedMessage?.id).toBe(mine.id));
|
|
1486
|
+
act(() => composer?.textComposer.setText('after edit'));
|
|
1487
|
+
act(() => {
|
|
1488
|
+
void submit();
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
await waitFor(() => expect(updateMessage).toHaveBeenCalled());
|
|
1492
|
+
await waitFor(() => {
|
|
1493
|
+
const row = within(getByTestId(`message-list-item-${mine.id}`));
|
|
1494
|
+
expect(row.getByText('after edit')).toBeTruthy();
|
|
1495
|
+
expect(row.queryByText('before edit')).toBeNull();
|
|
1496
|
+
});
|
|
1497
|
+
}, 15000);
|
|
1498
|
+
});
|
|
@@ -586,4 +586,89 @@ describe('Thread lifecycle', () => {
|
|
|
586
586
|
expect(markRead.mock.calls.every(([options]) => options?.thread_id)).toBe(true);
|
|
587
587
|
});
|
|
588
588
|
});
|
|
589
|
+
|
|
590
|
+
describe('reopening a thread left on an older page', () => {
|
|
591
|
+
const OLDER_PAGE_START = new Date('2020-01-01T00:00:00.000Z').getTime();
|
|
592
|
+
|
|
593
|
+
// A thread whose newest replies are loaded, but whose list shows an older page, as it does
|
|
594
|
+
// after a jump to a quoted or searched reply.
|
|
595
|
+
const setUpThreadOnOlderPage = async () => {
|
|
596
|
+
const testChannel = await watchChannel();
|
|
597
|
+
const parentMessage = testChannel.state.formatMessage(generateMessage({ user }));
|
|
598
|
+
const threadInstance = new ThreadClass({
|
|
599
|
+
channel: testChannel,
|
|
600
|
+
client: chatClient,
|
|
601
|
+
parentMessage,
|
|
602
|
+
});
|
|
603
|
+
jest.spyOn(threadInstance, 'reload').mockResolvedValue(undefined);
|
|
604
|
+
jest.spyOn(threadInstance.messagePaginator, 'toTail').mockResolvedValue(undefined);
|
|
605
|
+
|
|
606
|
+
threadInstance.messagePaginator.setItems({
|
|
607
|
+
valueOrFactory: Array.from({ length: 10 }, (_, i) =>
|
|
608
|
+
generateMessage({ id: `newest-${i}`, parent_id: parentMessage.id }),
|
|
609
|
+
),
|
|
610
|
+
isFirstPage: true,
|
|
611
|
+
isLastPage: true,
|
|
612
|
+
});
|
|
613
|
+
threadInstance.messagePaginator.ingestPage({
|
|
614
|
+
page: Array.from({ length: 5 }, (_, i) =>
|
|
615
|
+
generateMessage({
|
|
616
|
+
id: `older-${i}`,
|
|
617
|
+
parent_id: parentMessage.id,
|
|
618
|
+
timestamp: OLDER_PAGE_START + i * 1000,
|
|
619
|
+
}),
|
|
620
|
+
),
|
|
621
|
+
policy: 'strict-overlap-only',
|
|
622
|
+
setActive: true,
|
|
623
|
+
});
|
|
624
|
+
expect(threadInstance.messagePaginator.isActiveIntervalAtHead).toBe(false);
|
|
625
|
+
|
|
626
|
+
return threadInstance;
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
it('shows the newest replies again', async () => {
|
|
630
|
+
const threadInstance = await setUpThreadOnOlderPage();
|
|
631
|
+
|
|
632
|
+
openThread(threadInstance);
|
|
633
|
+
|
|
634
|
+
await waitFor(() =>
|
|
635
|
+
expect(threadInstance.messagePaginator.isActiveIntervalAtHead).toBe(true),
|
|
636
|
+
);
|
|
637
|
+
expect(threadInstance.messagePaginator.items?.map((reply) => reply.id)).toContain('newest-9');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('keeps a jump the integrator already made to a loaded reply', async () => {
|
|
641
|
+
const threadInstance = await setUpThreadOnOlderPage();
|
|
642
|
+
// A deep link from the screen: the target is loaded, so the jump lands at once.
|
|
643
|
+
await threadInstance.messagePaginator.jumpToMessage('older-2', {
|
|
644
|
+
focusReason: 'jump-to-message',
|
|
645
|
+
});
|
|
646
|
+
const jumpToTheLatestMessageSpy = jest.spyOn(
|
|
647
|
+
threadInstance.messagePaginator,
|
|
648
|
+
'jumpToTheLatestMessage',
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
openThread(threadInstance);
|
|
652
|
+
|
|
653
|
+
expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
|
|
654
|
+
expect(threadInstance.messagePaginator.isActiveIntervalAtHead).toBe(false);
|
|
655
|
+
expect(
|
|
656
|
+
threadInstance.messagePaginator.messageFocusSignal.getLatestValue().signal?.messageId,
|
|
657
|
+
).toBe('older-2');
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('leaves a jump that is still in flight alone', async () => {
|
|
661
|
+
const threadInstance = await setUpThreadOnOlderPage();
|
|
662
|
+
// A deep link to a reply that is not loaded: its query is in flight.
|
|
663
|
+
jest.spyOn(threadInstance.messagePaginator, 'isLoading', 'get').mockReturnValue(true);
|
|
664
|
+
const jumpToTheLatestMessageSpy = jest.spyOn(
|
|
665
|
+
threadInstance.messagePaginator,
|
|
666
|
+
'jumpToTheLatestMessage',
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
openThread(threadInstance);
|
|
670
|
+
|
|
671
|
+
expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
|
|
672
|
+
});
|
|
673
|
+
});
|
|
589
674
|
});
|
package/src/components/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export * from './Channel/hooks/useCreateChannelContext';
|
|
|
34
34
|
export * from './Channel/hooks/useCreateInputMessageInputContext';
|
|
35
35
|
export * from './Channel/hooks/useCreateMessagesContext';
|
|
36
36
|
export * from './Channel/hooks/useCreateThreadContext';
|
|
37
|
+
export * from './Channel/hooks/useJumpToLatestOnOpen';
|
|
37
38
|
|
|
38
39
|
export * from './ChannelList/ChannelList';
|
|
39
40
|
export * from './ChannelList/ChannelListFooterLoadingIndicator';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MessageComposer } from 'stream-chat';
|
|
2
|
+
|
|
3
|
+
import { initiateClientWithChannels } from '../../mock-builders/api/initiateClientWithChannels';
|
|
4
|
+
import { setupCommandUIMiddlewares } from '../setupCommandUIMiddlewares';
|
|
5
|
+
|
|
6
|
+
const countMiddleware = (composer: MessageComposer) => {
|
|
7
|
+
const count = (middleware: ReadonlyArray<{ id: string }>, id: string) =>
|
|
8
|
+
middleware.filter((md) => md.id === id).length;
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
composition: count(
|
|
12
|
+
composer.compositionMiddlewareExecutor.installedMiddleware,
|
|
13
|
+
'stream-io/message-composer-middleware/command-string-injection',
|
|
14
|
+
),
|
|
15
|
+
draftComposition: count(
|
|
16
|
+
composer.draftCompositionMiddlewareExecutor.installedMiddleware,
|
|
17
|
+
'stream-io/message-composer-middleware/draft-command-string-injection',
|
|
18
|
+
),
|
|
19
|
+
textActiveCommandGuard: count(
|
|
20
|
+
composer.textComposer.middlewareExecutor.installedMiddleware,
|
|
21
|
+
'stream-io/text-composer/active-command-guard',
|
|
22
|
+
),
|
|
23
|
+
textCommandStringExtraction: count(
|
|
24
|
+
composer.textComposer.middlewareExecutor.installedMiddleware,
|
|
25
|
+
'stream-io/text-composer/command-string-extraction',
|
|
26
|
+
),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('setupCommandUIMiddlewares', () => {
|
|
31
|
+
it('inserts each command middleware once, however often it runs on the same composer', async () => {
|
|
32
|
+
const {
|
|
33
|
+
channels: [channel],
|
|
34
|
+
} = await initiateClientWithChannels();
|
|
35
|
+
const composer = channel.messageComposer;
|
|
36
|
+
|
|
37
|
+
// The `messageComposer` setup function re-runs on every config change and re-subscription.
|
|
38
|
+
setupCommandUIMiddlewares(composer);
|
|
39
|
+
setupCommandUIMiddlewares(composer);
|
|
40
|
+
setupCommandUIMiddlewares(composer);
|
|
41
|
+
|
|
42
|
+
expect(countMiddleware(composer)).toEqual({
|
|
43
|
+
composition: 1,
|
|
44
|
+
draftComposition: 1,
|
|
45
|
+
textActiveCommandGuard: 1,
|
|
46
|
+
textCommandStringExtraction: 1,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -17,15 +17,18 @@ export const setupCommandUIMiddlewares = (messageComposer: MessageComposer) => {
|
|
|
17
17
|
messageComposer.draftCompositionMiddlewareExecutor.insert({
|
|
18
18
|
middleware: [createDraftCommandInjectionMiddleware(messageComposer)],
|
|
19
19
|
position: { after: 'stream-io/message-composer-middleware/draft-attachments' },
|
|
20
|
+
unique: true,
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
messageComposer.textComposer.middlewareExecutor.insert({
|
|
23
24
|
middleware: [createActiveCommandGuardMiddleware() as TextComposerMiddleware],
|
|
24
25
|
position: { before: 'stream-io/text-composer/commands-middleware' },
|
|
26
|
+
unique: true,
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
messageComposer.textComposer.middlewareExecutor.insert({
|
|
28
30
|
middleware: [createCommandStringExtractionMiddleware() as TextComposerMiddleware],
|
|
29
31
|
position: { after: 'stream-io/text-composer/commands-middleware' },
|
|
32
|
+
unique: true,
|
|
30
33
|
});
|
|
31
34
|
};
|
package/src/version.json
CHANGED