diff --git a/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx b/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx
new file mode 100644
index 0000000000..8b5e3eefd9
--- /dev/null
+++ b/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx
@@ -0,0 +1,226 @@
+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 = {null};
+
+ let result!: ReturnType;
+ await act(() => {
+ result = render(strict ? {chat} : chat);
+ });
+ return result;
+};
+
+const countListeners = (client: StreamChat, type: string) =>
+ client.listeners[type]?.length ?? 0;
+
+const featureDisabledError = () =>
+ new ErrorFromResponse('failed', {
+ code: 114,
+ response: {
+ data: {
+ code: 114,
+ duration: '',
+ exception_fields: { reason: 'scheduled_messages_disabled' },
+ message: 'failed',
+ more_info: '',
+ StatusCode: 400,
+ },
+ } as ErrorFromResponse['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({null});
+ });
+
+ 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('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);
+ });
+});
diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts
index 0af9603cfd..93ae945b6b 100644
--- a/src/components/Chat/hooks/useChat.ts
+++ b/src/components/Chat/hooks/useChat.ts
@@ -8,6 +8,7 @@ import {
isLanguageSupported,
Streami18n,
} from '../../../i18n';
+import { useLoadScheduledMessages } from './useLoadScheduledMessages';
import type {
AppSettingsAPIResponse,
@@ -67,15 +68,19 @@ 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);
+
useEffect(() => {
setMutes(clientMutes);
diff --git a/src/components/Chat/hooks/useLoadScheduledMessages.ts b/src/components/Chat/hooks/useLoadScheduledMessages.ts
new file mode 100644
index 0000000000..52d5cf48dc
--- /dev/null
+++ b/src/components/Chat/hooks/useLoadScheduledMessages.ts
@@ -0,0 +1,44 @@
+import { useEffect } from 'react';
+import { getScheduledMessageErrorReason, ScheduledMessageErrorReason } from 'stream-chat';
+import type { Event, StreamChat } from 'stream-chat';
+
+const isFeatureDisabledError = (error: Error) =>
+ getScheduledMessageErrorReason(error) === ScheduledMessageErrorReason.Disabled;
+
+export const useLoadScheduledMessages = (client: StreamChat) => {
+ useEffect(() => {
+ const { scheduledMessages } = client;
+
+ const loadScheduledMessages = () => {
+ const { isDisabled, isLoaded, isLoading } =
+ scheduledMessages.state.getLatestValue();
+ const hasConnectedUser = !!client.user?.id && !client.anonymous;
+
+ const shouldLoad = hasConnectedUser && !isDisabled && !isLoaded && !isLoading;
+
+ if (!shouldLoad) return;
+
+ scheduledMessages.load().catch((error: Error) => {
+ if (isFeatureDisabledError(error)) return;
+
+ client.logger('warn', 'scheduledMessages: load failed', {
+ error,
+ tags: ['scheduled_messages'],
+ });
+ });
+ };
+
+ const loadWhenOnline = ({ online }: Event) => {
+ if (online) loadScheduledMessages();
+ };
+
+ loadScheduledMessages();
+
+ const subscriptions = [
+ client.on('connection.changed', loadWhenOnline),
+ client.on('connection.recovered', loadScheduledMessages),
+ ];
+
+ return () => subscriptions.forEach(({ unsubscribe }) => unsubscribe());
+ }, [client]);
+};
diff --git a/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx
new file mode 100644
index 0000000000..be8571e3b6
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx
@@ -0,0 +1,180 @@
+import React from 'react';
+import type { PropsWithChildren } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+import { MessageComposer } from 'stream-chat';
+import type { LocationComposerState, StreamChat } from 'stream-chat';
+
+import {
+ SCHEDULE_MESSAGE_CAPABILITY,
+ useCanScheduleMessage,
+} from '../useCanScheduleMessage';
+import { ChannelStateProvider, ChatProvider } from '../../../../context';
+import { MessageComposerControllerProvider } from '../../../MessageComposer';
+import {
+ generateMessage,
+ getTestClientWithUser,
+ mockChannelStateContext,
+ mockChatContext,
+} from '../../../../mock-builders';
+
+type Conditions = {
+ hasCapability: boolean;
+ hasCommand: boolean;
+ hasLocation: boolean;
+ hasPoll: boolean;
+ isDisabled: boolean;
+ isEditingSentMessage: boolean;
+};
+
+const CONDITION_NAMES: Array = [
+ 'hasCapability',
+ 'hasCommand',
+ 'hasLocation',
+ 'hasPoll',
+ 'isDisabled',
+ 'isEditingSentMessage',
+];
+
+const ALL_CONDITIONS: Conditions[] = Array.from(
+ { length: 2 ** CONDITION_NAMES.length },
+ (_, combination) =>
+ CONDITION_NAMES.reduce(
+ (conditions, name, bit) => ({
+ ...conditions,
+ [name]: !!(combination & (1 << bit)),
+ }),
+ {} as Conditions,
+ ),
+);
+
+const describeConditions = (conditions: Conditions) =>
+ CONDITION_NAMES.map((name) => `${name}=${conditions[name]}`).join(', ');
+
+const location = fromPartial({
+ created_by_device_id: 'device-1',
+ latitude: 1,
+ longitude: 2,
+});
+
+const setup = async ({ hasCapability = true }: { hasCapability?: boolean } = {}) => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const channel = client.channel('messaging', 'general');
+ client.configs[channel.cid] = fromPartial({
+ commands: [{ args: '[text]', description: 'Post a random gif', name: 'giphy' }],
+ });
+ const composer = new MessageComposer({ client, compositionContext: channel });
+ const channelCapabilities = { [SCHEDULE_MESSAGE_CAPABILITY]: hasCapability };
+ const renders = { count: 0 };
+
+ const wrapper = ({ children }: PropsWithChildren) => (
+
+
+
+ {children}
+
+
+
+ );
+
+ const hook = renderHook(
+ () => {
+ renders.count += 1;
+ return useCanScheduleMessage();
+ },
+ { wrapper },
+ );
+
+ return { ...hook, client, composer, renders };
+};
+
+const applyConditions = (
+ client: StreamChat,
+ composer: MessageComposer,
+ { hasCommand, hasLocation, hasPoll, isDisabled, isEditingSentMessage }: Conditions,
+) => {
+ client.scheduledMessages.state.partialNext({ isDisabled });
+ composer.state.partialNext({
+ editedMessage: isEditingSentMessage ? generateMessage() : null,
+ pollId: hasPoll ? 'poll-1' : null,
+ });
+ composer.textComposer.state.partialNext({
+ command: hasCommand ? { name: 'giphy' } : null,
+ });
+ composer.locationComposer.state.partialNext({
+ location: hasLocation ? location : null,
+ });
+};
+
+describe('useCanScheduleMessage', () => {
+ it.each(
+ ALL_CONDITIONS.map((conditions) => [describeConditions(conditions), conditions]),
+ )('%s', async (_, conditions) => {
+ const { client, composer, result } = await setup({
+ hasCapability: conditions.hasCapability,
+ });
+ const { hasCapability, hasCommand, hasLocation, hasPoll, isDisabled } = conditions;
+ const expected =
+ hasCapability &&
+ !isDisabled &&
+ !conditions.isEditingSentMessage &&
+ !hasCommand &&
+ !hasPoll &&
+ !hasLocation;
+
+ act(() => {
+ applyConditions(client, composer, conditions);
+ });
+
+ expect(result.current).toBe(expected);
+ });
+
+ it('is false when the text starts with a command of the channel', async () => {
+ const { composer, result } = await setup();
+
+ act(() => {
+ composer.textComposer.state.partialNext({ text: '/giphy cats' });
+ });
+
+ expect(result.current).toBe(false);
+ });
+
+ it('stays true for text that only looks like a command', async () => {
+ const { composer, result } = await setup();
+
+ act(() => {
+ composer.textComposer.state.partialNext({ text: '/not-a-command hello' });
+ });
+
+ expect(result.current).toBe(true);
+ });
+
+ it('becomes schedulable again when the blocking condition goes away', async () => {
+ const { composer, result } = await setup();
+
+ act(() => {
+ composer.state.partialNext({ pollId: 'poll-1' });
+ });
+ expect(result.current).toBe(false);
+
+ act(() => {
+ composer.state.partialNext({ pollId: null });
+ });
+ expect(result.current).toBe(true);
+ });
+
+ it('does not re-render while typing ordinary text', async () => {
+ const { composer, renders } = await setup();
+ const rendersBefore = renders.count;
+
+ act(() => {
+ composer.textComposer.state.partialNext({ text: 'h' });
+ composer.textComposer.state.partialNext({ text: 'he' });
+ composer.textComposer.state.partialNext({ text: 'hello' });
+ });
+
+ expect(renders.count).toBe(rendersBefore);
+ });
+});
diff --git a/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx
new file mode 100644
index 0000000000..dded2a7400
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import type { PropsWithChildren } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import type { StreamChat } from 'stream-chat';
+
+import { useScheduledMessageCount } from '../useScheduledMessageCount';
+import type { UseScheduledMessageCountParams } from '../useScheduledMessageCount';
+import { ChatProvider } from '../../../../context';
+import {
+ generateScheduledMessage,
+ getTestClientWithUser,
+ mockChatContext,
+} from '../../../../mock-builders';
+
+const cid = 'messaging:general';
+const otherCid = 'messaging:random';
+
+const renderUseScheduledMessageCount = (
+ client: StreamChat,
+ params: UseScheduledMessageCountParams,
+) => {
+ const wrapper = ({ children }: PropsWithChildren) => (
+ {children}
+ );
+ const renders = { count: 0 };
+
+ const hook = renderHook(
+ () => {
+ renders.count += 1;
+ return useScheduledMessageCount(params);
+ },
+ { wrapper },
+ );
+
+ return { ...hook, renders };
+};
+
+describe('useScheduledMessageCount', () => {
+ it('counts the channel-level scheduled messages of a channel', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ [
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({
+ cid,
+ message: { parent_id: 'parent-1', text: 'reply' },
+ }),
+ generateScheduledMessage({ cid: otherCid }),
+ ].forEach(client.scheduledMessages.upsertToState);
+
+ const { result } = renderUseScheduledMessageCount(client, { cid });
+
+ expect(result.current).toBe(2);
+ });
+
+ it('counts the scheduled replies of a thread', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ [
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({
+ cid,
+ message: { parent_id: 'parent-1', text: 'reply' },
+ }),
+ ].forEach(client.scheduledMessages.upsertToState);
+
+ const { result } = renderUseScheduledMessageCount(client, {
+ cid,
+ parentId: 'parent-1',
+ });
+
+ expect(result.current).toBe(1);
+ });
+
+ it('counts only the thread replies of the given channel', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ [
+ generateScheduledMessage({
+ cid,
+ message: { parent_id: 'parent-1', text: 'reply' },
+ }),
+ generateScheduledMessage({
+ cid: otherCid,
+ message: { parent_id: 'parent-1', text: 'reply elsewhere' },
+ }),
+ ].forEach(client.scheduledMessages.upsertToState);
+
+ const { result } = renderUseScheduledMessageCount(client, {
+ cid,
+ parentId: 'parent-1',
+ });
+
+ expect(result.current).toBe(1);
+ });
+
+ it('updates when a scheduled message is added or removed', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const scheduledMessage = generateScheduledMessage({ cid });
+ const { result } = renderUseScheduledMessageCount(client, { cid });
+
+ act(() => {
+ client.scheduledMessages.upsertToState(scheduledMessage);
+ });
+ expect(result.current).toBe(1);
+
+ act(() => {
+ client.scheduledMessages.removeFromState(scheduledMessage.id);
+ });
+ expect(result.current).toBe(0);
+ });
+
+ it('does not re-render when the count stays the same', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const scheduledMessage = generateScheduledMessage({ cid });
+ client.scheduledMessages.upsertToState(scheduledMessage);
+ const { renders } = renderUseScheduledMessageCount(client, { cid });
+ const rendersBefore = renders.count;
+
+ act(() => {
+ client.scheduledMessages.upsertToState({
+ ...scheduledMessage,
+ message: { text: 'edited' },
+ });
+ client.scheduledMessages.upsertToState(generateScheduledMessage({ cid: otherCid }));
+ });
+
+ expect(renders.count).toBe(rendersBefore);
+ });
+});
diff --git a/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx
new file mode 100644
index 0000000000..25664b57a5
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import type { PropsWithChildren } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import type { StreamChat } from 'stream-chat';
+
+import { useScheduledMessages } from '../useScheduledMessages';
+import type { UseScheduledMessagesParams } from '../useScheduledMessages';
+import { ChatProvider } from '../../../../context';
+import {
+ generateScheduledMessage,
+ getTestClientWithUser,
+ mockChatContext,
+} from '../../../../mock-builders';
+
+const cid = 'messaging:general';
+const otherCid = 'messaging:random';
+
+const renderUseScheduledMessages = (
+ client: StreamChat,
+ params?: UseScheduledMessagesParams,
+) => {
+ const wrapper = ({ children }: PropsWithChildren) => (
+ {children}
+ );
+ const renders = { count: 0 };
+
+ const hook = renderHook(
+ () => {
+ renders.count += 1;
+ return useScheduledMessages(params);
+ },
+ { wrapper },
+ );
+
+ return { ...hook, renders };
+};
+
+describe('useScheduledMessages', () => {
+ it('returns the channel-level scheduled messages ordered by scheduled_at', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const later = generateScheduledMessage({ cid, scheduled_at: '2030-01-02T00:00:00Z' });
+ const sooner = generateScheduledMessage({
+ cid,
+ scheduled_at: '2030-01-01T00:00:00Z',
+ });
+ const reply = generateScheduledMessage({
+ cid,
+ message: { parent_id: 'parent-1', text: 'reply' },
+ });
+ const elsewhere = generateScheduledMessage({ cid: otherCid });
+ [later, reply, elsewhere, sooner].forEach(client.scheduledMessages.upsertToState);
+
+ const { result } = renderUseScheduledMessages(client, { cid });
+
+ expect(result.current.scheduledMessages).toEqual([sooner, later]);
+ });
+
+ it('returns the replies of a thread when parentId is given', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const reply = generateScheduledMessage({
+ cid,
+ message: { parent_id: 'parent-1', text: 'reply' },
+ });
+ const channelLevel = generateScheduledMessage({ cid });
+ [reply, channelLevel].forEach(client.scheduledMessages.upsertToState);
+
+ const { result } = renderUseScheduledMessages(client, { cid, parentId: 'parent-1' });
+
+ expect(result.current.scheduledMessages).toEqual([reply]);
+ });
+
+ it('returns every scheduled message ordered by scheduled_at without a scope', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const later = generateScheduledMessage({ cid, scheduled_at: '2030-01-02T00:00:00Z' });
+ const sooner = generateScheduledMessage({
+ cid: otherCid,
+ scheduled_at: '2030-01-01T00:00:00Z',
+ });
+ [later, sooner].forEach(client.scheduledMessages.upsertToState);
+
+ const { result } = renderUseScheduledMessages(client);
+
+ expect(result.current.scheduledMessages).toEqual([sooner, later]);
+ });
+
+ it('exposes the loading flags of the store', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const { result } = renderUseScheduledMessages(client, { cid });
+
+ act(() => {
+ client.scheduledMessages.state.partialNext({
+ isDisabled: true,
+ isLoaded: true,
+ isLoading: false,
+ });
+ });
+
+ expect(result.current).toEqual(
+ expect.objectContaining({ isDisabled: true, isLoaded: true, isLoading: false }),
+ );
+ });
+
+ it('updates when a scheduled message of its scope changes', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const scheduledMessage = generateScheduledMessage({ cid });
+ const { result } = renderUseScheduledMessages(client, { cid });
+
+ act(() => {
+ client.scheduledMessages.upsertToState(scheduledMessage);
+ });
+
+ expect(result.current.scheduledMessages).toEqual([scheduledMessage]);
+ });
+
+ it('does not re-render when a scheduled message outside its scope changes', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ client.scheduledMessages.upsertToState(generateScheduledMessage({ cid }));
+ const { renders, result } = renderUseScheduledMessages(client, { cid });
+ const rendersBefore = renders.count;
+ const listBefore = result.current.scheduledMessages;
+
+ act(() => {
+ client.scheduledMessages.upsertToState(generateScheduledMessage({ cid: otherCid }));
+ });
+
+ expect(renders.count).toBe(rendersBefore);
+ expect(result.current.scheduledMessages).toBe(listBefore);
+ });
+});
diff --git a/src/components/ScheduledMessages/hooks/index.ts b/src/components/ScheduledMessages/hooks/index.ts
new file mode 100644
index 0000000000..b1deb1e29e
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useCanScheduleMessage';
+export * from './useScheduledMessageCount';
+export * from './useScheduledMessages';
diff --git a/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts
new file mode 100644
index 0000000000..ff31502c04
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts
@@ -0,0 +1,80 @@
+import { useMemo } from 'react';
+import type {
+ Channel,
+ LocationComposerState,
+ MessageComposerState,
+ ScheduledMessageManagerState,
+ TextComposerState,
+} from 'stream-chat';
+
+import { useChannelStateContext, useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController';
+
+export const SCHEDULE_MESSAGE_CAPABILITY = 'schedule-message';
+
+const COMMAND_PATTERN = /^\/(\S+)/;
+
+const startsWithChannelCommand = (text: string, channel: Channel) => {
+ const [, commandName] = text.match(COMMAND_PATTERN) ?? [];
+
+ if (!commandName) return false;
+
+ const commands = channel.getConfig()?.commands ?? [];
+
+ const isChannelCommand = commands.some(
+ ({ name }) => name?.toLowerCase() === commandName.toLowerCase(),
+ );
+
+ return isChannelCommand;
+};
+
+const scheduledMessagesSelector = ({ isDisabled }: ScheduledMessageManagerState) => ({
+ isDisabled,
+});
+
+const messageComposerSelector = ({ editedMessage, pollId }: MessageComposerState) => ({
+ hasPoll: !!pollId,
+ isEditingSentMessage: !!editedMessage,
+});
+
+const locationComposerSelector = ({ location }: LocationComposerState) => ({
+ hasLocation: !!location,
+});
+
+const createTextComposerSelector =
+ (channel: Channel) =>
+ ({ command, text }: TextComposerState) => ({
+ hasCommand: !!command || startsWithChannelCommand(text, channel),
+ });
+
+export const useCanScheduleMessage = () => {
+ const { client } = useChatContext('useCanScheduleMessage');
+ const { channelCapabilities } = useChannelStateContext('useCanScheduleMessage');
+ const messageComposer = useMessageComposerController();
+ const { channel, locationComposer, textComposer } = messageComposer;
+
+ const textComposerSelector = useMemo(
+ () => createTextComposerSelector(channel),
+ [channel],
+ );
+
+ const { isDisabled } = useStateStore(
+ client.scheduledMessages.state,
+ scheduledMessagesSelector,
+ );
+ const { hasPoll, isEditingSentMessage } = useStateStore(
+ messageComposer.state,
+ messageComposerSelector,
+ );
+ const { hasCommand } = useStateStore(textComposer.state, textComposerSelector);
+ const { hasLocation } = useStateStore(locationComposer.state, locationComposerSelector);
+
+ const hasCapability = !!channelCapabilities[SCHEDULE_MESSAGE_CAPABILITY];
+ const compositionCanBeScheduled = !hasCommand && !hasPoll && !hasLocation;
+
+ const canScheduleMessage =
+ hasCapability && !isDisabled && !isEditingSentMessage && compositionCanBeScheduled;
+
+ return canScheduleMessage;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts b/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts
new file mode 100644
index 0000000000..c7b25fe035
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts
@@ -0,0 +1,53 @@
+import { useMemo } from 'react';
+import type { ScheduledMessageManager } from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+
+export type UseScheduledMessageCountParams = {
+ cid: string;
+ parentId?: string;
+};
+
+const countThreadReplies = (
+ manager: ScheduledMessageManager,
+ { cid, parentId }: UseScheduledMessageCountParams,
+) => {
+ const allScheduledMessages = Array.from(manager.scheduledMessages.values());
+
+ const threadReplyCount = allScheduledMessages.filter(
+ ({ cid: messageCid, message }) =>
+ messageCid === cid && message.parent_id === parentId,
+ ).length;
+
+ return threadReplyCount;
+};
+
+const createScheduledMessageCountSelector = (
+ manager: ScheduledMessageManager,
+ params: UseScheduledMessageCountParams,
+) => {
+ const { cid, parentId } = params;
+
+ const countScheduledMessages = () =>
+ parentId ? countThreadReplies(manager, params) : manager.getCountForChannel(cid);
+
+ return () => ({ scheduledMessageCount: countScheduledMessages() });
+};
+
+export const useScheduledMessageCount = ({
+ cid,
+ parentId,
+}: UseScheduledMessageCountParams) => {
+ const { client } = useChatContext('useScheduledMessageCount');
+ const { scheduledMessages: manager } = client;
+
+ const selector = useMemo(
+ () => createScheduledMessageCountSelector(manager, { cid, parentId }),
+ [cid, manager, parentId],
+ );
+
+ const { scheduledMessageCount } = useStateStore(manager.state, selector);
+
+ return scheduledMessageCount;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessages.ts b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts
new file mode 100644
index 0000000000..db61b09541
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts
@@ -0,0 +1,87 @@
+import { useMemo } from 'react';
+import type {
+ ScheduledMessageManager,
+ ScheduledMessageManagerState,
+ ScheduledMessageResponse,
+} from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+
+export type UseScheduledMessagesParams = {
+ cid?: string;
+ parentId?: string;
+};
+
+export type ScheduledMessagesSnapshot = {
+ isDisabled: boolean;
+ isLoaded: boolean;
+ isLoading: boolean;
+ scheduledMessages: ScheduledMessageResponse[];
+};
+
+const byScheduledAt = (a: ScheduledMessageResponse, b: ScheduledMessageResponse) =>
+ Date.parse(a.scheduled_at) - Date.parse(b.scheduled_at) || a.id.localeCompare(b.id);
+
+const isSameList = (
+ current: ScheduledMessageResponse[],
+ previous: ScheduledMessageResponse[],
+) =>
+ current.length === previous.length &&
+ current.every((scheduledMessage, index) => scheduledMessage === previous[index]);
+
+const getScopedScheduledMessages = (
+ manager: ScheduledMessageManager,
+ { cid, parentId }: UseScheduledMessagesParams,
+) => {
+ if (parentId) return manager.getForThread(parentId);
+
+ if (cid) return manager.getForChannel(cid);
+
+ const allScheduledMessages = Array.from(manager.scheduledMessages.values());
+
+ return allScheduledMessages.sort(byScheduledAt);
+};
+
+const createScheduledMessagesSelector = (
+ manager: ScheduledMessageManager,
+ params: UseScheduledMessagesParams,
+) => {
+ let cachedScheduledMessages: ScheduledMessageResponse[] = [];
+
+ return ({
+ isDisabled,
+ isLoaded,
+ isLoading,
+ }: ScheduledMessageManagerState): ScheduledMessagesSnapshot => {
+ const scheduledMessages = getScopedScheduledMessages(manager, params);
+
+ if (!isSameList(scheduledMessages, cachedScheduledMessages)) {
+ cachedScheduledMessages = scheduledMessages;
+ }
+
+ return {
+ isDisabled,
+ isLoaded,
+ isLoading,
+ scheduledMessages: cachedScheduledMessages,
+ };
+ };
+};
+
+export const useScheduledMessages = ({
+ cid,
+ parentId,
+}: UseScheduledMessagesParams = {}) => {
+ const { client } = useChatContext('useScheduledMessages');
+ const { scheduledMessages: manager } = client;
+
+ const selector = useMemo(
+ () => createScheduledMessagesSelector(manager, { cid, parentId }),
+ [cid, manager, parentId],
+ );
+
+ const scheduledMessagesSnapshot = useStateStore(manager.state, selector);
+
+ return scheduledMessagesSnapshot;
+};
diff --git a/src/components/ScheduledMessages/index.ts b/src/components/ScheduledMessages/index.ts
new file mode 100644
index 0000000000..4cc90d02bd
--- /dev/null
+++ b/src/components/ScheduledMessages/index.ts
@@ -0,0 +1 @@
+export * from './hooks';
diff --git a/src/components/index.ts b/src/components/index.ts
index f8fedddfd6..6471290ebe 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -38,6 +38,7 @@ export * from './Notifications';
export * from './Poll';
export * from './Reactions';
export * from './SafeAnchor';
+export * from './ScheduledMessages';
export * from './SummarizedMessagePreview';
export * from './TextareaComposer';
export * from './Thread';
diff --git a/src/mock-builders/api/index.ts b/src/mock-builders/api/index.ts
index 244a4e375f..f0c9721621 100644
--- a/src/mock-builders/api/index.ts
+++ b/src/mock-builders/api/index.ts
@@ -23,4 +23,5 @@ export * from './getOrCreateChannel';
export * from './markRead';
export * from './threadReplies';
export * from './sendMessage';
+export * from './scheduledMessages';
export * from './error';
diff --git a/src/mock-builders/api/scheduledMessages.ts b/src/mock-builders/api/scheduledMessages.ts
new file mode 100644
index 0000000000..25d524fe14
--- /dev/null
+++ b/src/mock-builders/api/scheduledMessages.ts
@@ -0,0 +1,58 @@
+import type {
+ MessageResponse,
+ ScheduledMessageErrorReason,
+ ScheduledMessageResponse,
+} from 'stream-chat';
+
+import { mockedApiResponse } from './utils';
+
+type ScheduledMessageRequestMethod = 'delete' | 'patch' | 'post';
+
+const SCHEDULED_MESSAGE_ERROR_STATUS: Record = {
+ 121: 409,
+ 122: 409,
+};
+
+export const queryScheduledMessagesApi = (
+ scheduledMessages: ScheduledMessageResponse[] = [],
+ next?: string,
+) => mockedApiResponse({ next, scheduled_messages: scheduledMessages }, 'post');
+
+export const scheduledMessageApi = (
+ scheduledMessage: ScheduledMessageResponse,
+ method: ScheduledMessageRequestMethod = 'patch',
+) => mockedApiResponse({ scheduled_message: scheduledMessage }, method);
+
+export const sendScheduledMessageApi = (
+ scheduledMessage: ScheduledMessageResponse,
+ message: Partial = {},
+) =>
+ mockedApiResponse(
+ {
+ message: {
+ id: scheduledMessage.id,
+ text: scheduledMessage.message.text,
+ ...message,
+ },
+ scheduled_message: scheduledMessage,
+ },
+ 'post',
+ );
+
+export const erroredScheduledMessageApi = (
+ code: number,
+ reason: ScheduledMessageErrorReason,
+ method: ScheduledMessageRequestMethod = 'post',
+) => {
+ const status = SCHEDULED_MESSAGE_ERROR_STATUS[code] ?? 400;
+
+ const error = {
+ code,
+ duration: '0.01',
+ exception_fields: { reason },
+ message: `scheduled message request failed: ${reason}`,
+ StatusCode: status,
+ };
+
+ return mockedApiResponse(error, method, status);
+};
diff --git a/src/mock-builders/api/utils.ts b/src/mock-builders/api/utils.ts
index 044c9abf13..4789aea2f1 100644
--- a/src/mock-builders/api/utils.ts
+++ b/src/mock-builders/api/utils.ts
@@ -1,4 +1,4 @@
-type HttpMethod = 'get' | 'post' | 'put' | 'delete';
+type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
export interface MockedApiResponse {
response: {
diff --git a/src/mock-builders/event/index.ts b/src/mock-builders/event/index.ts
index f6a118519a..af17890aca 100644
--- a/src/mock-builders/event/index.ts
+++ b/src/mock-builders/event/index.ts
@@ -17,3 +17,4 @@ export { default as dispatchNotificationMutesUpdated } from './notificationMutes
export { default as dispatchNotificationRemovedFromChannel } from './notificationRemovedFromChannel';
export { default as dispatchUserMessagesDeletedEvent } from './userMessagesDeleted';
export { default as dispatchUserUpdatedEvent } from './userUpdated';
+export * from './scheduledMessage';
diff --git a/src/mock-builders/event/scheduledMessage.ts b/src/mock-builders/event/scheduledMessage.ts
new file mode 100644
index 0000000000..b50ca6382a
--- /dev/null
+++ b/src/mock-builders/event/scheduledMessage.ts
@@ -0,0 +1,27 @@
+import type {
+ ScheduledMessageEventType,
+ ScheduledMessageResponse,
+ StreamChat,
+} from 'stream-chat';
+
+export type DispatchScheduledMessageEventParams = {
+ client: StreamChat;
+ scheduledMessage: ScheduledMessageResponse;
+ type: ScheduledMessageEventType;
+};
+
+export const dispatchScheduledMessageEvent = ({
+ client,
+ scheduledMessage,
+ type,
+}: DispatchScheduledMessageEventParams) => {
+ const { cid, user } = scheduledMessage;
+
+ client.dispatchEvent({
+ cid,
+ created_at: new Date().toISOString(),
+ scheduled_message: scheduledMessage,
+ type,
+ user,
+ });
+};
diff --git a/src/mock-builders/generator/index.ts b/src/mock-builders/generator/index.ts
index fad35360e9..3abb5aad55 100644
--- a/src/mock-builders/generator/index.ts
+++ b/src/mock-builders/generator/index.ts
@@ -6,5 +6,6 @@ export * from './messageDraft';
export * from './poll';
export * from './reaction';
export * from './reminder';
+export * from './scheduledMessage';
export * from './sharedLocation';
export * from './user';
diff --git a/src/mock-builders/generator/scheduledMessage.ts b/src/mock-builders/generator/scheduledMessage.ts
new file mode 100644
index 0000000000..039d0d8e5a
--- /dev/null
+++ b/src/mock-builders/generator/scheduledMessage.ts
@@ -0,0 +1,26 @@
+import { nanoid } from 'nanoid';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+const ONE_HOUR_MS = 60 * 60 * 1000;
+
+export const generateScheduledMessage = (
+ data: Partial = {},
+): ScheduledMessageResponse => {
+ const now = new Date();
+ const createdAt = now.toISOString();
+ const scheduledAt = new Date(now.getTime() + ONE_HOUR_MS).toISOString();
+
+ const scheduledMessage: ScheduledMessageResponse = {
+ cid: 'messaging:general',
+ created_at: createdAt,
+ id: nanoid(),
+ message: { text: nanoid() },
+ scheduled_at: scheduledAt,
+ status: 'scheduled',
+ updated_at: createdAt,
+ user: { id: 'user-id' },
+ ...data,
+ };
+
+ return scheduledMessage;
+};
diff --git a/src/mock-builders/index.ts b/src/mock-builders/index.ts
index 328b84ed83..47167c8f7c 100644
--- a/src/mock-builders/index.ts
+++ b/src/mock-builders/index.ts
@@ -20,6 +20,7 @@ const connectUser = (client: StreamChat, user: Partial) =>
interface MockClientOverrides {
getAppSettings?: StreamChat['getAppSettings'];
queryReactions?: StreamChat['queryReactions'];
+ queryScheduledMessages?: StreamChat['queryScheduledMessages'];
}
function mockClient(client: StreamChat, mocks: MockClientOverrides = {}) {
@@ -32,6 +33,10 @@ function mockClient(client: StreamChat, mocks: MockClientOverrides = {}) {
mocks.queryReactions ??
((() => Promise.resolve({})) as unknown as StreamChat['queryReactions']),
);
+ vi.spyOn(client, 'queryScheduledMessages').mockImplementation(
+ mocks.queryScheduledMessages ??
+ (() => Promise.resolve({ duration: '', scheduled_messages: [] })),
+ );
client.tokenManager = fromPartial({
getToken: vi.fn(() => token),
tokenReady: vi.fn(() => true),