Skip to content

Commit b2ff0e5

Browse files
feat(ScheduledMessages): load scheduled messages in Chat and add hooks
Chat now registers the client.scheduledMessages subscriptions. It loads the user's scheduled messages once a non-anonymous user is connected, and retries after the connection comes back. A disabled feature (error 114) is not logged. Add useScheduledMessages, useScheduledMessageCount and useCanScheduleMessage, plus mock builders for scheduled messages.
1 parent 1ad45b9 commit b2ff0e5

20 files changed

Lines changed: 1057 additions & 1 deletion
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import React, { StrictMode } from 'react';
2+
import { act, cleanup, render } from '@testing-library/react';
3+
import { ErrorFromResponse } from 'stream-chat';
4+
import type { APIErrorResponse, StreamChat } from 'stream-chat';
5+
6+
import { Chat } from '..';
7+
import {
8+
dispatchConnectionChangedEvent,
9+
dispatchConnectionRecoveredEvent,
10+
dispatchScheduledMessageEvent,
11+
generateScheduledMessage,
12+
getTestClient,
13+
getTestClientWithUser,
14+
} from '../../../mock-builders';
15+
16+
const renderChat = async (client: StreamChat, { strict = false } = {}) => {
17+
const chat = <Chat client={client}>{null}</Chat>;
18+
19+
let result!: ReturnType<typeof render>;
20+
await act(() => {
21+
result = render(strict ? <StrictMode>{chat}</StrictMode> : chat);
22+
});
23+
return result;
24+
};
25+
26+
const countListeners = (client: StreamChat, type: string) =>
27+
client.listeners[type]?.length ?? 0;
28+
29+
const featureDisabledError = () =>
30+
new ErrorFromResponse<APIErrorResponse>('failed', {
31+
code: 114,
32+
response: {
33+
data: {
34+
code: 114,
35+
duration: '',
36+
exception_fields: { reason: 'scheduled_messages_disabled' },
37+
message: 'failed',
38+
more_info: '',
39+
StatusCode: 400,
40+
},
41+
} as ErrorFromResponse<APIErrorResponse>['response'],
42+
status: 400,
43+
});
44+
45+
describe('Chat scheduled messages wiring', () => {
46+
afterEach(cleanup);
47+
48+
it('loads the scheduled messages once the user is connected', async () => {
49+
const client = await getTestClientWithUser({ id: 'user-1' });
50+
51+
await renderChat(client);
52+
53+
expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
54+
expect(client.queryScheduledMessages).toHaveBeenCalledWith({ limit: 100 });
55+
expect(client.scheduledMessages.state.getLatestValue().isLoaded).toBe(true);
56+
});
57+
58+
it('does not load before a user is connected', async () => {
59+
const client = getTestClient();
60+
61+
await renderChat(client);
62+
63+
expect(client.queryScheduledMessages).not.toHaveBeenCalled();
64+
});
65+
66+
it('loads when a user connects after the first render, without a re-render', async () => {
67+
const client = getTestClient();
68+
await renderChat(client);
69+
70+
await client.connectUser({ id: 'user-1' }, 'token');
71+
await act(() => {
72+
dispatchConnectionChangedEvent(client, true);
73+
});
74+
75+
expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
76+
});
77+
78+
it('does not load when the connection goes offline', async () => {
79+
const client = getTestClient();
80+
await renderChat(client);
81+
82+
await client.connectUser({ id: 'user-1' }, 'token');
83+
await act(() => {
84+
dispatchConnectionChangedEvent(client, false);
85+
});
86+
87+
expect(client.queryScheduledMessages).not.toHaveBeenCalled();
88+
});
89+
90+
it('does not load for an anonymous user', async () => {
91+
const client = await getTestClientWithUser({ id: '!anon' });
92+
client.anonymous = true;
93+
94+
await renderChat(client);
95+
96+
expect(client.queryScheduledMessages).not.toHaveBeenCalled();
97+
});
98+
99+
it('retries a failed load when the connection comes back', async () => {
100+
const client = await getTestClientWithUser({ id: 'user-1' });
101+
vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(new Error('offline'));
102+
await renderChat(client);
103+
104+
await act(() => {
105+
dispatchConnectionChangedEvent(client, true);
106+
});
107+
108+
expect(client.queryScheduledMessages).toHaveBeenCalledTimes(2);
109+
expect(client.scheduledMessages.state.getLatestValue().isLoaded).toBe(true);
110+
});
111+
112+
it('retries a failed load on connection.recovered', async () => {
113+
const client = await getTestClientWithUser({ id: 'user-1' });
114+
vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(new Error('offline'));
115+
await renderChat(client);
116+
117+
await act(() => {
118+
dispatchConnectionRecoveredEvent(client);
119+
});
120+
121+
expect(client.scheduledMessages.state.getLatestValue().isLoaded).toBe(true);
122+
});
123+
124+
it('does not retry or warn when the feature is disabled', async () => {
125+
const client = await getTestClientWithUser({ id: 'user-1' });
126+
vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(
127+
featureDisabledError(),
128+
);
129+
const loggerSpy = vi.spyOn(client, 'logger');
130+
await renderChat(client);
131+
132+
await act(() => {
133+
dispatchConnectionChangedEvent(client, true);
134+
});
135+
136+
expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
137+
expect(client.scheduledMessages.state.getLatestValue().isDisabled).toBe(true);
138+
expect(loggerSpy).not.toHaveBeenCalledWith(
139+
'warn',
140+
'scheduledMessages: load failed',
141+
expect.anything(),
142+
);
143+
});
144+
145+
it('does not load again when the connection comes back after a successful load', async () => {
146+
const client = await getTestClientWithUser({ id: 'user-1' });
147+
await renderChat(client);
148+
149+
await act(() => {
150+
dispatchConnectionChangedEvent(client, true);
151+
});
152+
153+
expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
154+
});
155+
156+
it('does not load again on re-render', async () => {
157+
const client = await getTestClientWithUser({ id: 'user-1' });
158+
const { rerender } = await renderChat(client);
159+
160+
await act(() => {
161+
rerender(<Chat client={client}>{null}</Chat>);
162+
});
163+
164+
expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
165+
});
166+
167+
it('queries and subscribes once under StrictMode', async () => {
168+
const client = await getTestClientWithUser({ id: 'user-1' });
169+
170+
await renderChat(client, { strict: true });
171+
172+
expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
173+
expect(countListeners(client, 'scheduled_message.created')).toBe(1);
174+
expect(countListeners(client, 'scheduled_message.sent')).toBe(1);
175+
});
176+
177+
it('keeps the store in sync from events while mounted', async () => {
178+
const client = await getTestClientWithUser({ id: 'user-1' });
179+
const scheduledMessage = generateScheduledMessage();
180+
await renderChat(client);
181+
182+
act(() => {
183+
dispatchScheduledMessageEvent({
184+
client,
185+
scheduledMessage,
186+
type: 'scheduled_message.created',
187+
});
188+
});
189+
190+
expect(client.scheduledMessages.getFromState(scheduledMessage.id)).toEqual(
191+
scheduledMessage,
192+
);
193+
});
194+
195+
it('unsubscribes on unmount', async () => {
196+
const client = await getTestClientWithUser({ id: 'user-1' });
197+
const connectionChangedListeners = countListeners(client, 'connection.changed');
198+
const connectionRecoveredListeners = countListeners(client, 'connection.recovered');
199+
const { unmount } = await renderChat(client);
200+
201+
unmount();
202+
203+
expect(client.scheduledMessages.hasSubscriptions).toBe(false);
204+
expect(countListeners(client, 'scheduled_message.created')).toBe(0);
205+
expect(countListeners(client, 'connection.changed')).toBe(connectionChangedListeners);
206+
expect(countListeners(client, 'connection.recovered')).toBe(
207+
connectionRecoveredListeners,
208+
);
209+
});
210+
211+
it('logs and keeps rendering when the load fails', async () => {
212+
const client = await getTestClientWithUser({ id: 'user-1' });
213+
const error = new Error('boom');
214+
vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(error);
215+
const loggerSpy = vi.spyOn(client, 'logger');
216+
217+
await renderChat(client);
218+
219+
expect(loggerSpy).toHaveBeenCalledWith(
220+
'warn',
221+
'scheduledMessages: load failed',
222+
expect.objectContaining({ error }),
223+
);
224+
expect(client.scheduledMessages.state.getLatestValue().lastQueryError).toBe(error);
225+
});
226+
});

‎src/components/Chat/hooks/useChat.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isLanguageSupported,
99
Streami18n,
1010
} from '../../../i18n';
11+
import { useLoadScheduledMessages } from './useLoadScheduledMessages';
1112

1213
import type {
1314
AppSettingsAPIResponse,
@@ -67,15 +68,19 @@ export const useChat = ({
6768
client.polls.registerSubscriptions();
6869
client.reminders.registerSubscriptions();
6970
client.reminders.initTimers();
71+
client.scheduledMessages.registerSubscriptions();
7072

7173
return () => {
7274
client.threads.unregisterSubscriptions();
7375
client.polls.unregisterSubscriptions();
7476
client.reminders.unregisterSubscriptions();
7577
client.reminders.clearTimers();
78+
client.scheduledMessages.unregisterSubscriptions();
7679
};
7780
}, [client]);
7881

82+
useLoadScheduledMessages(client);
83+
7984
useEffect(() => {
8085
setMutes(clientMutes);
8186

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useEffect } from 'react';
2+
import { getScheduledMessageErrorReason, ScheduledMessageErrorReason } from 'stream-chat';
3+
import type { Event, StreamChat } from 'stream-chat';
4+
5+
const isFeatureDisabledError = (error: Error) =>
6+
getScheduledMessageErrorReason(error) === ScheduledMessageErrorReason.Disabled;
7+
8+
export const useLoadScheduledMessages = (client: StreamChat) => {
9+
useEffect(() => {
10+
const { scheduledMessages } = client;
11+
12+
const loadScheduledMessages = () => {
13+
const { isDisabled, isLoaded, isLoading } =
14+
scheduledMessages.state.getLatestValue();
15+
const hasConnectedUser = !!client.user?.id && !client.anonymous;
16+
17+
const shouldLoad = hasConnectedUser && !isDisabled && !isLoaded && !isLoading;
18+
19+
if (!shouldLoad) return;
20+
21+
scheduledMessages.load().catch((error: Error) => {
22+
if (isFeatureDisabledError(error)) return;
23+
24+
client.logger('warn', 'scheduledMessages: load failed', {
25+
error,
26+
tags: ['scheduled_messages'],
27+
});
28+
});
29+
};
30+
31+
const loadWhenOnline = ({ online }: Event) => {
32+
if (online) loadScheduledMessages();
33+
};
34+
35+
loadScheduledMessages();
36+
37+
const subscriptions = [
38+
client.on('connection.changed', loadWhenOnline),
39+
client.on('connection.recovered', loadScheduledMessages),
40+
];
41+
42+
return () => subscriptions.forEach(({ unsubscribe }) => unsubscribe());
43+
}, [client]);
44+
};

0 commit comments

Comments
 (0)