({
editedMessage,
+ editedScheduledMessage,
+ failedScheduledMessage,
pollId,
quotedMessage,
});
@@ -47,6 +51,10 @@ export type MessageComposerPreviewVisibilityFilterParams = {
/** The composer's custom data (`messageComposer.customDataManager`). */
customData: CustomDataManagerState['custom'];
editedMessage: MessageComposerState['editedMessage'];
+ /** The scheduled message being edited. */
+ editedScheduledMessage: MessageComposerState['editedScheduledMessage'];
+ /** The failed scheduled message loaded to be scheduled or sent again. */
+ failedScheduledMessage: MessageComposerState['failedScheduledMessage'];
/** Link previews that are loading or loaded. */
linkPreviews: LinkPreview[];
location: LocationComposerState['location'];
@@ -65,10 +73,13 @@ export type MessageComposerPreviewVisibilityFilterParams = {
export const useMessageComposerPreviewState =
(): MessageComposerPreviewVisibilityFilterParams => {
const messageComposer = useMessageComposerController();
- const { editedMessage, pollId, quotedMessage } = useStateStore(
- messageComposer.state,
- messageComposerStateSelector,
- );
+ const {
+ editedMessage,
+ editedScheduledMessage,
+ failedScheduledMessage,
+ pollId,
+ quotedMessage,
+ } = useStateStore(messageComposer.state, messageComposerStateSelector);
const { attachments } = useStateStore(
messageComposer.attachmentManager.state,
attachmentManagerStateSelector,
@@ -101,6 +112,8 @@ export const useMessageComposerPreviewState =
attachments,
customData,
editedMessage,
+ editedScheduledMessage,
+ failedScheduledMessage,
linkPreviews,
location,
pollId,
@@ -110,6 +123,8 @@ export const useMessageComposerPreviewState =
attachments,
customData,
editedMessage,
+ editedScheduledMessage,
+ failedScheduledMessage,
linkPreviews,
location,
pollId,
diff --git a/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts b/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts
index b2c59665c..bf8ebf9fc 100644
--- a/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts
+++ b/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts
@@ -1,20 +1,35 @@
import { useCallback } from 'react';
+import { useChatContext } from '../../../context';
+import { reloadOnScheduledMessageConflict } from '../../ScheduledMessages/utils/scheduledMessageErrors';
import { useMessageComposerController } from './useMessageComposerController';
/**
- * Submits the composer: saves the edit while a message is being edited, sends a new message
- * otherwise. The choice is made when the callback runs and every submit control uses this hook, so
- * the send button and the Enter key cannot disagree.
+ * Submits the composer: saves the edit while a message or a scheduled message is being edited,
+ * sends a new message otherwise. The choice is made when the callback runs and every submit control
+ * uses this hook, so the send button and the Enter key cannot disagree.
*
* @returns Submit callback, stable while the composer is the same.
*/
export const useMessageComposerSubmitFn = () => {
+ const { client } = useChatContext();
const messageComposer = useMessageComposerController();
- return useCallback(
- () =>
- messageComposer.editedMessage ? messageComposer.update() : messageComposer.send(),
- [messageComposer],
- );
+ const updateScheduledMessage = useCallback(async () => {
+ const result = await messageComposer.updateScheduledMessage();
+
+ if (result.status === 'failed') {
+ reloadOnScheduledMessageConflict(client.scheduledMessages, result.error);
+ }
+
+ return result;
+ }, [client, messageComposer]);
+
+ return useCallback(() => {
+ if (messageComposer.editedScheduledMessage) return updateScheduledMessage();
+
+ return messageComposer.editedMessage
+ ? messageComposer.update()
+ : messageComposer.send();
+ }, [messageComposer, updateScheduledMessage]);
};
diff --git a/src/components/ScheduledMessages/DeleteScheduledMessageAlert.tsx b/src/components/ScheduledMessages/DeleteScheduledMessageAlert.tsx
new file mode 100644
index 000000000..3c3e6b61e
--- /dev/null
+++ b/src/components/ScheduledMessages/DeleteScheduledMessageAlert.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+
+import { useTranslationContext } from '../../context';
+import { Button } from '../Button';
+import { Alert } from '../Dialog';
+import type { DeleteScheduledMessageAlertProps } from './types';
+
+export const DeleteScheduledMessageAlert = ({
+ channelName,
+ onCancel,
+ onDelete,
+}: DeleteScheduledMessageAlertProps) => {
+ const { t } = useTranslationContext();
+
+ const description = channelName
+ ? t(
+ 'scheduledMessages.thisMessageToNameWontBeSentThis',
+ "This message to {{ name }} won't be sent. This can't be undone.",
+ {
+ name: channelName,
+ },
+ )
+ : t(
+ 'scheduledMessages.thisMessageWontBeSentThisCantBe',
+ "This message won't be sent. This can't be undone.",
+ );
+
+ return (
+
+
+
+
+ {t('scheduledMessages.delete', 'Delete')}
+
+
+ {t('scheduledMessages.cancel', 'Cancel')}
+
+
+
+ );
+};
diff --git a/src/components/ScheduledMessages/EditedScheduledMessagePreview.tsx b/src/components/ScheduledMessages/EditedScheduledMessagePreview.tsx
new file mode 100644
index 000000000..56f902765
--- /dev/null
+++ b/src/components/ScheduledMessages/EditedScheduledMessagePreview.tsx
@@ -0,0 +1,26 @@
+import React, { useMemo } from 'react';
+
+import { useTranslationContext } from '../../context';
+import { QuotedMessagePreviewUI } from '../MessageComposer/QuotedMessagePreview';
+import type { EditedScheduledMessagePreviewProps } from './types';
+import { toStoredScheduledMessagePreview } from './utils';
+
+export const EditedScheduledMessagePreview = ({
+ onCancel,
+ scheduledMessage,
+}: EditedScheduledMessagePreviewProps) => {
+ const { t } = useTranslationContext();
+
+ const previewMessage = useMemo(
+ () => toStoredScheduledMessagePreview(scheduledMessage),
+ [scheduledMessage],
+ );
+
+ return (
+
+ );
+};
diff --git a/src/components/ScheduledMessages/FailedScheduledMessagePreview.tsx b/src/components/ScheduledMessages/FailedScheduledMessagePreview.tsx
new file mode 100644
index 000000000..28ba1548d
--- /dev/null
+++ b/src/components/ScheduledMessages/FailedScheduledMessagePreview.tsx
@@ -0,0 +1,26 @@
+import React, { useMemo } from 'react';
+
+import { useTranslationContext } from '../../context';
+import { QuotedMessagePreviewUI } from '../MessageComposer/QuotedMessagePreview';
+import type { FailedScheduledMessagePreviewProps } from './types';
+import { toStoredScheduledMessagePreview } from './utils';
+
+export const FailedScheduledMessagePreview = ({
+ onCancel,
+ scheduledMessage,
+}: FailedScheduledMessagePreviewProps) => {
+ const { t } = useTranslationContext();
+
+ const previewMessage = useMemo(
+ () => toStoredScheduledMessagePreview(scheduledMessage),
+ [scheduledMessage],
+ );
+
+ return (
+
+ );
+};
diff --git a/src/components/ScheduledMessages/RescheduleMessageDialog.tsx b/src/components/ScheduledMessages/RescheduleMessageDialog.tsx
new file mode 100644
index 000000000..b84bda6f6
--- /dev/null
+++ b/src/components/ScheduledMessages/RescheduleMessageDialog.tsx
@@ -0,0 +1,83 @@
+import { nsToDate } from 'stream-chat';
+import React, { useCallback, useMemo } from 'react';
+
+import { useChatContext, useTranslationContext } from '../../context';
+import { useNotificationApi } from '../Notifications';
+import {
+ SCHEDULED_MESSAGE_ACTIONS_EMITTER,
+ ScheduledMessageNotificationType,
+} from './constants';
+import { ScheduleTimeDialog } from './ScheduleTimeDialog';
+import type { RescheduleMessageDialogProps } from './types';
+import {
+ getScheduledMessageChannel,
+ reloadOnScheduledMessageConflict,
+ toStoredScheduledMessagePreview,
+} from './utils';
+
+export const RescheduleMessageDialog = ({
+ close,
+ scheduledMessage,
+}: RescheduleMessageDialogProps) => {
+ const { t } = useTranslationContext();
+ const { client } = useChatContext();
+ const { addNotification } = useNotificationApi();
+
+ const { id, scheduled_at } = scheduledMessage;
+ const { scheduledMessages: manager } = client;
+
+ const channel = useMemo(
+ () => getScheduledMessageChannel(client, scheduledMessage),
+ [client, scheduledMessage],
+ );
+ const previewMessage = useMemo(
+ () => toStoredScheduledMessagePreview(scheduledMessage),
+ [scheduledMessage],
+ );
+ const initialScheduledAt = nsToDate(scheduled_at);
+ const errorMessage = t(
+ 'scheduledMessages.couldntRescheduleTheMessageTryAgain',
+ "Couldn't reschedule the message. Try again",
+ );
+ const scheduledTime = t('timestamp.ScheduledMessageSendTime', {
+ timestamp: initialScheduledAt,
+ });
+
+ const reschedule = useCallback(
+ async (scheduledAt: Date) => {
+ try {
+ await manager.updateScheduledMessage(id, {
+ scheduled_at: scheduledAt,
+ });
+ } catch (error) {
+ reloadOnScheduledMessageConflict(manager, error);
+ addNotification({
+ emitter: SCHEDULED_MESSAGE_ACTIONS_EMITTER,
+ error: error as Error,
+ message: errorMessage,
+ severity: 'error',
+ type: ScheduledMessageNotificationType.RescheduleFailed,
+ });
+
+ throw error;
+ }
+ },
+ [addNotification, errorMessage, id, manager],
+ );
+
+ return (
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduleMessageButton.tsx b/src/components/ScheduledMessages/ScheduleMessageButton.tsx
new file mode 100644
index 000000000..9d6e60fa5
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduleMessageButton.tsx
@@ -0,0 +1,115 @@
+import React, { useCallback, useRef } from 'react';
+
+import {
+ useComponentContext,
+ useComponentContextIcons,
+ useTranslationContext,
+} from '../../context';
+import { Button } from '../Button';
+import { ContextMenu, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog';
+import { useMessageComposerHasSendableData } from '../MessageComposer/hooks';
+import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
+import { GlobalModal } from '../Modal';
+import { Portal } from '../Portal/Portal';
+import { SCHEDULE_MESSAGE_MENU_ID } from './constants';
+import {
+ useScheduleMessage,
+ useScheduleMessageDialog,
+ useSchedulePresets,
+} from './hooks';
+import { ScheduleMessageDialog as DefaultScheduleMessageDialog } from './ScheduleMessageDialog';
+import { ScheduleMessageMenu } from './ScheduleMessageMenu';
+import { ScheduleMessageMenuHeader } from './ScheduleMessageMenuHeader';
+import { getChannelContainer } from './utils';
+
+const getMenuDialogId = (threadId?: string | null) =>
+ threadId ? `${SCHEDULE_MESSAGE_MENU_ID}-thread` : SCHEDULE_MESSAGE_MENU_ID;
+
+const ignoreReportedError = () => undefined;
+
+export const ScheduleMessageButton = () => {
+ const { t } = useTranslationContext();
+ const { IconChevronUp } = useComponentContextIcons();
+ const {
+ ContextMenu: ContextMenuComponent = ContextMenu,
+ Modal = GlobalModal,
+ ScheduleMessageDialog = DefaultScheduleMessageDialog,
+ } = useComponentContext();
+
+ const { threadId } = useMessageComposerController();
+ const hasSendableData = useMessageComposerHasSendableData();
+ const scheduleMessage = useScheduleMessage();
+ const menuDialogId = getMenuDialogId(threadId);
+ const { dialog: menuDialog, dialogManager } = useDialogOnNearestManager({
+ id: menuDialogId,
+ });
+ const isMenuOpen = useDialogIsOpen(menuDialogId, dialogManager?.id);
+
+ const menuButtonRef = useRef
(null);
+
+ const presets = useSchedulePresets(isMenuOpen);
+ const { closeDialog, isDialogOpen, openDialog } =
+ useScheduleMessageDialog(menuButtonRef);
+
+ const toggleMenu = useCallback(() => menuDialog.toggle(), [menuDialog]);
+
+ const scheduleAtPreset = useCallback(
+ (scheduledAt: Date) => {
+ scheduleMessage(scheduledAt).catch(ignoreReportedError);
+ },
+ [scheduleMessage],
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+ {isDialogOpen && }
+
+
+ >
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduleMessageDialog.tsx b/src/components/ScheduledMessages/ScheduleMessageDialog.tsx
new file mode 100644
index 000000000..edd377827
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduleMessageDialog.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+
+import { useTranslationContext } from '../../context';
+import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
+import { useScheduledMessagePreview, useScheduleMessage } from './hooks';
+import { ScheduleTimeDialog } from './ScheduleTimeDialog';
+import type { ScheduleMessageDialogProps } from './types';
+
+export const ScheduleMessageDialog = ({ close }: ScheduleMessageDialogProps) => {
+ const { t } = useTranslationContext();
+
+ const { channel } = useMessageComposerController();
+ const previewMessage = useScheduledMessagePreview();
+ const scheduleMessage = useScheduleMessage();
+
+ return (
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx b/src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx
new file mode 100644
index 000000000..ae75be05f
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx
@@ -0,0 +1,29 @@
+import clsx from 'clsx';
+import React from 'react';
+
+import { useComponentContextIcons } from '../../context';
+import { ScheduleMessageNoticeSeverity } from './constants';
+import type { ScheduleMessageDialogNoticeProps } from './types';
+
+export const ScheduleMessageDialogNotice = ({
+ children,
+ severity,
+}: ScheduleMessageDialogNoticeProps) => {
+ const { IconExclamationCircleFill, IconInfo } = useComponentContextIcons();
+
+ const isError = severity === ScheduleMessageNoticeSeverity.Error;
+ const Icon = isError ? IconExclamationCircleFill : IconInfo;
+
+ return (
+
+
+ {children}
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduleMessageMenu.tsx b/src/components/ScheduledMessages/ScheduleMessageMenu.tsx
new file mode 100644
index 000000000..76bee864d
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduleMessageMenu.tsx
@@ -0,0 +1,51 @@
+import React, { useCallback } from 'react';
+
+import { useComponentContextIcons, useTranslationContext } from '../../context';
+import { ContextMenuButton, useContextMenuContext } from '../Dialog';
+import { ScheduleMessagePresetButton } from './ScheduleMessagePresetButton';
+import type { ScheduleMessageMenuProps } from './types';
+
+export const ScheduleMessageMenu = ({
+ onCustomTimeSelect,
+ onPresetSelect,
+ presets,
+}: ScheduleMessageMenuProps) => {
+ const { t } = useTranslationContext();
+ const { IconClock } = useComponentContextIcons();
+ const { closeMenu } = useContextMenuContext();
+
+ const selectPreset = useCallback(
+ (scheduledAt: Date) => {
+ closeMenu();
+ onPresetSelect(scheduledAt);
+ },
+ [closeMenu, onPresetSelect],
+ );
+
+ const selectCustomTime = useCallback(() => {
+ closeMenu();
+ onCustomTimeSelect();
+ }, [closeMenu, onCustomTimeSelect]);
+
+ return (
+ <>
+ {presets.map((scheduledAt) => (
+
+ ))}
+
+
+ {t('scheduledMessages.customTime', 'Custom time')}
+
+ >
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx b/src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx
new file mode 100644
index 000000000..726668faa
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { useTranslationContext } from '../../context';
+import { ContextMenuHeader } from '../Dialog';
+
+export const ScheduleMessageMenuHeader = () => {
+ const { t } = useTranslationContext();
+
+ return (
+
+ {t('scheduledMessages.scheduleMessage', 'Schedule message')}
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx b/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx
new file mode 100644
index 000000000..92fbe3d11
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx
@@ -0,0 +1,25 @@
+import React, { useCallback } from 'react';
+
+import { useComponentContextIcons, useTranslationContext } from '../../context';
+import { ContextMenuButton } from '../Dialog';
+import type { ScheduleMessagePresetButtonProps } from './types';
+
+export const ScheduleMessagePresetButton = ({
+ onSelect,
+ scheduledAt,
+}: ScheduleMessagePresetButtonProps) => {
+ const { t } = useTranslationContext();
+ const { IconClock } = useComponentContextIcons();
+
+ const selectPreset = useCallback(() => onSelect(scheduledAt), [onSelect, scheduledAt]);
+
+ return (
+
+ {t('timestamp.SchedulePreset', { timestamp: scheduledAt })}
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduleTimeDialog.tsx b/src/components/ScheduledMessages/ScheduleTimeDialog.tsx
new file mode 100644
index 000000000..4b608c884
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduleTimeDialog.tsx
@@ -0,0 +1,174 @@
+import React, { useCallback } from 'react';
+
+import { useComponentContextIcons, useTranslationContext } from '../../context';
+import type { StreamTFunction } from '../../i18n/types';
+import { useWSConnectionState } from '../Chat/hooks/useWSConnectionState';
+import { useChannelDisplayName } from '../ChannelListItem/hooks/useChannelDisplayName';
+import { Prompt } from '../Dialog';
+import { TextInput } from '../Form';
+import { QuotedMessagePreviewUI } from '../MessageComposer/QuotedMessagePreview';
+import { ScheduleMessageNoticeSeverity, ScheduleTimeError } from './constants';
+import { useScheduleTimeForm, useScheduleTimeSubmit } from './hooks';
+import { ScheduleMessageDialogNotice } from './ScheduleMessageDialogNotice';
+import type { ScheduleTimeDialogProps } from './types';
+import {
+ formatTimeZoneOffset,
+ fromDateInputValue,
+ openNativePicker,
+ toDateInputValue,
+} from './utils';
+
+const translateScheduleTimeError = (
+ t: StreamTFunction,
+ timeError?: ScheduleTimeError,
+) => {
+ if (timeError === ScheduleTimeError.TooSoon)
+ return t('scheduledMessages.chooseALaterTime', 'Choose a later time');
+
+ if (timeError === ScheduleTimeError.TooFar)
+ return t('scheduledMessages.chooseAnEarlierTime', 'Choose an earlier time');
+
+ return undefined;
+};
+
+export const ScheduleTimeDialog = ({
+ channel,
+ close,
+ errorMessage,
+ initialScheduledAt,
+ previewLabel,
+ previewMessage,
+ submit: submitScheduledAt,
+ submitLabel,
+ title,
+}: ScheduleTimeDialogProps) => {
+ const { t } = useTranslationContext();
+ const { IconCalendar, IconCaretDown, IconClock } = useComponentContextIcons();
+
+ const channelDisplayName = useChannelDisplayName(channel);
+ const isOnline = useWSConnectionState()?.isHealthy ?? false;
+ const {
+ bounds,
+ dateValue,
+ onDateChange,
+ onTimeChange,
+ scheduledAt,
+ timeError,
+ timeValue,
+ } = useScheduleTimeForm({ channel, initialScheduledAt });
+ const { hasFailed, isScheduling, submit } = useScheduleTimeSubmit(
+ submitScheduledAt,
+ close,
+ );
+
+ const canSchedule = !!scheduledAt && !timeError && isOnline && !isScheduling;
+ const timeErrorMessage = translateScheduleTimeError(t, timeError);
+ const timeZone = formatTimeZoneOffset(scheduledAt ?? bounds.earliest);
+ const latestDateValue = bounds.latest ? toDateInputValue(bounds.latest) : undefined;
+ const selectedDate = fromDateInputValue(dateValue);
+ const dateLabel = selectedDate
+ ? t('timestamp.ScheduleDate', { timestamp: selectedDate })
+ : undefined;
+ const messageToLabel = channelDisplayName
+ ? t('scheduledMessages.messageToName', 'Message to {{ name }}', {
+ name: channelDisplayName,
+ })
+ : title;
+ const authorLabel = previewLabel ?? messageToLabel;
+
+ const scheduleAtPickedTime = useCallback(() => {
+ if (scheduledAt) submit(scheduledAt);
+ }, [scheduledAt, submit]);
+
+ return (
+
+
+
+
+ {!isOnline && (
+
+ {t(
+ 'scheduledMessages.youreOfflineReconnectToScheduleThisMessage',
+ "You're offline. Reconnect to schedule this message.",
+ )}
+
+ )}
+ {hasFailed && (
+
+ {errorMessage}
+
+ )}
+
+
+
+
+ {dateLabel}
+
+ >
+ }
+ max={latestDateValue}
+ min={toDateInputValue(bounds.earliest)}
+ onChange={onDateChange}
+ onClick={openNativePicker}
+ required
+ trailing={ }
+ type='date'
+ value={dateValue}
+ />
+ }
+ onChange={onTimeChange}
+ onClick={openNativePicker}
+ required
+ trailing={ }
+ type='time'
+ value={timeValue}
+ />
+
+
+ {t(
+ 'scheduledMessages.sentInYourTimeZoneTimezone',
+ 'Sent in your time zone ({{ timeZone }})',
+ { timeZone },
+ )}
+
+
+
+
+
+ {t('scheduledMessages.cancel', 'Cancel')}
+
+
+ {submitLabel ?? t('scheduledMessages.schedule', 'Schedule')}
+
+
+
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageActions.tsx b/src/components/ScheduledMessages/ScheduledMessageActions.tsx
new file mode 100644
index 000000000..72f6ae941
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageActions.tsx
@@ -0,0 +1,175 @@
+import clsx from 'clsx';
+import React, { useCallback, useMemo, useRef } from 'react';
+
+import {
+ useChatContext,
+ useComponentContext,
+ useComponentContextIcons,
+ useTranslationContext,
+} from '../../context';
+import { useChannelDisplayName } from '../ChannelListItem/hooks/useChannelDisplayName';
+import { Button } from '../Button';
+import { ContextMenu, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog';
+import { GlobalModal } from '../Modal';
+import { SCHEDULED_MESSAGE_ACTIONS_MENU_ID, ScheduledMessageAction } from './constants';
+import { DeleteScheduledMessageAlert as DefaultDeleteScheduledMessageAlert } from './DeleteScheduledMessageAlert';
+import { useScheduledMessageActionHandlers, useScheduleMessageDialog } from './hooks';
+import { RescheduleMessageDialog as DefaultRescheduleMessageDialog } from './RescheduleMessageDialog';
+import { ScheduledMessageActionsMenu } from './ScheduledMessageActionsMenu';
+import type { ScheduledMessageActionsProps } from './types';
+import { getScheduledMessageActions, hasScheduledMessageEditButton } from './utils';
+
+export const ScheduledMessageActions = ({
+ onEdit,
+ scheduledMessage,
+}: ScheduledMessageActionsProps) => {
+ const { t } = useTranslationContext();
+ const { IconEdit, IconMore } = useComponentContextIcons();
+ const {
+ ContextMenu: ContextMenuComponent = ContextMenu,
+ DeleteScheduledMessageAlert = DefaultDeleteScheduledMessageAlert,
+ Modal = GlobalModal,
+ RescheduleMessageDialog = DefaultRescheduleMessageDialog,
+ } = useComponentContext();
+
+ const { client } = useChatContext();
+ const channelName = useChannelDisplayName(
+ client.channelManager.get(scheduledMessage.cid),
+ );
+
+ const { copyText, deleteScheduledMessage, isPending, sendNow } =
+ useScheduledMessageActionHandlers(scheduledMessage);
+ const menuDialogId = `${SCHEDULED_MESSAGE_ACTIONS_MENU_ID}-${scheduledMessage.id}`;
+ const { dialog: menuDialog, dialogManager } = useDialogOnNearestManager({
+ id: menuDialogId,
+ });
+ const isMenuOpen = useDialogIsOpen(menuDialogId, dialogManager?.id);
+
+ const menuButtonRef = useRef(null);
+
+ const rescheduleDialog = useScheduleMessageDialog(menuButtonRef);
+ const deleteAlert = useScheduleMessageDialog(menuButtonRef);
+
+ const actions = useMemo(
+ () => getScheduledMessageActions(scheduledMessage, { canEdit: !!onEdit }),
+ [onEdit, scheduledMessage],
+ );
+ const hasEditButton = hasScheduledMessageEditButton(scheduledMessage, actions);
+ const menuActions = useMemo(
+ () =>
+ hasEditButton
+ ? actions.filter((action) => action !== ScheduledMessageAction.Edit)
+ : actions,
+ [actions, hasEditButton],
+ );
+
+ const toggleMenu = useCallback(() => menuDialog.toggle(), [menuDialog]);
+
+ const editScheduledMessage = useCallback(
+ () => onEdit?.(scheduledMessage),
+ [onEdit, scheduledMessage],
+ );
+
+ const sendScheduledMessageNow = useCallback(() => {
+ sendNow();
+ }, [sendNow]);
+
+ const confirmDelete = useCallback(() => {
+ deleteAlert.closeDialog();
+ deleteScheduledMessage();
+ }, [deleteAlert, deleteScheduledMessage]);
+
+ return (
+
+
+
+
+ {hasEditButton && (
+
+
+
+ )}
+
+
+ {rescheduleDialog.isDialogOpen && (
+
+ )}
+
+
+ {deleteAlert.isDialogOpen && (
+
+ )}
+
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageActionsMenu.tsx b/src/components/ScheduledMessages/ScheduledMessageActionsMenu.tsx
new file mode 100644
index 000000000..bc3596bef
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageActionsMenu.tsx
@@ -0,0 +1,98 @@
+import React, { Fragment, useMemo } from 'react';
+
+import { useComponentContextIcons, useTranslationContext } from '../../context';
+import { ContextMenuButton, useContextMenuContext } from '../Dialog';
+import { ScheduledMessageAction } from './constants';
+import type { ScheduledMessageActionsMenuProps } from './types';
+
+export const ScheduledMessageActionsMenu = ({
+ actions,
+ onCopy,
+ onDelete,
+ onEdit,
+ onReschedule,
+ onSendNow,
+}: ScheduledMessageActionsMenuProps) => {
+ const { t } = useTranslationContext();
+ const { IconClock, IconCopy, IconDelete, IconEdit, IconSend } =
+ useComponentContextIcons();
+ const { closeMenu } = useContextMenuContext();
+
+ const menuItems = useMemo(
+ () => ({
+ [ScheduledMessageAction.Copy]: {
+ Icon: IconCopy,
+ label: t('scheduledMessages.copyText', 'Copy text'),
+ onSelect: onCopy,
+ },
+ [ScheduledMessageAction.Delete]: {
+ Icon: IconDelete,
+ label: t('scheduledMessages.delete', 'Delete'),
+ onSelect: onDelete,
+ },
+ [ScheduledMessageAction.Edit]: {
+ Icon: IconEdit,
+ label: t('scheduledMessages.editReschedule', 'Edit & reschedule'),
+ onSelect: onEdit,
+ },
+ [ScheduledMessageAction.Reschedule]: {
+ Icon: IconClock,
+ label: t('scheduledMessages.reschedule', 'Reschedule'),
+ onSelect: onReschedule,
+ },
+ [ScheduledMessageAction.SendNow]: {
+ Icon: IconSend,
+ label: t('scheduledMessages.sendNow', 'Send now'),
+ onSelect: onSendNow,
+ },
+ }),
+ [
+ IconClock,
+ IconCopy,
+ IconDelete,
+ IconEdit,
+ IconSend,
+ onCopy,
+ onDelete,
+ onEdit,
+ onReschedule,
+ onSendNow,
+ t,
+ ],
+ );
+
+ return (
+ <>
+ {actions.map((action, index) => {
+ const { Icon, label, onSelect } = menuItems[action];
+ const isDelete = action === ScheduledMessageAction.Delete;
+ const hasSeparator = isDelete && index > 1;
+
+ const selectAction = () => {
+ closeMenu();
+ onSelect();
+ };
+
+ return (
+
+ {hasSeparator && (
+
+ )}
+
+ {label}
+
+
+ );
+ })}
+ >
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageGroupHeader.tsx b/src/components/ScheduledMessages/ScheduledMessageGroupHeader.tsx
new file mode 100644
index 000000000..892c86fb8
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageGroupHeader.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import { useTranslationContext } from '../../context';
+import { ScheduledMessageGroupType } from './constants';
+import type { ScheduledMessageGroupHeaderProps } from './types';
+
+export const ScheduledMessageGroupHeader = ({
+ group,
+}: ScheduledMessageGroupHeaderProps) => {
+ const { t } = useTranslationContext();
+
+ const { day, type } = group;
+
+ const title =
+ type === ScheduledMessageGroupType.Failed
+ ? t('scheduledMessages.failed', 'Failed')
+ : t('timestamp.ScheduledMessageGroup', { timestamp: day });
+
+ return {title} ;
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageItem.tsx b/src/components/ScheduledMessages/ScheduledMessageItem.tsx
new file mode 100644
index 000000000..664fe207b
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageItem.tsx
@@ -0,0 +1,83 @@
+import clsx from 'clsx';
+import { nsToDate } from 'stream-chat';
+import React, { useMemo } from 'react';
+
+import {
+ useChatContext,
+ useComponentContext,
+ useTranslationContext,
+} from '../../context';
+import { ChannelAvatar } from '../Avatar';
+import { useChannelPreviewInfo } from '../ChannelListItem/hooks/useChannelPreviewInfo';
+import { SummarizedMessagePreview } from '../SummarizedMessagePreview';
+import { ScheduledMessageActions as DefaultScheduledMessageActions } from './ScheduledMessageActions';
+import { ScheduledMessageItemStatus } from './ScheduledMessageItemStatus';
+import type { ScheduledMessageItemProps } from './types';
+import { isFailedScheduledMessage, toStoredScheduledMessagePreview } from './utils';
+
+export const ScheduledMessageItem = ({
+ onEdit,
+ scheduledMessage,
+}: ScheduledMessageItemProps) => {
+ const { t } = useTranslationContext();
+ const { client } = useChatContext();
+ const { ScheduledMessageActions: ScheduledMessageActionsOverride } =
+ useComponentContext();
+
+ const { cid, scheduled_at } = scheduledMessage;
+ const channel = client.channelManager.get(cid);
+ const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({
+ channel,
+ });
+ const ScheduledMessageActions =
+ ScheduledMessageActionsOverride === undefined
+ ? DefaultScheduledMessageActions
+ : ScheduledMessageActionsOverride;
+
+ const previewMessage = useMemo(
+ () => toStoredScheduledMessagePreview(scheduledMessage),
+ [scheduledMessage],
+ );
+
+ const isFailed = isFailedScheduledMessage(scheduledMessage);
+ const sendTime = t('timestamp.ScheduledMessageSendTime', {
+ timestamp: nsToDate(scheduled_at),
+ });
+
+ return (
+
+
+ {t('scheduledMessages.sendTime', 'Send {{ time }}', { time: sendTime })}
+
+ {ScheduledMessageActions && (
+
+ )}
+
+
+
+ {displayTitle && (
+
+ {displayTitle}
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageItemStatus.tsx b/src/components/ScheduledMessages/ScheduledMessageItemStatus.tsx
new file mode 100644
index 000000000..a6749045a
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageItemStatus.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { SCHEDULED_MESSAGE_STATUS } from 'stream-chat';
+
+import { useComponentContextIcons, useTranslationContext } from '../../context';
+import type { ScheduledMessageItemStatusProps } from './types';
+import { translateScheduledMessageFailureReason } from './utils';
+
+export const ScheduledMessageItemStatus = ({
+ scheduledMessage,
+}: ScheduledMessageItemStatusProps) => {
+ const { t } = useTranslationContext();
+ const { IconExclamationTriangleFill, IconLoading } = useComponentContextIcons();
+
+ const { failure_reason, status } = scheduledMessage;
+
+ if (status === SCHEDULED_MESSAGE_STATUS.Failed) {
+ return (
+
+
+ {translateScheduledMessageFailureReason(t, failure_reason)}
+
+ );
+ }
+
+ if (status === SCHEDULED_MESSAGE_STATUS.Sending) {
+ return (
+
+
+ {t('scheduledMessages.sending', 'Sending...')}
+
+ );
+ }
+
+ return null;
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageList.tsx b/src/components/ScheduledMessages/ScheduledMessageList.tsx
new file mode 100644
index 000000000..42cb6ec2b
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageList.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+
+import { useComponentContext, useTranslationContext } from '../../context';
+import { Button } from '../Button';
+import {
+ useScheduledMessageGroups,
+ useScheduledMessagesLoadState,
+ useScheduledMessagesPagination,
+} from './hooks';
+import { ScheduledMessageGroupHeader } from './ScheduledMessageGroupHeader';
+import { ScheduledMessageItem as DefaultScheduledMessageItem } from './ScheduledMessageItem';
+import { ScheduledMessageListLoading } from './ScheduledMessageListLoading';
+import { ScheduledMessagesEmptyList } from './ScheduledMessagesEmptyList';
+import { ScheduledMessagesLoadError } from './ScheduledMessagesLoadError';
+import { ScheduledMessagesNoResults } from './ScheduledMessagesNoResults';
+import type { ScheduledMessageListProps } from './types';
+
+export const ScheduledMessageList = ({
+ cid,
+ onEdit,
+ parentId,
+ searchQuery,
+}: ScheduledMessageListProps) => {
+ const { t } = useTranslationContext();
+ const { ScheduledMessageItem = DefaultScheduledMessageItem } = useComponentContext();
+
+ const { groups, hasScheduledMessages } = useScheduledMessageGroups({
+ cid,
+ parentId,
+ searchQuery,
+ });
+ const { canLoadMore, isLoading, loadMore } = useScheduledMessagesPagination({ cid });
+ const { hasLoadFailed, isFirstLoad, reload } = useScheduledMessagesLoadState({ cid });
+
+ if (!groups.length) {
+ if (hasScheduledMessages) return ;
+
+ if (isFirstLoad) return ;
+
+ if (hasLoadFailed) return ;
+
+ return ;
+ }
+
+ return (
+
+ {groups.map((group) => (
+
+
+
+ {group.scheduledMessages.map((scheduledMessage) => (
+
+
+
+ ))}
+
+
+ ))}
+ {canLoadMore && (
+
+ {t('scheduledMessages.loadMore', 'Load more')}
+
+ )}
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx b/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx
new file mode 100644
index 000000000..88e0d6849
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx
@@ -0,0 +1,66 @@
+import React, { useCallback, useState } from 'react';
+import type { ChangeEvent } from 'react';
+
+import {
+ useComponentContext,
+ useComponentContextIcons,
+ useTranslationContext,
+} from '../../context';
+import { Prompt } from '../Dialog';
+import { TextInput } from '../Form';
+import { useScheduledMessageCount } from './hooks';
+import { ScheduledMessageList as DefaultScheduledMessageList } from './ScheduledMessageList';
+import type { ScheduledMessageListDialogProps } from './types';
+
+export const ScheduledMessageListDialog = ({
+ cid,
+ close,
+ onEdit,
+ parentId,
+}: ScheduledMessageListDialogProps) => {
+ const { t } = useTranslationContext();
+ const { IconSearch } = useComponentContextIcons();
+ const { ScheduledMessageList = DefaultScheduledMessageList } = useComponentContext();
+
+ const scheduledMessageCount = useScheduledMessageCount({ cid, parentId });
+
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const updateSearchQuery = useCallback(
+ ({ target }: ChangeEvent) => setSearchQuery(target.value),
+ [],
+ );
+
+ const title = parentId
+ ? t('scheduledMessages.scheduledReplies', 'Scheduled replies')
+ : t('scheduledMessages.scheduledMessages', 'Scheduled messages');
+
+ return (
+
+
+
+ {scheduledMessageCount > 0 && (
+ }
+ onChange={updateSearchQuery}
+ placeholder={t('scheduledMessages.search', 'Search')}
+ type='search'
+ value={searchQuery}
+ />
+ )}
+
+
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageListLoading.tsx b/src/components/ScheduledMessages/ScheduledMessageListLoading.tsx
new file mode 100644
index 000000000..1f709dc60
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageListLoading.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+
+import { useTranslationContext } from '../../context';
+import { SCHEDULED_MESSAGE_LIST_LOADING_ROW_COUNT } from './constants';
+
+const LOADING_ROW_KEYS = Array.from(
+ { length: SCHEDULED_MESSAGE_LIST_LOADING_ROW_COUNT },
+ (_, index) => `loading-row-${index}`,
+);
+
+export const ScheduledMessageListLoading = () => {
+ const { t } = useTranslationContext();
+
+ return (
+
+ {LOADING_ROW_KEYS.map((key) => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessagesBanner.tsx b/src/components/ScheduledMessages/ScheduledMessagesBanner.tsx
new file mode 100644
index 000000000..0029b6d03
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessagesBanner.tsx
@@ -0,0 +1,114 @@
+import clsx from 'clsx';
+import React, { useCallback, useRef } from 'react';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import {
+ useComponentContext,
+ useComponentContextIcons,
+ useTranslationContext,
+} from '../../context';
+import { useChannelCapabilities } from '../Channel/hooks/useChannelCapabilities';
+import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
+import { GlobalModal } from '../Modal';
+import { Portal } from '../Portal/Portal';
+import { SCHEDULE_MESSAGE_CAPABILITY } from './constants';
+import {
+ useFailedScheduledMessageCount,
+ useScheduledMessageCount,
+ useScheduleMessageDialog,
+} from './hooks';
+import { ScheduledMessageListDialog } from './ScheduledMessageListDialog';
+import { getChannelContainer, openScheduledMessageInComposer } from './utils';
+
+export const ScheduledMessagesBanner = () => {
+ const { t } = useTranslationContext();
+ const { IconClock, IconExclamationMark } = useComponentContextIcons();
+ const { Modal = GlobalModal } = useComponentContext();
+
+ const messageComposer = useMessageComposerController();
+ const { channel, threadId } = messageComposer;
+ const { cid } = channel;
+ const channelCapabilities = useChannelCapabilities({ cid });
+ const parentId = threadId ?? undefined;
+ const scheduledMessageCount = useScheduledMessageCount({ cid, parentId });
+ const failedScheduledMessageCount = useFailedScheduledMessageCount({ cid, parentId });
+
+ const viewButtonRef = useRef(null);
+
+ const { closeDialog, isDialogOpen, openDialog } =
+ useScheduleMessageDialog(viewButtonRef);
+
+ const editScheduledMessage = useCallback(
+ (scheduledMessage: ScheduledMessageResponse) => {
+ closeDialog();
+ openScheduledMessageInComposer(messageComposer, scheduledMessage);
+ },
+ [closeDialog, messageComposer],
+ );
+
+ const hasCapability = channelCapabilities.has(SCHEDULE_MESSAGE_CAPABILITY);
+ const hasFailedScheduledMessages = failedScheduledMessageCount > 0;
+ const isBannerVisible =
+ (hasCapability && scheduledMessageCount > 0) || hasFailedScheduledMessages;
+ const scheduledLabel = parentId
+ ? t('scheduledMessages.banner.scheduledReplies', {
+ count: scheduledMessageCount,
+ defaultValue_one: '{{ count }} scheduled reply',
+ defaultValue_other: '{{ count }} scheduled replies',
+ })
+ : t('scheduledMessages.banner.scheduledMessages', {
+ count: scheduledMessageCount,
+ defaultValue_one: '{{ count }} scheduled message',
+ defaultValue_other: '{{ count }} scheduled messages',
+ });
+ const label = hasFailedScheduledMessages
+ ? t('scheduledMessages.banner.failedToSend', {
+ count: failedScheduledMessageCount,
+ defaultValue_one: '{{ count }} failed to send',
+ defaultValue_other: '{{ count }} failed to send',
+ })
+ : scheduledLabel;
+ const BannerIcon = hasFailedScheduledMessages ? IconExclamationMark : IconClock;
+
+ if (!isBannerVisible && !isDialogOpen) return null;
+
+ return (
+ <>
+ {isBannerVisible && (
+
+
+ {label}
+
+ {t('scheduledMessages.viewAll', 'View all')}
+
+
+ )}
+
+
+ {isDialogOpen && (
+
+ )}
+
+
+ >
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessagesEmptyList.tsx b/src/components/ScheduledMessages/ScheduledMessagesEmptyList.tsx
new file mode 100644
index 000000000..e4859e2a7
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessagesEmptyList.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import { useComponentContextIcons, useTranslationContext } from '../../context';
+import { ScheduledMessagesEmptyState } from './ScheduledMessagesEmptyState';
+
+export const ScheduledMessagesEmptyList = () => {
+ const { t } = useTranslationContext();
+ const { IconClock } = useComponentContextIcons();
+
+ return (
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessagesEmptyState.tsx b/src/components/ScheduledMessages/ScheduledMessagesEmptyState.tsx
new file mode 100644
index 000000000..2110487d2
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessagesEmptyState.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import type { ScheduledMessagesEmptyStateProps } from './types';
+
+export const ScheduledMessagesEmptyState = ({
+ children,
+ description,
+ Icon,
+ title,
+}: ScheduledMessagesEmptyStateProps) => (
+
+
+
{title}
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+);
diff --git a/src/components/ScheduledMessages/ScheduledMessagesLoadError.tsx b/src/components/ScheduledMessages/ScheduledMessagesLoadError.tsx
new file mode 100644
index 000000000..4aa38d048
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessagesLoadError.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+
+import { useComponentContextIcons, useTranslationContext } from '../../context';
+import { Button } from '../Button';
+import { ScheduledMessagesEmptyState } from './ScheduledMessagesEmptyState';
+import type { ScheduledMessagesLoadErrorProps } from './types';
+
+export const ScheduledMessagesLoadError = ({
+ onRetry,
+}: ScheduledMessagesLoadErrorProps) => {
+ const { t } = useTranslationContext();
+ const { IconExclamationMark } = useComponentContextIcons();
+
+ return (
+
+
+ {t('scheduledMessages.tryAgain', 'Try again')}
+
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessagesNoResults.tsx b/src/components/ScheduledMessages/ScheduledMessagesNoResults.tsx
new file mode 100644
index 000000000..dbbdd85c5
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessagesNoResults.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { useComponentContextIcons, useTranslationContext } from '../../context';
+import { ScheduledMessagesEmptyState } from './ScheduledMessagesEmptyState';
+
+export const ScheduledMessagesNoResults = () => {
+ const { t } = useTranslationContext();
+ const { IconSearch } = useComponentContextIcons();
+
+ return (
+
+ );
+};
diff --git a/src/components/ScheduledMessages/__tests__/EditScheduledMessage.test.tsx b/src/components/ScheduledMessages/__tests__/EditScheduledMessage.test.tsx
new file mode 100644
index 000000000..5e50cbc7f
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/EditScheduledMessage.test.tsx
@@ -0,0 +1,156 @@
+import '../../..';
+import React from 'react';
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+
+import { Channel } from '../../Channel';
+import { Chat } from '../../Chat';
+import { MessageComposer } from '../../MessageComposer';
+import {
+ generateScheduledMessage,
+ generateUser,
+ initClientWithChannels,
+} from '../../../mock-builders';
+
+const user = generateUser({ id: 'user-id' });
+
+const renderComposer = async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [
+ {
+ channel: {
+ id: 'general',
+ own_capabilities: ['send-message', 'schedule-message'],
+ type: 'messaging',
+ },
+ },
+ ],
+ customUser: user,
+ });
+
+ await act(() => {
+ render(
+
+
+
+
+ ,
+ );
+ });
+
+ const scheduledMessage = generateScheduledMessage({
+ cid: channel.cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ });
+ const { messageComposer } = channel;
+
+ const failedMessage = generateScheduledMessage({
+ cid: channel.cid,
+ failure_reason: 'channel_frozen',
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ status: 'failed',
+ });
+
+ const startEditing = () =>
+ act(() => messageComposer.editScheduledMessage(scheduledMessage));
+
+ const startSchedulingAgain = () =>
+ act(() => messageComposer.rescheduleFailedMessage(failedMessage));
+
+ return {
+ channel,
+ client,
+ failedMessage,
+ messageComposer,
+ scheduledMessage,
+ startEditing,
+ startSchedulingAgain,
+ };
+};
+
+describe('editing a scheduled message in the composer', () => {
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('shows the scheduled edit preview and hides the schedule button', async () => {
+ const { startEditing } = await renderComposer();
+
+ await startEditing();
+
+ expect(screen.getByText('Edit scheduled message')).toBeInTheDocument();
+ expect(screen.queryByTestId('schedule-message-button')).not.toBeInTheDocument();
+ });
+
+ it('saves the edit with updateScheduledMessage instead of sending', async () => {
+ const { channel, messageComposer, scheduledMessage, startEditing } =
+ await renderComposer();
+ const updateScheduledMessage = vi
+ .spyOn(messageComposer, 'updateScheduledMessage')
+ .mockResolvedValue({ scheduledMessage, status: 'scheduled' });
+ const sendMessage = vi.spyOn(channel, 'sendMessage');
+
+ await startEditing();
+ await act(() => fireEvent.click(screen.getByTestId('send-button')));
+
+ expect(updateScheduledMessage).toHaveBeenCalledTimes(1);
+ expect(sendMessage).not.toHaveBeenCalled();
+ });
+
+ it('restores the earlier draft when the edit is cancelled', async () => {
+ const { messageComposer, startEditing } = await renderComposer();
+ await act(() => messageComposer.textComposer.setText('my unsent draft'));
+
+ await startEditing();
+ await act(() =>
+ fireEvent.click(screen.getByRole('button', { name: /cancel|remove/i })),
+ );
+
+ expect(screen.queryByText('Edit scheduled message')).not.toBeInTheDocument();
+ expect(messageComposer.textComposer.text).toBe('my unsent draft');
+ });
+
+ describe('scheduling a failed message again', () => {
+ it('shows the schedule-again preview and keeps the schedule button', async () => {
+ const { startSchedulingAgain } = await renderComposer();
+
+ await startSchedulingAgain();
+
+ expect(screen.getByText('Schedule again')).toBeInTheDocument();
+ expect(screen.getByTestId('schedule-message-button')).toBeInTheDocument();
+ });
+
+ it('restores the earlier draft when cancelled', async () => {
+ const { messageComposer, startSchedulingAgain } = await renderComposer();
+ await act(() => messageComposer.textComposer.setText('my unsent draft'));
+
+ await startSchedulingAgain();
+ await act(() =>
+ fireEvent.click(screen.getByRole('button', { name: /cancel|remove/i })),
+ );
+
+ expect(screen.queryByText('Schedule again')).not.toBeInTheDocument();
+ expect(messageComposer.textComposer.text).toBe('my unsent draft');
+ });
+
+ it('deletes the failed message after sending it now', async () => {
+ const { channel, client, failedMessage, startSchedulingAgain } =
+ await renderComposer();
+ vi.spyOn(channel, 'sendMessage').mockResolvedValue(
+ fromPartial({ message: { id: 'sent-id', text: 'Launch moved to Monday' } }),
+ );
+ const deleteScheduledMessage = vi
+ .spyOn(client.scheduledMessages, 'deleteScheduledMessage')
+ .mockResolvedValue(failedMessage);
+
+ await startSchedulingAgain();
+ await act(() => fireEvent.click(screen.getByTestId('send-button')));
+
+ expect(deleteScheduledMessage).toHaveBeenCalledWith(failedMessage.id);
+ });
+ });
+});
diff --git a/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx
new file mode 100644
index 000000000..385e4fd5d
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx
@@ -0,0 +1,433 @@
+import '../../..';
+import React from 'react';
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+import { ScheduledMessageErrorReason } from 'stream-chat';
+import type {
+ Channel as ChannelType,
+ ScheduledMessageResponse,
+ ScheduledMessageSubmitResult,
+ StreamChat,
+} from 'stream-chat';
+
+import { axe } from '../../../../axe-helper';
+import { Channel } from '../../Channel';
+import { Chat } from '../../Chat';
+import { WithComponents } from '../../../context/WithComponents';
+import { MessageComposer } from '../../MessageComposer';
+import {
+ convertDateToTimestamp,
+ erroredScheduledMessageApi,
+ generateScheduledMessage,
+ generateUser,
+ initClientWithChannels,
+ sendScheduledMessageApi,
+ setWSConnectionStatus,
+ useMockedApis,
+} from '../../../mock-builders';
+
+const user = generateUser({ id: 'user-id' });
+
+const WEDNESDAY_MORNING = new Date(2026, 9, 28, 10, 30);
+const MONDAY_MORNING = new Date(2026, 10, 2, 13, 0);
+
+const channelDataWith = (ownCapabilities: string[]) => ({
+ channel: {
+ id: 'general',
+ own_capabilities: ownCapabilities,
+ type: 'messaging',
+ },
+});
+
+type SetupOptions = {
+ canSchedule?: boolean;
+ components?: Parameters[0]['overrides'];
+ text?: string;
+};
+
+const renderComposer = async ({
+ canSchedule = true,
+ components = {},
+ text = 'Launch moved to Monday',
+}: SetupOptions = {}) => {
+ const ownCapabilities = canSchedule
+ ? ['send-message', 'schedule-message']
+ : ['send-message'];
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [channelDataWith(ownCapabilities)],
+ customUser: user,
+ });
+
+ await act(() => {
+ render(
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ if (text) {
+ await act(() => channel.messageComposer.textComposer.setText(text));
+ }
+
+ return { channel, client };
+};
+
+const openMenu = async () => {
+ await act(() => fireEvent.click(screen.getByTestId('schedule-message-button')));
+
+ return screen.findByTestId('schedule-message-menu');
+};
+
+const openDialog = async () => {
+ await openMenu();
+ await act(() => fireEvent.click(screen.getByText('Custom time')));
+
+ return screen.findByTestId('schedule-message-dialog');
+};
+
+const getNotifications = (client: StreamChat) =>
+ client.notifications.store.getLatestValue().notifications;
+
+const mockSchedule = (channel: ChannelType) =>
+ vi.spyOn(channel.messageComposer, 'schedule');
+
+const scheduledResult = (
+ scheduledMessage: ScheduledMessageResponse,
+): ScheduledMessageSubmitResult => ({ scheduledMessage, status: 'scheduled' });
+
+describe('ScheduleMessageButton', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] });
+ vi.setSystemTime(WEDNESDAY_MORNING);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ describe('split send button', () => {
+ it('renders the chevron next to the send button with the capability', async () => {
+ await renderComposer();
+
+ const chevron = screen.getByTestId('schedule-message-button');
+
+ expect(screen.getByTestId('send-button')).toBeInTheDocument();
+ expect(chevron).toHaveAttribute('aria-haspopup', 'menu');
+ expect(chevron).toHaveAttribute('aria-expanded', 'false');
+ expect(chevron.closest('.str-chat__split-send-button')).not.toBeNull();
+ });
+
+ it('keeps the single send button without the capability', async () => {
+ await renderComposer({ canSchedule: false });
+
+ expect(screen.getByTestId('send-button')).toBeInTheDocument();
+ expect(screen.queryByTestId('schedule-message-button')).not.toBeInTheDocument();
+ expect(document.querySelector('.str-chat__split-send-button')).toBeNull();
+ });
+
+ it('hides the chevron for a slash command', async () => {
+ await renderComposer({ text: '/giphy hello' });
+
+ expect(screen.queryByTestId('schedule-message-button')).not.toBeInTheDocument();
+ });
+
+ it('can be removed through ComponentContext', async () => {
+ await renderComposer({ components: { ScheduleMessageButton: null } });
+
+ expect(screen.getByTestId('send-button')).toBeInTheDocument();
+ expect(screen.queryByTestId('schedule-message-button')).not.toBeInTheDocument();
+ });
+
+ it('still sends immediately from the send button', async () => {
+ const { channel } = await renderComposer();
+ const schedule = mockSchedule(channel);
+ const sendMessage = vi
+ .spyOn(channel, 'sendMessage')
+ .mockResolvedValue(fromPartial({ message: { id: 'sent' } }));
+
+ await act(() => fireEvent.click(screen.getByTestId('send-button')));
+
+ await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
+ expect(sendMessage.mock.calls[0][0].scheduled_at).toBeUndefined();
+ expect(schedule).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('menu', () => {
+ it('shows the heading, the presets and Custom time', async () => {
+ await renderComposer();
+
+ const menu = await openMenu();
+
+ expect(menu).toHaveTextContent('Schedule message');
+ expect(screen.getByText('Tomorrow at 09:00')).toBeInTheDocument();
+ expect(screen.getByText('Tomorrow at 13:00')).toBeInTheDocument();
+ expect(screen.getByText('Monday at 09:00')).toBeInTheDocument();
+ expect(screen.getByText('Custom time')).toBeInTheDocument();
+ expect(screen.getByTestId('schedule-message-button')).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
+ });
+
+ it('labels the next-Monday preset with the weekday on a Monday', async () => {
+ vi.setSystemTime(MONDAY_MORNING);
+ await renderComposer();
+
+ await openMenu();
+
+ expect(screen.getByText('Tomorrow at 09:00')).toBeInTheDocument();
+ expect(screen.getByText('Monday at 09:00')).toBeInTheDocument();
+ });
+
+ it('confirms a time later today with the time only', async () => {
+ const { channel, client } = await renderComposer();
+ const scheduledMessage = generateScheduledMessage({
+ cid: channel.cid,
+ scheduled_at: convertDateToTimestamp(new Date(2026, 9, 28, 10, 43)),
+ });
+ mockSchedule(channel).mockResolvedValue(scheduledResult(scheduledMessage));
+
+ await openMenu();
+ await act(() => fireEvent.click(screen.getByText('Tomorrow at 09:00')));
+
+ await waitFor(() =>
+ expect(getNotifications(client)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ message: 'Message scheduled for 10:43' }),
+ ]),
+ ),
+ );
+ });
+
+ it('schedules at the picked preset and confirms with the server time', async () => {
+ const { channel, client } = await renderComposer();
+ const serverScheduledAt = new Date(2026, 9, 29, 9, 0);
+ const scheduledMessage = generateScheduledMessage({
+ cid: channel.cid,
+ scheduled_at: convertDateToTimestamp(serverScheduledAt),
+ });
+ const schedule = mockSchedule(channel).mockResolvedValue(
+ scheduledResult(scheduledMessage),
+ );
+
+ await openMenu();
+ await act(() => fireEvent.click(screen.getByText('Tomorrow at 09:00')));
+
+ expect(schedule).toHaveBeenCalledWith(new Date(2026, 9, 29, 9, 0));
+ await waitFor(() =>
+ expect(getNotifications(client)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ message: 'Message scheduled for Thu, Oct 29 at 09:00',
+ severity: 'success',
+ type: 'api:scheduled-message:create:success',
+ }),
+ ]),
+ ),
+ );
+ expect(screen.queryByTestId('schedule-message-menu')).not.toBeInTheDocument();
+ });
+
+ it('sends the composition with scheduled_at to the API', async () => {
+ const { channel, client } = await renderComposer();
+ const scheduledMessage = generateScheduledMessage({ cid: channel.cid });
+ useMockedApis(client, [sendScheduledMessageApi(scheduledMessage)]);
+ const sendMessage = vi.spyOn(channel, 'sendMessage');
+
+ await openMenu();
+ await act(() => fireEvent.click(screen.getByText('Tomorrow at 13:00')));
+
+ await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
+ expect(sendMessage.mock.calls[0][0].message.text).toBe('Launch moved to Monday');
+ expect(sendMessage.mock.calls[0][0].scheduled_at).toEqual(
+ new Date(2026, 9, 29, 13, 0),
+ );
+ await waitFor(() => expect(channel.messageComposer.textComposer.text).toBe(''));
+ });
+
+ it('refreshes the composer id when the server reports the id as taken', async () => {
+ const { channel, client } = await renderComposer();
+ useMockedApis(client, [
+ erroredScheduledMessageApi(119, ScheduledMessageErrorReason.IdTaken),
+ ]);
+ const idBefore = channel.messageComposer.id;
+
+ await openMenu();
+ await act(() => fireEvent.click(screen.getByText('Tomorrow at 09:00')));
+
+ await waitFor(() => expect(channel.messageComposer.id).not.toBe(idBefore));
+ expect(channel.messageComposer.textComposer.text).toBe('Launch moved to Monday');
+ });
+
+ it('has no axe violations', async () => {
+ await renderComposer();
+
+ const menu = await openMenu();
+
+ expect(await axe(menu)).toHaveNoViolations();
+ });
+ });
+
+ describe('custom time dialog', () => {
+ it('opens with the message preview, the default time and the time zone', async () => {
+ await renderComposer();
+
+ const dialog = await openDialog();
+
+ expect(dialog).toHaveTextContent('Schedule message');
+ expect(dialog).toHaveTextContent('Launch moved to Monday');
+ expect(screen.getByLabelText('Date')).toHaveValue('2026-10-28');
+ expect(screen.getByLabelText('Time')).toHaveValue('11:00');
+ expect(dialog).toHaveTextContent('Sent in your time zone (GMT)');
+ expect(screen.getByRole('button', { name: 'Schedule' })).toBeEnabled();
+ });
+
+ it('shows the picked date in the design format', async () => {
+ await renderComposer();
+
+ const dialog = await openDialog();
+ fireEvent.change(screen.getByLabelText('Date'), {
+ target: { value: '2026-11-03' },
+ });
+
+ expect(
+ dialog.querySelector('.str-chat__schedule-message-dialog__date-value'),
+ ).toHaveTextContent('Tue, Nov 3, 2026');
+ });
+
+ it('schedules at the picked date and time and closes', async () => {
+ const { channel } = await renderComposer();
+ const schedule = mockSchedule(channel).mockResolvedValue(
+ scheduledResult(generateScheduledMessage({ cid: channel.cid })),
+ );
+
+ await openDialog();
+ fireEvent.change(screen.getByLabelText('Date'), {
+ target: { value: '2026-11-03' },
+ });
+ fireEvent.change(screen.getByLabelText('Time'), { target: { value: '16:45' } });
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Schedule' })));
+
+ expect(schedule).toHaveBeenCalledWith(new Date(2026, 10, 3, 16, 45));
+ await waitFor(() =>
+ expect(screen.queryByTestId('schedule-message-dialog')).not.toBeInTheDocument(),
+ );
+ });
+
+ it('blocks a time before the minimum lead time', async () => {
+ await renderComposer();
+
+ await openDialog();
+ fireEvent.change(screen.getByLabelText('Time'), { target: { value: '10:31' } });
+
+ expect(screen.getByText('Choose a later time')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Schedule' })).toBeDisabled();
+ });
+
+ it('blocks a time beyond the maximum lead time', async () => {
+ await renderComposer();
+
+ await openDialog();
+ fireEvent.change(screen.getByLabelText('Date'), {
+ target: { value: '2027-06-01' },
+ });
+
+ expect(screen.getByText('Choose an earlier time')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Schedule' })).toBeDisabled();
+ });
+
+ it('keeps the dialog open and shows the error when scheduling fails', async () => {
+ const { channel } = await renderComposer();
+ const schedule = mockSchedule(channel).mockResolvedValue({
+ error: new Error('failed'),
+ status: 'failed',
+ });
+
+ const dialog = await openDialog();
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Schedule' })));
+
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent(
+ "Couldn't schedule message. Try again",
+ );
+ expect(screen.getByTestId('schedule-message-dialog')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Schedule' })).toBeEnabled();
+
+ schedule.mockResolvedValue(
+ scheduledResult(generateScheduledMessage({ cid: channel.cid })),
+ );
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Schedule' })));
+
+ expect(schedule).toHaveBeenCalledTimes(2);
+ await waitFor(() =>
+ expect(screen.queryByTestId('schedule-message-dialog')).not.toBeInTheDocument(),
+ );
+ });
+
+ it('disables Schedule while offline and enables it again on reconnect', async () => {
+ const { client } = await renderComposer();
+
+ await openDialog();
+ await act(() => setWSConnectionStatus(client, false));
+
+ expect(
+ screen.getByText("You're offline. Reconnect to schedule this message."),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Schedule' })).toBeDisabled();
+
+ await act(() => setWSConnectionStatus(client, true));
+
+ expect(
+ screen.queryByText("You're offline. Reconnect to schedule this message."),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Schedule' })).toBeEnabled();
+ });
+
+ it('closes without scheduling on Cancel', async () => {
+ const { channel } = await renderComposer();
+ const schedule = mockSchedule(channel);
+
+ await openDialog();
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Cancel' })));
+
+ expect(screen.queryByTestId('schedule-message-dialog')).not.toBeInTheDocument();
+ expect(schedule).not.toHaveBeenCalled();
+ });
+
+ it('renders a custom dialog from ComponentContext', async () => {
+ const CustomDialog = () =>
;
+ await renderComposer({ components: { ScheduleMessageDialog: CustomDialog } });
+
+ await openMenu();
+ await act(() => fireEvent.click(screen.getByText('Custom time')));
+
+ expect(await screen.findByTestId('custom-schedule-dialog')).toBeInTheDocument();
+ });
+
+ it('has no axe violations', async () => {
+ await renderComposer();
+
+ const dialog = await openDialog();
+
+ expect(await axe(dialog)).toHaveNoViolations();
+ });
+ });
+});
diff --git a/src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx
new file mode 100644
index 000000000..1840a59db
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx
@@ -0,0 +1,323 @@
+import React from 'react';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+import {
+ SCHEDULED_MESSAGE_FAILURE_REASON,
+ SCHEDULED_MESSAGE_STATUS,
+ StreamAPIError,
+} from 'stream-chat';
+import type { ScheduledMessageResponse, StreamChat } from 'stream-chat';
+
+import { axe } from '../../../../axe-helper';
+import {
+ ChatProvider,
+ DialogManagerProvider,
+ TranslationProvider,
+} from '../../../context';
+import { WithComponents } from '../../../context/WithComponents';
+import { Streami18n } from '../../../i18n';
+import {
+ convertDateToTimestamp,
+ generateScheduledMessage,
+ getTestClientWithUser,
+ hydrateScheduledMessages,
+ mockChatContext,
+} from '../../../mock-builders';
+import { ScheduledMessageNotificationType } from '../constants';
+import { ScheduledMessageList } from '../ScheduledMessageList';
+import type { EditScheduledMessage } from '../types';
+
+const cid = 'messaging:general';
+
+const WEDNESDAY_MORNING = new Date(2026, 9, 28, 10, 30);
+
+const notFoundError = new StreamAPIError('not found', {
+ code: 16,
+ response: fromPartial>({
+ data: { code: 16 },
+ status: 404,
+ }),
+ status: 404,
+});
+
+type RenderActionsOptions = {
+ components?: Parameters[0]['overrides'];
+ onEdit?: EditScheduledMessage;
+ scheduledMessage?: ScheduledMessageResponse;
+};
+
+const renderActions = async ({
+ components = {},
+ onEdit,
+ scheduledMessage = generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ scheduled_at: convertDateToTimestamp(new Date(2026, 9, 29, 9, 0)),
+ }),
+}: RenderActionsOptions = {}) => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ hydrateScheduledMessages(client, cid, [scheduledMessage]);
+
+ const { t, tDateTimeParser } = await new Streami18n({ language: 'en' }).init();
+
+ const view = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ return { ...view, client, scheduledMessage };
+};
+
+const openActionsMenu = async () => {
+ await act(() =>
+ fireEvent.click(screen.getByTestId('scheduled-message-actions-button')),
+ );
+
+ return screen.findByTestId('scheduled-message-actions-menu');
+};
+
+const selectAction = async (action: string) => {
+ await openActionsMenu();
+ await act(() =>
+ fireEvent.click(screen.getByTestId(`scheduled-message-action-${action}`)),
+ );
+};
+
+const getNotificationTypes = (client: StreamChat) =>
+ client.notifications.store.getLatestValue().notifications.map(({ type }) => type);
+
+const getMenuLabels = () =>
+ screen.getAllByRole('menuitem').map(({ textContent }) => textContent?.trim());
+
+describe('ScheduledMessageActions', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] });
+ vi.setSystemTime(WEDNESDAY_MORNING);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('offers an Edit button and Reschedule, Send now and Delete for a scheduled message', async () => {
+ await renderActions({ onEdit: vi.fn() });
+
+ await openActionsMenu();
+
+ expect(screen.getByTestId('scheduled-message-edit-button')).toBeInTheDocument();
+ expect(getMenuLabels()).toEqual(['Reschedule', 'Send now', 'Delete']);
+ expect(screen.getAllByRole('separator')).toHaveLength(1);
+ });
+
+ it('leaves out Edit when the list has no edit handler', async () => {
+ await renderActions();
+
+ await openActionsMenu();
+
+ expect(screen.queryByTestId('scheduled-message-edit-button')).not.toBeInTheDocument();
+ expect(getMenuLabels()).toEqual(['Reschedule', 'Send now', 'Delete']);
+ });
+
+ it('disables the actions while the message is being sent', async () => {
+ await renderActions({
+ scheduledMessage: generateScheduledMessage({
+ cid,
+ status: SCHEDULED_MESSAGE_STATUS.Sending,
+ }),
+ });
+
+ expect(screen.getByTestId('scheduled-message-actions-button')).toBeDisabled();
+ });
+
+ it('offers Copy and Delete for a message in a deleted channel', async () => {
+ await renderActions({
+ onEdit: vi.fn(),
+ scheduledMessage: generateScheduledMessage({
+ cid,
+ failure_reason: SCHEDULED_MESSAGE_FAILURE_REASON.ChannelDeleted,
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ }),
+ });
+
+ await openActionsMenu();
+
+ expect(getMenuLabels()).toEqual(['Copy text', 'Delete']);
+ expect(screen.queryByRole('separator')).not.toBeInTheDocument();
+ });
+
+ it('hands the scheduled message to the edit handler', async () => {
+ const onEdit = vi.fn();
+ const { scheduledMessage } = await renderActions({ onEdit });
+
+ await act(() => fireEvent.click(screen.getByTestId('scheduled-message-edit-button')));
+
+ expect(onEdit).toHaveBeenCalledWith(scheduledMessage);
+ });
+
+ it('sends the message now and confirms it', async () => {
+ const { client, scheduledMessage } = await renderActions();
+ const sendNow = vi
+ .spyOn(client.scheduledMessages, 'sendScheduledMessageNow')
+ .mockResolvedValue(scheduledMessage);
+
+ await selectAction('send_now');
+
+ expect(sendNow).toHaveBeenCalledWith(scheduledMessage.id);
+ await waitFor(() =>
+ expect(getNotificationTypes(client)).toContain(
+ ScheduledMessageNotificationType.SendNowSucceeded,
+ ),
+ );
+ });
+
+ it('reports a send-now failure with its error', async () => {
+ const { client } = await renderActions();
+ vi.spyOn(client.scheduledMessages, 'sendScheduledMessageNow').mockRejectedValue(
+ notFoundError,
+ );
+
+ await selectAction('send_now');
+
+ await waitFor(() =>
+ expect(client.notifications.store.getLatestValue().notifications).toEqual([
+ expect.objectContaining({
+ originalError: notFoundError,
+ type: ScheduledMessageNotificationType.SendNowFailed,
+ }),
+ ]),
+ );
+ });
+
+ it('deletes only after the deletion is confirmed', async () => {
+ const { client, scheduledMessage } = await renderActions();
+ const deleteScheduledMessage = vi
+ .spyOn(client.scheduledMessages, 'deleteScheduledMessage')
+ .mockResolvedValue(scheduledMessage);
+
+ await selectAction('delete');
+ const alert = await screen.findByTestId('delete-scheduled-message-alert');
+
+ expect(alert).toHaveTextContent('Delete scheduled message?');
+ expect(alert).toHaveTextContent("This message won't be sent. This can't be undone.");
+ expect(deleteScheduledMessage).not.toHaveBeenCalled();
+
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Delete' })));
+
+ expect(deleteScheduledMessage).toHaveBeenCalledWith(scheduledMessage.id);
+ });
+
+ it('keeps the message when the deletion is cancelled', async () => {
+ const { client } = await renderActions();
+ const deleteScheduledMessage = vi.spyOn(
+ client.scheduledMessages,
+ 'deleteScheduledMessage',
+ );
+
+ await selectAction('delete');
+ await screen.findByTestId('delete-scheduled-message-alert');
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Cancel' })));
+
+ expect(deleteScheduledMessage).not.toHaveBeenCalled();
+ expect(
+ screen.queryByTestId('delete-scheduled-message-alert'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('reschedules from the dialog pre-filled with the current time', async () => {
+ const { client, scheduledMessage } = await renderActions();
+ const updateScheduledMessage = vi
+ .spyOn(client.scheduledMessages, 'updateScheduledMessage')
+ .mockResolvedValue(scheduledMessage);
+
+ await selectAction('reschedule');
+ const dialog = await screen.findByTestId('schedule-message-dialog');
+
+ expect(dialog).toHaveTextContent('Reschedule message');
+ expect(dialog).toHaveTextContent('Scheduled for Thu, Oct 29 at 09:00');
+ expect(screen.getByLabelText('Date')).toHaveValue('2026-10-29');
+ expect(screen.getByLabelText('Time')).toHaveValue('09:00');
+
+ fireEvent.change(screen.getByLabelText('Time'), { target: { value: '14:30' } });
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Save' })));
+
+ expect(updateScheduledMessage).toHaveBeenCalledWith(scheduledMessage.id, {
+ scheduled_at: new Date(2026, 9, 29, 14, 30),
+ });
+ await waitFor(() =>
+ expect(screen.queryByTestId('schedule-message-dialog')).not.toBeInTheDocument(),
+ );
+ });
+
+ it('keeps the reschedule dialog open and reports a failure', async () => {
+ const { client } = await renderActions();
+ vi.spyOn(client.scheduledMessages, 'updateScheduledMessage').mockRejectedValue(
+ notFoundError,
+ );
+
+ await selectAction('reschedule');
+ await screen.findByTestId('schedule-message-dialog');
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Save' })));
+
+ expect(
+ await screen.findByText("Couldn't reschedule the message. Try again"),
+ ).toBeInTheDocument();
+ expect(getNotificationTypes(client)).toContain(
+ ScheduledMessageNotificationType.RescheduleFailed,
+ );
+ });
+
+ it('copies the text of a message from a deleted channel', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+ await renderActions({
+ scheduledMessage: generateScheduledMessage({
+ cid,
+ failure_reason: SCHEDULED_MESSAGE_FAILURE_REASON.ChannelDeleted,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ }),
+ });
+
+ await selectAction('copy');
+
+ expect(writeText).toHaveBeenCalledWith('Launch moved to Monday');
+ });
+
+ it('renders the delete confirmation through the DeleteScheduledMessageAlert slot', async () => {
+ const CustomAlert = () =>
;
+ await renderActions({ components: { DeleteScheduledMessageAlert: CustomAlert } });
+
+ await selectAction('delete');
+
+ expect(await screen.findByTestId('custom-delete-alert')).toBeInTheDocument();
+ });
+
+ it('renders the reschedule dialog through the RescheduleMessageDialog slot', async () => {
+ const CustomDialog = () =>
;
+ await renderActions({ components: { RescheduleMessageDialog: CustomDialog } });
+
+ await selectAction('reschedule');
+
+ expect(await screen.findByTestId('custom-reschedule-dialog')).toBeInTheDocument();
+ });
+
+ it('has no accessibility violations with the menu open', async () => {
+ const { container } = await renderActions({ onEdit: vi.fn() });
+
+ await openActionsMenu();
+
+ expect(await axe(container)).toHaveNoViolations();
+ });
+});
diff --git a/src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx
new file mode 100644
index 000000000..10d12ee88
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx
@@ -0,0 +1,341 @@
+import React from 'react';
+import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { SCHEDULED_MESSAGE_FAILURE_REASON, SCHEDULED_MESSAGE_STATUS } from 'stream-chat';
+import type { PaginatorState, ScheduledMessageResponse, StreamChat } from 'stream-chat';
+
+import { axe } from '../../../../axe-helper';
+import {
+ ChatProvider,
+ DialogManagerProvider,
+ TranslationProvider,
+} from '../../../context';
+import { WithComponents } from '../../../context/WithComponents';
+import { Streami18n } from '../../../i18n';
+import {
+ convertDateToTimestamp,
+ generateScheduledMessage,
+ getTestClientWithUser,
+ hydrateScheduledMessages,
+ mockChatContext,
+} from '../../../mock-builders';
+import { ScheduledMessageList } from '../ScheduledMessageList';
+import type { EditScheduledMessage, ScheduledMessageItemProps } from '../types';
+
+const cid = 'messaging:general';
+
+const WEDNESDAY_MORNING = new Date(2026, 9, 28, 10, 30);
+
+const atLocalTime = (day: number, hour: number) =>
+ convertDateToTimestamp(new Date(2026, 9, day, hour, 0));
+
+type RenderListOptions = {
+ components?: Parameters[0]['overrides'];
+ onEdit?: EditScheduledMessage;
+ scheduledMessages?: ScheduledMessageResponse[];
+ searchQuery?: string;
+};
+
+const renderList = async ({
+ components = {},
+ onEdit,
+ scheduledMessages = [],
+ searchQuery,
+}: RenderListOptions = {}) => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ hydrateScheduledMessages(client, cid, scheduledMessages);
+
+ const { t, tDateTimeParser } = await new Streami18n({ language: 'en' }).init();
+
+ const view = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ return { ...view, client };
+};
+
+const getGroupHeaders = () =>
+ screen.getAllByRole('heading', { level: 3 }).map(({ textContent }) => textContent);
+
+const getPaginator = (client: StreamChat) =>
+ client.scheduledMessages.getChannelPaginator(cid);
+
+const setPaginatorState = (
+ client: StreamChat,
+ state: Partial>,
+) => act(() => getPaginator(client).state.partialNext(state));
+
+const setNextCursor = (client: StreamChat) =>
+ setPaginatorState(client, { hasMoreTail: true });
+
+describe('ScheduledMessageList', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] });
+ vi.setSystemTime(WEDNESDAY_MORNING);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('shows the empty state without scheduled messages', async () => {
+ await renderList();
+
+ expect(screen.getByText('No scheduled messages')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Click the arrow next to the send button in a channel to schedule a message.',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('shows skeleton rows while the first page loads', async () => {
+ const { client } = await renderList();
+
+ setPaginatorState(client, { isLoading: true, items: undefined });
+
+ expect(screen.queryByText('No scheduled messages')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('status', { name: 'Loading scheduled messages' }),
+ ).toBeInTheDocument();
+ });
+
+ it('shows the empty state while a later reload runs', async () => {
+ const { client } = await renderList();
+
+ setPaginatorState(client, { isLoading: true });
+
+ expect(screen.getByText('No scheduled messages')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('scheduled-message-list-loading'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows the load error and reloads on retry', async () => {
+ const { client } = await renderList();
+ const reload = vi.spyOn(getPaginator(client), 'reload').mockResolvedValue(undefined);
+
+ setPaginatorState(client, {
+ isLoading: false,
+ items: undefined,
+ lastQueryError: new Error('offline'),
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
+
+ expect(screen.getByText("Couldn't load scheduled messages")).toBeInTheDocument();
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps showing stored messages when a reload fails', async () => {
+ const { client } = await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ }),
+ ],
+ });
+
+ setPaginatorState(client, { lastQueryError: new Error('offline') });
+
+ expect(screen.getByText('Launch moved to Monday')).toBeInTheDocument();
+ expect(
+ screen.queryByText("Couldn't load scheduled messages"),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows only the messages matching the search query', async () => {
+ await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ }),
+ generateScheduledMessage({ cid, message: { custom: {}, text: 'Lunch at noon' } }),
+ ],
+ searchQuery: 'launch',
+ });
+
+ expect(screen.getByText('Launch moved to Monday')).toBeInTheDocument();
+ expect(screen.queryByText('Lunch at noon')).not.toBeInTheDocument();
+ });
+
+ it('shows no results when the search matches nothing', async () => {
+ await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ }),
+ ],
+ searchQuery: 'friday',
+ });
+
+ expect(screen.getByText('No messages found')).toBeInTheDocument();
+ expect(screen.queryByText('No scheduled messages')).not.toBeInTheDocument();
+ });
+
+ it('lists failed messages first, then one group per day', async () => {
+ await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({ cid, scheduled_at: atLocalTime(29, 9) }),
+ generateScheduledMessage({ cid, scheduled_at: atLocalTime(30, 13) }),
+ generateScheduledMessage({
+ cid,
+ scheduled_at: atLocalTime(29, 13),
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ }),
+ ],
+ });
+
+ expect(getGroupHeaders()).toEqual(['Failed', 'Tomorrow', 'Friday']);
+ });
+
+ it('shows the send time and preview of each message', async () => {
+ await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ scheduled_at: atLocalTime(29, 9),
+ }),
+ ],
+ });
+
+ const item = screen.getByTestId('scheduled-message-item');
+
+ expect(within(item).getByText('Launch moved to Monday')).toBeInTheDocument();
+ expect(within(item).getByText('Send Thu, Oct 29 at 09:00')).toBeInTheDocument();
+ expect(within(item).queryByText('Scheduled')).not.toBeInTheDocument();
+ });
+
+ it('summarises attachments when the message has no text', async () => {
+ await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: {
+ attachments: [
+ { custom: {}, image_url: 'https://example.com/a.png', type: 'image' },
+ ],
+ custom: {},
+ text: '',
+ },
+ }),
+ ],
+ });
+
+ expect(screen.getByText('Image')).toBeInTheDocument();
+ });
+
+ it('marks a message that is being sent', async () => {
+ await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({ cid, status: SCHEDULED_MESSAGE_STATUS.Sending }),
+ ],
+ });
+
+ expect(screen.getByText('Sending...')).toBeInTheDocument();
+ });
+
+ it('explains why a message failed', async () => {
+ await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ failure_reason: SCHEDULED_MESSAGE_FAILURE_REASON.ChannelFrozen,
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ }),
+ ],
+ });
+
+ const item = screen.getByTestId('scheduled-message-item');
+
+ expect(item).toHaveClass('str-chat__scheduled-message-item--failed');
+ expect(within(item).getByText('This channel is frozen')).toBeInTheDocument();
+ });
+
+ it('loads more only when the store has another page', async () => {
+ const { client } = await renderList({
+ scheduledMessages: [generateScheduledMessage({ cid })],
+ });
+ const loadMore = vi
+ .spyOn(getPaginator(client), 'toTail')
+ .mockResolvedValue(undefined);
+
+ expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
+
+ setNextCursor(client);
+ fireEvent.click(screen.getByRole('button', { name: 'Load more' }));
+
+ expect(loadMore).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables load more while a page is loading', async () => {
+ const { client } = await renderList({
+ scheduledMessages: [generateScheduledMessage({ cid })],
+ });
+
+ setPaginatorState(client, { hasMoreTail: true, isLoading: true });
+
+ expect(screen.getByRole('button', { name: 'Load more' })).toBeDisabled();
+ });
+
+ it('hides the row actions when the ScheduledMessageActions slot is null', async () => {
+ await renderList({
+ components: { ScheduledMessageActions: null },
+ scheduledMessages: [generateScheduledMessage({ cid })],
+ });
+
+ expect(
+ screen.queryByTestId('scheduled-message-actions-button'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('renders items through the ScheduledMessageItem slot', async () => {
+ const CustomItem = ({ scheduledMessage }: ScheduledMessageItemProps) => (
+ {scheduledMessage.id}
+ );
+
+ await renderList({
+ components: { ScheduledMessageItem: CustomItem },
+ scheduledMessages: [generateScheduledMessage({ cid, id: 'scheduled-id' })],
+ });
+
+ expect(screen.getByTestId('custom-item')).toHaveTextContent('scheduled-id');
+ });
+
+ it.each([
+ ['loading', { isLoading: true, items: undefined }],
+ [
+ 'load error',
+ { isLoading: false, items: undefined, lastQueryError: new Error('offline') },
+ ],
+ ])('has no accessibility violations in the %s state', async (_, state) => {
+ const { client, container } = await renderList();
+
+ setPaginatorState(client, state);
+
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it('has no accessibility violations', async () => {
+ const { container } = await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({ cid, status: SCHEDULED_MESSAGE_STATUS.Failed }),
+ ],
+ });
+
+ expect(await axe(container)).toHaveNoViolations();
+ });
+});
diff --git a/src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx
new file mode 100644
index 000000000..0c7b1401e
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx
@@ -0,0 +1,313 @@
+import '../../..';
+import React from 'react';
+import type { PropsWithChildren } from 'react';
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+import { formatMessage, ScheduledMessageEventType, Thread } from 'stream-chat';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import { axe } from '../../../../axe-helper';
+import { Channel } from '../../Channel';
+import { Chat } from '../../Chat';
+import { WithComponents } from '../../../context/WithComponents';
+import { MessageComposer } from '../../MessageComposer';
+import { ThreadProvider } from '../../Threads';
+import {
+ dispatchScheduledMessageEvent,
+ generateMessage,
+ generateScheduledMessage,
+ generateUser,
+ initClientWithChannels,
+} from '../../../mock-builders';
+
+const user = generateUser({ id: 'user-id' });
+
+const cid = 'messaging:general';
+const parentId = 'parent-1';
+
+const threadReply = () =>
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, parent_id: parentId, text: 'reply' },
+ });
+
+type RenderComposerOptions = {
+ awaitsScheduledMessages?: boolean;
+ canSchedule?: boolean;
+ components?: Parameters[0]['overrides'];
+ inThread?: boolean;
+ scheduledMessages?: ScheduledMessageResponse[];
+};
+
+const renderComposer = async ({
+ awaitsScheduledMessages = true,
+ canSchedule = true,
+ components = {},
+ inThread = false,
+ scheduledMessages = [],
+}: RenderComposerOptions = {}) => {
+ const ownCapabilities = canSchedule
+ ? ['send-message', 'schedule-message']
+ : ['send-message'];
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [
+ {
+ channel: { id: 'general', own_capabilities: ownCapabilities, type: 'messaging' },
+ },
+ ],
+ customUser: user,
+ });
+ vi.spyOn(client, 'queryScheduledMessages').mockResolvedValue(
+ fromPartial({ duration: '', scheduled_messages: scheduledMessages }),
+ );
+
+ const thread = inThread
+ ? new Thread({
+ channel,
+ client,
+ parentMessage: formatMessage(generateMessage({ cid, id: parentId })),
+ })
+ : undefined;
+
+ const ThreadScope = ({ children }: PropsWithChildren) =>
+ thread ? {children} : children;
+
+ await act(() => {
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ if (awaitsScheduledMessages) {
+ await waitFor(() =>
+ expect(client.scheduledMessages.getChannelPaginator(cid).hasLoadedPage).toBe(true),
+ );
+ }
+
+ return { channel, client };
+};
+
+const queryBanner = () => screen.queryByTestId('scheduled-messages-banner');
+
+describe('ScheduledMessagesBanner', () => {
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('stays hidden without scheduled messages', async () => {
+ await renderComposer();
+
+ expect(queryBanner()).not.toBeInTheDocument();
+ });
+
+ it('counts the scheduled messages of the channel', async () => {
+ await renderComposer({
+ scheduledMessages: [
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({ cid }),
+ threadReply(),
+ ],
+ });
+
+ expect(queryBanner()).toHaveTextContent('2 scheduled messages');
+ });
+
+ it('uses the singular for one scheduled message', async () => {
+ await renderComposer({ scheduledMessages: [generateScheduledMessage({ cid })] });
+
+ expect(queryBanner()).toHaveTextContent('1 scheduled message');
+ expect(queryBanner()).not.toHaveTextContent('1 scheduled messages');
+ });
+
+ it('switches to the failed count when a scheduled message failed', async () => {
+ await renderComposer({
+ scheduledMessages: [
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({ cid, status: 'failed' }),
+ ],
+ });
+
+ expect(queryBanner()).toHaveTextContent('1 failed to send');
+ expect(queryBanner()).not.toHaveTextContent('scheduled messages');
+ expect(queryBanner()).toHaveClass('str-chat__scheduled-messages-banner--failed');
+ });
+
+ it('leaves out the failed count when nothing failed', async () => {
+ await renderComposer({ scheduledMessages: [generateScheduledMessage({ cid })] });
+
+ expect(queryBanner()).not.toHaveTextContent('failed');
+ });
+
+ it('disappears when the store is reset for another user', async () => {
+ const { client } = await renderComposer({
+ scheduledMessages: [generateScheduledMessage({ cid })],
+ });
+
+ act(() => client.scheduledMessages.resetState());
+
+ expect(queryBanner()).not.toBeInTheDocument();
+ });
+
+ it('follows the scheduled messages of the next user after a reset', async () => {
+ const { client } = await renderComposer({
+ scheduledMessages: [generateScheduledMessage({ cid })],
+ });
+
+ act(() => client.scheduledMessages.resetState());
+ act(() =>
+ client.scheduledMessages.hydrateChannelState(cid, [
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({ cid }),
+ ]),
+ );
+
+ expect(queryBanner()).toHaveTextContent('2 scheduled messages');
+ });
+
+ it('counts only the replies of the thread in a thread composer', async () => {
+ await renderComposer({
+ inThread: true,
+ scheduledMessages: [generateScheduledMessage({ cid }), threadReply()],
+ });
+
+ expect(queryBanner()).toHaveTextContent('1 scheduled reply');
+ });
+
+ it('stays hidden without the schedule-message capability', async () => {
+ await renderComposer({
+ canSchedule: false,
+ scheduledMessages: [generateScheduledMessage({ cid })],
+ });
+
+ expect(queryBanner()).not.toBeInTheDocument();
+ });
+
+ it('shows failed scheduled messages without the schedule-message capability', async () => {
+ await renderComposer({
+ canSchedule: false,
+ scheduledMessages: [generateScheduledMessage({ cid, status: 'failed' })],
+ });
+
+ expect(queryBanner()).toHaveTextContent('1 failed to send');
+ expect(queryBanner()).toHaveTextContent('View all');
+ });
+
+ it('updates when scheduled message events arrive', async () => {
+ const { client } = await renderComposer();
+ const scheduledMessage = generateScheduledMessage({ cid });
+
+ act(() =>
+ dispatchScheduledMessageEvent({
+ client,
+ scheduledMessage,
+ type: ScheduledMessageEventType.Created,
+ }),
+ );
+
+ await waitFor(() => expect(queryBanner()).toHaveTextContent('1 scheduled message'));
+
+ act(() =>
+ dispatchScheduledMessageEvent({
+ client,
+ scheduledMessage: { ...scheduledMessage, status: 'sent' },
+ type: ScheduledMessageEventType.Sent,
+ }),
+ );
+
+ await waitFor(() => expect(queryBanner()).not.toBeInTheDocument());
+ });
+
+ it('opens the scheduled message list from View all', async () => {
+ await renderComposer({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ }),
+ ],
+ });
+
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'View all' })));
+
+ const dialog = await screen.findByTestId('scheduled-message-list-dialog');
+
+ expect(dialog).toHaveTextContent('Scheduled messages');
+ expect(dialog).toHaveTextContent('Launch moved to Monday');
+ });
+
+ it('filters the scheduled message list with the search field', async () => {
+ await renderComposer({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ }),
+ generateScheduledMessage({ cid, message: { custom: {}, text: 'Lunch at noon' } }),
+ ],
+ });
+
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'View all' })));
+ const dialog = await screen.findByTestId('scheduled-message-list-dialog');
+ fireEvent.change(within(dialog).getByRole('searchbox', { name: 'Search' }), {
+ target: { value: 'launch' },
+ });
+
+ expect(dialog).toHaveTextContent('Launch moved to Monday');
+ expect(dialog).not.toHaveTextContent('Lunch at noon');
+ });
+
+ it('titles the list as replies in a thread', async () => {
+ await renderComposer({ inThread: true, scheduledMessages: [threadReply()] });
+
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'View all' })));
+
+ expect(await screen.findByTestId('scheduled-message-list-dialog')).toHaveTextContent(
+ 'Scheduled replies',
+ );
+ });
+
+ it('can be hidden through the ScheduledMessagesBanner slot', async () => {
+ const { client } = await renderComposer({
+ awaitsScheduledMessages: false,
+ components: { ScheduledMessagesBanner: null },
+ scheduledMessages: [generateScheduledMessage({ cid })],
+ });
+
+ expect(queryBanner()).not.toBeInTheDocument();
+ expect(client.queryScheduledMessages).not.toHaveBeenCalled();
+ });
+
+ it('has no accessibility violations', async () => {
+ await renderComposer({ scheduledMessages: [generateScheduledMessage({ cid })] });
+
+ expect(await axe(queryBanner() as HTMLElement)).toHaveNoViolations();
+ });
+
+ it('has no accessibility violations with failed messages', async () => {
+ await renderComposer({
+ scheduledMessages: [generateScheduledMessage({ cid, status: 'failed' })],
+ });
+
+ expect(await axe(queryBanner() as HTMLElement)).toHaveNoViolations();
+ });
+});
diff --git a/src/components/ScheduledMessages/constants.ts b/src/components/ScheduledMessages/constants.ts
new file mode 100644
index 000000000..11cc7563e
--- /dev/null
+++ b/src/components/ScheduledMessages/constants.ts
@@ -0,0 +1,80 @@
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+export const SCHEDULE_MESSAGE_CAPABILITY = 'schedule-message';
+
+export const NO_SCHEDULED_MESSAGES: ScheduledMessageResponse[] = [];
+
+export const SCHEDULE_MESSAGE_MENU_ID = 'schedule-message-menu';
+
+export const SCHEDULED_MESSAGE_ACTIONS_MENU_ID = 'scheduled-message-actions-menu';
+
+export const SCHEDULED_MESSAGE_ACTIONS_EMITTER = 'ScheduledMessageActions';
+
+export const SCHEDULED_MESSAGES_EMITTER = 'ScheduledMessages';
+
+export const SCHEDULE_MESSAGE_NOTIFICATION_EMITTER = 'ScheduleMessage';
+
+export const CLOCK_SKEW_BUFFER_MS = 60 * 1000;
+
+export const SchedulePresetHour = {
+ Afternoon: 13,
+ Morning: 9,
+} as const;
+
+export type SchedulePresetHour =
+ (typeof SchedulePresetHour)[keyof typeof SchedulePresetHour];
+
+export const ScheduleTimeError = {
+ TooFar: 'too_far',
+ TooSoon: 'too_soon',
+} as const;
+
+export type ScheduleTimeError =
+ (typeof ScheduleTimeError)[keyof typeof ScheduleTimeError];
+
+export const ScheduleMessageNoticeSeverity = {
+ Error: 'error',
+ Info: 'info',
+} as const;
+
+export type ScheduleMessageNoticeSeverity =
+ (typeof ScheduleMessageNoticeSeverity)[keyof typeof ScheduleMessageNoticeSeverity];
+
+export const ScheduledMessageNotificationType = {
+ Blocked: 'validation:scheduled-message:blocked',
+ CompositionEnded: 'validation:scheduled-message:composition:ended',
+ CreateFailed: 'api:scheduled-message:create:failed',
+ CreateSucceeded: 'api:scheduled-message:create:success',
+ DeleteFailed: 'api:scheduled-message:delete:failed',
+ RescheduleFailed: 'api:scheduled-message:reschedule:failed',
+ SendFailed: 'api:scheduled-message:send:failed',
+ SendNowFailed: 'api:scheduled-message:send-now:failed',
+ SendNowSucceeded: 'api:scheduled-message:send-now:success',
+ UpdateFailed: 'api:scheduled-message:update:failed',
+} as const;
+
+export type ScheduledMessageNotificationType =
+ (typeof ScheduledMessageNotificationType)[keyof typeof ScheduledMessageNotificationType];
+
+export const ScheduledMessageGroupType = {
+ Day: 'day',
+ Failed: 'failed',
+} as const;
+
+export type ScheduledMessageGroupType =
+ (typeof ScheduledMessageGroupType)[keyof typeof ScheduledMessageGroupType];
+
+export const FAILED_SCHEDULED_MESSAGE_GROUP_ID = 'failed';
+
+export const SCHEDULED_MESSAGE_LIST_LOADING_ROW_COUNT = 6;
+
+export const ScheduledMessageAction = {
+ Copy: 'copy',
+ Delete: 'delete',
+ Edit: 'edit',
+ Reschedule: 'reschedule',
+ SendNow: 'send_now',
+} as const;
+
+export type ScheduledMessageAction =
+ (typeof ScheduledMessageAction)[keyof typeof ScheduledMessageAction];
diff --git a/src/components/ScheduledMessages/hooks/__tests__/scheduleMessageHooks.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/scheduleMessageHooks.test.tsx
new file mode 100644
index 000000000..6a3f54466
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/__tests__/scheduleMessageHooks.test.tsx
@@ -0,0 +1,260 @@
+import React, { createRef } from 'react';
+import type { PropsWithChildren } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { MessageComposer } from 'stream-chat';
+import type { Channel, ScheduledMessageSubmitResult, StreamChat } from 'stream-chat';
+
+import {
+ ChannelInstanceProvider,
+ ChatProvider,
+ MessageComposerControllerProvider,
+} from '../../../../context';
+import {
+ generateScheduledMessage,
+ getTestClientWithUser,
+ mockChatContext,
+} from '../../../../mock-builders';
+import { useScheduleMessageDialog } from '../useScheduleMessageDialog';
+import { useScheduleMessageSubmit } from '../useScheduleMessageSubmit';
+import { useSchedulePresets } from '../useSchedulePresets';
+import { useScheduleTimeForm } from '../useScheduleTimeForm';
+
+const WEDNESDAY_MORNING = new Date(2026, 9, 28, 10, 30);
+
+const createWrapper = (
+ client: StreamChat,
+ channel: Channel,
+ composer: MessageComposer,
+) => {
+ const Wrapper = ({ children }: PropsWithChildren) => (
+
+
+
+ {children}
+
+
+
+ );
+
+ return Wrapper;
+};
+
+const setup = async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const channel = client.channel('messaging', 'general');
+ const composer = new MessageComposer({ client, compositionContext: channel });
+ const wrapper = createWrapper(client, channel, composer);
+
+ return { channel, client, composer, wrapper };
+};
+
+const deferred = () => {
+ let resolve: (value: T) => void = () => undefined;
+ let reject: (error: Error) => void = () => undefined;
+ const promise = new Promise((onResolve, onReject) => {
+ resolve = onResolve;
+ reject = onReject;
+ });
+
+ return { promise, reject, resolve };
+};
+
+describe('scheduled message hooks', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] });
+ vi.setSystemTime(WEDNESDAY_MORNING);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ describe('useSchedulePresets', () => {
+ it('computes nothing while the menu is closed', async () => {
+ const { wrapper } = await setup();
+
+ const { rerender, result } = renderHook(() => useSchedulePresets(false), {
+ wrapper,
+ });
+ const closedPresets = result.current;
+ rerender();
+
+ expect(closedPresets).toEqual([]);
+ expect(result.current).toBe(closedPresets);
+ });
+
+ it('keeps the same presets across re-renders while the menu is open', async () => {
+ const { wrapper } = await setup();
+
+ const { rerender, result } = renderHook(() => useSchedulePresets(true), {
+ wrapper,
+ });
+ const openPresets = result.current;
+ rerender();
+
+ expect(openPresets).toHaveLength(3);
+ expect(result.current).toBe(openPresets);
+ });
+ });
+
+ describe('useScheduleTimeForm', () => {
+ it('starts at the next whole hour after the earliest time', async () => {
+ const { channel, wrapper } = await setup();
+
+ const { result } = renderHook(() => useScheduleTimeForm({ channel }), { wrapper });
+
+ expect(result.current.dateValue).toBe('2026-10-28');
+ expect(result.current.timeValue).toBe('11:00');
+ expect(result.current.scheduledAt).toEqual(new Date(2026, 9, 28, 11, 0));
+ expect(result.current.timeError).toBeUndefined();
+ });
+
+ it('keeps handlers and the picked time stable across re-renders', async () => {
+ const { channel, wrapper } = await setup();
+
+ const { rerender, result } = renderHook(() => useScheduleTimeForm({ channel }), {
+ wrapper,
+ });
+ const { onDateChange, onTimeChange, scheduledAt } = result.current;
+ rerender();
+
+ expect(result.current.onDateChange).toBe(onDateChange);
+ expect(result.current.onTimeChange).toBe(onTimeChange);
+ expect(result.current.scheduledAt).toBe(scheduledAt);
+ });
+
+ it('starts at the given time when rescheduling', async () => {
+ const { channel, wrapper } = await setup();
+ const initialScheduledAt = new Date(2026, 9, 30, 15, 45);
+
+ const { result } = renderHook(
+ () => useScheduleTimeForm({ channel, initialScheduledAt }),
+ { wrapper },
+ );
+
+ expect(result.current.dateValue).toBe('2026-10-30');
+ expect(result.current.timeValue).toBe('15:45');
+ expect(result.current.scheduledAt).toEqual(initialScheduledAt);
+ });
+ });
+
+ describe('useScheduleMessageSubmit', () => {
+ it('calls onScheduled after a successful schedule', async () => {
+ const { composer, wrapper } = await setup();
+ vi.spyOn(composer, 'schedule').mockResolvedValue({
+ scheduledMessage: generateScheduledMessage(),
+ status: 'scheduled',
+ });
+ const onScheduled = vi.fn();
+
+ const { result } = renderHook(() => useScheduleMessageSubmit(onScheduled), {
+ wrapper,
+ });
+ await act(() => result.current.submit(new Date(2026, 9, 29, 9)));
+
+ expect(onScheduled).toHaveBeenCalledTimes(1);
+ expect(result.current.hasFailed).toBe(false);
+ expect(result.current.isScheduling).toBe(false);
+ });
+
+ it('reports a failure and allows another attempt', async () => {
+ const { composer, wrapper } = await setup();
+ vi.spyOn(composer, 'schedule').mockResolvedValue({
+ error: new Error('failed'),
+ status: 'failed',
+ });
+ const onScheduled = vi.fn();
+
+ const { result } = renderHook(() => useScheduleMessageSubmit(onScheduled), {
+ wrapper,
+ });
+ await act(() => result.current.submit(new Date(2026, 9, 29, 9)));
+
+ expect(onScheduled).not.toHaveBeenCalled();
+ expect(result.current.hasFailed).toBe(true);
+ expect(result.current.isScheduling).toBe(false);
+ });
+
+ it('does not call onScheduled after unmount when the request succeeds late', async () => {
+ const { composer, wrapper } = await setup();
+ const pendingSchedule = deferred();
+ vi.spyOn(composer, 'schedule').mockReturnValue(pendingSchedule.promise);
+ const onScheduled = vi.fn();
+ const consoleError = vi.spyOn(console, 'error');
+
+ const { result, unmount } = renderHook(
+ () => useScheduleMessageSubmit(onScheduled),
+ {
+ wrapper,
+ },
+ );
+ let submitting: Promise = Promise.resolve();
+ act(() => {
+ submitting = result.current.submit(new Date(2026, 9, 29, 9));
+ });
+ unmount();
+ pendingSchedule.resolve({
+ scheduledMessage: generateScheduledMessage(),
+ status: 'scheduled',
+ });
+ await submitting;
+
+ expect(onScheduled).not.toHaveBeenCalled();
+ expect(consoleError).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('useScheduleMessageDialog', () => {
+ it('returns focus to the trigger after closing when focus fell to the body', () => {
+ const trigger = document.createElement('button');
+ document.body.appendChild(trigger);
+ const returnFocusRef = createRef();
+ Object.assign(returnFocusRef, { current: trigger });
+ const requestFrame = vi
+ .spyOn(window, 'requestAnimationFrame')
+ .mockImplementation((callback) => {
+ callback(0);
+ return 1;
+ });
+
+ const { result } = renderHook(() => useScheduleMessageDialog(returnFocusRef));
+ act(() => result.current.openDialog());
+ act(() => result.current.closeDialog());
+
+ expect(result.current.isDialogOpen).toBe(false);
+ expect(requestFrame).toHaveBeenCalledTimes(1);
+ expect(document.activeElement).toBe(trigger);
+
+ trigger.remove();
+ });
+
+ it('cancels the pending focus restore on unmount', () => {
+ const returnFocusRef = createRef();
+ vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(42);
+ const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame');
+
+ const { result, unmount } = renderHook(() =>
+ useScheduleMessageDialog(returnFocusRef),
+ );
+ act(() => result.current.openDialog());
+ act(() => result.current.closeDialog());
+ unmount();
+
+ expect(cancelFrame).toHaveBeenCalledWith(42);
+ });
+
+ it('keeps open and close stable across re-renders', () => {
+ const returnFocusRef = createRef();
+
+ const { rerender, result } = renderHook(() =>
+ useScheduleMessageDialog(returnFocusRef),
+ );
+ const { closeDialog, openDialog } = result.current;
+ rerender();
+
+ expect(result.current.openDialog).toBe(openDialog);
+ expect(result.current.closeDialog).toBe(closeDialog);
+ });
+ });
+});
diff --git a/src/components/ScheduledMessages/hooks/__tests__/scheduledMessageListHooks.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/scheduledMessageListHooks.test.tsx
new file mode 100644
index 000000000..f7c6c219e
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/__tests__/scheduledMessageListHooks.test.tsx
@@ -0,0 +1,194 @@
+import React from 'react';
+import type { PropsWithChildren } from 'react';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { SCHEDULED_MESSAGE_STATUS, ScheduledMessageEventType } from 'stream-chat';
+import type { StreamChat } from 'stream-chat';
+
+import { useScheduledMessageGroups } from '../useScheduledMessageGroups';
+import { useScheduledMessagesPagination } from '../useScheduledMessagesPagination';
+import { ChatProvider } from '../../../../context';
+import {
+ convertDateToTimestamp,
+ dispatchScheduledMessageEvent,
+ generateScheduledMessage,
+ getTestClientWithUser,
+ hydrateScheduledMessages,
+ mockChatContext,
+} from '../../../../mock-builders';
+
+const cid = 'messaging:general';
+const parentId = 'parent-1';
+
+const createWrapper = (client: StreamChat) => {
+ const ChatWrapper = ({ children }: PropsWithChildren) => (
+ {children}
+ );
+
+ return ChatWrapper;
+};
+
+const atLocalTime = (day: number, hour: number) =>
+ convertDateToTimestamp(new Date(2026, 9, day, hour, 0));
+
+const mockScheduledMessagePage = (client: StreamChat, next?: string) =>
+ vi.mocked(client.queryScheduledMessages).mockResolvedValueOnce({
+ duration: '',
+ next,
+ scheduled_messages: [generateScheduledMessage({ cid })],
+ });
+
+describe('useScheduledMessageGroups', () => {
+ it('groups the channel messages with failed ones first', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const failed = generateScheduledMessage({
+ cid,
+ scheduled_at: atLocalTime(30, 9),
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ });
+ const upcoming = generateScheduledMessage({ cid, scheduled_at: atLocalTime(29, 9) });
+ const threadReply = generateScheduledMessage({
+ cid,
+ message: { custom: {}, parent_id: parentId, text: 'reply' },
+ });
+ hydrateScheduledMessages(client, cid, [failed, upcoming, threadReply]);
+
+ const { result } = renderHook(() => useScheduledMessageGroups({ cid }), {
+ wrapper: createWrapper(client),
+ });
+
+ expect(
+ result.current.groups.map(({ scheduledMessages }) => scheduledMessages),
+ ).toEqual([[failed], [upcoming]]);
+ });
+
+ it('groups only the replies of a thread', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const threadReply = generateScheduledMessage({
+ cid,
+ message: { custom: {}, parent_id: parentId, text: 'reply' },
+ });
+ hydrateScheduledMessages(client, cid, [
+ generateScheduledMessage({ cid }),
+ threadReply,
+ ]);
+
+ const { result } = renderHook(() => useScheduledMessageGroups({ cid, parentId }), {
+ wrapper: createWrapper(client),
+ });
+
+ expect(result.current.groups).toHaveLength(1);
+ expect(result.current.groups[0].scheduledMessages).toEqual([threadReply]);
+ });
+
+ it('regroups when a scheduled message event arrives', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ client.scheduledMessages.registerSubscriptions();
+ hydrateScheduledMessages(client, cid);
+ const scheduledMessage = generateScheduledMessage({ cid });
+
+ const { result } = renderHook(() => useScheduledMessageGroups({ cid }), {
+ wrapper: createWrapper(client),
+ });
+
+ expect(result.current.groups).toEqual([]);
+
+ act(() =>
+ dispatchScheduledMessageEvent({
+ client,
+ scheduledMessage,
+ type: ScheduledMessageEventType.Created,
+ }),
+ );
+
+ await waitFor(() => expect(result.current.groups).toHaveLength(1));
+
+ client.scheduledMessages.unregisterSubscriptions();
+ });
+
+ it('keeps the same groups when another channel changes', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ hydrateScheduledMessages(client, cid, [generateScheduledMessage({ cid })]);
+
+ const { result } = renderHook(() => useScheduledMessageGroups({ cid }), {
+ wrapper: createWrapper(client),
+ });
+ const initialGroups = result.current.groups;
+
+ act(() =>
+ client.scheduledMessages.upsertToState(
+ generateScheduledMessage({ cid: 'messaging:random' }),
+ ),
+ );
+
+ expect(result.current.groups).toBe(initialGroups);
+ });
+});
+
+describe('useScheduledMessagesPagination', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('cannot load more once the channel list is complete', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ hydrateScheduledMessages(client, cid, [generateScheduledMessage({ cid })]);
+
+ const { result } = renderHook(() => useScheduledMessagesPagination({ cid }), {
+ wrapper: createWrapper(client),
+ });
+
+ expect(result.current.canLoadMore).toBe(false);
+ });
+
+ it('can load more while the first page has a next cursor', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ mockScheduledMessagePage(client, 'cursor');
+
+ const { result } = renderHook(() => useScheduledMessagesPagination({ cid }), {
+ wrapper: createWrapper(client),
+ });
+
+ expect(result.current.canLoadMore).toBe(false);
+ await waitFor(() => expect(result.current.canLoadMore).toBe(true));
+ });
+
+ it('loads the next page through the channel paginator', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ hydrateScheduledMessages(client, cid);
+ const toTail = vi
+ .spyOn(client.scheduledMessages.getChannelPaginator(cid), 'toTail')
+ .mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useScheduledMessagesPagination({ cid }), {
+ wrapper: createWrapper(client),
+ });
+
+ act(() => result.current.loadMore());
+
+ expect(toTail).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps a failed page load in the paginator state instead of throwing', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const error = new Error('Network Error');
+ mockScheduledMessagePage(client, 'cursor');
+ vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(error);
+ const paginator = client.scheduledMessages.getChannelPaginator(cid);
+
+ const { result } = renderHook(() => useScheduledMessagesPagination({ cid }), {
+ wrapper: createWrapper(client),
+ });
+ await waitFor(() => expect(result.current.canLoadMore).toBe(true));
+
+ act(() => result.current.loadMore());
+
+ await waitFor(() =>
+ expect(paginator.state.getLatestValue()).toEqual(
+ expect.objectContaining({ isLoading: false, lastQueryError: error }),
+ ),
+ );
+ expect(result.current).toEqual(
+ expect.objectContaining({ canLoadMore: true, isLoading: false }),
+ );
+ });
+});
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 000000000..92c699284
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx
@@ -0,0 +1,198 @@
+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 { Command, LocationComposerState, StreamChat } from 'stream-chat';
+
+import { SCHEDULE_MESSAGE_CAPABILITY } from '../../constants';
+import { useCanScheduleMessage } from '../useCanScheduleMessage';
+import {
+ ChannelInstanceProvider,
+ ChatProvider,
+ MessageComposerControllerProvider,
+} from '../../../../context';
+import {
+ generateMessage,
+ generateScheduledMessage,
+ getTestClientWithUser,
+ 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,
+ message_id: 'message-1',
+});
+
+const setup = async ({ hasCapability = true }: { hasCapability?: boolean } = {}) => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const channel = client.channel('messaging', 'general');
+ client.channelServerConfigs = {
+ [channel.cid]: fromPartial({
+ commands: [{ args: '[text]', description: 'Post a random gif', name: 'giphy' }],
+ scheduled_messages_max_lead_time_seconds: 120 * 24 * 60 * 60,
+ scheduled_messages_max_per_channel: 10,
+ scheduled_messages_max_per_user: 100,
+ scheduled_messages_min_lead_time_seconds: 60,
+ }),
+ };
+ channel.data = {
+ own_capabilities: hasCapability ? [SCHEDULE_MESSAGE_CAPABILITY] : [],
+ };
+ const composer = new MessageComposer({ client, compositionContext: channel });
+ 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 ? fromPartial({ 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 while a scheduled message is edited', async () => {
+ const { composer, result } = await setup();
+
+ act(() => {
+ composer.state.partialNext({ editedScheduledMessage: generateScheduledMessage() });
+ });
+
+ expect(result.current).toBe(false);
+ });
+
+ 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 000000000..c0a1d8db5
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx
@@ -0,0 +1,133 @@
+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 '../../types';
+import { ChatProvider } from '../../../../context';
+import {
+ generateScheduledMessage,
+ getTestClientWithUser,
+ hydrateScheduledMessages,
+ 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' });
+ hydrateScheduledMessages(client, cid, [
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({
+ cid,
+ message: { parent_id: 'parent-1', text: 'reply' },
+ }),
+ ]);
+ hydrateScheduledMessages(client, otherCid, [
+ generateScheduledMessage({ cid: otherCid }),
+ ]);
+
+ 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' });
+ hydrateScheduledMessages(client, cid, [
+ generateScheduledMessage({ cid }),
+ generateScheduledMessage({
+ cid,
+ message: { parent_id: 'parent-1', text: 'reply' },
+ }),
+ ]);
+
+ 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' });
+ hydrateScheduledMessages(client, cid, [
+ generateScheduledMessage({
+ cid,
+ message: { parent_id: 'parent-1', text: 'reply' },
+ }),
+ ]);
+ hydrateScheduledMessages(client, otherCid, [
+ generateScheduledMessage({
+ cid: otherCid,
+ message: { parent_id: 'parent-1', text: 'reply elsewhere' },
+ }),
+ ]);
+
+ 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 });
+ hydrateScheduledMessages(client, cid, [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 000000000..f40bb7958
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx
@@ -0,0 +1,172 @@
+import React from 'react';
+import type { PropsWithChildren } from 'react';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import type { StreamChat } from 'stream-chat';
+
+import { useScheduledMessages } from '../useScheduledMessages';
+import type { UseScheduledMessagesParams } from '../../types';
+import { ChatProvider } from '../../../../context';
+import {
+ convertDateToTimestamp,
+ generateScheduledMessage,
+ getTestClientWithUser,
+ hydrateScheduledMessages,
+ 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: convertDateToTimestamp('2030-01-02T00:00:00Z'),
+ });
+ const sooner = generateScheduledMessage({
+ cid,
+ scheduled_at: convertDateToTimestamp('2030-01-01T00:00:00Z'),
+ });
+ const reply = generateScheduledMessage({
+ cid,
+ message: { custom: {}, parent_id: 'parent-1', text: 'reply' },
+ });
+ const elsewhere = generateScheduledMessage({ cid: otherCid });
+ hydrateScheduledMessages(client, cid, [later, reply, sooner]);
+ hydrateScheduledMessages(client, otherCid, [elsewhere]);
+
+ 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: { custom: {}, parent_id: 'parent-1', text: 'reply' },
+ });
+ const channelLevel = generateScheduledMessage({ cid });
+ hydrateScheduledMessages(client, cid, [reply, channelLevel]);
+
+ 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: convertDateToTimestamp('2030-01-02T00:00:00Z'),
+ });
+ const sooner = generateScheduledMessage({
+ cid: otherCid,
+ scheduled_at: convertDateToTimestamp('2030-01-01T00:00:00Z'),
+ });
+ vi.mocked(client.queryScheduledMessages).mockResolvedValue({
+ duration: '',
+ scheduled_messages: [later, sooner],
+ });
+
+ const { result } = renderUseScheduledMessages(client);
+
+ await waitFor(() =>
+ expect(result.current.scheduledMessages).toEqual([sooner, later]),
+ );
+ expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1);
+ });
+
+ it('queries the first page of its channel when the channel was not hydrated', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const scheduledMessage = generateScheduledMessage({ cid });
+ vi.mocked(client.queryScheduledMessages).mockResolvedValue({
+ duration: '',
+ scheduled_messages: [scheduledMessage],
+ });
+
+ const { result } = renderUseScheduledMessages(client, { cid });
+
+ await waitFor(() =>
+ expect(result.current.scheduledMessages).toEqual([scheduledMessage]),
+ );
+ expect(client.queryScheduledMessages).toHaveBeenCalledWith(
+ expect.objectContaining({ filter: expect.objectContaining({ cid }) }),
+ );
+ });
+
+ it('does not query a channel hydrated by its channel query', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ hydrateScheduledMessages(client, cid);
+
+ const { result } = renderUseScheduledMessages(client, { cid });
+
+ expect(result.current).toEqual(
+ expect.objectContaining({
+ isLoaded: true,
+ isLoading: false,
+ scheduledMessages: [],
+ }),
+ );
+ expect(client.queryScheduledMessages).not.toHaveBeenCalled();
+ });
+
+ it('exposes whether scheduled messages are disabled', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const { result } = renderUseScheduledMessages(client, { cid });
+
+ act(() => {
+ client.scheduledMessages.state.partialNext({ isDisabled: true });
+ });
+
+ expect(result.current.isDisabled).toBe(true);
+ });
+
+ 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' });
+ hydrateScheduledMessages(client, cid, [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 000000000..9c5ad0ce8
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/index.ts
@@ -0,0 +1,16 @@
+export * from './useCanScheduleMessage';
+export * from './useFailedScheduledMessageCount';
+export * from './useScheduledMessageActionHandlers';
+export * from './useScheduledMessageCount';
+export * from './useScheduledMessageGroups';
+export * from './useScheduledMessagePaginator';
+export * from './useScheduledMessagePreview';
+export * from './useScheduledMessages';
+export * from './useScheduledMessagesLoadState';
+export * from './useScheduledMessagesPagination';
+export * from './useScheduleMessage';
+export * from './useScheduleMessageDialog';
+export * from './useScheduleMessageSubmit';
+export * from './useSchedulePresets';
+export * from './useScheduleTimeForm';
+export * from './useScheduleTimeSubmit';
diff --git a/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts
new file mode 100644
index 000000000..5e628b2e4
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts
@@ -0,0 +1,66 @@
+import { useMemo } from 'react';
+import type {
+ MessageComposer,
+ MessageComposerState,
+ ScheduledMessageManagerState,
+} from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+import { useChannelCapabilities } from '../../Channel/hooks/useChannelCapabilities';
+import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController';
+import { SCHEDULE_MESSAGE_CAPABILITY } from '../constants';
+
+const scheduledMessagesSelector = ({ isDisabled }: ScheduledMessageManagerState) => ({
+ isDisabled,
+});
+
+const messageComposerSelector = ({
+ editedMessage,
+ editedScheduledMessage,
+}: MessageComposerState) => ({
+ isEditing: !!editedMessage || !!editedScheduledMessage,
+});
+
+const createScheduleBlockReasonSelector = (messageComposer: MessageComposer) => () => ({
+ scheduleBlockReason: messageComposer.scheduleBlockReason,
+});
+
+export const useCanScheduleMessage = () => {
+ const { client } = useChatContext();
+ const messageComposer = useMessageComposerController();
+ const { channel, locationComposer, textComposer } = messageComposer;
+ const channelCapabilities = useChannelCapabilities({ cid: channel.cid });
+
+ const { isDisabled } = useStateStore(
+ client.scheduledMessages.state,
+ scheduledMessagesSelector,
+ );
+ const scheduleBlockReasonSelector = useMemo(
+ () => createScheduleBlockReasonSelector(messageComposer),
+ [messageComposer],
+ );
+
+ const { isEditing } = useStateStore(messageComposer.state, messageComposerSelector);
+ const { scheduleBlockReason: blockReasonFromComposer } = useStateStore(
+ messageComposer.state,
+ scheduleBlockReasonSelector,
+ );
+ const { scheduleBlockReason: blockReasonFromText } = useStateStore(
+ textComposer.state,
+ scheduleBlockReasonSelector,
+ );
+ const { scheduleBlockReason: blockReasonFromLocation } = useStateStore(
+ locationComposer.state,
+ scheduleBlockReasonSelector,
+ );
+
+ const hasCapability = channelCapabilities.has(SCHEDULE_MESSAGE_CAPABILITY);
+ const compositionCanBeScheduled =
+ !blockReasonFromComposer && !blockReasonFromText && !blockReasonFromLocation;
+
+ const canScheduleMessage =
+ hasCapability && !isDisabled && !isEditing && compositionCanBeScheduled;
+
+ return canScheduleMessage;
+};
diff --git a/src/components/ScheduledMessages/hooks/useFailedScheduledMessageCount.ts b/src/components/ScheduledMessages/hooks/useFailedScheduledMessageCount.ts
new file mode 100644
index 000000000..ed339943b
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useFailedScheduledMessageCount.ts
@@ -0,0 +1,32 @@
+import { useMemo } from 'react';
+import type { PaginatorState, ScheduledMessageResponse } from 'stream-chat';
+
+import { useStateStore } from '../../../store';
+import { NO_SCHEDULED_MESSAGES } from '../constants';
+import type { UseScheduledMessageCountParams } from '../types';
+import { getScopedScheduledMessages, isFailedScheduledMessage } from '../utils';
+import { useScheduledMessagePaginator } from './useScheduledMessagePaginator';
+
+const createFailedScheduledMessageCountSelector =
+ (params: UseScheduledMessageCountParams) =>
+ ({ items = NO_SCHEDULED_MESSAGES }: PaginatorState) => ({
+ failedScheduledMessageCount: getScopedScheduledMessages(items, params).filter(
+ isFailedScheduledMessage,
+ ).length,
+ });
+
+export const useFailedScheduledMessageCount = ({
+ cid,
+ parentId,
+}: UseScheduledMessageCountParams) => {
+ const paginator = useScheduledMessagePaginator({ cid });
+
+ const selector = useMemo(
+ () => createFailedScheduledMessageCountSelector({ cid, parentId }),
+ [cid, parentId],
+ );
+
+ const { failedScheduledMessageCount } = useStateStore(paginator.state, selector);
+
+ return failedScheduledMessageCount;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduleMessage.ts b/src/components/ScheduledMessages/hooks/useScheduleMessage.ts
new file mode 100644
index 000000000..ef49e9157
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduleMessage.ts
@@ -0,0 +1,61 @@
+import { useCallback } from 'react';
+import { nsToDate, ScheduledMessageErrorReason } from 'stream-chat';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import { useTranslationContext } from '../../../context';
+import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController';
+import { useNotificationApi } from '../../Notifications';
+import {
+ SCHEDULE_MESSAGE_NOTIFICATION_EMITTER,
+ ScheduledMessageNotificationType,
+} from '../constants';
+import { isScheduledMessageError } from '../utils';
+
+export const useScheduleMessage = () => {
+ const { t } = useTranslationContext();
+ const { addNotification } = useNotificationApi();
+
+ const messageComposer = useMessageComposerController();
+
+ const notifyScheduled = useCallback(
+ ({ scheduled_at }: ScheduledMessageResponse) => {
+ const time = t('timestamp.ScheduledMessageConfirmation', {
+ timestamp: nsToDate(scheduled_at),
+ });
+
+ addNotification({
+ emitter: SCHEDULE_MESSAGE_NOTIFICATION_EMITTER,
+ message: t(
+ 'scheduledMessages.messageScheduledForTime',
+ 'Message scheduled for {{ time }}',
+ { time },
+ ),
+ severity: 'success',
+ type: ScheduledMessageNotificationType.CreateSucceeded,
+ });
+ },
+ [addNotification, t],
+ );
+
+ const scheduleMessage = useCallback(
+ async (scheduledAt: Date) => {
+ const result = await messageComposer.schedule(scheduledAt);
+ const { error, scheduledMessage, status } = result;
+
+ if (scheduledMessage) notifyScheduled(scheduledMessage);
+
+ if (status === 'failed') {
+ if (isScheduledMessageError(error, ScheduledMessageErrorReason.IdTaken)) {
+ messageComposer.refreshId();
+ }
+
+ throw error;
+ }
+
+ return result;
+ },
+ [messageComposer, notifyScheduled],
+ );
+
+ return scheduleMessage;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduleMessageDialog.ts b/src/components/ScheduledMessages/hooks/useScheduleMessageDialog.ts
new file mode 100644
index 000000000..8a9e7c5be
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduleMessageDialog.ts
@@ -0,0 +1,43 @@
+import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
+
+import type { ScheduleMessageDialogState } from '../types';
+
+const hasFocusFallenToBody = () => {
+ const { activeElement } = document;
+
+ return !activeElement || activeElement === document.body;
+};
+
+export const useScheduleMessageDialog = (
+ returnFocusRef: RefObject,
+) => {
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const shouldRestoreFocusRef = useRef(false);
+
+ const openDialog = useCallback(() => setIsDialogOpen(true), []);
+
+ const closeDialog = useCallback(() => {
+ shouldRestoreFocusRef.current = true;
+ setIsDialogOpen(false);
+ }, []);
+
+ useEffect(() => {
+ if (isDialogOpen || !shouldRestoreFocusRef.current) return;
+
+ const frame = requestAnimationFrame(() => {
+ shouldRestoreFocusRef.current = false;
+
+ if (hasFocusFallenToBody()) returnFocusRef.current?.focus();
+ });
+
+ return () => cancelAnimationFrame(frame);
+ }, [isDialogOpen, returnFocusRef]);
+
+ const scheduleMessageDialog: ScheduleMessageDialogState = {
+ closeDialog,
+ isDialogOpen,
+ openDialog,
+ };
+
+ return scheduleMessageDialog;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduleMessageSubmit.ts b/src/components/ScheduledMessages/hooks/useScheduleMessageSubmit.ts
new file mode 100644
index 000000000..291efb134
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduleMessageSubmit.ts
@@ -0,0 +1,10 @@
+import { useScheduleMessage } from './useScheduleMessage';
+import { useScheduleTimeSubmit } from './useScheduleTimeSubmit';
+
+export const useScheduleMessageSubmit = (onScheduled: () => void) => {
+ const scheduleMessage = useScheduleMessage();
+
+ const scheduleMessageSubmit = useScheduleTimeSubmit(scheduleMessage, onScheduled);
+
+ return scheduleMessageSubmit;
+};
diff --git a/src/components/ScheduledMessages/hooks/useSchedulePresets.ts b/src/components/ScheduledMessages/hooks/useSchedulePresets.ts
new file mode 100644
index 000000000..27394f8f8
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useSchedulePresets.ts
@@ -0,0 +1,20 @@
+import { useMemo } from 'react';
+
+import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController';
+import { getAvailableSchedulePresets, getChannelScheduleTimeBounds } from '../utils';
+
+const NO_PRESETS: Date[] = [];
+
+export const useSchedulePresets = (isMenuOpen: boolean) => {
+ const { channel } = useMessageComposerController();
+
+ const presets = useMemo(() => {
+ if (!isMenuOpen) return NO_PRESETS;
+
+ const now = new Date();
+
+ return getAvailableSchedulePresets(now, getChannelScheduleTimeBounds(channel, now));
+ }, [channel, isMenuOpen]);
+
+ return presets;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduleTimeForm.ts b/src/components/ScheduledMessages/hooks/useScheduleTimeForm.ts
new file mode 100644
index 000000000..d741c09ff
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduleTimeForm.ts
@@ -0,0 +1,52 @@
+import { type ChangeEvent, useCallback, useMemo, useState } from 'react';
+
+import type { ScheduleTimeForm, ScheduleTimeFormParams } from '../types';
+import {
+ fromDateAndTimeInputValues,
+ getChannelScheduleTimeBounds,
+ getDefaultScheduledAt,
+ getScheduleTimeError,
+ toDateInputValue,
+ toTimeInputValue,
+} from '../utils';
+
+const readInputValue = ({ target }: ChangeEvent) => target.value;
+
+export const useScheduleTimeForm = ({
+ channel,
+ initialScheduledAt,
+}: ScheduleTimeFormParams) => {
+ const [bounds] = useState(() => getChannelScheduleTimeBounds(channel, new Date()));
+ const [initialValue] = useState(
+ () => initialScheduledAt ?? getDefaultScheduledAt(bounds),
+ );
+ const [dateValue, setDateValue] = useState(() => toDateInputValue(initialValue));
+ const [timeValue, setTimeValue] = useState(() => toTimeInputValue(initialValue));
+
+ const scheduledAt = useMemo(
+ () => fromDateAndTimeInputValues(dateValue, timeValue),
+ [dateValue, timeValue],
+ );
+ const timeError = scheduledAt ? getScheduleTimeError(scheduledAt, bounds) : undefined;
+
+ const onDateChange = useCallback(
+ (event: ChangeEvent) => setDateValue(readInputValue(event)),
+ [],
+ );
+ const onTimeChange = useCallback(
+ (event: ChangeEvent) => setTimeValue(readInputValue(event)),
+ [],
+ );
+
+ const scheduleTimeForm: ScheduleTimeForm = {
+ bounds,
+ dateValue,
+ onDateChange,
+ onTimeChange,
+ scheduledAt,
+ timeError,
+ timeValue,
+ };
+
+ return scheduleTimeForm;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduleTimeSubmit.ts b/src/components/ScheduledMessages/hooks/useScheduleTimeSubmit.ts
new file mode 100644
index 000000000..c0320e70b
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduleTimeSubmit.ts
@@ -0,0 +1,40 @@
+import { useCallback, useState } from 'react';
+
+import { useIsMounted } from '../../Channel/hooks/useIsMounted';
+import type { ScheduleMessageSubmit, SubmitScheduledAt } from '../types';
+
+export const useScheduleTimeSubmit = (
+ submitScheduledAt: SubmitScheduledAt,
+ onSubmitted: () => void,
+) => {
+ const isMounted = useIsMounted();
+
+ const [hasFailed, setHasFailed] = useState(false);
+ const [isScheduling, setIsScheduling] = useState(false);
+
+ const submit = useCallback(
+ async (scheduledAt: Date) => {
+ setHasFailed(false);
+ setIsScheduling(true);
+
+ try {
+ await submitScheduledAt(scheduledAt);
+
+ if (isMounted.current) onSubmitted();
+ } catch {
+ if (isMounted.current) setHasFailed(true);
+ } finally {
+ if (isMounted.current) setIsScheduling(false);
+ }
+ },
+ [isMounted, onSubmitted, submitScheduledAt],
+ );
+
+ const scheduleTimeSubmit: ScheduleMessageSubmit = {
+ hasFailed,
+ isScheduling,
+ submit,
+ };
+
+ return scheduleTimeSubmit;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessageActionHandlers.ts b/src/components/ScheduledMessages/hooks/useScheduledMessageActionHandlers.ts
new file mode 100644
index 000000000..d6cac3f62
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessageActionHandlers.ts
@@ -0,0 +1,102 @@
+import { useCallback, useState } from 'react';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import { useChatContext, useTranslationContext } from '../../../context';
+import { useIsMounted } from '../../Channel/hooks/useIsMounted';
+import { useNotificationApi } from '../../Notifications';
+import {
+ SCHEDULED_MESSAGE_ACTIONS_EMITTER,
+ ScheduledMessageNotificationType,
+} from '../constants';
+import type { ScheduledMessageActionHandlers } from '../types';
+import { reloadOnScheduledMessageConflict } from '../utils';
+
+export const useScheduledMessageActionHandlers = (
+ scheduledMessage: ScheduledMessageResponse,
+) => {
+ const { client } = useChatContext();
+ const { t } = useTranslationContext();
+ const { addNotification } = useNotificationApi();
+ const isMounted = useIsMounted();
+
+ const [isPending, setIsPending] = useState(false);
+
+ const { scheduledMessages: manager } = client;
+ const { id, message } = scheduledMessage;
+ const { text = '' } = message;
+
+ const notifyFailure = useCallback(
+ (error: Error, type: ScheduledMessageNotificationType, fallbackMessage: string) => {
+ reloadOnScheduledMessageConflict(manager, error);
+
+ addNotification({
+ emitter: SCHEDULED_MESSAGE_ACTIONS_EMITTER,
+ error,
+ message: fallbackMessage,
+ severity: 'error',
+ type,
+ });
+ },
+ [addNotification, manager],
+ );
+
+ const sendNow = useCallback(async () => {
+ setIsPending(true);
+
+ try {
+ await manager.sendScheduledMessageNow(id);
+
+ addNotification({
+ emitter: SCHEDULED_MESSAGE_ACTIONS_EMITTER,
+ message: t('scheduledMessages.scheduledMessageSent', 'Scheduled message sent'),
+ severity: 'success',
+ type: ScheduledMessageNotificationType.SendNowSucceeded,
+ });
+ } catch (error) {
+ notifyFailure(
+ error as Error,
+ ScheduledMessageNotificationType.SendNowFailed,
+ t(
+ 'scheduledMessages.couldntSendTheMessageTryAgain',
+ "Couldn't send the message. Try again",
+ ),
+ );
+ } finally {
+ if (isMounted.current) setIsPending(false);
+ }
+ }, [addNotification, id, isMounted, manager, notifyFailure, t]);
+
+ const deleteScheduledMessage = useCallback(async () => {
+ setIsPending(true);
+
+ try {
+ await manager.deleteScheduledMessage(id);
+ } catch (error) {
+ notifyFailure(
+ error as Error,
+ ScheduledMessageNotificationType.DeleteFailed,
+ t(
+ 'scheduledMessages.couldntDeleteTheScheduledMessageTryAgain',
+ "Couldn't delete the scheduled message. Try again",
+ ),
+ );
+ } finally {
+ if (isMounted.current) setIsPending(false);
+ }
+ }, [id, isMounted, manager, notifyFailure, t]);
+
+ const copyText = useCallback(() => {
+ if (!text) return;
+
+ navigator.clipboard?.writeText(text).catch(() => undefined);
+ }, [text]);
+
+ const scheduledMessageActionHandlers: ScheduledMessageActionHandlers = {
+ copyText,
+ deleteScheduledMessage,
+ isPending,
+ sendNow,
+ };
+
+ return scheduledMessageActionHandlers;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts b/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts
new file mode 100644
index 000000000..517a358c5
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts
@@ -0,0 +1,30 @@
+import { useMemo } from 'react';
+import type { PaginatorState, ScheduledMessageResponse } from 'stream-chat';
+
+import { useStateStore } from '../../../store';
+import { NO_SCHEDULED_MESSAGES } from '../constants';
+import type { UseScheduledMessageCountParams } from '../types';
+import { getScopedScheduledMessages } from '../utils';
+import { useScheduledMessagePaginator } from './useScheduledMessagePaginator';
+
+const createScheduledMessageCountSelector =
+ (params: UseScheduledMessageCountParams) =>
+ ({ items = NO_SCHEDULED_MESSAGES }: PaginatorState) => ({
+ scheduledMessageCount: getScopedScheduledMessages(items, params).length,
+ });
+
+export const useScheduledMessageCount = ({
+ cid,
+ parentId,
+}: UseScheduledMessageCountParams) => {
+ const paginator = useScheduledMessagePaginator({ cid });
+
+ const selector = useMemo(
+ () => createScheduledMessageCountSelector({ cid, parentId }),
+ [cid, parentId],
+ );
+
+ const { scheduledMessageCount } = useStateStore(paginator.state, selector);
+
+ return scheduledMessageCount;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessageGroups.ts b/src/components/ScheduledMessages/hooks/useScheduledMessageGroups.ts
new file mode 100644
index 000000000..b9409457b
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessageGroups.ts
@@ -0,0 +1,29 @@
+import { useMemo } from 'react';
+
+import type { ScheduledMessageGroups, UseScheduledMessageGroupsParams } from '../types';
+import { filterScheduledMessagesByText, groupScheduledMessages } from '../utils';
+import { useScheduledMessages } from './useScheduledMessages';
+
+export const useScheduledMessageGroups = ({
+ cid,
+ parentId,
+ searchQuery,
+}: UseScheduledMessageGroupsParams) => {
+ const { isLoaded, scheduledMessages } = useScheduledMessages({ cid, parentId });
+
+ const groups = useMemo(
+ () =>
+ groupScheduledMessages(
+ filterScheduledMessagesByText(scheduledMessages, searchQuery),
+ ),
+ [scheduledMessages, searchQuery],
+ );
+
+ const scheduledMessageGroups: ScheduledMessageGroups = {
+ groups,
+ hasScheduledMessages: scheduledMessages.length > 0,
+ isLoaded,
+ };
+
+ return scheduledMessageGroups;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessagePaginator.ts b/src/components/ScheduledMessages/hooks/useScheduledMessagePaginator.ts
new file mode 100644
index 000000000..858731d30
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessagePaginator.ts
@@ -0,0 +1,49 @@
+import { useEffect, useMemo } from 'react';
+import type {
+ PaginatorState,
+ ScheduledMessageManager,
+ ScheduledMessagePaginator,
+ ScheduledMessageResponse,
+} from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+import type { UseScheduledMessagePaginatorParams } from '../types';
+
+const createPaginatorSelector =
+ (manager: ScheduledMessageManager, cid?: string) => () => ({
+ paginator: cid ? manager.getChannelPaginator(cid) : manager.paginator,
+ });
+
+const createAwaitsFirstPageSelector =
+ (paginator: ScheduledMessagePaginator) =>
+ ({ isLoading, lastQueryError }: PaginatorState) => ({
+ awaitsFirstPage: !paginator.hasLoadedPage && !isLoading && !lastQueryError,
+ });
+
+export const useScheduledMessagePaginator = ({
+ cid,
+}: UseScheduledMessagePaginatorParams = {}) => {
+ const { client } = useChatContext();
+ const { scheduledMessages: manager } = client;
+
+ const paginatorSelector = useMemo(
+ () => createPaginatorSelector(manager, cid),
+ [cid, manager],
+ );
+ const { paginator } = useStateStore(manager.state, paginatorSelector);
+
+ const awaitsFirstPageSelector = useMemo(
+ () => createAwaitsFirstPageSelector(paginator),
+ [paginator],
+ );
+ const { awaitsFirstPage } = useStateStore(paginator.state, awaitsFirstPageSelector);
+
+ useEffect(() => {
+ if (!awaitsFirstPage) return;
+
+ paginator.toTailDebounced();
+ }, [awaitsFirstPage, paginator]);
+
+ return paginator;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts b/src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts
new file mode 100644
index 000000000..47b5679f8
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts
@@ -0,0 +1,40 @@
+import { useMemo } from 'react';
+import type {
+ AttachmentManagerState,
+ TextComposerState,
+ UserResponse,
+} from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController';
+import { toComposedScheduledMessagePreview } from '../utils';
+
+const textComposerSelector = ({ text }: TextComposerState) => ({ text });
+
+const attachmentManagerSelector = ({ attachments }: AttachmentManagerState) => ({
+ attachments,
+});
+
+export const useScheduledMessagePreview = () => {
+ const { client } = useChatContext();
+ const messageComposer = useMessageComposerController();
+ const { attachmentManager, channel, id, textComposer } = messageComposer;
+ const { text } = useStateStore(textComposer.state, textComposerSelector);
+ const { attachments } = useStateStore(
+ attachmentManager.state,
+ attachmentManagerSelector,
+ );
+
+ const previewMessage = useMemo(
+ () =>
+ toComposedScheduledMessagePreview(
+ { attachments, cid: channel.cid, id, text },
+ (client.user ?? { id: client.userID ?? '' }) as UserResponse,
+ new Date(),
+ ),
+ [attachments, channel.cid, client.user, client.userID, id, text],
+ );
+
+ return previewMessage;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessages.ts b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts
new file mode 100644
index 000000000..18f64370b
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts
@@ -0,0 +1,53 @@
+import { useMemo } from 'react';
+import type {
+ PaginatorState,
+ ScheduledMessageManagerState,
+ ScheduledMessageResponse,
+} from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+import { NO_SCHEDULED_MESSAGES } from '../constants';
+import type { ScheduledMessagesSnapshot, UseScheduledMessagesParams } from '../types';
+import { getScopedScheduledMessages } from '../utils';
+import { useScheduledMessagePaginator } from './useScheduledMessagePaginator';
+
+const managerStateSelector = ({ isDisabled }: ScheduledMessageManagerState) => ({
+ isDisabled,
+});
+
+const paginatorStateSelector = ({
+ isLoading,
+ items,
+}: PaginatorState) => ({ isLoading, items });
+
+export const useScheduledMessages = ({
+ cid,
+ parentId,
+}: UseScheduledMessagesParams = {}) => {
+ const { client } = useChatContext();
+
+ const paginator = useScheduledMessagePaginator({ cid });
+ const { isDisabled } = useStateStore(
+ client.scheduledMessages.state,
+ managerStateSelector,
+ );
+ const { isLoading, items } = useStateStore(paginator.state, paginatorStateSelector);
+
+ const scheduledMessages = useMemo(
+ () =>
+ items
+ ? getScopedScheduledMessages(items, { cid, parentId })
+ : NO_SCHEDULED_MESSAGES,
+ [cid, items, parentId],
+ );
+
+ const scheduledMessagesSnapshot: ScheduledMessagesSnapshot = {
+ isDisabled,
+ isLoaded: !!items,
+ isLoading,
+ scheduledMessages,
+ };
+
+ return scheduledMessagesSnapshot;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessagesLoadState.ts b/src/components/ScheduledMessages/hooks/useScheduledMessagesLoadState.ts
new file mode 100644
index 000000000..312d0c36c
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessagesLoadState.ts
@@ -0,0 +1,40 @@
+import { useCallback } from 'react';
+import type { PaginatorState, ScheduledMessageResponse } from 'stream-chat';
+
+import { useStateStore } from '../../../store';
+import { getFirstPageLoadError } from '../../Loading/getFirstPageLoadError';
+import type {
+ ScheduledMessagesLoadState,
+ UseScheduledMessagePaginatorParams,
+} from '../types';
+import { useScheduledMessagePaginator } from './useScheduledMessagePaginator';
+
+const loadStateSelector = (state: PaginatorState) => ({
+ hasLoadFailed: !!getFirstPageLoadError(state),
+ isFirstLoad: state.items === undefined && !state.lastQueryError,
+});
+
+const ignoreErrorKeptInState = () => undefined;
+
+export const useScheduledMessagesLoadState = ({
+ cid,
+}: UseScheduledMessagePaginatorParams = {}) => {
+ const paginator = useScheduledMessagePaginator({ cid });
+
+ const { hasLoadFailed, isFirstLoad } = useStateStore(
+ paginator.state,
+ loadStateSelector,
+ );
+
+ const reload = useCallback(() => {
+ paginator.reload().catch(ignoreErrorKeptInState);
+ }, [paginator]);
+
+ const scheduledMessagesLoadState: ScheduledMessagesLoadState = {
+ hasLoadFailed,
+ isFirstLoad,
+ reload,
+ };
+
+ return scheduledMessagesLoadState;
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessagesPagination.ts b/src/components/ScheduledMessages/hooks/useScheduledMessagesPagination.ts
new file mode 100644
index 000000000..e8d3e315e
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessagesPagination.ts
@@ -0,0 +1,40 @@
+import { useCallback } from 'react';
+import type { PaginatorState, ScheduledMessageResponse } from 'stream-chat';
+
+import { useStateStore } from '../../../store';
+import type {
+ ScheduledMessagesPagination,
+ UseScheduledMessagePaginatorParams,
+} from '../types';
+import { useScheduledMessagePaginator } from './useScheduledMessagePaginator';
+
+const paginationSelector = ({
+ hasMoreTail,
+ isLoading,
+ items,
+}: PaginatorState) => ({
+ canLoadMore: !!items && hasMoreTail,
+ isLoading,
+});
+
+const ignoreErrorKeptInState = () => undefined;
+
+export const useScheduledMessagesPagination = ({
+ cid,
+}: UseScheduledMessagePaginatorParams = {}) => {
+ const paginator = useScheduledMessagePaginator({ cid });
+
+ const { canLoadMore, isLoading } = useStateStore(paginator.state, paginationSelector);
+
+ const loadMore = useCallback(() => {
+ paginator.toTail().catch(ignoreErrorKeptInState);
+ }, [paginator]);
+
+ const scheduledMessagesPagination: ScheduledMessagesPagination = {
+ canLoadMore,
+ isLoading,
+ loadMore,
+ };
+
+ return scheduledMessagesPagination;
+};
diff --git a/src/components/ScheduledMessages/index.ts b/src/components/ScheduledMessages/index.ts
new file mode 100644
index 000000000..61b6bc7fa
--- /dev/null
+++ b/src/components/ScheduledMessages/index.ts
@@ -0,0 +1,33 @@
+export {
+ SCHEDULE_MESSAGE_CAPABILITY,
+ ScheduledMessageAction,
+ ScheduledMessageGroupType,
+ ScheduleMessageNoticeSeverity,
+ ScheduleTimeError,
+} from './constants';
+export * from './DeleteScheduledMessageAlert';
+export * from './EditedScheduledMessagePreview';
+export * from './FailedScheduledMessagePreview';
+export * from './hooks';
+export * from './RescheduleMessageDialog';
+export * from './ScheduledMessageActions';
+export * from './ScheduledMessageActionsMenu';
+export * from './ScheduledMessageGroupHeader';
+export * from './ScheduledMessageItem';
+export * from './ScheduledMessageItemStatus';
+export * from './ScheduledMessageList';
+export * from './ScheduledMessageListDialog';
+export * from './ScheduledMessageListLoading';
+export * from './ScheduledMessagesBanner';
+export * from './ScheduledMessagesEmptyList';
+export * from './ScheduledMessagesEmptyState';
+export * from './ScheduledMessagesLoadError';
+export * from './ScheduledMessagesNoResults';
+export * from './ScheduleMessageButton';
+export * from './ScheduleMessageDialog';
+export * from './ScheduleMessageDialogNotice';
+export * from './ScheduleMessageMenu';
+export * from './ScheduleMessageMenuHeader';
+export * from './ScheduleMessagePresetButton';
+export * from './ScheduleTimeDialog';
+export * from './types';
diff --git a/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss b/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss
new file mode 100644
index 000000000..518978215
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss
@@ -0,0 +1,61 @@
+@use '../../../styling/utils';
+
+.str-chat {
+ --str-chat__split-send-button-caret-size: var(--str-chat__button-visual-height-xs);
+
+ .str-chat__split-send-button {
+ position: relative;
+ display: inline-flex;
+
+ .str-chat__send-button.str-chat__button--circular {
+ aspect-ratio: auto;
+
+ .str-chat__icon.str-chat__icon--send {
+ position: relative;
+ z-index: 1;
+ height: var(--str-chat__icon-size-sm);
+ width: var(--str-chat__icon-size-sm);
+ }
+
+ padding-inline: var(--str-chat__spacing-sm)
+ calc(
+ var(--str-chat__spacing-xs) + var(--str-chat__split-send-button-caret-size) +
+ var(--str-chat__spacing-xxs)
+ );
+ }
+
+ .str-chat__schedule-message-button {
+ position: absolute;
+ inset-inline-end: var(--str-chat__spacing-xxs);
+ inset-block-start: 50%;
+ translate: 0 -50%;
+
+ &[aria-expanded='true']:not(:hover)::after {
+ content: none;
+ }
+ }
+
+ &:has(.str-chat__schedule-message-button[aria-expanded='true'])
+ .str-chat__send-button:not(:disabled) {
+ @include utils.overlay-after(var(--str-chat__background-utility-selected));
+ }
+ }
+
+ .str-chat__schedule-message-menu {
+ min-width: 224px;
+
+ .str-chat__schedule-message-menu__header {
+ display: flex;
+ align-items: center;
+ min-height: 32px;
+ padding: var(--str-chat__spacing-xs);
+ }
+
+ .str-chat__schedule-message-menu__separator {
+ height: 1px;
+ margin-block: var(--str-chat__spacing-xxs);
+ margin-inline: calc(-1 * var(--str-chat__spacing-xxs));
+ background-color: var(--str-chat__border-core-subtle);
+ }
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss b/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss
new file mode 100644
index 000000000..0f523e02b
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss
@@ -0,0 +1,122 @@
+@use '../../../styling/utils';
+
+.str-chat {
+ --str-chat__schedule-message-dialog-max-width: 504px;
+ --str-chat__schedule-message-dialog-notice-info-bg: color-mix(
+ in srgb,
+ var(--str-chat__accent-primary) 5%,
+ transparent
+ );
+ --str-chat__schedule-message-dialog-notice-error-bg: color-mix(
+ in srgb,
+ var(--str-chat__accent-error) 5%,
+ transparent
+ );
+
+ .str-chat__schedule-message-dialog {
+ width: 100%;
+ max-width: var(--str-chat__schedule-message-dialog-max-width);
+
+ .str-chat__schedule-message-dialog__body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--str-chat__spacing-md);
+ }
+
+ .str-chat__schedule-message-dialog__preview {
+ background-color: var(--str-chat__chat-bg-outgoing);
+
+ .str-chat__quoted-message-indicator {
+ background-color: var(--str-chat__chat-reply-indicator-outgoing);
+ }
+ }
+
+ .str-chat__schedule-message-dialog__fields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--str-chat__spacing-sm);
+
+ input[type='date'],
+ input[type='time'] {
+ cursor: pointer;
+ }
+
+ .str-chat__form-text-input__wrapper {
+ box-sizing: border-box;
+ justify-content: center;
+ min-height: var(--str-chat__button-visual-height-md);
+ }
+
+ .str-chat__form-text-input__input {
+ font: var(--str-chat__font-caption-default);
+ }
+
+ .str-chat__form-text-input__trailing {
+ pointer-events: none;
+ }
+
+ input::-webkit-calendar-picker-indicator {
+ display: none;
+ }
+ }
+
+ .str-chat__schedule-message-dialog__date {
+ .str-chat__form-text-input__wrapper {
+ position: relative;
+ }
+
+ .str-chat__form-text-input__leading {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ justify-content: flex-start;
+ gap: var(--str-chat__spacing-xs);
+ min-width: 0;
+ }
+
+ .str-chat__form-text-input__input {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ }
+ }
+
+ .str-chat__schedule-message-dialog__date-value {
+ @include utils.ellipsis-text();
+ font: var(--str-chat__font-caption-default);
+ color: var(--str-chat__input-text-default);
+ }
+
+ .str-chat__schedule-message-dialog__notice {
+ display: flex;
+ align-items: center;
+ gap: var(--str-chat__spacing-xs);
+ padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-sm);
+ border-radius: var(--str-chat__radius-md);
+ color: var(--str-chat__text-primary);
+ font: var(--str-chat__font-metadata-emphasis);
+
+ .str-chat__schedule-message-dialog__notice-icon {
+ flex-shrink: 0;
+ height: var(--str-chat__icon-size-sm);
+ width: var(--str-chat__icon-size-sm);
+ }
+ }
+
+ .str-chat__schedule-message-dialog__notice--info {
+ background-color: var(--str-chat__schedule-message-dialog-notice-info-bg);
+
+ .str-chat__schedule-message-dialog__notice-icon {
+ color: var(--str-chat__accent-primary);
+ }
+ }
+
+ .str-chat__schedule-message-dialog__notice--error {
+ background-color: var(--str-chat__schedule-message-dialog-notice-error-bg);
+
+ .str-chat__schedule-message-dialog__notice-icon {
+ color: var(--str-chat__accent-error);
+ }
+ }
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/ScheduledMessageActions.scss b/src/components/ScheduledMessages/styling/ScheduledMessageActions.scss
new file mode 100644
index 000000000..40b59afc7
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduledMessageActions.scss
@@ -0,0 +1,40 @@
+@use '../../../styling/utils';
+
+.str-chat {
+ .str-chat__scheduled-message-group__item {
+ position: relative;
+
+ &:hover,
+ &:focus-within,
+ &:has(.str-chat__scheduled-message-actions--active) {
+ background-color: var(--str-chat__background-utility-hover);
+
+ .str-chat__scheduled-message-actions {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ }
+ }
+
+ .str-chat__scheduled-message-actions {
+ display: flex;
+ position: absolute;
+ inset-block-start: var(--str-chat__spacing-xs);
+ inset-inline-end: var(--str-chat__spacing-md);
+ padding: var(--str-chat__spacing-xxs);
+ border-radius: var(--str-chat__radius-md);
+ background-color: var(--str-chat__background-core-elevation-3);
+ box-shadow: var(--str-chat__box-shadow-2);
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ .str-chat__scheduled-message-actions-menu {
+ .str-chat__scheduled-message-actions-menu__separator {
+ height: 1px;
+ margin-block: var(--str-chat__spacing-xxs);
+ margin-inline: calc(-1 * var(--str-chat__spacing-xxs));
+ background-color: var(--str-chat__border-core-subtle);
+ }
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/ScheduledMessageList.scss b/src/components/ScheduledMessages/styling/ScheduledMessageList.scss
new file mode 100644
index 000000000..059b3f378
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduledMessageList.scss
@@ -0,0 +1,246 @@
+@use '../../../styling/utils';
+
+.str-chat {
+ --str-chat__scheduled-message-list-dialog-max-width: 520px;
+
+ .str-chat__scheduled-message-list-dialog {
+ width: var(--str-chat__scheduled-message-list-dialog-max-width);
+ max-width: 100%;
+
+ .str-chat__scheduled-message-list-dialog__body {
+ overflow-y: auto;
+
+ .str-chat__scheduled-message-group__item,
+ .str-chat__scheduled-message-list-loading__row {
+ margin-inline: calc(-1 * var(--str-chat__spacing-xl));
+ }
+ }
+ }
+
+ .str-chat__scheduled-message-list-dialog__search {
+ margin-block-end: var(--str-chat__spacing-xs);
+ }
+
+ .str-chat__scheduled-message-list {
+ display: flex;
+ flex-direction: column;
+
+ .str-chat__scheduled-message-list__load-more {
+ align-self: center;
+ margin-block: var(--str-chat__spacing-sm);
+ }
+ }
+
+ .str-chat__scheduled-message-group {
+ display: flex;
+ flex-direction: column;
+
+ .str-chat__scheduled-message-group__header {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ .str-chat__scheduled-message-group__items {
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ .str-chat__scheduled-message-group__item {
+ padding: var(--str-chat__spacing-md) var(--str-chat__spacing-xl);
+ border-block-end: 1px solid var(--str-chat__border-core-subtle);
+ }
+ }
+
+ .str-chat__scheduled-message-group:last-of-type
+ .str-chat__scheduled-message-group__item:last-child {
+ border-block-end: none;
+ }
+
+ .str-chat__scheduled-message-item {
+ display: flex;
+ flex-direction: column;
+ gap: var(--str-chat__spacing-xs);
+ min-width: 0;
+
+ .str-chat__scheduled-message-item__time {
+ color: var(--str-chat__text-tertiary);
+ font: var(--str-chat__font-caption-default);
+ }
+
+ .str-chat__scheduled-message-item__body {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--str-chat__spacing-sm);
+ min-width: 0;
+ }
+
+ .str-chat__scheduled-message-item__content {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-width: 0;
+ color: var(--str-chat__text-primary);
+ font: var(--str-chat__font-body-default);
+ }
+
+ .str-chat__scheduled-message-item__channel-name {
+ @include utils.ellipsis-text();
+ color: var(--str-chat__text-secondary);
+ font: var(--str-chat__font-caption-emphasis);
+ }
+
+ .str-chat__summarized-message-preview {
+ min-width: 0;
+ }
+
+ .str-chat__scheduled-message-item__status {
+ display: flex;
+ align-items: center;
+ gap: var(--str-chat__spacing-xxs);
+ margin-block-start: var(--str-chat__spacing-xxs);
+ color: var(--str-chat__text-secondary);
+ font: var(--str-chat__font-metadata-default);
+ }
+
+ .str-chat__scheduled-message-item__status--failed {
+ color: var(--str-chat__accent-error);
+ font: var(--str-chat__font-caption-default);
+ }
+
+ .str-chat__scheduled-message-item__status-icon {
+ flex-shrink: 0;
+ height: var(--str-chat__icon-size-sm);
+ width: var(--str-chat__icon-size-sm);
+ }
+ }
+
+ .str-chat__scheduled-messages-empty-list {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--str-chat__spacing-xxs);
+ padding-block: var(--str-chat__spacing-3xl);
+ padding-inline: var(--str-chat__spacing-md);
+ text-align: center;
+
+ .str-chat__scheduled-messages-empty-list__icon {
+ width: 32px;
+ height: 32px;
+ margin-block-end: var(--str-chat__spacing-xs);
+ color: var(--str-chat__text-tertiary);
+ }
+
+ .str-chat__scheduled-messages-empty-list__title,
+ .str-chat__scheduled-messages-empty-list__description {
+ margin: 0;
+ }
+
+ .str-chat__scheduled-messages-empty-list__title {
+ color: var(--str-chat__text-primary);
+ font: var(--str-chat__font-caption-emphasis);
+ }
+
+ .str-chat__scheduled-messages-empty-list__description {
+ color: var(--str-chat__text-secondary);
+ font: var(--str-chat__font-caption-default);
+ }
+
+ .str-chat__scheduled-messages-empty-list__action {
+ margin-block-start: var(--str-chat__spacing-sm);
+ }
+ }
+
+ .str-chat__scheduled-message-list-loading {
+ display: flex;
+ flex-direction: column;
+
+ .str-chat__scheduled-message-list-loading__row {
+ display: flex;
+ flex-direction: column;
+ gap: var(--str-chat__spacing-xs);
+ padding: var(--str-chat__spacing-md) var(--str-chat__spacing-xl);
+ border-block-end: 1px solid var(--str-chat__border-core-subtle);
+ }
+
+ .str-chat__scheduled-message-list-loading__body {
+ display: flex;
+ align-items: center;
+ gap: var(--str-chat__spacing-sm);
+ }
+
+ .str-chat__scheduled-message-list-loading__lines {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: var(--str-chat__spacing-xs);
+ }
+
+ .str-chat__scheduled-message-list-loading__avatar {
+ flex-shrink: 0;
+ width: 40px;
+ }
+
+ .str-chat__scheduled-message-list-loading__time {
+ width: 30%;
+ }
+
+ .str-chat__scheduled-message-list-loading__time,
+ .str-chat__scheduled-message-list-loading__avatar,
+ .str-chat__scheduled-message-list-loading__title,
+ .str-chat__scheduled-message-list-loading__subtitle {
+ height: var(--str-chat__size-12);
+ border-radius: 999px;
+ background-image: linear-gradient(
+ 90deg,
+ var(--str-chat__skeleton-loading-base) 0%,
+ var(--str-chat__skeleton-loading-highlight) 50%,
+ var(--str-chat__skeleton-loading-base) 100%
+ );
+ background-repeat: no-repeat;
+ background-size: 200% 100%;
+ animation: str-chat__scheduled-message-list-loading-shimmer 1.2s linear infinite;
+ }
+
+ .str-chat__scheduled-message-list-loading__avatar {
+ height: 40px;
+ }
+
+ .str-chat__scheduled-message-list-loading__title {
+ width: 40%;
+ }
+
+ .str-chat__scheduled-message-list-loading__subtitle {
+ width: 80%;
+ }
+ }
+}
+
+@keyframes str-chat__scheduled-message-list-loading-shimmer {
+ from {
+ background-position: 200% 0;
+ }
+
+ to {
+ background-position: -200% 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .str-chat .str-chat__scheduled-message-list-loading__time,
+ .str-chat .str-chat__scheduled-message-list-loading__avatar,
+ .str-chat .str-chat__scheduled-message-list-loading__title,
+ .str-chat .str-chat__scheduled-message-list-loading__subtitle {
+ animation: none;
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/ScheduledMessagesBanner.scss b/src/components/ScheduledMessages/styling/ScheduledMessagesBanner.scss
new file mode 100644
index 000000000..b9c3447fc
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduledMessagesBanner.scss
@@ -0,0 +1,48 @@
+@use '../../../styling/utils';
+
+.str-chat {
+ .str-chat__scheduled-messages-banner {
+ display: flex;
+ align-items: center;
+ gap: var(--str-chat__spacing-sm);
+ width: 100%;
+ min-height: var(--str-chat__button-visual-height-md);
+ box-sizing: border-box;
+ padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-md);
+ border-radius: var(--str-chat__radius-max);
+ background-color: var(--str-chat__background-core-elevation-2);
+ box-shadow: var(--str-chat__box-shadow-3);
+ color: var(--str-chat__text-primary);
+ font: var(--str-chat__font-metadata-emphasis);
+
+ .str-chat__scheduled-messages-banner__icon {
+ flex-shrink: 0;
+ height: var(--str-chat__icon-size-md);
+ width: var(--str-chat__icon-size-md);
+ color: var(--str-chat__accent-primary);
+ }
+
+ .str-chat__scheduled-messages-banner__label {
+ @include utils.ellipsis-text();
+ flex: 1;
+ min-width: 0;
+ }
+
+ &.str-chat__scheduled-messages-banner--failed
+ .str-chat__scheduled-messages-banner__icon {
+ color: var(--str-chat__accent-error);
+ }
+
+ .str-chat__scheduled-messages-banner__view-button {
+ @include utils.button-reset;
+ flex-shrink: 0;
+ cursor: pointer;
+ color: var(--str-chat__text-link);
+ font: var(--str-chat__font-metadata-default);
+
+ &:focus-visible {
+ @include utils.focusable;
+ }
+ }
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/index.scss b/src/components/ScheduledMessages/styling/index.scss
new file mode 100644
index 000000000..ecc428d26
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/index.scss
@@ -0,0 +1,5 @@
+@use 'ScheduledMessageActions';
+@use 'ScheduledMessageList';
+@use 'ScheduledMessagesBanner';
+@use 'ScheduleMessageButton';
+@use 'ScheduleMessageDialog';
diff --git a/src/components/ScheduledMessages/types.ts b/src/components/ScheduledMessages/types.ts
new file mode 100644
index 000000000..6c168a3ac
--- /dev/null
+++ b/src/components/ScheduledMessages/types.ts
@@ -0,0 +1,217 @@
+import type { ChangeEventHandler, ReactNode } from 'react';
+import type {
+ Attachment,
+ Channel,
+ LocalMessage,
+ ScheduledMessageResponse,
+} from 'stream-chat';
+
+import type { IconComponent } from '../Icons';
+
+import type {
+ ScheduledMessageAction,
+ ScheduledMessageGroupType,
+ ScheduleMessageNoticeSeverity,
+ ScheduleTimeError,
+} from './constants';
+
+export type UseScheduledMessagesParams = {
+ cid?: string;
+ parentId?: string;
+};
+
+export type UseScheduledMessagePaginatorParams = Pick;
+
+export type ScheduledMessagesSnapshot = {
+ isDisabled: boolean;
+ isLoaded: boolean;
+ isLoading: boolean;
+ scheduledMessages: ScheduledMessageResponse[];
+};
+
+export type UseScheduledMessageCountParams = {
+ cid: string;
+ parentId?: string;
+};
+
+export type ScheduleTimeBounds = {
+ earliest: Date;
+ latest?: Date;
+};
+
+export type ScheduledMessagePreviewSource = {
+ attachments: Attachment[];
+ cid: string;
+ id: string;
+ text: string;
+};
+
+export type ScheduledMessageGroup = {
+ id: string;
+ scheduledMessages: ScheduledMessageResponse[];
+ type: ScheduledMessageGroupType;
+ day?: Date;
+};
+
+export type UseScheduledMessageGroupsParams = UseScheduledMessagesParams & {
+ searchQuery?: string;
+};
+
+export type ScheduledMessageGroups = {
+ groups: ScheduledMessageGroup[];
+ hasScheduledMessages: boolean;
+ isLoaded: boolean;
+};
+
+export type ScheduledMessagesLoadState = {
+ hasLoadFailed: boolean;
+ isFirstLoad: boolean;
+ reload: () => void;
+};
+
+export type ScheduledMessagesPagination = {
+ canLoadMore: boolean;
+ isLoading: boolean;
+ loadMore: () => void;
+};
+
+export type ScheduleTimeForm = {
+ bounds: ScheduleTimeBounds;
+ dateValue: string;
+ onDateChange: ChangeEventHandler;
+ onTimeChange: ChangeEventHandler;
+ timeValue: string;
+ scheduledAt?: Date;
+ timeError?: ScheduleTimeError;
+};
+
+export type ScheduleTimeFormParams = {
+ channel: Channel;
+ initialScheduledAt?: Date;
+};
+
+export type SubmitScheduledAt = (scheduledAt: Date) => Promise;
+
+export type ScheduleMessageSubmit = {
+ hasFailed: boolean;
+ isScheduling: boolean;
+ submit: (scheduledAt: Date) => Promise;
+};
+
+export type ScheduleMessageDialogState = {
+ closeDialog: () => void;
+ isDialogOpen: boolean;
+ openDialog: () => void;
+};
+
+export type ScheduleMessageMenuProps = {
+ onCustomTimeSelect: () => void;
+ onPresetSelect: (scheduledAt: Date) => void;
+ presets: Date[];
+};
+
+export type ScheduleMessagePresetButtonProps = {
+ onSelect: (scheduledAt: Date) => void;
+ scheduledAt: Date;
+};
+
+export type ScheduleMessageDialogProps = {
+ close: () => void;
+};
+
+export type ScheduleTimeDialogProps = {
+ channel: Channel;
+ close: () => void;
+ errorMessage: string;
+ previewMessage: LocalMessage;
+ submit: SubmitScheduledAt;
+ title: string;
+ initialScheduledAt?: Date;
+ previewLabel?: string;
+ submitLabel?: string;
+};
+
+export type RescheduleMessageDialogProps = {
+ close: () => void;
+ scheduledMessage: ScheduledMessageResponse;
+};
+
+export type DeleteScheduledMessageAlertProps = {
+ onCancel: () => void;
+ onDelete: () => void;
+ channelName?: string;
+};
+
+export type ScheduleMessageDialogNoticeProps = {
+ children: ReactNode;
+ severity: ScheduleMessageNoticeSeverity;
+};
+
+export type EditScheduledMessage = (scheduledMessage: ScheduledMessageResponse) => void;
+
+export type ScheduledMessageListProps = {
+ cid: string;
+ onEdit?: EditScheduledMessage;
+ parentId?: string;
+ searchQuery?: string;
+};
+
+export type ScheduledMessagesEmptyStateProps = {
+ Icon: IconComponent;
+ title: string;
+ children?: ReactNode;
+ description?: string;
+};
+
+export type ScheduledMessagesLoadErrorProps = {
+ onRetry: () => void;
+};
+
+export type ScheduledMessageListDialogProps = Omit<
+ ScheduledMessageListProps,
+ 'searchQuery'
+> & {
+ close: () => void;
+};
+
+export type ScheduledMessageGroupHeaderProps = {
+ group: ScheduledMessageGroup;
+};
+
+export type ScheduledMessageItemProps = {
+ scheduledMessage: ScheduledMessageResponse;
+ onEdit?: EditScheduledMessage;
+};
+
+export type ScheduledMessageActionsProps = ScheduledMessageItemProps;
+
+export type ScheduledMessageActionsOptions = {
+ canEdit: boolean;
+};
+
+export type ScheduledMessageActionsMenuProps = {
+ actions: ScheduledMessageAction[];
+ onCopy: () => void;
+ onDelete: () => void;
+ onEdit: () => void;
+ onReschedule: () => void;
+ onSendNow: () => void;
+};
+
+export type ScheduledMessageActionHandlers = {
+ copyText: () => void;
+ deleteScheduledMessage: () => Promise;
+ isPending: boolean;
+ sendNow: () => Promise;
+};
+
+export type EditedScheduledMessagePreviewProps = {
+ onCancel: () => void;
+ scheduledMessage: ScheduledMessageResponse;
+};
+
+export type FailedScheduledMessagePreviewProps = EditedScheduledMessagePreviewProps;
+
+export type ScheduledMessageItemStatusProps = {
+ scheduledMessage: ScheduledMessageResponse;
+};
diff --git a/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts b/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts
new file mode 100644
index 000000000..b847401d4
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts
@@ -0,0 +1,47 @@
+import {
+ fromDateAndTimeInputValues,
+ fromDateInputValue,
+ toDateInputValue,
+ toTimeInputValue,
+} from '../dateTimeInputValues';
+
+describe('dateTimeInputValues', () => {
+ it('formats the local date for a date input', () => {
+ expect(toDateInputValue(new Date(2026, 0, 5, 7, 4))).toBe('2026-01-05');
+ });
+
+ it('formats the local time for a time input', () => {
+ expect(toTimeInputValue(new Date(2026, 0, 5, 7, 4))).toBe('07:04');
+ });
+
+ it('parses the input values as local time', () => {
+ expect(fromDateAndTimeInputValues('2026-01-05', '07:04')).toEqual(
+ new Date(2026, 0, 5, 7, 4),
+ );
+ });
+
+ it('round-trips a local date and time', () => {
+ const date = new Date(2026, 11, 31, 23, 59);
+
+ expect(
+ fromDateAndTimeInputValues(toDateInputValue(date), toTimeInputValue(date)),
+ ).toEqual(date);
+ });
+
+ it.each([
+ ['a missing date', '', '07:04'],
+ ['a missing time', '2026-01-05', ''],
+ ['an invalid date', 'not-a-date', '07:04'],
+ ])('returns undefined for %s', (_, dateValue, timeValue) => {
+ expect(fromDateAndTimeInputValues(dateValue, timeValue)).toBeUndefined();
+ });
+
+ it('reads a date input value as local midnight', () => {
+ expect(fromDateInputValue('2026-11-03')).toEqual(new Date(2026, 10, 3));
+ });
+
+ it('returns undefined for an empty or invalid date input value', () => {
+ expect(fromDateInputValue('')).toBeUndefined();
+ expect(fromDateInputValue('2026-13-45')).toBeUndefined();
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/schedulePresets.test.ts b/src/components/ScheduledMessages/utils/__tests__/schedulePresets.test.ts
new file mode 100644
index 000000000..bf4f2af13
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/schedulePresets.test.ts
@@ -0,0 +1,141 @@
+import {
+ getAvailableSchedulePresets,
+ getDaysUntilNextMonday,
+ getLocalDateAtHour,
+ getSchedulePresets,
+ isSameLocalDay,
+} from '../schedulePresets';
+import { getScheduleTimeBounds } from '../scheduleTimeBounds';
+
+const DST_TIME_ZONE = 'Europe/Skopje';
+
+const DEFAULT_LIMITS = {
+ maxLeadTimeSeconds: 120 * 24 * 60 * 60,
+ maxPerChannel: 10,
+ maxPerUser: 100,
+ minLeadTimeSeconds: 60,
+};
+
+const toLocalParts = (date: Date) => ({
+ date: date.getDate(),
+ day: date.getDay(),
+ hours: date.getHours(),
+ minutes: date.getMinutes(),
+ month: date.getMonth(),
+});
+
+describe('schedulePresets', () => {
+ const originalTimeZone = process.env.TZ;
+
+ beforeAll(() => {
+ process.env.TZ = DST_TIME_ZONE;
+ });
+
+ afterAll(() => {
+ process.env.TZ = originalTimeZone;
+ });
+
+ describe('getLocalDateAtHour', () => {
+ it('moves the date by whole days and sets the hour', () => {
+ const date = new Date(2026, 9, 28, 10, 30, 45);
+
+ const localDate = getLocalDateAtHour(date, 2, 13);
+
+ expect(localDate).toEqual(new Date(2026, 9, 30, 13, 0, 0, 0));
+ expect(date).toEqual(new Date(2026, 9, 28, 10, 30, 45));
+ });
+
+ it('keeps the wall-clock hour across a daylight saving change', () => {
+ const dayBeforeDstEnds = new Date(2026, 9, 24, 12, 0);
+
+ const localDate = getLocalDateAtHour(dayBeforeDstEnds, 1, 9);
+
+ expect(localDate.getHours()).toBe(9);
+ expect(localDate.getTimezoneOffset()).not.toBe(
+ dayBeforeDstEnds.getTimezoneOffset(),
+ );
+ });
+ });
+
+ describe('getDaysUntilNextMonday', () => {
+ it.each([
+ ['Sunday', new Date(2026, 10, 1), 1],
+ ['Monday', new Date(2026, 10, 2), 7],
+ ['Wednesday', new Date(2026, 10, 4), 5],
+ ['Saturday', new Date(2026, 10, 7), 2],
+ ])('returns the days from %s', (_, date, expected) => {
+ expect(getDaysUntilNextMonday(date)).toBe(expected);
+ });
+ });
+
+ describe('isSameLocalDay', () => {
+ it('compares the local calendar day only', () => {
+ expect(
+ isSameLocalDay(new Date(2026, 0, 1, 0, 1), new Date(2026, 0, 1, 23, 59)),
+ ).toBe(true);
+ expect(
+ isSameLocalDay(new Date(2026, 0, 1, 23, 59), new Date(2026, 0, 2, 0, 1)),
+ ).toBe(false);
+ });
+ });
+
+ describe('getSchedulePresets', () => {
+ it('returns tomorrow at 09:00, tomorrow at 13:00 and next Monday at 09:00', () => {
+ const wednesday = new Date(2026, 9, 28, 10, 30);
+
+ const presets = getSchedulePresets(wednesday);
+
+ expect(presets.map(toLocalParts)).toEqual([
+ { date: 29, day: 4, hours: 9, minutes: 0, month: 9 },
+ { date: 29, day: 4, hours: 13, minutes: 0, month: 9 },
+ { date: 2, day: 1, hours: 9, minutes: 0, month: 10 },
+ ]);
+ });
+
+ it('drops the Monday preset when tomorrow is Monday', () => {
+ const sunday = new Date(2026, 10, 1, 18, 0);
+
+ const presets = getSchedulePresets(sunday);
+
+ expect(presets.map(toLocalParts)).toEqual([
+ { date: 2, day: 1, hours: 9, minutes: 0, month: 10 },
+ { date: 2, day: 1, hours: 13, minutes: 0, month: 10 },
+ ]);
+ });
+
+ it('rolls over the end of the month and the year', () => {
+ const lastDayOfYear = new Date(2026, 11, 31, 22, 0);
+
+ const [tomorrowMorning] = getSchedulePresets(lastDayOfYear);
+
+ expect(tomorrowMorning).toEqual(new Date(2027, 0, 1, 9, 0));
+ });
+ });
+
+ describe('getAvailableSchedulePresets', () => {
+ it('keeps every preset within the default limits', () => {
+ const now = new Date(2026, 9, 28, 10, 30);
+
+ const presets = getAvailableSchedulePresets(
+ now,
+ getScheduleTimeBounds(DEFAULT_LIMITS, now),
+ );
+
+ expect(presets).toHaveLength(3);
+ });
+
+ it('drops the presets beyond the maximum lead time', () => {
+ const now = new Date(2026, 9, 28, 10, 30);
+ const oneDayLimits = { ...DEFAULT_LIMITS, maxLeadTimeSeconds: 24 * 60 * 60 };
+
+ const presets = getAvailableSchedulePresets(
+ now,
+ getScheduleTimeBounds(oneDayLimits, now),
+ );
+
+ expect(presets.map(toLocalParts)).toEqual([
+ { date: 29, day: 4, hours: 9, minutes: 0, month: 9 },
+ ]);
+ });
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduleTimeBounds.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduleTimeBounds.test.ts
new file mode 100644
index 000000000..5433d6804
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/scheduleTimeBounds.test.ts
@@ -0,0 +1,107 @@
+import { fromPartial } from '@total-typescript/shoehorn';
+import type { Channel } from 'stream-chat';
+
+import { ScheduleTimeError } from '../../constants';
+import {
+ getChannelScheduleTimeBounds,
+ getDefaultScheduledAt,
+ getScheduleTimeBounds,
+ getScheduleTimeError,
+ isWithinScheduleTimeBounds,
+} from '../scheduleTimeBounds';
+
+const MINUTE_MS = 60 * 1000;
+
+const DEFAULT_LIMITS = {
+ maxLeadTimeSeconds: 120 * 24 * 60 * 60,
+ maxPerChannel: 10,
+ maxPerUser: 100,
+ minLeadTimeSeconds: 60,
+};
+
+const now = new Date(2026, 9, 28, 10, 30, 15);
+
+describe('getScheduleTimeBounds', () => {
+ it('adds a one-minute buffer to the minimum lead time', () => {
+ const { earliest, latest } = getScheduleTimeBounds(DEFAULT_LIMITS, now);
+
+ expect(earliest.getTime() - now.getTime()).toBe(2 * MINUTE_MS);
+ expect(latest!.getTime() - now.getTime()).toBe(
+ DEFAULT_LIMITS.maxLeadTimeSeconds * 1000,
+ );
+ });
+
+ it('only requires the one-minute buffer and sets no latest time without limits', () => {
+ const { earliest, latest } = getScheduleTimeBounds(undefined, now);
+
+ expect(earliest.getTime() - now.getTime()).toBe(MINUTE_MS);
+ expect(latest).toBeUndefined();
+ });
+});
+
+describe('getChannelScheduleTimeBounds', () => {
+ it('reads the limits from the channel config', () => {
+ const channel = fromPartial({
+ serverConfig: fromPartial({
+ scheduled_messages_max_lead_time_seconds: 3600,
+ scheduled_messages_max_per_channel: 10,
+ scheduled_messages_max_per_user: 100,
+ scheduled_messages_min_lead_time_seconds: 300,
+ }),
+ });
+
+ const { earliest, latest } = getChannelScheduleTimeBounds(channel, now);
+
+ expect(earliest.getTime() - now.getTime()).toBe(6 * MINUTE_MS);
+ expect(latest!.getTime() - now.getTime()).toBe(60 * MINUTE_MS);
+ });
+
+ it('leaves the latest time open when the channel config has no limits', () => {
+ const channel = fromPartial({ serverConfig: undefined });
+
+ expect(getChannelScheduleTimeBounds(channel, now)).toEqual(
+ getScheduleTimeBounds(undefined, now),
+ );
+ });
+});
+
+describe('getScheduleTimeError', () => {
+ const bounds = getScheduleTimeBounds(DEFAULT_LIMITS, now);
+
+ it('accepts both bounds', () => {
+ expect(getScheduleTimeError(bounds.earliest, bounds)).toBeUndefined();
+ expect(getScheduleTimeError(bounds.latest!, bounds)).toBeUndefined();
+ expect(isWithinScheduleTimeBounds(bounds.earliest, bounds)).toBe(true);
+ });
+
+ it('rejects a time before the earliest time', () => {
+ const tooSoon = new Date(bounds.earliest.getTime() - 1);
+
+ expect(getScheduleTimeError(tooSoon, bounds)).toBe(ScheduleTimeError.TooSoon);
+ expect(isWithinScheduleTimeBounds(tooSoon, bounds)).toBe(false);
+ });
+
+ it('rejects a time after the latest time', () => {
+ const tooFar = new Date(bounds.latest!.getTime() + 1);
+
+ expect(getScheduleTimeError(tooFar, bounds)).toBe(ScheduleTimeError.TooFar);
+ });
+
+ it('accepts any later time when there is no latest time', () => {
+ const openBounds = getScheduleTimeBounds(undefined, now);
+ const farFuture = new Date(2036, 0, 1);
+
+ expect(getScheduleTimeError(farFuture, openBounds)).toBeUndefined();
+ });
+});
+
+describe('getDefaultScheduledAt', () => {
+ it('returns the first whole hour after the earliest time', () => {
+ const bounds = getScheduleTimeBounds(DEFAULT_LIMITS, now);
+
+ const defaultScheduledAt = getDefaultScheduledAt(bounds);
+
+ expect(defaultScheduledAt).toEqual(new Date(2026, 9, 28, 11, 0, 0, 0));
+ expect(isWithinScheduleTimeBounds(defaultScheduledAt, bounds)).toBe(true);
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduledMessageActions.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageActions.test.ts
new file mode 100644
index 000000000..09cc47030
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageActions.test.ts
@@ -0,0 +1,65 @@
+import { SCHEDULED_MESSAGE_FAILURE_REASON, SCHEDULED_MESSAGE_STATUS } from 'stream-chat';
+
+import { ScheduledMessageAction } from '../../constants';
+import { getScheduledMessageActions } from '../scheduledMessageActions';
+import { generateScheduledMessage } from '../../../../mock-builders';
+
+const { Copy, Delete, Edit, Reschedule, SendNow } = ScheduledMessageAction;
+
+describe('getScheduledMessageActions', () => {
+ it('offers every action for a scheduled message', () => {
+ const scheduledMessage = generateScheduledMessage();
+
+ expect(getScheduledMessageActions(scheduledMessage, { canEdit: true })).toEqual([
+ Edit,
+ Reschedule,
+ SendNow,
+ Delete,
+ ]);
+ });
+
+ it('leaves out Edit when the host cannot edit', () => {
+ const scheduledMessage = generateScheduledMessage();
+
+ expect(getScheduledMessageActions(scheduledMessage, { canEdit: false })).toEqual([
+ Reschedule,
+ SendNow,
+ Delete,
+ ]);
+ });
+
+ it('offers nothing while the message is being sent', () => {
+ const scheduledMessage = generateScheduledMessage({
+ status: SCHEDULED_MESSAGE_STATUS.Sending,
+ });
+
+ expect(getScheduledMessageActions(scheduledMessage, { canEdit: true })).toEqual([]);
+ });
+
+ it('offers Edit and Delete for a failed message', () => {
+ const scheduledMessage = generateScheduledMessage({
+ failure_reason: SCHEDULED_MESSAGE_FAILURE_REASON.ChannelFrozen,
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ });
+
+ expect(getScheduledMessageActions(scheduledMessage, { canEdit: true })).toEqual([
+ Edit,
+ Delete,
+ ]);
+ expect(getScheduledMessageActions(scheduledMessage, { canEdit: false })).toEqual([
+ Delete,
+ ]);
+ });
+
+ it('offers Copy and Delete when the channel was deleted', () => {
+ const scheduledMessage = generateScheduledMessage({
+ failure_reason: SCHEDULED_MESSAGE_FAILURE_REASON.ChannelDeleted,
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ });
+
+ expect(getScheduledMessageActions(scheduledMessage, { canEdit: true })).toEqual([
+ Copy,
+ Delete,
+ ]);
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduledMessageComposer.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageComposer.test.ts
new file mode 100644
index 000000000..77d79c159
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageComposer.test.ts
@@ -0,0 +1,49 @@
+import { MessageComposer, SCHEDULED_MESSAGE_STATUS } from 'stream-chat';
+
+import { openScheduledMessageInComposer } from '../scheduledMessageComposer';
+import {
+ generateScheduledMessage,
+ getTestClientWithUser,
+} from '../../../../mock-builders';
+
+const setup = async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const channel = client.channel('messaging', 'general');
+ const composer = new MessageComposer({ client, compositionContext: channel });
+ const editScheduledMessage = vi
+ .spyOn(composer, 'editScheduledMessage')
+ .mockResolvedValue(undefined);
+ const rescheduleFailedMessage = vi
+ .spyOn(composer, 'rescheduleFailedMessage')
+ .mockResolvedValue(undefined);
+
+ return { composer, editScheduledMessage, rescheduleFailedMessage };
+};
+
+describe('openScheduledMessageInComposer', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('edits a pending scheduled message', async () => {
+ const { composer, editScheduledMessage, rescheduleFailedMessage } = await setup();
+ const scheduledMessage = generateScheduledMessage();
+
+ await openScheduledMessageInComposer(composer, scheduledMessage);
+
+ expect(editScheduledMessage).toHaveBeenCalledWith(scheduledMessage);
+ expect(rescheduleFailedMessage).not.toHaveBeenCalled();
+ });
+
+ it('schedules a failed message again', async () => {
+ const { composer, editScheduledMessage, rescheduleFailedMessage } = await setup();
+ const scheduledMessage = generateScheduledMessage({
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ });
+
+ await openScheduledMessageInComposer(composer, scheduledMessage);
+
+ expect(rescheduleFailedMessage).toHaveBeenCalledWith(scheduledMessage);
+ expect(editScheduledMessage).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduledMessageFailureReason.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageFailureReason.test.ts
new file mode 100644
index 000000000..fdc155046
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageFailureReason.test.ts
@@ -0,0 +1,46 @@
+import { SCHEDULED_MESSAGE_FAILURE_REASON } from 'stream-chat';
+
+import { translateScheduledMessageFailureReason } from '../scheduledMessageFailureReason';
+import { mockT } from '../../../../mock-builders';
+import type { StreamTFunction } from '../../../../i18n/types';
+
+const t = mockT as StreamTFunction;
+
+describe('translateScheduledMessageFailureReason', () => {
+ it.each([
+ [SCHEDULED_MESSAGE_FAILURE_REASON.Banned, "You can't send messages in this channel"],
+ [
+ SCHEDULED_MESSAGE_FAILURE_REASON.NoPermission,
+ "You're no longer allowed to post here",
+ ],
+ [SCHEDULED_MESSAGE_FAILURE_REASON.ChannelFrozen, 'This channel is frozen'],
+ [SCHEDULED_MESSAGE_FAILURE_REASON.ChannelDeleted, 'This channel was deleted'],
+ [SCHEDULED_MESSAGE_FAILURE_REASON.ModerationBlocked, 'Blocked by moderation'],
+ [SCHEDULED_MESSAGE_FAILURE_REASON.HookRejected, 'Rejected by the app'],
+ [SCHEDULED_MESSAGE_FAILURE_REASON.Expired, 'Not sent: the service was delayed'],
+ [
+ SCHEDULED_MESSAGE_FAILURE_REASON.InvalidMessage,
+ "The message no longer meets the app's limits. Edit it",
+ ],
+ [
+ SCHEDULED_MESSAGE_FAILURE_REASON.PendingNotSupported,
+ 'This channel now holds messages for approval. Schedule it again',
+ ],
+ [SCHEDULED_MESSAGE_FAILURE_REASON.InternalError, "Couldn't send. Try again"],
+ ])('translates %s', (failureReason, expected) => {
+ expect(translateScheduledMessageFailureReason(t, failureReason)).toBe(expected);
+ });
+
+ it('shows no copy when the user was deleted', () => {
+ expect(
+ translateScheduledMessageFailureReason(
+ t,
+ SCHEDULED_MESSAGE_FAILURE_REASON.UserDeleted,
+ ),
+ ).toBeUndefined();
+ });
+
+ it('falls back to the generic copy without a reason', () => {
+ expect(translateScheduledMessageFailureReason(t)).toBe("Couldn't send. Try again");
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduledMessageGroups.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageGroups.test.ts
new file mode 100644
index 000000000..33742054b
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageGroups.test.ts
@@ -0,0 +1,89 @@
+import { nsToDate, SCHEDULED_MESSAGE_STATUS } from 'stream-chat';
+import type { ScheduledMessageStatus, TimestampNS } from 'stream-chat';
+
+import { ScheduledMessageGroupType } from '../../constants';
+import {
+ groupScheduledMessages,
+ isFailedScheduledMessage,
+} from '../scheduledMessageGroups';
+import {
+ convertDateToTimestamp,
+ generateScheduledMessage,
+} from '../../../../mock-builders';
+
+const atLocalTime = (day: number, hour: number) =>
+ convertDateToTimestamp(new Date(2026, 9, day, hour, 0));
+
+const scheduledFor = (
+ id: string,
+ scheduledAt: TimestampNS,
+ status: ScheduledMessageStatus = SCHEDULED_MESSAGE_STATUS.Scheduled,
+) => generateScheduledMessage({ id, scheduled_at: scheduledAt, status });
+
+describe('isFailedScheduledMessage', () => {
+ it.each([
+ [SCHEDULED_MESSAGE_STATUS.Failed, true],
+ [SCHEDULED_MESSAGE_STATUS.Scheduled, false],
+ [SCHEDULED_MESSAGE_STATUS.Sending, false],
+ ])('treats the %s status as failed: %s', (status, expected) => {
+ expect(isFailedScheduledMessage(scheduledFor('id', atLocalTime(29, 9), status))).toBe(
+ expected,
+ );
+ });
+});
+
+describe('groupScheduledMessages', () => {
+ it('returns no groups for an empty list', () => {
+ expect(groupScheduledMessages([])).toEqual([]);
+ });
+
+ it('puts failed messages in one group before the upcoming days', () => {
+ const failed = scheduledFor(
+ 'failed',
+ atLocalTime(30, 9),
+ SCHEDULED_MESSAGE_STATUS.Failed,
+ );
+ const upcoming = scheduledFor('upcoming', atLocalTime(29, 9));
+
+ const groups = groupScheduledMessages([upcoming, failed]);
+
+ expect(groups.map(({ id, type }) => [id, type])).toEqual([
+ ['failed', ScheduledMessageGroupType.Failed],
+ ['2026-10-29', ScheduledMessageGroupType.Day],
+ ]);
+ expect(groups[0].scheduledMessages).toEqual([failed]);
+ expect(groups[0].day).toBeUndefined();
+ });
+
+ it('groups upcoming messages by local day and keeps their order', () => {
+ const morning = scheduledFor('morning', atLocalTime(29, 9));
+ const afternoon = scheduledFor('afternoon', atLocalTime(29, 13));
+ const nextDay = scheduledFor('next-day', atLocalTime(30, 9));
+
+ const groups = groupScheduledMessages([morning, afternoon, nextDay]);
+
+ expect(groups.map(({ id }) => id)).toEqual(['2026-10-29', '2026-10-30']);
+ expect(groups[0].scheduledMessages).toEqual([morning, afternoon]);
+ expect(groups[1].scheduledMessages).toEqual([nextDay]);
+ expect(groups[0].day).toEqual(nsToDate(morning.scheduled_at));
+ });
+
+ it('keeps sending messages in their day group', () => {
+ const sending = scheduledFor(
+ 'sending',
+ atLocalTime(29, 9),
+ SCHEDULED_MESSAGE_STATUS.Sending,
+ );
+
+ const [group] = groupScheduledMessages([sending]);
+
+ expect(group.type).toBe(ScheduledMessageGroupType.Day);
+ expect(group.scheduledMessages).toEqual([sending]);
+ });
+
+ it('leaves out the failed group when nothing failed', () => {
+ const groups = groupScheduledMessages([scheduledFor('upcoming', atLocalTime(29, 9))]);
+
+ expect(groups.map(({ type }) => type)).toEqual([ScheduledMessageGroupType.Day]);
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduledMessageHelpers.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageHelpers.test.ts
new file mode 100644
index 000000000..6a4297860
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageHelpers.test.ts
@@ -0,0 +1,103 @@
+import { fromPartial } from '@total-typescript/shoehorn';
+import { ScheduledMessageErrorReason, StreamAPIError } from 'stream-chat';
+import type { Attachment, UserResponse } from 'stream-chat';
+
+import { isScheduledMessageError } from '../scheduledMessageErrors';
+import {
+ toComposedScheduledMessagePreview,
+ toStoredScheduledMessagePreview,
+} from '../scheduledMessagePreview';
+import {
+ convertDateToTimestamp,
+ generateScheduledMessage,
+} from '../../../../mock-builders';
+
+const createApiError = (code: number) =>
+ new StreamAPIError('request failed', {
+ code,
+ response: fromPartial>({
+ data: { code },
+ status: 400,
+ }),
+ status: 400,
+ });
+
+describe('isScheduledMessageError', () => {
+ it('matches an API error with the given reason', () => {
+ expect(
+ isScheduledMessageError(createApiError(119), ScheduledMessageErrorReason.IdTaken),
+ ).toBe(true);
+ });
+
+ it.each([
+ ['another reason', createApiError(118)],
+ ['a network error', new Error('Network Error')],
+ ['a thrown string', 'failed'],
+ ])('does not match %s', (_, error) => {
+ expect(isScheduledMessageError(error, ScheduledMessageErrorReason.IdTaken)).toBe(
+ false,
+ );
+ });
+});
+
+describe('toComposedScheduledMessagePreview', () => {
+ it('builds a local message from the composition dated now', () => {
+ const now = new Date(2026, 9, 28, 10, 30);
+ const timestamp = convertDateToTimestamp(now);
+ const user = fromPartial({ id: 'user-id' });
+ const attachments: Attachment[] = [{ custom: {}, type: 'image' }];
+
+ const previewMessage = toComposedScheduledMessagePreview(
+ {
+ attachments,
+ cid: 'messaging:general',
+ id: 'message-id',
+ text: 'Launch moved to Monday',
+ },
+ user,
+ now,
+ );
+
+ expect(previewMessage).toMatchObject({
+ attachments,
+ cid: 'messaging:general',
+ created_at: timestamp,
+ id: 'message-id',
+ text: 'Launch moved to Monday',
+ type: 'regular',
+ updated_at: timestamp,
+ user,
+ });
+ });
+});
+
+describe('toStoredScheduledMessagePreview', () => {
+ it('builds a local message updated at the scheduled time', () => {
+ const scheduledAt = convertDateToTimestamp(new Date(2026, 9, 29, 9, 0));
+ const attachments: Attachment[] = [{ custom: {}, type: 'image' }];
+ const scheduledMessage = generateScheduledMessage({
+ id: 'scheduled-id',
+ message: { attachments, custom: {}, text: 'Launch moved to Monday' },
+ scheduled_at: scheduledAt,
+ });
+
+ const previewMessage = toStoredScheduledMessagePreview(scheduledMessage);
+
+ expect(previewMessage).toMatchObject({
+ attachments,
+ cid: scheduledMessage.cid,
+ created_at: scheduledMessage.created_at,
+ id: 'scheduled-id',
+ text: 'Launch moved to Monday',
+ updated_at: scheduledAt,
+ });
+ });
+
+ it('uses an empty attachment list when the message has none', () => {
+ const scheduledMessage = generateScheduledMessage({
+ message: { custom: {}, text: 'Hi' },
+ });
+
+ expect(toStoredScheduledMessagePreview(scheduledMessage).attachments).toEqual([]);
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduledMessageScope.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageScope.test.ts
new file mode 100644
index 000000000..a5fdb6469
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageScope.test.ts
@@ -0,0 +1,34 @@
+import { generateScheduledMessage } from '../../../../mock-builders';
+import { getScopedScheduledMessages } from '../scheduledMessageScope';
+
+const cid = 'messaging:general';
+
+const channelMessage = generateScheduledMessage({ cid });
+const threadReply = generateScheduledMessage({
+ cid,
+ message: { custom: {}, parent_id: 'parent-1', text: 'reply' },
+});
+const otherThreadReply = generateScheduledMessage({
+ cid,
+ message: { custom: {}, parent_id: 'parent-2', text: 'other reply' },
+});
+
+const scheduledMessages = [channelMessage, threadReply, otherThreadReply];
+
+describe('getScopedScheduledMessages', () => {
+ it('keeps only the replies of the thread when a parent id is given', () => {
+ expect(
+ getScopedScheduledMessages(scheduledMessages, { cid, parentId: 'parent-1' }),
+ ).toEqual([threadReply]);
+ });
+
+ it('leaves out thread replies for a channel', () => {
+ expect(getScopedScheduledMessages(scheduledMessages, { cid })).toEqual([
+ channelMessage,
+ ]);
+ });
+
+ it('keeps every scheduled message without a scope', () => {
+ expect(getScopedScheduledMessages(scheduledMessages, {})).toBe(scheduledMessages);
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduledMessageSearch.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageSearch.test.ts
new file mode 100644
index 000000000..2a5e8a605
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageSearch.test.ts
@@ -0,0 +1,29 @@
+import { filterScheduledMessagesByText } from '../scheduledMessageSearch';
+import { generateScheduledMessage } from '../../../../mock-builders';
+
+const launch = generateScheduledMessage({ message: { text: 'Launch moved to Monday' } });
+const lunch = generateScheduledMessage({ message: { text: 'Lunch at noon' } });
+const attachmentOnly = generateScheduledMessage({ message: { text: '' } });
+
+const scheduledMessages = [launch, lunch, attachmentOnly];
+
+describe('filterScheduledMessagesByText', () => {
+ it('returns every message for an empty or blank query', () => {
+ expect(filterScheduledMessagesByText(scheduledMessages)).toBe(scheduledMessages);
+ expect(filterScheduledMessagesByText(scheduledMessages, ' ')).toBe(
+ scheduledMessages,
+ );
+ });
+
+ it('matches the message text case-insensitively', () => {
+ expect(filterScheduledMessagesByText(scheduledMessages, 'MONDAY')).toEqual([launch]);
+ });
+
+ it('ignores surrounding whitespace in the query', () => {
+ expect(filterScheduledMessagesByText(scheduledMessages, ' lunch ')).toEqual([lunch]);
+ });
+
+ it('returns no messages when nothing matches', () => {
+ expect(filterScheduledMessagesByText(scheduledMessages, 'friday')).toEqual([]);
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/__tests__/timeZone.test.ts b/src/components/ScheduledMessages/utils/__tests__/timeZone.test.ts
new file mode 100644
index 000000000..477d27d1a
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/__tests__/timeZone.test.ts
@@ -0,0 +1,21 @@
+import { formatTimeZoneOffset } from '../timeZone';
+
+describe('formatTimeZoneOffset', () => {
+ const originalTimeZone = process.env.TZ;
+
+ afterEach(() => {
+ process.env.TZ = originalTimeZone;
+ });
+
+ it.each([
+ ['UTC', new Date(Date.UTC(2026, 0, 15)), 'GMT'],
+ ['Europe/Skopje', new Date(Date.UTC(2026, 0, 15)), 'GMT+1'],
+ ['Europe/Skopje', new Date(Date.UTC(2026, 6, 15)), 'GMT+2'],
+ ['Asia/Kolkata', new Date(Date.UTC(2026, 0, 15)), 'GMT+5:30'],
+ ['America/New_York', new Date(Date.UTC(2026, 0, 15)), 'GMT-5'],
+ ])('formats %s on %s as %s', (timeZone, date, expected) => {
+ process.env.TZ = timeZone;
+
+ expect(formatTimeZoneOffset(date)).toBe(expected);
+ });
+});
diff --git a/src/components/ScheduledMessages/utils/channelContainer.ts b/src/components/ScheduledMessages/utils/channelContainer.ts
new file mode 100644
index 000000000..48dd86097
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/channelContainer.ts
@@ -0,0 +1,3 @@
+import { CHANNEL_CONTAINER_ID } from '../../Channel/constants';
+
+export const getChannelContainer = () => document.getElementById(CHANNEL_CONTAINER_ID);
diff --git a/src/components/ScheduledMessages/utils/dateTimeInputValues.ts b/src/components/ScheduledMessages/utils/dateTimeInputValues.ts
new file mode 100644
index 000000000..819f4bd31
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/dateTimeInputValues.ts
@@ -0,0 +1,40 @@
+const padTwoDigits = (value: number) => String(value).padStart(2, '0');
+
+export const toDateInputValue = (date: Date) => {
+ const year = date.getFullYear();
+ const month = padTwoDigits(date.getMonth() + 1);
+ const day = padTwoDigits(date.getDate());
+
+ const dateInputValue = `${year}-${month}-${day}`;
+
+ return dateInputValue;
+};
+
+export const toTimeInputValue = (date: Date) => {
+ const hours = padTwoDigits(date.getHours());
+ const minutes = padTwoDigits(date.getMinutes());
+
+ const timeInputValue = `${hours}:${minutes}`;
+
+ return timeInputValue;
+};
+
+export const fromDateAndTimeInputValues = (dateValue: string, timeValue: string) => {
+ if (!dateValue || !timeValue) return undefined;
+
+ const scheduledAt = new Date(`${dateValue}T${timeValue}`);
+
+ const isValidDate = !Number.isNaN(scheduledAt.getTime());
+
+ return isValidDate ? scheduledAt : undefined;
+};
+
+export const fromDateInputValue = (dateValue: string) => {
+ if (!dateValue) return undefined;
+
+ const date = new Date(`${dateValue}T00:00`);
+
+ const isValidDate = !Number.isNaN(date.getTime());
+
+ return isValidDate ? date : undefined;
+};
diff --git a/src/components/ScheduledMessages/utils/index.ts b/src/components/ScheduledMessages/utils/index.ts
new file mode 100644
index 000000000..529c10dd5
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/index.ts
@@ -0,0 +1,15 @@
+export * from './channelContainer';
+export * from './dateTimeInputValues';
+export * from './nativePicker';
+export * from './schedulePresets';
+export * from './scheduledMessageActions';
+export * from './scheduledMessageChannel';
+export * from './scheduledMessageComposer';
+export * from './scheduledMessageErrors';
+export * from './scheduledMessageFailureReason';
+export * from './scheduledMessageGroups';
+export * from './scheduledMessagePreview';
+export * from './scheduledMessageScope';
+export * from './scheduledMessageSearch';
+export * from './scheduleTimeBounds';
+export * from './timeZone';
diff --git a/src/components/ScheduledMessages/utils/nativePicker.ts b/src/components/ScheduledMessages/utils/nativePicker.ts
new file mode 100644
index 000000000..f7d2412a6
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/nativePicker.ts
@@ -0,0 +1,9 @@
+import type { MouseEvent } from 'react';
+
+export const openNativePicker = ({ currentTarget }: MouseEvent) => {
+ try {
+ currentTarget.showPicker?.();
+ } catch {
+ return;
+ }
+};
diff --git a/src/components/ScheduledMessages/utils/schedulePresets.ts b/src/components/ScheduledMessages/utils/schedulePresets.ts
new file mode 100644
index 000000000..4a13c67ba
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/schedulePresets.ts
@@ -0,0 +1,57 @@
+import { SchedulePresetHour } from '../constants';
+import type { ScheduleTimeBounds } from '../types';
+import { isWithinScheduleTimeBounds } from './scheduleTimeBounds';
+
+const DAYS_IN_WEEK = 7;
+const MONDAY = 1;
+const TOMORROW = 1;
+
+export const getLocalDateAtHour = (date: Date, dayOffset: number, hour: number) => {
+ const localDate = new Date(date);
+
+ localDate.setDate(date.getDate() + dayOffset);
+ localDate.setHours(hour, 0, 0, 0);
+
+ return localDate;
+};
+
+export const getDaysUntilNextMonday = (date: Date) => {
+ const daysUntilMonday = (DAYS_IN_WEEK - date.getDay() + MONDAY) % DAYS_IN_WEEK;
+
+ const daysUntilNextMonday = daysUntilMonday || DAYS_IN_WEEK;
+
+ return daysUntilNextMonday;
+};
+
+export const isSameLocalDay = (first: Date, second: Date) =>
+ first.toDateString() === second.toDateString();
+
+export const getSchedulePresets = (now: Date) => {
+ const tomorrowMorning = getLocalDateAtHour(now, TOMORROW, SchedulePresetHour.Morning);
+ const tomorrowAfternoon = getLocalDateAtHour(
+ now,
+ TOMORROW,
+ SchedulePresetHour.Afternoon,
+ );
+ const nextMondayMorning = getLocalDateAtHour(
+ now,
+ getDaysUntilNextMonday(now),
+ SchedulePresetHour.Morning,
+ );
+
+ const isTomorrowMonday = isSameLocalDay(nextMondayMorning, tomorrowMorning);
+
+ const presets = isTomorrowMonday
+ ? [tomorrowMorning, tomorrowAfternoon]
+ : [tomorrowMorning, tomorrowAfternoon, nextMondayMorning];
+
+ return presets;
+};
+
+export const getAvailableSchedulePresets = (now: Date, bounds: ScheduleTimeBounds) => {
+ const availablePresets = getSchedulePresets(now).filter((preset) =>
+ isWithinScheduleTimeBounds(preset, bounds),
+ );
+
+ return availablePresets;
+};
diff --git a/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts b/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts
new file mode 100644
index 000000000..c30be5aee
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts
@@ -0,0 +1,58 @@
+import { getScheduledMessageLimits } from 'stream-chat';
+import type { Channel, ScheduledMessageLimits } from 'stream-chat';
+
+import { CLOCK_SKEW_BUFFER_MS, ScheduleTimeError } from '../constants';
+import type { ScheduleTimeBounds } from '../types';
+
+const MS_IN_SECOND = 1000;
+
+const NO_SCHEDULE_TIME_LIMITS: Partial = {};
+
+export const getScheduleTimeBounds = (
+ limits: ScheduledMessageLimits | undefined,
+ now: Date,
+): ScheduleTimeBounds => {
+ const { maxLeadTimeSeconds, minLeadTimeSeconds = 0 } =
+ limits ?? NO_SCHEDULE_TIME_LIMITS;
+ const nowMs = now.getTime();
+
+ const earliest = new Date(
+ nowMs + minLeadTimeSeconds * MS_IN_SECOND + CLOCK_SKEW_BUFFER_MS,
+ );
+ const latest =
+ maxLeadTimeSeconds === undefined
+ ? undefined
+ : new Date(nowMs + maxLeadTimeSeconds * MS_IN_SECOND);
+
+ const bounds = { earliest, latest };
+
+ return bounds;
+};
+
+export const getChannelScheduleTimeBounds = (channel: Channel, now: Date) =>
+ getScheduleTimeBounds(getScheduledMessageLimits(channel), now);
+
+export const getScheduleTimeError = (
+ scheduledAt: Date,
+ { earliest, latest }: ScheduleTimeBounds,
+): ScheduleTimeError | undefined => {
+ if (scheduledAt < earliest) return ScheduleTimeError.TooSoon;
+
+ if (latest && scheduledAt > latest) return ScheduleTimeError.TooFar;
+
+ return undefined;
+};
+
+export const isWithinScheduleTimeBounds = (
+ scheduledAt: Date,
+ bounds: ScheduleTimeBounds,
+) => !getScheduleTimeError(scheduledAt, bounds);
+
+export const getDefaultScheduledAt = ({ earliest }: ScheduleTimeBounds) => {
+ const defaultScheduledAt = new Date(earliest);
+
+ defaultScheduledAt.setMinutes(0, 0, 0);
+ defaultScheduledAt.setHours(defaultScheduledAt.getHours() + 1);
+
+ return defaultScheduledAt;
+};
diff --git a/src/components/ScheduledMessages/utils/scheduledMessageActions.ts b/src/components/ScheduledMessages/utils/scheduledMessageActions.ts
new file mode 100644
index 000000000..9df267f1a
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduledMessageActions.ts
@@ -0,0 +1,52 @@
+import { SCHEDULED_MESSAGE_FAILURE_REASON, SCHEDULED_MESSAGE_STATUS } from 'stream-chat';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import { ScheduledMessageAction } from '../constants';
+import type { ScheduledMessageActionsOptions } from '../types';
+
+const PENDING_ACTIONS: ScheduledMessageAction[] = [
+ ScheduledMessageAction.Edit,
+ ScheduledMessageAction.Reschedule,
+ ScheduledMessageAction.SendNow,
+ ScheduledMessageAction.Delete,
+];
+
+const CHANNEL_DELETED_ACTIONS: ScheduledMessageAction[] = [
+ ScheduledMessageAction.Copy,
+ ScheduledMessageAction.Delete,
+];
+
+const FAILED_ACTIONS: ScheduledMessageAction[] = [
+ ScheduledMessageAction.Edit,
+ ScheduledMessageAction.Delete,
+];
+
+const NO_ACTIONS: ScheduledMessageAction[] = [];
+
+const getActionsForStatus = ({ failure_reason, status }: ScheduledMessageResponse) => {
+ if (status === SCHEDULED_MESSAGE_STATUS.Sending) return NO_ACTIONS;
+
+ if (status !== SCHEDULED_MESSAGE_STATUS.Failed) return PENDING_ACTIONS;
+
+ return failure_reason === SCHEDULED_MESSAGE_FAILURE_REASON.ChannelDeleted
+ ? CHANNEL_DELETED_ACTIONS
+ : FAILED_ACTIONS;
+};
+
+export const getScheduledMessageActions = (
+ scheduledMessage: ScheduledMessageResponse,
+ { canEdit }: ScheduledMessageActionsOptions,
+) => {
+ const actions = getActionsForStatus(scheduledMessage).filter(
+ (action) => canEdit || action !== ScheduledMessageAction.Edit,
+ );
+
+ return actions;
+};
+
+export const hasScheduledMessageEditButton = (
+ scheduledMessage: ScheduledMessageResponse,
+ actions: ScheduledMessageAction[],
+) =>
+ scheduledMessage.status !== SCHEDULED_MESSAGE_STATUS.Failed &&
+ actions.includes(ScheduledMessageAction.Edit);
diff --git a/src/components/ScheduledMessages/utils/scheduledMessageChannel.ts b/src/components/ScheduledMessages/utils/scheduledMessageChannel.ts
new file mode 100644
index 000000000..5fa530f54
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduledMessageChannel.ts
@@ -0,0 +1,14 @@
+import type { ScheduledMessageResponse, StreamChat } from 'stream-chat';
+
+const CID_SEPARATOR = ':';
+
+export const getScheduledMessageChannel = (
+ client: StreamChat,
+ { cid }: ScheduledMessageResponse,
+) => {
+ const [type, id] = cid.split(CID_SEPARATOR);
+
+ const channel = client.channelManager.get(cid) ?? client.channel(type, id);
+
+ return channel;
+};
diff --git a/src/components/ScheduledMessages/utils/scheduledMessageComposer.ts b/src/components/ScheduledMessages/utils/scheduledMessageComposer.ts
new file mode 100644
index 000000000..71a5409a5
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduledMessageComposer.ts
@@ -0,0 +1,14 @@
+import type { MessageComposer, ScheduledMessageResponse } from 'stream-chat';
+
+import { isFailedScheduledMessage } from './scheduledMessageGroups';
+
+export const openScheduledMessageInComposer = (
+ messageComposer: MessageComposer,
+ scheduledMessage: ScheduledMessageResponse,
+) => {
+ const openScheduledMessage = isFailedScheduledMessage(scheduledMessage)
+ ? messageComposer.rescheduleFailedMessage
+ : messageComposer.editScheduledMessage;
+
+ return openScheduledMessage(scheduledMessage);
+};
diff --git a/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts b/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts
new file mode 100644
index 000000000..94bd22e31
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts
@@ -0,0 +1,16 @@
+import { getScheduledMessageErrorReason, ScheduledMessageErrorReason } from 'stream-chat';
+import type { ScheduledMessageManager } from 'stream-chat';
+
+export const isScheduledMessageError = (
+ error: unknown,
+ reason: ScheduledMessageErrorReason,
+) => error instanceof Error && getScheduledMessageErrorReason(error) === reason;
+
+export const reloadOnScheduledMessageConflict = (
+ manager: ScheduledMessageManager,
+ error: unknown,
+) => {
+ if (!isScheduledMessageError(error, ScheduledMessageErrorReason.Conflict)) return;
+
+ manager.reload().catch(() => undefined);
+};
diff --git a/src/components/ScheduledMessages/utils/scheduledMessageFailureReason.ts b/src/components/ScheduledMessages/utils/scheduledMessageFailureReason.ts
new file mode 100644
index 000000000..e7e1f5412
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduledMessageFailureReason.ts
@@ -0,0 +1,48 @@
+import type { StreamTFunction } from '../../../i18n/types';
+import { SCHEDULED_MESSAGE_FAILURE_REASON } from 'stream-chat';
+import type { ScheduledMessageFailureReason } from 'stream-chat';
+
+export const translateScheduledMessageFailureReason = (
+ t: StreamTFunction,
+ failureReason?: ScheduledMessageFailureReason,
+) => {
+ switch (failureReason) {
+ case SCHEDULED_MESSAGE_FAILURE_REASON.Banned:
+ return t(
+ 'scheduledMessages.youCantSendMessagesInThisChannel',
+ "You can't send messages in this channel",
+ );
+ case SCHEDULED_MESSAGE_FAILURE_REASON.NoPermission:
+ return t(
+ 'scheduledMessages.youreNoLongerAllowedToPostHere',
+ "You're no longer allowed to post here",
+ );
+ case SCHEDULED_MESSAGE_FAILURE_REASON.ChannelFrozen:
+ return t('scheduledMessages.thisChannelIsFrozen', 'This channel is frozen');
+ case SCHEDULED_MESSAGE_FAILURE_REASON.ChannelDeleted:
+ return t('scheduledMessages.thisChannelWasDeleted', 'This channel was deleted');
+ case SCHEDULED_MESSAGE_FAILURE_REASON.UserDeleted:
+ return undefined;
+ case SCHEDULED_MESSAGE_FAILURE_REASON.ModerationBlocked:
+ return t('scheduledMessages.blockedByModeration', 'Blocked by moderation');
+ case SCHEDULED_MESSAGE_FAILURE_REASON.HookRejected:
+ return t('scheduledMessages.rejectedByTheApp', 'Rejected by the app');
+ case SCHEDULED_MESSAGE_FAILURE_REASON.Expired:
+ return t(
+ 'scheduledMessages.notSentTheServiceWasDelayed',
+ 'Not sent: the service was delayed',
+ );
+ case SCHEDULED_MESSAGE_FAILURE_REASON.InvalidMessage:
+ return t(
+ 'scheduledMessages.theMessageNoLongerMeetsTheAppsLimits',
+ "The message no longer meets the app's limits. Edit it",
+ );
+ case SCHEDULED_MESSAGE_FAILURE_REASON.PendingNotSupported:
+ return t(
+ 'scheduledMessages.thisChannelNowHoldsMessagesForApprovalSchedule',
+ 'This channel now holds messages for approval. Schedule it again',
+ );
+ default:
+ return t('scheduledMessages.couldntSendTryAgain', "Couldn't send. Try again");
+ }
+};
diff --git a/src/components/ScheduledMessages/utils/scheduledMessageGroups.ts b/src/components/ScheduledMessages/utils/scheduledMessageGroups.ts
new file mode 100644
index 000000000..8bdf770dc
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduledMessageGroups.ts
@@ -0,0 +1,61 @@
+import { nsToDate, SCHEDULED_MESSAGE_STATUS } from 'stream-chat';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import {
+ FAILED_SCHEDULED_MESSAGE_GROUP_ID,
+ ScheduledMessageGroupType,
+} from '../constants';
+import type { ScheduledMessageGroup } from '../types';
+import { toDateInputValue } from './dateTimeInputValues';
+
+export const isFailedScheduledMessage = ({ status }: ScheduledMessageResponse) =>
+ status === SCHEDULED_MESSAGE_STATUS.Failed;
+
+const createFailedGroup = (
+ scheduledMessages: ScheduledMessageResponse[],
+): ScheduledMessageGroup => ({
+ id: FAILED_SCHEDULED_MESSAGE_GROUP_ID,
+ scheduledMessages,
+ type: ScheduledMessageGroupType.Failed,
+});
+
+const createDayGroup = (id: string, day: Date): ScheduledMessageGroup => ({
+ day,
+ id,
+ scheduledMessages: [],
+ type: ScheduledMessageGroupType.Day,
+});
+
+export const groupScheduledMessagesByDay = (
+ scheduledMessages: ScheduledMessageResponse[],
+) => {
+ const dayGroups = new Map();
+
+ scheduledMessages.forEach((scheduledMessage) => {
+ const day = nsToDate(scheduledMessage.scheduled_at);
+ const dayId = toDateInputValue(day);
+
+ const dayGroup = dayGroups.get(dayId) ?? createDayGroup(dayId, day);
+
+ dayGroup.scheduledMessages.push(scheduledMessage);
+ dayGroups.set(dayId, dayGroup);
+ });
+
+ const groupsByDay = Array.from(dayGroups.values());
+
+ return groupsByDay;
+};
+
+export const groupScheduledMessages = (scheduledMessages: ScheduledMessageResponse[]) => {
+ const failedMessages = scheduledMessages.filter(isFailedScheduledMessage);
+ const upcomingMessages = scheduledMessages.filter(
+ (scheduledMessage) => !isFailedScheduledMessage(scheduledMessage),
+ );
+
+ const failedGroups = failedMessages.length ? [createFailedGroup(failedMessages)] : [];
+ const dayGroups = groupScheduledMessagesByDay(upcomingMessages);
+
+ const groups = [...failedGroups, ...dayGroups];
+
+ return groups;
+};
diff --git a/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts b/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts
new file mode 100644
index 000000000..4dd0f4631
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts
@@ -0,0 +1,31 @@
+import { dateToNs, toScheduledMessagePreview } from 'stream-chat';
+import type { LocalMessage, ScheduledMessageResponse, UserResponse } from 'stream-chat';
+
+import type { ScheduledMessagePreviewSource } from '../types';
+
+export const toStoredScheduledMessagePreview = (
+ scheduledMessage: ScheduledMessageResponse,
+): LocalMessage => toScheduledMessagePreview(scheduledMessage);
+
+export const toComposedScheduledMessagePreview = (
+ { attachments, cid, id, text }: ScheduledMessagePreviewSource,
+ user: UserResponse,
+ now: Date,
+): LocalMessage => {
+ const timestamp = dateToNs(now);
+
+ const scheduledMessage: ScheduledMessageResponse = {
+ cid,
+ created_at: timestamp,
+ id,
+ message: { attachments, custom: {}, text },
+ scheduled_at: timestamp,
+ status: 'scheduled',
+ updated_at: timestamp,
+ user,
+ };
+
+ const previewMessage = toScheduledMessagePreview(scheduledMessage);
+
+ return previewMessage;
+};
diff --git a/src/components/ScheduledMessages/utils/scheduledMessageScope.ts b/src/components/ScheduledMessages/utils/scheduledMessageScope.ts
new file mode 100644
index 000000000..1e9ba054c
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduledMessageScope.ts
@@ -0,0 +1,18 @@
+import {
+ getChannelLevelScheduledMessages,
+ getThreadScheduledMessages,
+} from 'stream-chat';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import type { UseScheduledMessagesParams } from '../types';
+
+export const getScopedScheduledMessages = (
+ scheduledMessages: ScheduledMessageResponse[],
+ { cid, parentId }: UseScheduledMessagesParams,
+) => {
+ if (parentId) return getThreadScheduledMessages(scheduledMessages, parentId);
+
+ if (cid) return getChannelLevelScheduledMessages(scheduledMessages);
+
+ return scheduledMessages;
+};
diff --git a/src/components/ScheduledMessages/utils/scheduledMessageSearch.ts b/src/components/ScheduledMessages/utils/scheduledMessageSearch.ts
new file mode 100644
index 000000000..e41153060
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/scheduledMessageSearch.ts
@@ -0,0 +1,20 @@
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+const normalizeSearchText = (text: string) => text.trim().toLocaleLowerCase();
+
+export const filterScheduledMessagesByText = (
+ scheduledMessages: ScheduledMessageResponse[],
+ searchQuery = '',
+) => {
+ const normalizedQuery = normalizeSearchText(searchQuery);
+
+ if (!normalizedQuery) return scheduledMessages;
+
+ const matchingScheduledMessages = scheduledMessages.filter(({ message }) => {
+ const { text = '' } = message;
+
+ return normalizeSearchText(text).includes(normalizedQuery);
+ });
+
+ return matchingScheduledMessages;
+};
diff --git a/src/components/ScheduledMessages/utils/timeZone.ts b/src/components/ScheduledMessages/utils/timeZone.ts
new file mode 100644
index 000000000..31cd005d4
--- /dev/null
+++ b/src/components/ScheduledMessages/utils/timeZone.ts
@@ -0,0 +1,18 @@
+const MINUTES_IN_HOUR = 60;
+
+export const formatTimeZoneOffset = (date: Date) => {
+ const offsetMinutes = -date.getTimezoneOffset();
+
+ if (!offsetMinutes) return 'GMT';
+
+ const sign = offsetMinutes < 0 ? '-' : '+';
+ const absoluteOffsetMinutes = Math.abs(offsetMinutes);
+ const hours = Math.floor(absoluteOffsetMinutes / MINUTES_IN_HOUR);
+ const minutes = absoluteOffsetMinutes % MINUTES_IN_HOUR;
+
+ const timeZoneOffset = minutes
+ ? `GMT${sign}${hours}:${String(minutes).padStart(2, '0')}`
+ : `GMT${sign}${hours}`;
+
+ return timeZoneOffset;
+};
diff --git a/src/components/index.ts b/src/components/index.ts
index 64061386f..9f1f998c3 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -10,6 +10,7 @@ export * from './Channel';
export * from './ChannelHeader';
export * from './ChannelList';
export * from './ChannelListItem';
+export * from './ScheduledMessages';
export * from './Search';
export * from './SkipNavigation';
export * from './Chat';
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index 45195702f..805a60c64 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -78,10 +78,24 @@ import type { SummarizedMessagePreviewProps } from '../components/SummarizedMess
import type { TypingIndicatorProps } from '../components/TypingIndicator';
import type { AttachmentUploadProgressIndicatorProps } from '../components/Attachment/components/AttachmentUploadProgressIndicator';
import type { IconSlots } from '../components/Icons/slots';
+import type {
+ DeleteScheduledMessageAlertProps,
+ EditedScheduledMessagePreviewProps,
+ FailedScheduledMessagePreviewProps,
+ RescheduleMessageDialogProps,
+ ScheduledMessageActionsProps,
+ ScheduledMessageItemProps,
+ ScheduledMessageListProps,
+ ScheduleMessageDialogProps,
+} from '../components/ScheduledMessages';
export type ComponentContextValue = {
/** Progress of an attachment whose upload is still in flight. */
AttachmentUploadProgressIndicator?: React.ComponentType;
+ /** Confirmation before a scheduled message is deleted. */
+ DeleteScheduledMessageAlert?: React.ComponentType;
+ /** Composer preview while a scheduled message is edited. */
+ EditedScheduledMessagePreview?: React.ComponentType;
/** Custom UI component rendered when a paginated list (e.g. the channel list) is empty. */
EmptyListIndicator?: React.ComponentType;
/** Custom UI component rendered at the top/bottom edge of a paginated list. */
@@ -89,6 +103,8 @@ export type ComponentContextValue = {
hasEnded: boolean;
reached: 'top' | 'bottom';
}>;
+ /** Composer preview while a failed scheduled message is scheduled again. */
+ FailedScheduledMessagePreview?: React.ComponentType;
/** Custom UI component rendered while a paginated list loads its first page. */
FirstPageLoadingIndicator?: React.ComponentType;
/** Custom UI component rendered per item of a paginated list. */
@@ -113,6 +129,20 @@ export type ComponentContextValue = {
AttachmentPreviewList?: React.ComponentType;
/** Control for adding attachments to the `MessageComposer`. */
AttachmentSelector?: React.ComponentType;
+ /** Dialog that picks a new time for a scheduled message. */
+ RescheduleMessageDialog?: React.ComponentType;
+ /** Actions of a scheduled message list item; `null` hides them. */
+ ScheduledMessageActions?: React.ComponentType | null;
+ /** One item of the scheduled message list. */
+ ScheduledMessageItem?: React.ComponentType;
+ /** Scheduled message list in the banner dialog and the Channel Detail section. */
+ ScheduledMessageList?: React.ComponentType;
+ /** Scheduled messages banner above the composer; `null` hides it. */
+ ScheduledMessagesBanner?: React.ComponentType | null;
+ /** Schedule button next to the send button; `null` hides it. */
+ ScheduleMessageButton?: React.ComponentType | null;
+ /** Custom-time dialog for scheduling a message. */
+ ScheduleMessageDialog?: React.ComponentType;
/**
* Voice recording preview above the composer attachments, rendered without props. To change the
* preview, render `VoiceRecordingPreviewSlot` with your own `VoiceRecordingPreview`.
diff --git a/src/i18n/TranslationBuilder/notifications/translators.ts b/src/i18n/TranslationBuilder/notifications/translators.ts
index c13837122..934422571 100644
--- a/src/i18n/TranslationBuilder/notifications/translators.ts
+++ b/src/i18n/TranslationBuilder/notifications/translators.ts
@@ -1,7 +1,19 @@
-import type { Notification } from 'stream-chat';
+import {
+ getScheduledMessageErrorReason,
+ isScheduledMessageNotFoundError,
+ ScheduledMessageBlockReason,
+ ScheduledMessageCompositionEndReason,
+ ScheduledMessageErrorReason,
+ StreamAPIError,
+} from 'stream-chat';
+import type { Notification, ScheduledMessageFailureReason } from 'stream-chat';
+
+import { ScheduledMessageNotificationType } from '../../../components/ScheduledMessages/constants';
+import { translateScheduledMessageFailureReason } from '../../../components/ScheduledMessages/utils/scheduledMessageFailureReason';
import type { NotificationTranslatorOptions } from './types';
import type { Translator } from '../../index';
+import type { StreamTFunction } from '../../types';
const normalizeReason = (notification?: Notification) => {
const reason = notification?.metadata?.reason;
@@ -94,3 +106,205 @@ export const translateCommandDisabled: Translator
return t('notification.commandDisabled', 'Command not available');
};
+
+const FORBIDDEN_STATUS = 403;
+
+const isForbiddenError = (error?: unknown) =>
+ error instanceof StreamAPIError && error.status === FORBIDDEN_STATUS;
+
+export const translateScheduledMessageCreateFailed: Translator<
+ NotificationTranslatorOptions
+> = ({ options: { notification }, t }) => {
+ const error = notification?.originalError;
+ const reason = getScheduledMessageErrorReason(error);
+
+ if (reason === ScheduledMessageErrorReason.Disabled) {
+ return t(
+ 'scheduledMessages.notification.disabled',
+ 'Scheduled messages are turned off',
+ );
+ }
+
+ if (reason === ScheduledMessageErrorReason.TooSoon) {
+ return t('scheduledMessages.chooseALaterTime', 'Choose a later time');
+ }
+
+ if (reason === ScheduledMessageErrorReason.TooFar) {
+ return t('scheduledMessages.chooseAnEarlierTime', 'Choose an earlier time');
+ }
+
+ if (reason === ScheduledMessageErrorReason.ChannelLimit) {
+ return t(
+ 'scheduledMessages.notification.channelLimit',
+ "You've reached the limit of scheduled messages in this channel",
+ );
+ }
+
+ if (reason === ScheduledMessageErrorReason.UserLimit) {
+ return t(
+ 'scheduledMessages.notification.userLimit',
+ "You've reached the limit of scheduled messages",
+ );
+ }
+
+ if (isForbiddenError(error)) {
+ return t(
+ 'scheduledMessages.notification.forbidden',
+ "You can't schedule messages here",
+ );
+ }
+
+ return t(
+ 'scheduledMessages.couldntScheduleMessageTryAgain',
+ "Couldn't schedule message. Try again",
+ );
+};
+
+export const translateScheduledMessageBlocked: Translator<
+ NotificationTranslatorOptions
+> = ({ options: { notification }, t }) => {
+ const { reason } = notification?.metadata ?? {};
+
+ if (reason === ScheduledMessageBlockReason.Command) {
+ return t(
+ 'scheduledMessages.notification.commandBlocked',
+ "Slash commands can't be scheduled",
+ );
+ }
+
+ if (reason === ScheduledMessageBlockReason.Poll) {
+ return t('scheduledMessages.notification.pollBlocked', "Polls can't be scheduled");
+ }
+
+ if (reason === ScheduledMessageBlockReason.SharedLocation) {
+ return t(
+ 'scheduledMessages.notification.locationBlocked',
+ "Locations can't be scheduled",
+ );
+ }
+
+ return t('scheduledMessages.notification.blocked', "This message can't be scheduled");
+};
+
+const translateScheduledMessageActionFallback = (t: StreamTFunction, type?: string) => {
+ if (type === ScheduledMessageNotificationType.RescheduleFailed) {
+ return t(
+ 'scheduledMessages.couldntRescheduleTheMessageTryAgain',
+ "Couldn't reschedule the message. Try again",
+ );
+ }
+
+ if (type === ScheduledMessageNotificationType.SendNowFailed) {
+ return t(
+ 'scheduledMessages.notification.sendNowFailed',
+ "Couldn't send the message. Try again",
+ );
+ }
+
+ if (type === ScheduledMessageNotificationType.DeleteFailed) {
+ return t(
+ 'scheduledMessages.notification.deleteFailed',
+ "Couldn't delete the scheduled message. Try again",
+ );
+ }
+
+ return t(
+ 'scheduledMessages.notification.updateFailed',
+ "Couldn't update the scheduled message. Try again",
+ );
+};
+
+export const translateScheduledMessageActionFailed: Translator<
+ NotificationTranslatorOptions
+> = ({ options: { notification }, t }) => {
+ const error = notification?.originalError;
+ const reason = getScheduledMessageErrorReason(error);
+
+ if (isScheduledMessageNotFoundError(error)) {
+ return t('scheduledMessages.notification.alreadySent', 'Already sent');
+ }
+
+ if (reason === ScheduledMessageErrorReason.Sending) {
+ return t('scheduledMessages.notification.sending', 'This message is being sent');
+ }
+
+ if (reason === ScheduledMessageErrorReason.Conflict) {
+ return t('scheduledMessages.notification.conflict', 'Updated elsewhere. Try again');
+ }
+
+ if (reason === ScheduledMessageErrorReason.NotEditable) {
+ return t(
+ 'scheduledMessages.notification.notEditable',
+ "Failed messages can't be edited",
+ );
+ }
+
+ if (reason === ScheduledMessageErrorReason.TooSoon) {
+ return t('scheduledMessages.chooseALaterTime', 'Choose a later time');
+ }
+
+ if (reason === ScheduledMessageErrorReason.TooFar) {
+ return t('scheduledMessages.chooseAnEarlierTime', 'Choose an earlier time');
+ }
+
+ if (isForbiddenError(error)) {
+ return t(
+ 'scheduledMessages.notification.forbidden',
+ "You can't schedule messages here",
+ );
+ }
+
+ return translateScheduledMessageActionFallback(t, notification?.type);
+};
+
+export const translateScheduledMessageSendFailed: Translator<
+ NotificationTranslatorOptions
+> = ({ options: { notification }, t }) => {
+ const { reason } = notification?.metadata ?? {};
+ const reasonCopy = translateScheduledMessageFailureReason(
+ t,
+ reason as ScheduledMessageFailureReason | undefined,
+ );
+
+ if (!reasonCopy) {
+ return t('scheduledMessages.notification.notSent', 'Scheduled message not sent');
+ }
+
+ return t(
+ 'scheduledMessages.notification.notSentWithReason',
+ 'Scheduled message not sent: {{ reason }}',
+ { reason: reasonCopy },
+ );
+};
+
+export const translateScheduledMessageCompositionEnded: Translator<
+ NotificationTranslatorOptions
+> = ({ options: { notification }, t }) => {
+ const { reason } = notification?.metadata ?? {};
+
+ if (reason === ScheduledMessageCompositionEndReason.Sent) {
+ return t(
+ 'scheduledMessages.notification.alreadySentEdit',
+ 'This message was already sent',
+ );
+ }
+
+ if (reason === ScheduledMessageCompositionEndReason.Deleted) {
+ return t(
+ 'scheduledMessages.notification.deletedEdit',
+ 'This scheduled message was deleted',
+ );
+ }
+
+ if (reason === ScheduledMessageCompositionEndReason.Failed) {
+ return t(
+ 'scheduledMessages.notification.failedEdit',
+ 'This scheduled message failed to send',
+ );
+ }
+
+ return t(
+ 'scheduledMessages.notification.endedEdit',
+ 'This scheduled message can no longer be edited',
+ );
+};
diff --git a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts
index a713b7fa9..921900ec9 100644
--- a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts
+++ b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts
@@ -9,7 +9,13 @@ import {
translateCommandDisabled,
translatePollCreateFailed,
translatePollEndFailed,
+ translateScheduledMessageActionFailed,
+ translateScheduledMessageBlocked,
+ translateScheduledMessageCompositionEnded,
+ translateScheduledMessageCreateFailed,
+ translateScheduledMessageSendFailed,
} from './translators';
+import { ScheduledMessageNotificationType } from '../../../components/ScheduledMessages/constants';
import type { Translator } from '../../index';
type NotificationTranslator = Translator;
@@ -51,6 +57,13 @@ const coreNotificationTranslators: Record = {
'browser:audio:playback:error': translateBrowserAudioPlaybackError,
'browser:location:get:failed': ({ t }) =>
t('notification.locationGetFailed', 'Failed to retrieve location'),
+ [ScheduledMessageNotificationType.DeleteFailed]: translateScheduledMessageActionFailed,
+ [ScheduledMessageNotificationType.RescheduleFailed]:
+ translateScheduledMessageActionFailed,
+ [ScheduledMessageNotificationType.SendFailed]: translateScheduledMessageSendFailed,
+ [ScheduledMessageNotificationType.SendNowFailed]: translateScheduledMessageActionFailed,
};
export const translatorsByNotificationType: Record = {
diff --git a/src/i18n/__tests__/Streami18n.test.ts b/src/i18n/__tests__/Streami18n.test.ts
index a6f991b6d..b64897ef6 100644
--- a/src/i18n/__tests__/Streami18n.test.ts
+++ b/src/i18n/__tests__/Streami18n.test.ts
@@ -266,12 +266,16 @@ describe('Streami18n - the calendar keys that carry English words', () => {
// dayjs takes the calendar wording in the format string, so a few `timestamp.*` values embed
// English day words. A per-key `calendarFormats` replaces the locale's calendar, so
// `dayjsLocaleConfigForLanguage` cannot translate them; only overriding the key can. The
- // migration guide names these four, and this keeps that list honest.
+ // migration guide names these eight, and this keeps that list honest.
const KEYS_WITH_ENGLISH_WORDS = [
'timestamp.ChannelDetailPinnedMessageTimestamp',
'timestamp.ChannelPreviewTimestamp',
'timestamp.DateSeparator',
'timestamp.ReminderNotification',
+ 'timestamp.SchedulePreset',
+ 'timestamp.ScheduledMessageConfirmation',
+ 'timestamp.ScheduledMessageGroup',
+ 'timestamp.ScheduledMessageSendTime',
];
it('is exactly the set the migration guide documents', () => {
diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json
index a7a179b20..1abc72d75 100644
--- a/src/i18n/__tests__/catalog.fixture.json
+++ b/src/i18n/__tests__/catalog.fixture.json
@@ -589,6 +589,91 @@
"reactions.messageReactionsDetail.reactions.text_other": "{{ count }} reactions",
"reactions.messageReactionsDetail.tapRemove.ariaLabel": "Tap to remove: {{ reactionName }}",
"reactions.messageReactionsDetail.tapRemove.text": "Tap to remove",
+ "scheduledMessages.banner.failedToSend_one": "{{ count }} failed to send",
+ "scheduledMessages.banner.failedToSend_other": "{{ count }} failed to send",
+ "scheduledMessages.banner.scheduledMessages_one": "{{ count }} scheduled message",
+ "scheduledMessages.banner.scheduledMessages_other": "{{ count }} scheduled messages",
+ "scheduledMessages.banner.scheduledReplies_one": "{{ count }} scheduled reply",
+ "scheduledMessages.banner.scheduledReplies_other": "{{ count }} scheduled replies",
+ "scheduledMessages.blockedByModeration": "Blocked by moderation",
+ "scheduledMessages.cancel": "Cancel",
+ "scheduledMessages.chooseALaterTime": "Choose a later time",
+ "scheduledMessages.chooseAnEarlierTime": "Choose an earlier time",
+ "scheduledMessages.clickTheArrowNextToTheSendButton": "Click the arrow next to the send button in a channel to schedule a message.",
+ "scheduledMessages.copyText": "Copy text",
+ "scheduledMessages.couldntDeleteTheScheduledMessageTryAgain": "Couldn't delete the scheduled message. Try again",
+ "scheduledMessages.couldntLoadScheduledMessages": "Couldn't load scheduled messages",
+ "scheduledMessages.couldntRescheduleTheMessageTryAgain": "Couldn't reschedule the message. Try again",
+ "scheduledMessages.couldntScheduleMessageTryAgain": "Couldn't schedule message. Try again",
+ "scheduledMessages.couldntSendTheMessageTryAgain": "Couldn't send the message. Try again",
+ "scheduledMessages.couldntSendTryAgain": "Couldn't send. Try again",
+ "scheduledMessages.customTime": "Custom time",
+ "scheduledMessages.date": "Date",
+ "scheduledMessages.delete": "Delete",
+ "scheduledMessages.deleteScheduledMessage": "Delete scheduled message?",
+ "scheduledMessages.edit": "Edit",
+ "scheduledMessages.editReschedule": "Edit & reschedule",
+ "scheduledMessages.editScheduledMessage": "Edit scheduled message",
+ "scheduledMessages.failed": "Failed",
+ "scheduledMessages.loadMore": "Load more",
+ "scheduledMessages.loadingScheduledMessages": "Loading scheduled messages",
+ "scheduledMessages.messageScheduledForTime": "Message scheduled for {{ time }}",
+ "scheduledMessages.messageToName": "Message to {{ name }}",
+ "scheduledMessages.noMessagesFound": "No messages found",
+ "scheduledMessages.noScheduledMessages": "No scheduled messages",
+ "scheduledMessages.notSentTheServiceWasDelayed": "Not sent: the service was delayed",
+ "scheduledMessages.notification.alreadySent": "Already sent",
+ "scheduledMessages.notification.alreadySentEdit": "This message was already sent",
+ "scheduledMessages.notification.blocked": "This message can't be scheduled",
+ "scheduledMessages.notification.channelLimit": "You've reached the limit of scheduled messages in this channel",
+ "scheduledMessages.notification.commandBlocked": "Slash commands can't be scheduled",
+ "scheduledMessages.notification.conflict": "Updated elsewhere. Try again",
+ "scheduledMessages.notification.deleteFailed": "Couldn't delete the scheduled message. Try again",
+ "scheduledMessages.notification.deletedEdit": "This scheduled message was deleted",
+ "scheduledMessages.notification.disabled": "Scheduled messages are turned off",
+ "scheduledMessages.notification.endedEdit": "This scheduled message can no longer be edited",
+ "scheduledMessages.notification.failedEdit": "This scheduled message failed to send",
+ "scheduledMessages.notification.forbidden": "You can't schedule messages here",
+ "scheduledMessages.notification.locationBlocked": "Locations can't be scheduled",
+ "scheduledMessages.notification.notEditable": "Failed messages can't be edited",
+ "scheduledMessages.notification.notSent": "Scheduled message not sent",
+ "scheduledMessages.notification.notSentWithReason": "Scheduled message not sent: {{ reason }}",
+ "scheduledMessages.notification.pollBlocked": "Polls can't be scheduled",
+ "scheduledMessages.notification.sendNowFailed": "Couldn't send the message. Try again",
+ "scheduledMessages.notification.sending": "This message is being sent",
+ "scheduledMessages.notification.updateFailed": "Couldn't update the scheduled message. Try again",
+ "scheduledMessages.notification.userLimit": "You've reached the limit of scheduled messages",
+ "scheduledMessages.openScheduleMessageMenu.ariaLabel": "Open schedule message menu",
+ "scheduledMessages.openScheduledMessageActions.ariaLabel": "Open scheduled message actions",
+ "scheduledMessages.rejectedByTheApp": "Rejected by the app",
+ "scheduledMessages.reschedule": "Reschedule",
+ "scheduledMessages.rescheduleMessage": "Reschedule message",
+ "scheduledMessages.save": "Save",
+ "scheduledMessages.schedule": "Schedule",
+ "scheduledMessages.scheduleAgain": "Schedule again",
+ "scheduledMessages.scheduleMessage": "Schedule message",
+ "scheduledMessages.scheduledForTime": "Scheduled for {{ time }}",
+ "scheduledMessages.scheduledMessageActions": "Scheduled message actions",
+ "scheduledMessages.scheduledMessageSent": "Scheduled message sent",
+ "scheduledMessages.scheduledMessages": "Scheduled messages",
+ "scheduledMessages.scheduledReplies": "Scheduled replies",
+ "scheduledMessages.search": "Search",
+ "scheduledMessages.sendNow": "Send now",
+ "scheduledMessages.sendTime": "Send {{ time }}",
+ "scheduledMessages.sending": "Sending...",
+ "scheduledMessages.sentInYourTimeZoneTimezone": "Sent in your time zone ({{ timeZone }})",
+ "scheduledMessages.theMessageNoLongerMeetsTheAppsLimits": "The message no longer meets the app's limits. Edit it",
+ "scheduledMessages.thisChannelIsFrozen": "This channel is frozen",
+ "scheduledMessages.thisChannelNowHoldsMessagesForApprovalSchedule": "This channel now holds messages for approval. Schedule it again",
+ "scheduledMessages.thisChannelWasDeleted": "This channel was deleted",
+ "scheduledMessages.thisMessageToNameWontBeSentThis": "This message to {{ name }} won't be sent. This can't be undone.",
+ "scheduledMessages.thisMessageWontBeSentThisCantBe": "This message won't be sent. This can't be undone.",
+ "scheduledMessages.time": "Time",
+ "scheduledMessages.tryAgain": "Try again",
+ "scheduledMessages.viewAll": "View all",
+ "scheduledMessages.youCantSendMessagesInThisChannel": "You can't send messages in this channel",
+ "scheduledMessages.youreNoLongerAllowedToPostHere": "You're no longer allowed to post here",
+ "scheduledMessages.youreOfflineReconnectToScheduleThisMessage": "You're offline. Reconnect to schedule this message.",
"search.bar.clearSearch.ariaLabel": "Clear search",
"search.bar.exitSearch.ariaLabel": "Exit search",
"search.resultItem.selectUserChannel.ariaLabel": "Select User Channel: {{ name }}",
@@ -639,6 +724,11 @@
"timestamp.PollVote": "{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactWeekRounding: ceil) }}",
"timestamp.PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}",
"timestamp.ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}",
+ "timestamp.ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, MMM D, YYYY) }}",
+ "timestamp.SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"dddd [at] HH:mm\" }) }}",
+ "timestamp.ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, MMM D [at] HH:mm\", \"lastDay\": \"ddd, MMM D [at] HH:mm\", \"nextWeek\": \"ddd, MMM D [at] HH:mm\", \"lastWeek\": \"ddd, MMM D [at] HH:mm\", \"sameElse\": \"ddd, MMM D [at] HH:mm\" }) }}",
+ "timestamp.ScheduledMessageGroup": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}",
+ "timestamp.ScheduledMessageSendTime": "{{ timestamp | timestampFormatter(format: ddd, MMM D [at] HH:mm) }}",
"timestamp.SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}",
"translationBuilderTopic.notification": "{{value, notification}}",
"typing.manyUsers_one": "{{ count }} person is typing",
diff --git a/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts b/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts
new file mode 100644
index 000000000..88411a0fa
--- /dev/null
+++ b/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts
@@ -0,0 +1,242 @@
+import { fromPartial } from '@total-typescript/shoehorn';
+import { ScheduledMessageBlockReason, StreamAPIError } from 'stream-chat';
+import type { Notification } from 'stream-chat';
+
+import {
+ translateScheduledMessageActionFailed,
+ translateScheduledMessageBlocked,
+ translateScheduledMessageCompositionEnded,
+ translateScheduledMessageCreateFailed,
+ translateScheduledMessageSendFailed,
+} from '../TranslationBuilder/notifications/translators';
+import { translatorsByNotificationType } from '../TranslationBuilder/notifications/translatorsByNotificationType';
+import { mockT } from '../../mock-builders';
+
+const t = mockT;
+
+const createApiError = (code: number, status = 400, reason?: string) =>
+ new StreamAPIError('request failed', {
+ code,
+ response: fromPartial>({
+ data: { code, exception_fields: reason ? { reason } : {} },
+ status,
+ }),
+ status,
+ });
+
+const translate = (originalError?: Error) =>
+ translateScheduledMessageCreateFailed({
+ key: '',
+ options: {
+ notification: fromPartial({
+ message: 'Failed to schedule the message',
+ originalError,
+ type: 'api:scheduled-message:create:failed',
+ }),
+ },
+ t,
+ value: '',
+ });
+
+describe('translateScheduledMessageCreateFailed', () => {
+ it.each([
+ [114, 'Scheduled messages are turned off'],
+ [115, 'Choose a later time'],
+ [116, 'Choose an earlier time'],
+ [117, "You've reached the limit of scheduled messages in this channel"],
+ [118, "You've reached the limit of scheduled messages"],
+ ])('translates error code %s', (code, expected) => {
+ expect(translate(createApiError(code))).toBe(expected);
+ });
+
+ it('prefers the reason from exception_fields', () => {
+ expect(translate(createApiError(4, 400, 'scheduled_limit_user'))).toBe(
+ "You've reached the limit of scheduled messages",
+ );
+ });
+
+ it('translates a missing permission', () => {
+ expect(translate(createApiError(17, 403))).toBe("You can't schedule messages here");
+ });
+
+ it.each([
+ ['an id conflict', createApiError(119)],
+ ['an unknown API error', createApiError(4)],
+ ['a network error', new Error('Network Error')],
+ ['a missing error', undefined],
+ ])('falls back to the generic copy for %s', (_, error) => {
+ expect(translate(error)).toBe("Couldn't schedule message. Try again");
+ });
+});
+
+const translateBlocked = (reason?: string) =>
+ translateScheduledMessageBlocked({
+ key: '',
+ options: {
+ notification: fromPartial({
+ message: 'This message cannot be scheduled',
+ metadata: reason ? { reason } : undefined,
+ type: 'validation:scheduled-message:blocked',
+ }),
+ },
+ t,
+ value: '',
+ });
+
+describe('translateScheduledMessageBlocked', () => {
+ it.each([
+ [ScheduledMessageBlockReason.Command, "Slash commands can't be scheduled"],
+ [ScheduledMessageBlockReason.Poll, "Polls can't be scheduled"],
+ [ScheduledMessageBlockReason.SharedLocation, "Locations can't be scheduled"],
+ ])('translates the %s reason', (reason, expected) => {
+ expect(translateBlocked(reason)).toBe(expected);
+ });
+
+ it('falls back to the generic copy without a known reason', () => {
+ expect(translateBlocked()).toBe("This message can't be scheduled");
+ expect(translateBlocked('unknown')).toBe("This message can't be scheduled");
+ });
+});
+
+const translateAction = (type: string, originalError?: Error) =>
+ translateScheduledMessageActionFailed({
+ key: '',
+ options: {
+ notification: fromPartial({ originalError, type }),
+ },
+ t,
+ value: '',
+ });
+
+describe('translateScheduledMessageActionFailed', () => {
+ it('reports a message the server no longer has as already sent', () => {
+ expect(
+ translateAction('api:scheduled-message:send-now:failed', createApiError(16, 404)),
+ ).toBe('Already sent');
+ });
+
+ it.each([
+ [121, 'scheduled_message_sending', 'This message is being sent'],
+ [122, 'scheduled_message_conflict', 'Updated elsewhere. Try again'],
+ [120, 'scheduled_message_not_editable', "Failed messages can't be edited"],
+ [115, 'scheduled_at_too_soon', 'Choose a later time'],
+ [116, 'scheduled_at_too_far', 'Choose an earlier time'],
+ ])('translates error code %s (%s)', (code, reason, expected) => {
+ expect(
+ translateAction(
+ 'api:scheduled-message:update:failed',
+ createApiError(code, 400, reason),
+ ),
+ ).toBe(expected);
+ });
+
+ it.each([
+ [
+ 'api:scheduled-message:update:failed',
+ "Couldn't update the scheduled message. Try again",
+ ],
+ [
+ 'api:scheduled-message:reschedule:failed',
+ "Couldn't reschedule the message. Try again",
+ ],
+ ['api:scheduled-message:send-now:failed', "Couldn't send the message. Try again"],
+ [
+ 'api:scheduled-message:delete:failed',
+ "Couldn't delete the scheduled message. Try again",
+ ],
+ ])('falls back to the copy of %s', (type, expected) => {
+ expect(translateAction(type, new Error('network'))).toBe(expected);
+ });
+});
+
+const translateSendFailed = (reason?: string) =>
+ translateScheduledMessageSendFailed({
+ key: '',
+ options: {
+ notification: fromPartial({
+ metadata: { reason },
+ type: 'api:scheduled-message:send:failed',
+ }),
+ },
+ t,
+ value: '',
+ });
+
+describe('translateScheduledMessageSendFailed', () => {
+ it('names the failure reason', () => {
+ expect(translateSendFailed('channel_frozen')).toBe(
+ 'Scheduled message not sent: This channel is frozen',
+ );
+ });
+
+ it('uses the generic reason for an unknown one', () => {
+ expect(translateSendFailed('something_new')).toBe(
+ "Scheduled message not sent: Couldn't send. Try again",
+ );
+ });
+
+ it('leaves out a reason that is not shown to users', () => {
+ expect(translateSendFailed('user_deleted')).toBe('Scheduled message not sent');
+ });
+});
+
+describe('translateScheduledMessageCompositionEnded', () => {
+ const translateEnded = (reason?: string) =>
+ translateScheduledMessageCompositionEnded({
+ key: '',
+ options: {
+ notification: fromPartial({
+ metadata: { reason },
+ type: 'validation:scheduled-message:composition:ended',
+ }),
+ },
+ t,
+ value: '',
+ });
+
+ it.each([
+ ['sent', 'This message was already sent'],
+ ['deleted', 'This scheduled message was deleted'],
+ ['failed', 'This scheduled message failed to send'],
+ [undefined, 'This scheduled message can no longer be edited'],
+ ])('explains why the edit ended (%s)', (reason, expected) => {
+ expect(translateEnded(reason)).toBe(expected);
+ });
+
+ it('is registered for the composition-ended warning', () => {
+ expect(
+ translatorsByNotificationType['validation:scheduled-message:composition:ended'],
+ ).toBe(translateScheduledMessageCompositionEnded);
+ });
+});
+
+describe('scheduled message notification types', () => {
+ it('registers the send failure translator', () => {
+ expect(translatorsByNotificationType['api:scheduled-message:send:failed']).toBe(
+ translateScheduledMessageSendFailed,
+ );
+ });
+
+ it.each([
+ 'api:scheduled-message:update:failed',
+ 'api:scheduled-message:reschedule:failed',
+ 'api:scheduled-message:send-now:failed',
+ 'api:scheduled-message:delete:failed',
+ ])('registers the action failure translator for %s', (type) => {
+ expect(translatorsByNotificationType[type]).toBe(
+ translateScheduledMessageActionFailed,
+ );
+ });
+
+ it('registers translators for the composer notifications', () => {
+ expect(translatorsByNotificationType['api:scheduled-message:create:failed']).toBe(
+ translateScheduledMessageCreateFailed,
+ );
+ expect(translatorsByNotificationType['validation:scheduled-message:blocked']).toBe(
+ translateScheduledMessageBlocked,
+ );
+ expect(translatorsByNotificationType).not.toHaveProperty(
+ 'validation:scheduled-message:command',
+ );
+ });
+});
diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts
index d4e3249c2..66da285fa 100644
--- a/src/i18n/keys.ts
+++ b/src/i18n/keys.ts
@@ -600,6 +600,91 @@ export type TranslationCatalog = {
'reactions.messageReactionsDetail.reactions.text_other': '{{ count }} reactions';
'reactions.messageReactionsDetail.tapRemove.ariaLabel': 'Tap to remove: {{ reactionName }}';
'reactions.messageReactionsDetail.tapRemove.text': 'Tap to remove';
+ 'scheduledMessages.banner.failedToSend_one': '{{ count }} failed to send';
+ 'scheduledMessages.banner.failedToSend_other': '{{ count }} failed to send';
+ 'scheduledMessages.banner.scheduledMessages_one': '{{ count }} scheduled message';
+ 'scheduledMessages.banner.scheduledMessages_other': '{{ count }} scheduled messages';
+ 'scheduledMessages.banner.scheduledReplies_one': '{{ count }} scheduled reply';
+ 'scheduledMessages.banner.scheduledReplies_other': '{{ count }} scheduled replies';
+ 'scheduledMessages.blockedByModeration': 'Blocked by moderation';
+ 'scheduledMessages.cancel': 'Cancel';
+ 'scheduledMessages.chooseALaterTime': 'Choose a later time';
+ 'scheduledMessages.chooseAnEarlierTime': 'Choose an earlier time';
+ 'scheduledMessages.clickTheArrowNextToTheSendButton': 'Click the arrow next to the send button in a channel to schedule a message.';
+ 'scheduledMessages.copyText': 'Copy text';
+ 'scheduledMessages.couldntDeleteTheScheduledMessageTryAgain': "Couldn't delete the scheduled message. Try again";
+ 'scheduledMessages.couldntLoadScheduledMessages': "Couldn't load scheduled messages";
+ 'scheduledMessages.couldntRescheduleTheMessageTryAgain': "Couldn't reschedule the message. Try again";
+ 'scheduledMessages.couldntScheduleMessageTryAgain': "Couldn't schedule message. Try again";
+ 'scheduledMessages.couldntSendTheMessageTryAgain': "Couldn't send the message. Try again";
+ 'scheduledMessages.couldntSendTryAgain': "Couldn't send. Try again";
+ 'scheduledMessages.customTime': 'Custom time';
+ 'scheduledMessages.date': 'Date';
+ 'scheduledMessages.delete': 'Delete';
+ 'scheduledMessages.deleteScheduledMessage': 'Delete scheduled message?';
+ 'scheduledMessages.edit': 'Edit';
+ 'scheduledMessages.editReschedule': 'Edit & reschedule';
+ 'scheduledMessages.editScheduledMessage': 'Edit scheduled message';
+ 'scheduledMessages.failed': 'Failed';
+ 'scheduledMessages.loadMore': 'Load more';
+ 'scheduledMessages.loadingScheduledMessages': 'Loading scheduled messages';
+ 'scheduledMessages.messageScheduledForTime': 'Message scheduled for {{ time }}';
+ 'scheduledMessages.messageToName': 'Message to {{ name }}';
+ 'scheduledMessages.noMessagesFound': 'No messages found';
+ 'scheduledMessages.noScheduledMessages': 'No scheduled messages';
+ 'scheduledMessages.notSentTheServiceWasDelayed': 'Not sent: the service was delayed';
+ 'scheduledMessages.notification.alreadySent': 'Already sent';
+ 'scheduledMessages.notification.alreadySentEdit': 'This message was already sent';
+ 'scheduledMessages.notification.blocked': "This message can't be scheduled";
+ 'scheduledMessages.notification.channelLimit': "You've reached the limit of scheduled messages in this channel";
+ 'scheduledMessages.notification.commandBlocked': "Slash commands can't be scheduled";
+ 'scheduledMessages.notification.conflict': 'Updated elsewhere. Try again';
+ 'scheduledMessages.notification.deleteFailed': "Couldn't delete the scheduled message. Try again";
+ 'scheduledMessages.notification.deletedEdit': 'This scheduled message was deleted';
+ 'scheduledMessages.notification.disabled': 'Scheduled messages are turned off';
+ 'scheduledMessages.notification.endedEdit': 'This scheduled message can no longer be edited';
+ 'scheduledMessages.notification.failedEdit': 'This scheduled message failed to send';
+ 'scheduledMessages.notification.forbidden': "You can't schedule messages here";
+ 'scheduledMessages.notification.locationBlocked': "Locations can't be scheduled";
+ 'scheduledMessages.notification.notEditable': "Failed messages can't be edited";
+ 'scheduledMessages.notification.notSent': 'Scheduled message not sent';
+ 'scheduledMessages.notification.notSentWithReason': 'Scheduled message not sent: {{ reason }}';
+ 'scheduledMessages.notification.pollBlocked': "Polls can't be scheduled";
+ 'scheduledMessages.notification.sendNowFailed': "Couldn't send the message. Try again";
+ 'scheduledMessages.notification.sending': 'This message is being sent';
+ 'scheduledMessages.notification.updateFailed': "Couldn't update the scheduled message. Try again";
+ 'scheduledMessages.notification.userLimit': "You've reached the limit of scheduled messages";
+ 'scheduledMessages.openScheduleMessageMenu.ariaLabel': 'Open schedule message menu';
+ 'scheduledMessages.openScheduledMessageActions.ariaLabel': 'Open scheduled message actions';
+ 'scheduledMessages.rejectedByTheApp': 'Rejected by the app';
+ 'scheduledMessages.reschedule': 'Reschedule';
+ 'scheduledMessages.rescheduleMessage': 'Reschedule message';
+ 'scheduledMessages.save': 'Save';
+ 'scheduledMessages.schedule': 'Schedule';
+ 'scheduledMessages.scheduleAgain': 'Schedule again';
+ 'scheduledMessages.scheduleMessage': 'Schedule message';
+ 'scheduledMessages.scheduledForTime': 'Scheduled for {{ time }}';
+ 'scheduledMessages.scheduledMessageActions': 'Scheduled message actions';
+ 'scheduledMessages.scheduledMessageSent': 'Scheduled message sent';
+ 'scheduledMessages.scheduledMessages': 'Scheduled messages';
+ 'scheduledMessages.scheduledReplies': 'Scheduled replies';
+ 'scheduledMessages.search': 'Search';
+ 'scheduledMessages.sendNow': 'Send now';
+ 'scheduledMessages.sendTime': 'Send {{ time }}';
+ 'scheduledMessages.sending': 'Sending...';
+ 'scheduledMessages.sentInYourTimeZoneTimezone': 'Sent in your time zone ({{ timeZone }})';
+ 'scheduledMessages.theMessageNoLongerMeetsTheAppsLimits': "The message no longer meets the app's limits. Edit it";
+ 'scheduledMessages.thisChannelIsFrozen': 'This channel is frozen';
+ 'scheduledMessages.thisChannelNowHoldsMessagesForApprovalSchedule': 'This channel now holds messages for approval. Schedule it again';
+ 'scheduledMessages.thisChannelWasDeleted': 'This channel was deleted';
+ 'scheduledMessages.thisMessageToNameWontBeSentThis': "This message to {{ name }} won't be sent. This can't be undone.";
+ 'scheduledMessages.thisMessageWontBeSentThisCantBe': "This message won't be sent. This can't be undone.";
+ 'scheduledMessages.time': 'Time';
+ 'scheduledMessages.tryAgain': 'Try again';
+ 'scheduledMessages.viewAll': 'View all';
+ 'scheduledMessages.youCantSendMessagesInThisChannel': "You can't send messages in this channel";
+ 'scheduledMessages.youreNoLongerAllowedToPostHere': "You're no longer allowed to post here";
+ 'scheduledMessages.youreOfflineReconnectToScheduleThisMessage': "You're offline. Reconnect to schedule this message.";
'search.bar.clearSearch.ariaLabel': 'Clear search';
'search.bar.exitSearch.ariaLabel': 'Exit search';
'search.resultItem.selectUserChannel.ariaLabel': 'Select User Channel: {{ name }}';
@@ -650,6 +735,11 @@ export type TranslationCatalog = {
'timestamp.PollVote': '{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactWeekRounding: ceil) }}';
'timestamp.PollVoteTooltip': '{{ timestamp | timestampFormatter(calendar: true) }}';
'timestamp.ReminderNotification': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today] [at] HH:mm", "nextDay": "[Tomorrow] [at] HH:mm", "lastDay": "[Yesterday] [at] HH:mm", "nextWeek": "dddd [at] HH:mm", "lastWeek": "[Last] dddd [at] HH:mm", "sameElse": "ddd, D MMM [at] HH:mm" }) }}';
+ 'timestamp.ScheduleDate': '{{ timestamp | timestampFormatter(format: ddd, MMM D, YYYY) }}';
+ 'timestamp.SchedulePreset': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today] [at] HH:mm", "nextDay": "[Tomorrow] [at] HH:mm", "lastDay": "[Yesterday] [at] HH:mm", "nextWeek": "dddd [at] HH:mm", "lastWeek": "[Last] dddd [at] HH:mm", "sameElse": "dddd [at] HH:mm" }) }}';
+ 'timestamp.ScheduledMessageConfirmation': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "HH:mm", "nextDay": "ddd, MMM D [at] HH:mm", "lastDay": "ddd, MMM D [at] HH:mm", "nextWeek": "ddd, MMM D [at] HH:mm", "lastWeek": "ddd, MMM D [at] HH:mm", "sameElse": "ddd, MMM D [at] HH:mm" }) }}';
+ 'timestamp.ScheduledMessageGroup': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today]", "nextDay": "[Tomorrow]", "lastDay": "[Yesterday]", "nextWeek": "dddd", "lastWeek": "[Last] dddd", "sameElse": "ddd, D MMM" }) }}';
+ 'timestamp.ScheduledMessageSendTime': '{{ timestamp | timestampFormatter(format: ddd, MMM D [at] HH:mm) }}';
'timestamp.SystemMessage': '{{ timestamp | timestampFormatter(format: dddd L) }}';
'translationBuilderTopic.notification': '{{value, notification}}';
'typing.manyUsers_one': '{{ count }} person is typing';
diff --git a/src/i18n/runtimeDefaults.ts b/src/i18n/runtimeDefaults.ts
index f8eb368ed..83fc74cc6 100644
--- a/src/i18n/runtimeDefaults.ts
+++ b/src/i18n/runtimeDefaults.ts
@@ -4,9 +4,10 @@
* `translationBuilderTopic.*` (an i18next postProcessor directive). The `language.*` names come
* from `stream-chat`.
*
- * Four `timestamp.*` entries (`DateSeparator`, `ReminderNotification`, `ChannelPreviewTimestamp`,
- * `ChannelDetailPinnedMessageTimestamp`) hold English day words in `calendarFormats`, which
- * `dayjsLocaleConfigForLanguage` does not reach, so integrators override the key. Adding a fifth
+ * Eight `timestamp.*` entries (`DateSeparator`, `ReminderNotification`, `ChannelPreviewTimestamp`,
+ * `ChannelDetailPinnedMessageTimestamp`, `ScheduledMessageConfirmation`, `ScheduledMessageGroup`,
+ * `ScheduledMessageSendTime`, `SchedulePreset`) hold English words in their formats, which
+ * `dayjsLocaleConfigForLanguage` does not reach, so integrators override the key. Adding a ninth
* fails a guard in `__tests__/Streami18n.test.ts` that keeps the migration guide's list in sync.
*/
export const runtimeDefaults = {
@@ -32,6 +33,16 @@ export const runtimeDefaults = {
'timestamp.PollVoteTooltip': '{{ timestamp | timestampFormatter(calendar: true) }}',
'timestamp.ReminderNotification':
'{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today] [at] HH:mm", "nextDay": "[Tomorrow] [at] HH:mm", "lastDay": "[Yesterday] [at] HH:mm", "nextWeek": "dddd [at] HH:mm", "lastWeek": "[Last] dddd [at] HH:mm", "sameElse": "ddd, D MMM [at] HH:mm" }) }}',
+ 'timestamp.ScheduleDate':
+ '{{ timestamp | timestampFormatter(format: ddd, MMM D, YYYY) }}',
+ 'timestamp.ScheduledMessageConfirmation':
+ '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "HH:mm", "nextDay": "ddd, MMM D [at] HH:mm", "lastDay": "ddd, MMM D [at] HH:mm", "nextWeek": "ddd, MMM D [at] HH:mm", "lastWeek": "ddd, MMM D [at] HH:mm", "sameElse": "ddd, MMM D [at] HH:mm" }) }}',
+ 'timestamp.ScheduledMessageGroup':
+ '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today]", "nextDay": "[Tomorrow]", "lastDay": "[Yesterday]", "nextWeek": "dddd", "lastWeek": "[Last] dddd", "sameElse": "ddd, D MMM" }) }}',
+ 'timestamp.ScheduledMessageSendTime':
+ '{{ timestamp | timestampFormatter(format: ddd, MMM D [at] HH:mm) }}',
+ 'timestamp.SchedulePreset':
+ '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today] [at] HH:mm", "nextDay": "[Tomorrow] [at] HH:mm", "lastDay": "[Yesterday] [at] HH:mm", "nextWeek": "dddd [at] HH:mm", "lastWeek": "[Last] dddd [at] HH:mm", "sameElse": "dddd [at] HH:mm" }) }}',
'timestamp.SystemMessage': '{{ timestamp | timestampFormatter(format: dddd L) }}',
'translationBuilderTopic.notification': '{{value, notification}}',
};
diff --git a/src/mock-builders/api/index.ts b/src/mock-builders/api/index.ts
index d98a4e19c..8f35be0d0 100644
--- a/src/mock-builders/api/index.ts
+++ b/src/mock-builders/api/index.ts
@@ -111,3 +111,4 @@ export * from './markRead';
export * from './threadReplies';
export * from './sendMessage';
export * from './error';
+export * from './scheduledMessages';
diff --git a/src/mock-builders/api/scheduledMessages.ts b/src/mock-builders/api/scheduledMessages.ts
new file mode 100644
index 000000000..2fcd33dd7
--- /dev/null
+++ b/src/mock-builders/api/scheduledMessages.ts
@@ -0,0 +1,60 @@
+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,
+ details: [],
+ duration: '0.01',
+ exception_fields: { reason },
+ message: `scheduled message request failed: ${reason}`,
+ more_info: '',
+ status_code: status,
+ };
+
+ return mockedApiResponse(error, method, status);
+};
diff --git a/src/mock-builders/api/utils.ts b/src/mock-builders/api/utils.ts
index 044c9abf1..4789aea2f 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 934339b1e..536434912 100644
--- a/src/mock-builders/event/index.ts
+++ b/src/mock-builders/event/index.ts
@@ -1,3 +1,4 @@
+export * from './scheduledMessage';
export { default as dispatchChannelDeletedEvent } from './channelDeleted';
export { default as dispatchChannelHiddenEvent } from './channelHidden';
export { default as dispatchChannelTruncatedEvent } from './channelTruncated';
diff --git a/src/mock-builders/event/scheduledMessage.ts b/src/mock-builders/event/scheduledMessage.ts
new file mode 100644
index 000000000..6fd3b4d3a
--- /dev/null
+++ b/src/mock-builders/event/scheduledMessage.ts
@@ -0,0 +1,29 @@
+import { nowNs } from 'stream-chat';
+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: nowNs(),
+ custom: {},
+ scheduled_message: scheduledMessage,
+ type,
+ user,
+ } as Parameters[0]);
+};
diff --git a/src/mock-builders/generator/channel.ts b/src/mock-builders/generator/channel.ts
index cc91443b2..ba211dde9 100644
--- a/src/mock-builders/generator/channel.ts
+++ b/src/mock-builders/generator/channel.ts
@@ -56,6 +56,10 @@ export const generateChannel = (options?: GenerateChannelOptions): ChannelAPIRes
reactions: true,
read_events: true,
replies: true,
+ scheduled_messages_max_lead_time_seconds: 120 * 24 * 60 * 60,
+ scheduled_messages_max_per_channel: 10,
+ scheduled_messages_max_per_user: 100,
+ scheduled_messages_min_lead_time_seconds: 60,
search: true,
shared_locations: true,
typing_events: true,
diff --git a/src/mock-builders/generator/index.ts b/src/mock-builders/generator/index.ts
index 8c5edd626..e87c95305 100644
--- a/src/mock-builders/generator/index.ts
+++ b/src/mock-builders/generator/index.ts
@@ -7,6 +7,7 @@ export * from './messageDraft';
export * from './poll';
export * from './reaction';
export * from './reminder';
+export * from './scheduledMessage';
export * from './sharedLocation';
export * from './time';
export * from './user';
diff --git a/src/mock-builders/generator/scheduledMessage.ts b/src/mock-builders/generator/scheduledMessage.ts
new file mode 100644
index 000000000..4f82050a9
--- /dev/null
+++ b/src/mock-builders/generator/scheduledMessage.ts
@@ -0,0 +1,28 @@
+import { nanoid } from 'nanoid';
+import type { ScheduledMessageResponse, UserResponse } from 'stream-chat';
+
+import { convertDateToTimestamp } from './time';
+
+const ONE_HOUR_MS = 60 * 60 * 1000;
+
+export const generateScheduledMessage = (
+ data: Partial = {},
+): ScheduledMessageResponse => {
+ const now = new Date();
+ const createdAt = convertDateToTimestamp(now);
+ const scheduledAt = convertDateToTimestamp(new Date(now.getTime() + ONE_HOUR_MS));
+
+ const scheduledMessage: ScheduledMessageResponse = {
+ cid: 'messaging:general',
+ created_at: createdAt,
+ id: nanoid(),
+ message: { custom: {}, text: nanoid() },
+ scheduled_at: scheduledAt,
+ status: 'scheduled',
+ updated_at: createdAt,
+ user: { id: 'user-id' } as UserResponse,
+ ...data,
+ };
+
+ return scheduledMessage;
+};
diff --git a/src/mock-builders/index.ts b/src/mock-builders/index.ts
index 88f4ee0f7..8398a0258 100644
--- a/src/mock-builders/index.ts
+++ b/src/mock-builders/index.ts
@@ -29,6 +29,7 @@ const connectUser = (client: StreamChat, user: Partial) =>
interface MockClientOverrides {
getAppSettings?: StreamChat['getAppSettings'];
queryReactions?: StreamChat['queryReactions'];
+ queryScheduledMessages?: StreamChat['queryScheduledMessages'];
}
function mockClient(client: StreamChat, mocks: MockClientOverrides = {}) {
@@ -41,6 +42,14 @@ 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: [],
+ })) as unknown as StreamChat['queryScheduledMessages']),
+ );
client.tokenManager = fromPartial({
getToken: vi.fn(() => token),
tokenReady: vi.fn(() => true),
diff --git a/src/mock-builders/utils.ts b/src/mock-builders/utils.ts
index 754172521..e9e358a0f 100644
--- a/src/mock-builders/utils.ts
+++ b/src/mock-builders/utils.ts
@@ -2,6 +2,7 @@ import type {
ChannelConfigWithInfo,
ChannelResponse,
GetDraftResponse,
+ ScheduledMessageResponse,
StreamChat,
UserResponse,
} from 'stream-chat';
@@ -114,3 +115,9 @@ export const initClientWithChannels = async ({
return { channels, client };
};
+
+export const hydrateScheduledMessages = (
+ client: StreamChat,
+ cid: string,
+ scheduledMessages: ScheduledMessageResponse[] = [],
+) => client.scheduledMessages.hydrateChannelState(cid, scheduledMessages);
diff --git a/src/plugins/ChannelDetail/ChannelDetail.tsx b/src/plugins/ChannelDetail/ChannelDetail.tsx
index eaca06b33..056b307f2 100644
--- a/src/plugins/ChannelDetail/ChannelDetail.tsx
+++ b/src/plugins/ChannelDetail/ChannelDetail.tsx
@@ -20,6 +20,7 @@ import { ChannelManagementView } from './Views/ChannelManagementView';
import { ChannelMediaView } from './Views/ChannelMediaView';
import { ChannelMembersView } from './Views/ChannelMembersView';
import { PinnedMessagesView } from './Views/PinnedMessagesView';
+import { ScheduledMessagesView } from './Views/ScheduledMessagesView';
import { Prompt } from '../../components/Dialog';
import { useComponentContextIcons } from '../../context';
import { useStableCallback } from '../../utils';
@@ -48,6 +49,11 @@ const ChannelMediaNavButtonIcon = () => {
return ;
};
+const ScheduledMessagesNavButtonIcon = () => {
+ const { IconClock } = useComponentContextIcons();
+ return ;
+};
+
const ChannelFilesNavButtonIcon = () => {
const { IconFolder } = useComponentContextIcons();
@@ -78,6 +84,14 @@ export const PinnedMessagesNavButton = (props: SectionNavigatorNavButtonProps) =
/>
);
+export const ScheduledMessagesNavButton = (props: SectionNavigatorNavButtonProps) => (
+
+);
+
export const ChannelMediaNavButton = (props: SectionNavigatorNavButtonProps) => (
{
+ const { t } = useTranslationContext();
+ const { ScheduledMessageList = DefaultScheduledMessageList } = useComponentContext();
+ const { close } = useModalContext();
+ const { channel } = useChannelDetailContext();
+
+ const scheduledMessageCount = useScheduledMessageCount({ cid: channel.cid });
+
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const editScheduledMessage = useCallback(
+ (scheduledMessage: ScheduledMessageResponse) => {
+ close();
+ openScheduledMessageInComposer(channel.messageComposer, scheduledMessage);
+ },
+ [channel, close],
+ );
+
+ return (
+
+
+
+ {scheduledMessageCount > 0 && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/plugins/ChannelDetail/Views/ScheduledMessagesView/__tests__/ScheduledMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/__tests__/ScheduledMessagesView.test.tsx
new file mode 100644
index 000000000..523bd086e
--- /dev/null
+++ b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/__tests__/ScheduledMessagesView.test.tsx
@@ -0,0 +1,153 @@
+import React from 'react';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import { axe } from '../../../../../../axe-helper';
+import {
+ ChatProvider,
+ DialogManagerProvider,
+ ModalContextProvider,
+ TranslationProvider,
+} from '../../../../../context';
+import { WithComponents } from '../../../../../context/WithComponents';
+import { Streami18n } from '../../../../../i18n';
+import {
+ generateChannel,
+ generateScheduledMessage,
+ generateUser,
+ hydrateScheduledMessages,
+ initClientWithChannels,
+ mockChatContext,
+} from '../../../../../mock-builders';
+import { defaultChannelDetailSections } from '../../../ChannelDetail';
+import { ChannelDetailProvider } from '../../../ChannelDetailContext';
+import { ScheduledMessagesView } from '../ScheduledMessagesView';
+
+const cid = 'messaging:general';
+
+const isInChannel =
+ (channelCid: string) =>
+ ({ cid: scheduledMessageCid }: ScheduledMessageResponse) =>
+ scheduledMessageCid === channelCid;
+
+const renderView = async (
+ scheduledMessages: ScheduledMessageResponse[] = [],
+ components: Parameters[0]['overrides'] = {},
+) => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels({
+ channelsData: [generateChannel({ channel: { id: 'general', type: 'messaging' } })],
+ customUser: generateUser({ id: 'user-id' }),
+ });
+ const scheduledMessageCids = new Set([cid, ...scheduledMessages.map(({ cid }) => cid)]);
+ scheduledMessageCids.forEach((scheduledMessageCid) =>
+ hydrateScheduledMessages(
+ client,
+ scheduledMessageCid,
+ scheduledMessages.filter(isInChannel(scheduledMessageCid)),
+ ),
+ );
+
+ const close = vi.fn();
+ const translators = await new Streami18n({ language: 'en' }).init();
+
+ const view = render(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ return { ...view, channel, close };
+};
+
+describe('ScheduledMessagesView', () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('is one of the default channel detail sections', () => {
+ const sectionIds = defaultChannelDetailSections.map(({ id }) => id);
+
+ expect(sectionIds).toContain('scheduled-messages');
+ expect(sectionIds.indexOf('scheduled-messages')).toBe(
+ sectionIds.indexOf('pinned-messages') + 1,
+ );
+ });
+
+ it('lists the scheduled messages of the channel', async () => {
+ await renderView([
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ }),
+ generateScheduledMessage({
+ cid: 'messaging:random',
+ message: { custom: {}, text: 'Elsewhere' },
+ }),
+ ]);
+
+ expect(screen.getByText('Scheduled messages')).toBeInTheDocument();
+ expect(screen.getByText('Launch moved to Monday')).toBeInTheDocument();
+ expect(screen.queryByText('Elsewhere')).not.toBeInTheDocument();
+ });
+
+ it('shows the empty state when nothing is scheduled', async () => {
+ await renderView();
+
+ expect(screen.getByText('No scheduled messages')).toBeInTheDocument();
+ expect(screen.queryByRole('searchbox', { name: 'Search' })).not.toBeInTheDocument();
+ });
+
+ it('filters the list by the search field', async () => {
+ await renderView([
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ }),
+ generateScheduledMessage({ cid, message: { custom: {}, text: 'Lunch at noon' } }),
+ ]);
+
+ fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), {
+ target: { value: 'lunch' },
+ });
+
+ expect(screen.getByText('Lunch at noon')).toBeInTheDocument();
+ expect(screen.queryByText('Launch moved to Monday')).not.toBeInTheDocument();
+ });
+
+ it('renders the list through the ScheduledMessageList slot', async () => {
+ const CustomList = ({ cid: listCid }: { cid: string }) => (
+ {listCid}
+ );
+
+ await renderView([], { ScheduledMessageList: CustomList });
+
+ expect(screen.getByTestId('custom-scheduled-message-list')).toHaveTextContent(cid);
+ });
+
+ it('closes the channel detail modal from the header', async () => {
+ const { close } = await renderView();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('has no accessibility violations', async () => {
+ const { container } = await renderView([generateScheduledMessage({ cid })]);
+
+ expect(await axe(container)).toHaveNoViolations();
+ });
+});
diff --git a/src/plugins/ChannelDetail/Views/ScheduledMessagesView/index.ts b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/index.ts
new file mode 100644
index 000000000..81ab40274
--- /dev/null
+++ b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/index.ts
@@ -0,0 +1 @@
+export * from './ScheduledMessagesView';
diff --git a/src/plugins/ChannelDetail/index.ts b/src/plugins/ChannelDetail/index.ts
index 8d1a469d0..078f7cc8c 100644
--- a/src/plugins/ChannelDetail/index.ts
+++ b/src/plugins/ChannelDetail/index.ts
@@ -13,3 +13,4 @@ export * from './Views/ChannelMediaView';
export * from './Views/ChannelMemberDetailView';
export * from './Views/ChannelMembersView';
export * from './Views/PinnedMessagesView';
+export * from './Views/ScheduledMessagesView';
diff --git a/src/plugins/ChannelDetail/styling/ScheduledMessagesView.scss b/src/plugins/ChannelDetail/styling/ScheduledMessagesView.scss
new file mode 100644
index 000000000..c650ed919
--- /dev/null
+++ b/src/plugins/ChannelDetail/styling/ScheduledMessagesView.scss
@@ -0,0 +1,14 @@
+.str-chat__channel-detail__scheduled-messages-view {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.str-chat__channel-detail__scheduled-messages-view__body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+}
diff --git a/src/plugins/ChannelDetail/styling/index.scss b/src/plugins/ChannelDetail/styling/index.scss
index db73bf5d1..da4a7e965 100644
--- a/src/plugins/ChannelDetail/styling/index.scss
+++ b/src/plugins/ChannelDetail/styling/index.scss
@@ -9,3 +9,4 @@
@use 'PinnedMessagesView';
@use '../SectionNavigator/styling' as SectionNavigator;
@use '../VirtualizedList/styling' as VirtualizedList;
+@use 'ScheduledMessagesView';
diff --git a/src/styling/index.scss b/src/styling/index.scss
index 0504b424d..c68777f08 100644
--- a/src/styling/index.scss
+++ b/src/styling/index.scss
@@ -44,6 +44,7 @@
@use '../components/Notifications/styling' as Notifications;
@use '../components/Poll/styling' as Poll;
@use '../components/Reactions/styling' as Reactions;
+@use '../components/ScheduledMessages/styling' as ScheduledMessages;
@use '../components/Search/styling' as Search;
@use '../components/SkipNavigation/styling' as SkipNavigation;
@use '../components/SummarizedMessagePreview/styling' as SummarizedMessagePreview;