Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
b2ff0e5
feat(ScheduledMessages): load scheduled messages in Chat and add hooks
NikolaGoranNaumovski Oct 28, 2026
a0c18fd
feat(Icons): add IconCalendar and IconChevronUp
NikolaGoranNaumovski Oct 28, 2026
8df625a
feat(ScheduledMessages): add schedule time and preset utilities
NikolaGoranNaumovski Oct 28, 2026
e8b7901
feat(ScheduledMessages): add hooks for scheduling from the composer
NikolaGoranNaumovski Oct 28, 2026
7100270
feat(ScheduledMessages): add the schedule menu and custom time dialog
NikolaGoranNaumovski Oct 28, 2026
962a457
feat(i18n): translate scheduled message strings and notifications
NikolaGoranNaumovski Oct 28, 2026
79312fd
feat(MessageComposer): schedule messages from a split send button
NikolaGoranNaumovski Oct 28, 2026
a276086
refactor(ScheduledMessages): drop comments from the scheduling utilities
NikolaGoranNaumovski Oct 29, 2026
0f9ce9a
feat(i18n): translate blocked scheduled message warnings
NikolaGoranNaumovski Oct 29, 2026
96e7ef6
refactor(ScheduledMessages): share the channel container lookup
NikolaGoranNaumovski Oct 30, 2026
4903a92
feat(ScheduledMessages): add grouping, preview and failure copy utili…
NikolaGoranNaumovski Oct 30, 2026
04fd81c
feat(ScheduledMessages): add hooks for the scheduled message list
NikolaGoranNaumovski Oct 30, 2026
859175f
feat(ScheduledMessages): show scheduled messages above the composer
NikolaGoranNaumovski Oct 30, 2026
a85f390
feat(i18n): translate the scheduled message list and banner
NikolaGoranNaumovski Oct 30, 2026
537a4b3
feat(ChannelDetail): add a scheduled messages section
NikolaGoranNaumovski Oct 30, 2026
e6331d6
fix(ScheduledMessages): label the next-Monday preset with its weekday
NikolaGoranNaumovski Nov 2, 2026
9db4a20
Merge branch 'feat/react-1387-scheduled-messages-schedule' into feat/…
NikolaGoranNaumovski Nov 2, 2026
9d9b6c1
feat(ScheduledMessages): add search, loading and error states to the …
NikolaGoranNaumovski Nov 2, 2026
3c555dc
refactor(ScheduledMessages): share the date and time dialog
NikolaGoranNaumovski Nov 2, 2026
efbb8ad
feat(ScheduledMessages): add a row menu to manage scheduled messages
NikolaGoranNaumovski Nov 2, 2026
6774a37
feat(MessageComposer): edit scheduled messages in the composer
NikolaGoranNaumovski Nov 2, 2026
f34b161
feat(i18n): translate scheduled message actions
NikolaGoranNaumovski Nov 2, 2026
f27a5bd
feat(ScheduledMessages): schedule a failed message again
NikolaGoranNaumovski Nov 2, 2026
1311343
feat(ScheduledMessages): notify when a scheduled message fails to send
NikolaGoranNaumovski Nov 2, 2026
f7b91cf
feat(ScheduledMessages): show the failed count in the banner
NikolaGoranNaumovski Nov 2, 2026
97cf4a5
feat(ScheduledMessages): make the list, row actions and dialogs overr…
NikolaGoranNaumovski Nov 2, 2026
af22677
feat(i18n): translate scheduled message failures
NikolaGoranNaumovski Nov 2, 2026
28f718a
feat(ScheduledMessages): explain why a scheduled message edit ended
NikolaGoranNaumovski Nov 2, 2026
05b03dc
feat(i18n): translate the ended scheduled message edit
NikolaGoranNaumovski Nov 2, 2026
84956f1
Merge branch 'feat/react-1389-scheduled-messages-manage' into feat/re…
NikolaGoranNaumovski Nov 2, 2026
a08d252
fix(ScheduledMessages): match the split send button and menu to the d…
NikolaGoranNaumovski Nov 3, 2026
c1a3ad9
fix(ScheduledMessages): match the custom time dialog to the design
NikolaGoranNaumovski Nov 3, 2026
8c0aad5
feat(i18n): add the scheduled message date format
NikolaGoranNaumovski Nov 3, 2026
1d97564
fix(ScheduledMessages): confirm a scheduled message with the design copy
NikolaGoranNaumovski Nov 3, 2026
ddea99f
feat(i18n): translate the scheduled message confirmation
NikolaGoranNaumovski Nov 3, 2026
7f51aca
Merge branch 'feat/react-1387-scheduled-messages-schedule' into feat/…
NikolaGoranNaumovski Nov 3, 2026
a127cb6
fix(ScheduledMessages): match the scheduled messages banner to the de…
NikolaGoranNaumovski Nov 3, 2026
d21cc90
fix(ScheduledMessages): match the scheduled message list to the design
NikolaGoranNaumovski Nov 3, 2026
857f093
fix(ChannelDetail): label the scheduled section as in the design
NikolaGoranNaumovski Nov 3, 2026
2055237
feat(i18n): translate the scheduled message list rows
NikolaGoranNaumovski Nov 3, 2026
428b448
Merge branch 'feat/react-1388-scheduled-messages-list' into feat/reac…
NikolaGoranNaumovski Nov 3, 2026
bbfcb40
fix(ScheduledMessages): match the scheduled message row actions to th…
NikolaGoranNaumovski Nov 3, 2026
5f9e0f4
feat(i18n): translate the copy text action
NikolaGoranNaumovski Nov 3, 2026
cf035b8
fix(ScheduledMessages): align the scheduled message rows in the chann…
NikolaGoranNaumovski Nov 3, 2026
c5466ca
Merge branch 'feat/react-1388-scheduled-messages-list' into feat/reac…
NikolaGoranNaumovski Nov 3, 2026
d9e01cd
fix(ScheduledMessages): match the manage dialogs and toasts to the de…
NikolaGoranNaumovski Nov 3, 2026
8d10328
feat(i18n): translate the scheduled message manage copy
NikolaGoranNaumovski Nov 3, 2026
cf642f3
Merge branch 'feat/react-1389-scheduled-messages-manage' into feat/re…
NikolaGoranNaumovski Nov 3, 2026
67da052
fix(ScheduledMessages): match the failure banner and actions to the d…
NikolaGoranNaumovski Nov 3, 2026
ac092eb
feat(i18n): translate the scheduled message failure copy
NikolaGoranNaumovski Nov 3, 2026
ca53c72
Merge remote-tracking branch 'origin/release-v14' into feat/react-138…
NikolaGoranNaumovski Nov 9, 2026
76d9218
fix(ScheduledMessages): show failed scheduled messages when the chann…
NikolaGoranNaumovski Nov 9, 2026
c6ffcc6
fix(Channel): refresh channelCapabilities when only the capabilities …
NikolaGoranNaumovski Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import type { Channel } from 'stream-chat';

import { useCreateChannelStateContext } from '../useCreateChannelStateContext';

const channel = {
cid: 'messaging:general',
data: { name: 'General' },
initialized: false,
} as unknown as Channel;

const renderStateContext = (channelCapabilitiesArray: string[]) =>
renderHook(
({ capabilities }) =>
useCreateChannelStateContext({
channel,
channelCapabilitiesArray: capabilities,
notifications: [],
} as unknown as Parameters<typeof useCreateChannelStateContext>[0]),
{ initialProps: { capabilities: channelCapabilitiesArray } },
);

describe('useCreateChannelStateContext', () => {
it('maps the capabilities array to channelCapabilities', () => {
const { result } = renderStateContext(['send-message', 'schedule-message']);

expect(result.current.channelCapabilities).toEqual({
'schedule-message': true,
'send-message': true,
});
});

it('updates channelCapabilities when only the capabilities change', () => {
const { rerender, result } = renderStateContext(['send-message']);

rerender({ capabilities: ['send-message', 'schedule-message'] });

expect(result.current.channelCapabilities['schedule-message']).toBe(true);

rerender({ capabilities: ['send-message'] });

expect(result.current.channelCapabilities['schedule-message']).toBeUndefined();
});

it('keeps the same context when the capabilities only change order', () => {
const { rerender, result } = renderStateContext(['send-message', 'schedule-message']);
const firstContext = result.current;

rerender({ capabilities: ['schedule-message', 'send-message'] });

expect(result.current).toBe(firstContext);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export const useCreateChannelStateContext = (
channelCapabilities[capability] = true;
});

const channelCapabilitiesKey = [...channelCapabilitiesArray].sort().join();

// FIXME: this is crazy - I could not find out why the messages were not getting updated when only message properties that are not part
// of this serialization has been changed. A great example of memoization gone wrong.
const memoizedMessageData = skipMessageDataMemoization
Expand Down Expand Up @@ -141,6 +143,7 @@ export const useCreateChannelStateContext = (
// eslint-disable-next-line react-hooks/exhaustive-deps
[
channel.data?.name, // otherwise ChannelHeader will not be updated
channelCapabilitiesKey,
channelId,
channelUnreadUiState,
error,
Expand Down
267 changes: 267 additions & 0 deletions src/components/Chat/__tests__/ChatScheduledMessages.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import React, { StrictMode } from 'react';
import { act, cleanup, render } from '@testing-library/react';
import { ErrorFromResponse } from 'stream-chat';
import type { APIErrorResponse, StreamChat } from 'stream-chat';

import { Chat } from '..';
import {
dispatchConnectionChangedEvent,
dispatchConnectionRecoveredEvent,
dispatchScheduledMessageEvent,
generateScheduledMessage,
getTestClient,
getTestClientWithUser,
} from '../../../mock-builders';

const renderChat = async (client: StreamChat, { strict = false } = {}) => {
const chat = <Chat client={client}>{null}</Chat>;

let result!: ReturnType<typeof render>;
await act(() => {
result = render(strict ? <StrictMode>{chat}</StrictMode> : chat);
});
return result;
};

const countListeners = (client: StreamChat, type: string) =>
client.listeners[type]?.length ?? 0;

const featureDisabledError = () =>
new ErrorFromResponse<APIErrorResponse>('failed', {
code: 114,
response: {
data: {
code: 114,
duration: '',
exception_fields: { reason: 'scheduled_messages_disabled' },
message: 'failed',
more_info: '',
StatusCode: 400,
},
} as ErrorFromResponse<APIErrorResponse>['response'],
status: 400,
});

describe('Chat scheduled messages wiring', () => {
afterEach(cleanup);

it('loads the scheduled messages once the user is connected', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });

await renderChat(client);

expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
expect(client.queryScheduledMessages).toHaveBeenCalledWith({ limit: 100 });
expect(client.scheduledMessages.state.getLatestValue().isLoaded).toBe(true);
});

it('does not load before a user is connected', async () => {
const client = getTestClient();

await renderChat(client);

expect(client.queryScheduledMessages).not.toHaveBeenCalled();
});

it('loads when a user connects after the first render, without a re-render', async () => {
const client = getTestClient();
await renderChat(client);

await client.connectUser({ id: 'user-1' }, 'token');
await act(() => {
dispatchConnectionChangedEvent(client, true);
});

expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
});

it('does not load when the connection goes offline', async () => {
const client = getTestClient();
await renderChat(client);

await client.connectUser({ id: 'user-1' }, 'token');
await act(() => {
dispatchConnectionChangedEvent(client, false);
});

expect(client.queryScheduledMessages).not.toHaveBeenCalled();
});

it('does not load for an anonymous user', async () => {
const client = await getTestClientWithUser({ id: '!anon' });
client.anonymous = true;

await renderChat(client);

expect(client.queryScheduledMessages).not.toHaveBeenCalled();
});

it('retries a failed load when the connection comes back', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(new Error('offline'));
await renderChat(client);

await act(() => {
dispatchConnectionChangedEvent(client, true);
});

expect(client.queryScheduledMessages).toHaveBeenCalledTimes(2);
expect(client.scheduledMessages.state.getLatestValue().isLoaded).toBe(true);
});

it('retries a failed load on connection.recovered', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(new Error('offline'));
await renderChat(client);

await act(() => {
dispatchConnectionRecoveredEvent(client);
});

expect(client.scheduledMessages.state.getLatestValue().isLoaded).toBe(true);
});

it('does not retry or warn when the feature is disabled', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(
featureDisabledError(),
);
const loggerSpy = vi.spyOn(client, 'logger');
await renderChat(client);

await act(() => {
dispatchConnectionChangedEvent(client, true);
});

expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
expect(client.scheduledMessages.state.getLatestValue().isDisabled).toBe(true);
expect(loggerSpy).not.toHaveBeenCalledWith(
'warn',
'scheduledMessages: load failed',
expect.anything(),
);
});

it('does not load again when the connection comes back after a successful load', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
await renderChat(client);

await act(() => {
dispatchConnectionChangedEvent(client, true);
});

expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
});

it('does not load again on re-render', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
const { rerender } = await renderChat(client);

await act(() => {
rerender(<Chat client={client}>{null}</Chat>);
});

expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
});

it('queries and subscribes once under StrictMode', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });

await renderChat(client, { strict: true });

expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
expect(countListeners(client, 'scheduled_message.created')).toBe(1);
expect(countListeners(client, 'scheduled_message.sent')).toBe(1);
});

it('keeps the store in sync from events while mounted', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
const scheduledMessage = generateScheduledMessage();
await renderChat(client);

act(() => {
dispatchScheduledMessageEvent({
client,
scheduledMessage,
type: 'scheduled_message.created',
});
});

expect(client.scheduledMessages.getFromState(scheduledMessage.id)).toEqual(
scheduledMessage,
);
});

it('reports a scheduled message that failed to send', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
const scheduledMessage = generateScheduledMessage({
failure_reason: 'channel_frozen',
status: 'failed',
});
await renderChat(client);

act(() => {
dispatchScheduledMessageEvent({
client,
scheduledMessage,
type: 'scheduled_message.failed',
});
});

expect(client.notifications.store.getLatestValue().notifications).toEqual([
expect.objectContaining({
metadata: { reason: 'channel_frozen', scheduledMessageId: scheduledMessage.id },
severity: 'error',
type: 'api:scheduled-message:send:failed',
}),
]);
});

it('stops reporting failures after unmount', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
const { unmount } = await renderChat(client);

unmount();
act(() => {
dispatchScheduledMessageEvent({
client,
scheduledMessage: generateScheduledMessage({ status: 'failed' }),
type: 'scheduled_message.failed',
});
});

expect(client.notifications.store.getLatestValue().notifications).toEqual([]);
});

it('unsubscribes on unmount', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
const connectionChangedListeners = countListeners(client, 'connection.changed');
const connectionRecoveredListeners = countListeners(client, 'connection.recovered');
const { unmount } = await renderChat(client);

unmount();

expect(client.scheduledMessages.hasSubscriptions).toBe(false);
expect(countListeners(client, 'scheduled_message.created')).toBe(0);
expect(countListeners(client, 'connection.changed')).toBe(connectionChangedListeners);
expect(countListeners(client, 'connection.recovered')).toBe(
connectionRecoveredListeners,
);
});

it('logs and keeps rendering when the load fails', async () => {
const client = await getTestClientWithUser({ id: 'user-1' });
const error = new Error('boom');
vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(error);
const loggerSpy = vi.spyOn(client, 'logger');

await renderChat(client);

expect(loggerSpy).toHaveBeenCalledWith(
'warn',
'scheduledMessages: load failed',
expect.objectContaining({ error }),
);
expect(client.scheduledMessages.state.getLatestValue().lastQueryError).toBe(error);
});
});
7 changes: 7 additions & 0 deletions src/components/Chat/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
isLanguageSupported,
Streami18n,
} from '../../../i18n';
import { useLoadScheduledMessages } from './useLoadScheduledMessages';
import { useScheduledMessageFailureNotifications } from './useScheduledMessageFailureNotifications';

import type {
AppSettingsAPIResponse,
Expand Down Expand Up @@ -67,15 +69,20 @@ export const useChat = ({
client.polls.registerSubscriptions();
client.reminders.registerSubscriptions();
client.reminders.initTimers();
client.scheduledMessages.registerSubscriptions();

return () => {
client.threads.unregisterSubscriptions();
client.polls.unregisterSubscriptions();
client.reminders.unregisterSubscriptions();
client.reminders.clearTimers();
client.scheduledMessages.unregisterSubscriptions();
};
}, [client]);

useLoadScheduledMessages(client);
useScheduledMessageFailureNotifications(client);

useEffect(() => {
setMutes(clientMutes);

Expand Down
Loading
Loading