({
editedMessage,
+ editedScheduledMessage,
pollId,
quotedMessage,
});
@@ -47,6 +49,11 @@ export type MessageComposerPreviewVisibilityFilterParams = {
/** The composer's custom data (`messageComposer.customDataManager`). */
customData: CustomDataManagerState['custom'];
editedMessage: MessageComposerState['editedMessage'];
+ /**
+ * The scheduled message open in the composer: a pending one is edited, a failed one is sent or
+ * scheduled again.
+ */
+ editedScheduledMessage: MessageComposerState['editedScheduledMessage'];
/** Link previews that are loading or loaded. */
linkPreviews: LinkPreview[];
location: LocationComposerState['location'];
@@ -65,10 +72,8 @@ export type MessageComposerPreviewVisibilityFilterParams = {
export const useMessageComposerPreviewState =
(): MessageComposerPreviewVisibilityFilterParams => {
const messageComposer = useMessageComposerController();
- const { editedMessage, pollId, quotedMessage } = useStateStore(
- messageComposer.state,
- messageComposerStateSelector,
- );
+ const { editedMessage, editedScheduledMessage, pollId, quotedMessage } =
+ useStateStore(messageComposer.state, messageComposerStateSelector);
const { attachments } = useStateStore(
messageComposer.attachmentManager.state,
attachmentManagerStateSelector,
@@ -101,6 +106,7 @@ export const useMessageComposerPreviewState =
attachments,
customData,
editedMessage,
+ editedScheduledMessage,
linkPreviews,
location,
pollId,
@@ -110,6 +116,7 @@ export const useMessageComposerPreviewState =
attachments,
customData,
editedMessage,
+ editedScheduledMessage,
linkPreviews,
location,
pollId,
diff --git a/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts b/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts
index b2c59665ca..0079de12a0 100644
--- a/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts
+++ b/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts
@@ -1,20 +1,39 @@
import { useCallback } from 'react';
+import { useChatContext } from '../../../context';
+import {
+ isFailedScheduledMessage,
+ reloadOnScheduledMessageConflict,
+} from '../../ScheduledMessages/utils';
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],
- );
+ return useCallback(async () => {
+ // a failed one open in the composer is sent or scheduled again instead
+ const { editedScheduledMessage } = messageComposer;
+ if (editedScheduledMessage && !isFailedScheduledMessage(editedScheduledMessage)) {
+ const result = await messageComposer.updateScheduledMessage();
+ if (result.status === 'failed') {
+ reloadOnScheduledMessageConflict(
+ client,
+ messageComposer.channel.cid,
+ result.error,
+ );
+ }
+ return result;
+ }
+ return messageComposer.editedMessage
+ ? messageComposer.update()
+ : messageComposer.send();
+ }, [client, messageComposer]);
};
diff --git a/src/components/ScheduledMessages/ScheduleMessageButton.tsx b/src/components/ScheduledMessages/ScheduleMessageButton.tsx
new file mode 100644
index 0000000000..bec0c417f4
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduleMessageButton.tsx
@@ -0,0 +1,214 @@
+import React, { useCallback, useRef, useState } from 'react';
+import {
+ dateToNs,
+ getScheduledMessageErrorReason,
+ ScheduledMessageErrorReason,
+ toScheduledMessagePreview,
+} from 'stream-chat';
+import type { LocalMessage, UserResponse } from 'stream-chat';
+
+import {
+ useChatContext,
+ useComponentContext,
+ useComponentContextIcons,
+ useTranslationContext,
+} from '../../context';
+import { Button } from '../Button';
+import { CHANNEL_CONTAINER_ID } from '../Channel/constants';
+import {
+ ContextMenu,
+ ContextMenuButton,
+ ContextMenuHeader,
+ 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 { ScheduleTimeDialog } from './ScheduleTimeDialog';
+import { getSchedulePresets, getScheduleTimeBounds } from './utils';
+
+const getChannelContainer = () => document.getElementById(CHANNEL_CONTAINER_ID);
+
+const ScheduleMessageMenuHeader = () => {
+ const { t } = useTranslationContext();
+ return (
+
+ {t('scheduledMessages.scheduleMessage', 'Schedule message')}
+
+ );
+};
+
+/**
+ * The chevron next to the send button: schedules the composition at a preset time or opens a
+ * dialog to pick one.
+ */
+export const ScheduleMessageButton = () => {
+ const { t } = useTranslationContext();
+ const { client } = useChatContext();
+ const { IconChevronUp, IconClock } = useComponentContextIcons();
+ const { ContextMenu: ContextMenuComponent = ContextMenu, Modal = GlobalModal } =
+ useComponentContext();
+ const messageComposer = useMessageComposerController();
+ const hasSendableData = useMessageComposerHasSendableData();
+
+ const menuId = `schedule-message-menu${messageComposer.threadId ? '-thread' : ''}`;
+ const { dialog: menu, dialogManager } = useDialogOnNearestManager({ id: menuId });
+ const isMenuOpen = useDialogIsOpen(menuId, dialogManager?.id);
+ const buttonRef = useRef
(null);
+ const [dialogPreview, setDialogPreview] = useState();
+
+ // the banner confirms a scheduled message, so success needs no notification
+ const scheduleMessage = useCallback(
+ async (scheduledAt: Date) => {
+ const { error, status } = await messageComposer.schedule(scheduledAt);
+ if (status === 'scheduled' || status === 'sent') return;
+ // the server keeps the ID of a message it scheduled; the next attempt needs a new one
+ if (getScheduledMessageErrorReason(error) === ScheduledMessageErrorReason.IdTaken) {
+ messageComposer.refreshId();
+ }
+ throw error ?? new Error(`Message not scheduled: ${status}`);
+ },
+ [messageComposer],
+ );
+
+ // the dialog shows the failure itself, so the composer's notification of it is dropped
+ const scheduleMessageFromDialog = useCallback(
+ async (scheduledAt: Date) => {
+ const { notifications } = client;
+ const shownBefore = new Set(notifications.notifications.map(({ id }) => id));
+ try {
+ await scheduleMessage(scheduledAt);
+ } catch (error) {
+ notifications.notifications
+ .filter(
+ ({ id, origin }) =>
+ !shownBefore.has(id) && origin.context?.composer === messageComposer,
+ )
+ .forEach(({ id }) => notifications.remove(id));
+ throw error;
+ }
+ },
+ [client, messageComposer, scheduleMessage],
+ );
+
+ const openDialog = () => {
+ menu.close();
+ const now = dateToNs(new Date());
+ const { attachmentManager, channel, id, textComposer } = messageComposer;
+ setDialogPreview(
+ toScheduledMessagePreview({
+ cid: channel.cid,
+ created_at: now,
+ id,
+ message: {
+ attachments: attachmentManager.attachments,
+ custom: {},
+ text: textComposer.text,
+ },
+ scheduled_at: now,
+ status: 'scheduled',
+ updated_at: now,
+ user: (client.user ?? { id: client.userID ?? '' }) as UserResponse,
+ }),
+ );
+ };
+
+ const closeDialog = () => {
+ setDialogPreview(undefined);
+ // the menu item that opened the dialog is gone, so focus returns to the chevron
+ requestAnimationFrame(() => buttonRef.current?.focus());
+ };
+
+ return (
+ <>
+ menu.toggle()}
+ ref={buttonRef}
+ size='xs'
+ variant='primary'
+ >
+
+
+
+
+
+ {dialogPreview && (
+
+ )}
+
+
+ >
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduleTimeDialog.tsx b/src/components/ScheduledMessages/ScheduleTimeDialog.tsx
new file mode 100644
index 0000000000..e87ab6495f
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduleTimeDialog.tsx
@@ -0,0 +1,203 @@
+import React, { type MouseEvent, useState } from 'react';
+import type { Channel, LocalMessage } from 'stream-chat';
+
+import { useComponentContextIcons, useTranslationContext } from '../../context';
+import { useIsMounted } from '../Channel/hooks/useIsMounted';
+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 { ScheduledMessagesNotice } from './ScheduledMessagesNotice';
+import {
+ formatTimeZoneOffset,
+ fromInputValues,
+ getDefaultScheduledAt,
+ getScheduleTimeBounds,
+ getScheduleTimeError,
+ toDateInputValue,
+ toTimeInputValue,
+ translateScheduledMessageError,
+} from './utils';
+
+export type ScheduleTimeDialogProps = {
+ channel: Channel;
+ close: () => void;
+ /** Shown when `submit` rejects with an error that says nothing more specific; the dialog stays open. */
+ errorMessage: string;
+ previewMessage: LocalMessage;
+ /** Closes the dialog once it resolves. */
+ submit: (scheduledAt: Date) => Promise;
+ title: string;
+ initialScheduledAt?: Date;
+ /** Above the preview; "Message to {{ name }}" by default. */
+ previewLabel?: string;
+ submitLabel?: string;
+ /** Why an earlier time is refused; "Choose a later time" by default. */
+ tooSoonMessage?: string;
+};
+
+/** A click anywhere in a field, its icons included, opens the field's picker. */
+const openNativePicker = ({ target }: MouseEvent) => {
+ const input = (target as Element)
+ .closest('.str-chat__form-text-input__wrapper')
+ ?.querySelector('input');
+ try {
+ input?.showPicker?.();
+ } catch {
+ // not allowed outside a user gesture in some browsers
+ }
+};
+
+/** Picks a date and time within the channel's limits and submits it. */
+export const ScheduleTimeDialog = ({
+ channel,
+ close,
+ errorMessage,
+ initialScheduledAt,
+ previewLabel,
+ previewMessage,
+ submit,
+ submitLabel,
+ title,
+ tooSoonMessage,
+}: ScheduleTimeDialogProps) => {
+ const { t } = useTranslationContext();
+ const { IconCalendar, IconCaretDown, IconClock } = useComponentContextIcons();
+ const channelDisplayName = useChannelDisplayName(channel);
+ const isOnline = useWSConnectionState()?.isHealthy ?? false;
+ const isMounted = useIsMounted();
+
+ const [bounds] = useState(() => getScheduleTimeBounds(channel));
+ const [dateValue, setDateValue] = useState(() =>
+ toDateInputValue(initialScheduledAt ?? getDefaultScheduledAt(bounds)),
+ );
+ const [timeValue, setTimeValue] = useState(() =>
+ toTimeInputValue(initialScheduledAt ?? getDefaultScheduledAt(bounds)),
+ );
+ const [status, setStatus] = useState<'failed' | 'idle' | 'submitting'>('idle');
+ const [submitError, setSubmitError] = useState();
+
+ const scheduledAt = fromInputValues(dateValue, timeValue);
+ const timeError = scheduledAt && getScheduleTimeError(scheduledAt, bounds);
+ const selectedDate = fromInputValues(dateValue);
+ const canSubmit = !!scheduledAt && !timeError && isOnline && status !== 'submitting';
+
+ const submitScheduledAt = async () => {
+ if (!scheduledAt) return;
+ setStatus('submitting');
+ try {
+ await submit(scheduledAt);
+ if (isMounted.current) close();
+ } catch (error) {
+ if (!isMounted.current) return;
+ setSubmitError(error);
+ setStatus('failed');
+ }
+ };
+
+ return (
+
+
+
+
+ {!isOnline && (
+
+ {t(
+ 'scheduledMessages.youreOfflineReconnectToScheduleThisMessage',
+ "You're offline. Reconnect to schedule this message.",
+ )}
+
+ )}
+ {status === 'failed' && (
+
+ {translateScheduledMessageError(t, submitError) ?? errorMessage}
+
+ )}
+
+
+
+
+ {selectedDate &&
+ t('timestamp.ScheduleDate', { timestamp: selectedDate })}
+
+ >
+ }
+ max={bounds.latest && toDateInputValue(bounds.latest)}
+ min={toDateInputValue(bounds.earliest)}
+ onChange={({ target }) => setDateValue(target.value)}
+ required
+ trailing={ }
+ type='date'
+ value={dateValue}
+ />
+ }
+ onChange={({ target }) => setTimeValue(target.value)}
+ required
+ trailing={ }
+ type='time'
+ value={timeValue}
+ />
+
+ {/* a time out of bounds takes the time zone's place */}
+ {timeError ? (
+
+ {timeError === 'tooSoon'
+ ? (tooSoonMessage ??
+ t('scheduledMessages.chooseALaterTime', 'Choose a later time'))
+ : t('scheduledMessages.chooseAnEarlierTime', 'Choose an earlier time')}
+
+ ) : (
+
+ {t(
+ 'scheduledMessages.sentInYourTimeZoneTimezone',
+ 'Sent in your time zone ({{ timeZone }})',
+ { timeZone: formatTimeZoneOffset(scheduledAt ?? bounds.earliest) },
+ )}
+
+ )}
+
+
+
+
+ {t('scheduledMessages.cancel', 'Cancel')}
+
+
+ {submitLabel ?? t('scheduledMessages.schedule', 'Schedule')}
+
+
+
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageItem.tsx b/src/components/ScheduledMessages/ScheduledMessageItem.tsx
new file mode 100644
index 0000000000..5057c4f09a
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageItem.tsx
@@ -0,0 +1,446 @@
+import clsx from 'clsx';
+import React, { useRef, useState } from 'react';
+import { nsToDate, SCHEDULED_MESSAGE_STATUS } from 'stream-chat';
+import type { OwnCapabilitiesState, ScheduledMessageResponse } from 'stream-chat';
+
+import {
+ useChatContext,
+ useComponentContext,
+ useComponentContextIcons,
+ useTranslationContext,
+} from '../../context';
+import { ChannelAvatar } from '../Avatar';
+import { Button } from '../Button';
+import { useIsMounted } from '../Channel/hooks/useIsMounted';
+import { useChannelPreviewInfo } from '../ChannelListItem/hooks/useChannelPreviewInfo';
+import {
+ Alert,
+ ContextMenu,
+ ContextMenuButton,
+ useDialogIsOpen,
+ useDialogOnNearestManager,
+} from '../Dialog';
+import { GlobalModal } from '../Modal';
+import { useNotificationApi } from '../Notifications';
+import { useStateStore } from '../../store';
+import { SummarizedMessagePreview } from '../SummarizedMessagePreview';
+import { useStableId } from '../UtilityComponents/useStableId';
+import { VisuallyHidden } from '../VisuallyHidden';
+import { SCHEDULE_MESSAGE_CAPABILITY } from './hooks';
+import { useScheduledMessagePreview } from './hooks/useScheduledMessagePreview';
+import { ScheduleTimeDialog } from './ScheduleTimeDialog';
+import {
+ getChannelByCid,
+ getScheduledMessageActions,
+ isFailedScheduledMessage,
+ reloadOnScheduledMessageConflict,
+ SCHEDULED_MESSAGE_NOTIFICATION_TYPE,
+ SCHEDULED_MESSAGES_EMITTER,
+ translateScheduledMessageError,
+ translateScheduledMessageFailureReason,
+} from './utils';
+import type { ScheduledMessageAction } from './utils';
+
+export type ScheduledMessageItemProps = {
+ scheduledMessage: ScheduledMessageResponse;
+ /** Opens the message in the composer; without it the list offers no edit action. */
+ onEdit?: (scheduledMessage: ScheduledMessageResponse) => void;
+};
+
+type OpenDialog = 'delete' | 'reschedule';
+
+const selectComposeCapabilities = ({ ownCapabilities }: OwnCapabilitiesState) => ({
+ canSchedule: !!ownCapabilities?.includes(SCHEDULE_MESSAGE_CAPABILITY),
+ canSend: !!ownCapabilities?.includes('send-message'),
+});
+
+/** A scheduled message: when it is sent, where, its status and what can be done with it. */
+export const ScheduledMessageItem = ({
+ onEdit,
+ scheduledMessage,
+}: ScheduledMessageItemProps) => {
+ const { t } = useTranslationContext();
+ const { client } = useChatContext();
+ const {
+ IconClock,
+ IconCopy,
+ IconDelete,
+ IconEdit,
+ IconExclamationTriangleFill,
+ IconLoading,
+ IconMore,
+ IconSend,
+ } = useComponentContextIcons();
+ const { ContextMenu: ContextMenuComponent = ContextMenu, Modal = GlobalModal } =
+ useComponentContext();
+ const { addNotification } = useNotificationApi();
+ const isMounted = useIsMounted();
+
+ const { cid, failure_reason, id, message, scheduled_at, status } = scheduledMessage;
+ // shown from a loaded channel only; an action creates the instance it needs
+ const loadedChannel = client.channelManager.get(cid);
+ const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({
+ channel: loadedChannel,
+ });
+ const composeCapabilities = useStateStore(
+ loadedChannel?.state,
+ selectComposeCapabilities,
+ );
+ const editUnavailableId = useStableId();
+ const getChannel = () => getChannelByCid(client, cid);
+ const previewMessage = useScheduledMessagePreview(scheduledMessage);
+ const sendTime = nsToDate(scheduled_at);
+ const isFailed = isFailedScheduledMessage(scheduledMessage);
+ // a pending message is saved by scheduling it, a failed one is scheduled or sent again; a frozen
+ // channel allows neither. Unknown for a channel not loaded, so left on there.
+ const canEdit =
+ !composeCapabilities ||
+ composeCapabilities.canSchedule ||
+ (isFailed && composeCapabilities.canSend);
+ const editUnavailableProps = canEdit
+ ? undefined
+ : { 'aria-describedby': editUnavailableId, 'aria-disabled': true };
+
+ const menuId = `scheduled-message-actions-${id}`;
+ const { dialog: menu, dialogManager } = useDialogOnNearestManager({ id: menuId });
+ const isMenuOpen = useDialogIsOpen(menuId, dialogManager?.id);
+ const menuButtonRef = useRef(null);
+ const [openDialog, setOpenDialog] = useState();
+ const [isPending, setIsPending] = useState(false);
+
+ const actions = getScheduledMessageActions(scheduledMessage, !!onEdit);
+ // a pending message keeps "edit" as its own button, outside the menu
+ const hasEditButton = !isFailed && actions.includes('edit');
+ const menuActions = hasEditButton
+ ? actions.filter((action) => action !== 'edit')
+ : actions;
+
+ const closeDialog = () => {
+ setOpenDialog(undefined);
+ requestAnimationFrame(() => menuButtonRef.current?.focus());
+ };
+
+ const run = async (
+ request: () => Promise,
+ failure: { message: string; type: string },
+ success?: { message: string; type: string },
+ ) => {
+ setIsPending(true);
+ try {
+ await request();
+ if (success) {
+ addNotification({
+ emitter: SCHEDULED_MESSAGES_EMITTER,
+ severity: 'success',
+ ...success,
+ });
+ }
+ } catch (error) {
+ reloadOnScheduledMessageConflict(client, cid, error);
+ addNotification({
+ emitter: SCHEDULED_MESSAGES_EMITTER,
+ error: error as Error,
+ severity: 'error',
+ ...failure,
+ message: translateScheduledMessageError(t, error) ?? failure.message,
+ });
+ } finally {
+ if (isMounted.current) setIsPending(false);
+ }
+ };
+
+ const menuItems: Record<
+ ScheduledMessageAction,
+ { Icon: typeof IconClock; label: string; select: () => void }
+ > = {
+ copy: {
+ Icon: IconCopy,
+ label: t('scheduledMessages.copyText', 'Copy text'),
+ select: () =>
+ navigator.clipboard?.writeText(message.text ?? '').catch(() => undefined),
+ },
+ delete: {
+ Icon: IconDelete,
+ label: t('scheduledMessages.delete', 'Delete'),
+ select: () => setOpenDialog('delete'),
+ },
+ edit: {
+ Icon: IconEdit,
+ label: t('scheduledMessages.editReschedule', 'Edit & reschedule'),
+ select: () => onEdit?.(scheduledMessage),
+ },
+ reschedule: {
+ Icon: IconClock,
+ label: t('scheduledMessages.reschedule', 'Reschedule'),
+ select: () => setOpenDialog('reschedule'),
+ },
+ send_now: {
+ Icon: IconSend,
+ label: t('scheduledMessages.sendNow', 'Send now'),
+ select: () =>
+ run(
+ () => getChannel().sendScheduledMessageNow(id),
+ {
+ message: t(
+ 'scheduledMessages.couldntSendTheMessageTryAgain',
+ "Couldn't send the message. Try again",
+ ),
+ type: SCHEDULED_MESSAGE_NOTIFICATION_TYPE.sendNowFailed,
+ },
+ {
+ message: t(
+ 'scheduledMessages.scheduledMessageSent',
+ 'Scheduled message sent',
+ ),
+ type: SCHEDULED_MESSAGE_NOTIFICATION_TYPE.sendNowSucceeded,
+ },
+ ),
+ },
+ };
+
+ const rescheduleErrorMessage = t(
+ 'scheduledMessages.couldntRescheduleTheMessageTryAgain',
+ "Couldn't reschedule the message. Try again",
+ );
+ const reschedule = async (scheduledAt: Date) => {
+ try {
+ await getChannel().updateScheduledMessage(id, { scheduled_at: scheduledAt });
+ } catch (error) {
+ reloadOnScheduledMessageConflict(client, cid, error);
+ // the dialog stays open and shows the failure
+ throw error;
+ }
+ };
+
+ const deleteScheduledMessage = () => {
+ closeDialog();
+ void run(() => getChannel().deleteScheduledMessage(id), {
+ message: t(
+ 'scheduledMessages.couldntDeleteTheScheduledMessageTryAgain',
+ "Couldn't delete the scheduled message. Try again",
+ ),
+ type: SCHEDULED_MESSAGE_NOTIFICATION_TYPE.deleteFailed,
+ });
+ };
+
+ return (
+
+
+ {t('scheduledMessages.sendTime', 'Send {{ time }}', {
+ time: t('timestamp.ScheduledMessageSendTime', { timestamp: sendTime }),
+ })}
+
+
+ menu.toggle()}
+ ref={menuButtonRef}
+ size='xs'
+ variant='secondary'
+ >
+
+
+ {hasEditButton && (
+ {
+ if (canEdit) onEdit?.(scheduledMessage);
+ }}
+ size='xs'
+ variant='secondary'
+ >
+
+
+ )}
+
+
+ {!canEdit && (
+
+ {t(
+ 'scheduledMessages.cantSendInThisChannel',
+ "Unavailable: you can't send messages in this channel",
+ )}
+
+ )}
+
+
+
+ {displayTitle && (
+
+ {displayTitle}
+
+ )}
+
+ {status === SCHEDULED_MESSAGE_STATUS.Failed && (
+
+
+ {translateScheduledMessageFailureReason(t, failure_reason)}
+
+ )}
+ {status === SCHEDULED_MESSAGE_STATUS.Sending && (
+
+
+ {t('scheduledMessages.sending', 'Sending...')}
+
+ )}
+
+
+
+ {openDialog === 'reschedule' && (
+
+ )}
+
+
+ {openDialog === 'delete' && (
+
+
+
+
+ {t('scheduledMessages.delete', 'Delete')}
+
+
+ {t('scheduledMessages.cancel', 'Cancel')}
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageList.tsx b/src/components/ScheduledMessages/ScheduledMessageList.tsx
new file mode 100644
index 0000000000..ebba645fd4
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageList.tsx
@@ -0,0 +1,226 @@
+import React, { useEffect, useMemo } from 'react';
+import type { ComponentType } from 'react';
+import type {
+ ChannelDataState,
+ OwnCapabilitiesState,
+ PaginatorState,
+ ScheduledMessageResponse,
+ ScheduledMessagesView,
+} from 'stream-chat';
+
+import {
+ useChatContext,
+ useComponentContext,
+ useComponentContextIcons,
+ useTranslationContext,
+} from '../../context';
+import { useStateStore } from '../../store';
+import { EmptyStateIndicator } from '../EmptyStateIndicator';
+import { InfiniteScrollPaginator } from '../InfiniteScrollPaginator';
+import {
+ ListLoadErrorIndicator as BaseListLoadErrorIndicator,
+ LoadingIndicator,
+} from '../Loading';
+import type { ListLoadErrorIndicatorProps } from '../Loading';
+import { getFirstPageLoadError } from '../Loading/getFirstPageLoadError';
+import { useFirstPageLoadRetry } from '../Loading/hooks/useFirstPageLoadRetry';
+import { SCHEDULE_MESSAGE_CAPABILITY, useScheduledMessages } from './hooks';
+import type { ScheduledMessageItemProps } from './ScheduledMessageItem';
+import { ScheduledMessageItem as DefaultScheduledMessageItem } from './ScheduledMessageItem';
+import { ScheduledMessagesNotice } from './ScheduledMessagesNotice';
+import { getChannelByCid } from './utils';
+
+export type ScheduledMessageListProps = Pick & {
+ /**
+ * What to list: `channel.scheduledMessages`, `thread.scheduledMessages`, or a view of a list
+ * registered with `client.scheduledMessagePaginators`. Further pages load on scroll. When its
+ * paginator is a channel's, the list also says why the channel takes no scheduled messages, if
+ * it doesn't.
+ */
+ scheduledMessages: ScheduledMessagesView;
+ /** Lists only the messages it accepts, e.g. the search from `useScheduledMessagesSearch`. */
+ filter?: (scheduledMessage: ScheduledMessageResponse) => boolean;
+ /** Nothing is scheduled. */
+ EmptyListIndicator?: ComponentType;
+ /** Messages are scheduled, but none match the search. */
+ EmptySearchResultsIndicator?: ComponentType;
+ FirstPageLoadingIndicator?: ComponentType;
+ ListLoadErrorIndicator?: ComponentType;
+ LoadingNextPageIndicator?: ComponentType<{ isLoading?: boolean }>;
+};
+
+const selectLoadState = (state: PaginatorState) => ({
+ firstPageLoadError: getFirstPageLoadError(state),
+ hasMoreTail: state.hasMoreTail,
+ isFirstPageLoading: state.items === undefined && !state.lastQueryError,
+ isLoading: state.isLoading,
+ lastQueryFailed: !!state.lastQueryError,
+});
+
+const DefaultListLoadErrorIndicator = (props: ListLoadErrorIndicatorProps) => (
+
+);
+
+const DefaultEmptyListIndicator = () => (
+
+);
+
+const DefaultFirstPageLoadingIndicator = () => {
+ const { t } = useTranslationContext();
+ return (
+
+
+
+ );
+};
+
+const DefaultLoadingNextPageIndicator = ({ isLoading }: { isLoading?: boolean }) =>
+ isLoading ? : null;
+
+const DefaultEmptySearchResultsIndicator = () => {
+ const { t } = useTranslationContext();
+ const { IconSearch } = useComponentContextIcons();
+ return (
+
+
+
+ {t('scheduledMessages.noMessagesFound', 'No messages found')}
+
+
+ );
+};
+
+const selectScheduling = ({
+ data,
+ ownCapabilities,
+}: ChannelDataState & OwnCapabilitiesState) => ({
+ // unknown until the channel is loaded
+ canSchedule:
+ ownCapabilities === undefined ||
+ ownCapabilities.includes(SCHEDULE_MESSAGE_CAPABILITY),
+ frozen: !!data?.frozen,
+});
+
+/** Why the channel takes no scheduled messages: frozen, or the user is not allowed to. */
+const SchedulingUnavailableNotice = ({ cid }: { cid: string }) => {
+ const { t } = useTranslationContext();
+ const { client } = useChatContext();
+ const channel = getChannelByCid(client, cid);
+ const { canSchedule, frozen } = useStateStore(channel.state, selectScheduling);
+ if (canSchedule) return null;
+ return (
+
+ {frozen
+ ? t(
+ 'scheduledMessages.channelFrozenNotice',
+ "This channel is frozen. Scheduled messages won't be sent",
+ )
+ : t(
+ 'scheduledMessages.schedulingUnavailableNotice',
+ "You can't schedule messages in this channel",
+ )}
+
+ );
+};
+
+/**
+ * A scheduled-message view's messages, earliest first, loading further pages on scroll, with their
+ * list states. A view of a thread and a `filter` (searching) narrow its paginator's loaded
+ * messages, so while none of
+ * them are shown and more pages exist, the list loads the next one: with nothing shown there is
+ * nothing to scroll, and the messages to show may be on a page not loaded yet.
+ */
+export const ScheduledMessageList = ({
+ EmptyListIndicator = DefaultEmptyListIndicator,
+ EmptySearchResultsIndicator = DefaultEmptySearchResultsIndicator,
+ filter,
+ FirstPageLoadingIndicator = DefaultFirstPageLoadingIndicator,
+ ListLoadErrorIndicator = DefaultListLoadErrorIndicator,
+ LoadingNextPageIndicator = DefaultLoadingNextPageIndicator,
+ onEdit,
+ scheduledMessages: view,
+}: ScheduledMessageListProps) => {
+ const { ScheduledMessageItem = DefaultScheduledMessageItem } = useComponentContext();
+ const { paginator } = view;
+ const { scheduledMessages } = useScheduledMessages(view);
+ const shownMessages = useMemo(
+ () => (filter ? scheduledMessages.filter(filter) : scheduledMessages),
+ [filter, scheduledMessages],
+ );
+ const { t } = useTranslationContext();
+ const {
+ firstPageLoadError,
+ hasMoreTail,
+ isFirstPageLoading,
+ isLoading,
+ lastQueryFailed,
+ } = useStateStore(paginator.state, selectLoadState);
+ const firstPageLoadRetry = useFirstPageLoadRetry({
+ firstPageLoadError,
+ loadedMessage: t('scheduledMessages.loaded', 'Scheduled messages loaded'),
+ paginator,
+ reload: paginator.reload,
+ });
+ const showsNothing = !isFirstPageLoading && !shownMessages.length;
+ // stops at a failed page instead of retrying it
+ const loadsFurther = showsNothing && hasMoreTail && !lastQueryFailed;
+
+ useEffect(() => {
+ if (loadsFurther && !isLoading) view.toTail();
+ }, [isLoading, loadsFurther, view]);
+
+ const renderContent = () => {
+ if (firstPageLoadRetry.error) {
+ return (
+
+ );
+ }
+ if (isFirstPageLoading) return ;
+ if (loadsFurther) return ;
+ if (showsNothing) {
+ // some are scheduled, but the filter accepts none of them
+ return scheduledMessages.length ? (
+
+ ) : (
+
+ );
+ }
+ return (
+
+
+ {shownMessages.map((scheduledMessage) => (
+
+
+
+ ))}
+
+
+
+ );
+ };
+
+ return (
+ <>
+ {paginator.cid && }
+ {renderContent()}
+ >
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx b/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx
new file mode 100644
index 0000000000..0f577e5fa9
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx
@@ -0,0 +1,127 @@
+import clsx from 'clsx';
+import React, { useRef } from 'react';
+
+import {
+ useComponentContext,
+ useComponentContextIcons,
+ useTranslationContext,
+} from '../../context';
+import { Button } from '../Button';
+import { CHANNEL_CONTAINER_ID } from '../Channel/constants';
+import { Prompt } from '../Dialog';
+import { TextInput } from '../Form';
+import { GlobalModal } from '../Modal';
+import { Portal } from '../Portal/Portal';
+import { useScheduledMessages, useScheduledMessagesSearch } from './hooks';
+import type { ScheduledMessagesView } from 'stream-chat';
+import type { ScheduledMessageItemProps } from './ScheduledMessageItem';
+import { ScheduledMessageList as DefaultScheduledMessageList } from './ScheduledMessageList';
+
+const getChannelContainer = () => document.getElementById(CHANNEL_CONTAINER_ID);
+
+export type ScheduledMessageListDialogProps = Pick<
+ ScheduledMessageItemProps,
+ 'onEdit'
+> & {
+ close: () => void;
+ open: boolean;
+ /** What to list: `channel.scheduledMessages` or `thread.scheduledMessages`. */
+ scheduledMessages: ScheduledMessagesView;
+};
+
+/** The dialog's content, mounted only while it is open: the search starts empty each time. */
+const ScheduledMessageListDialogBody = ({
+ onEdit,
+ scheduledMessages: view,
+}: Pick) => {
+ const { t } = useTranslationContext();
+ const { IconSearch, IconXCircle } = useComponentContextIcons();
+ const { ScheduledMessageList = DefaultScheduledMessageList } = useComponentContext();
+ const { scheduledMessages } = useScheduledMessages(view);
+ const { filter, searchQuery, setSearchQuery } = useScheduledMessagesSearch();
+ const searchInputRef = useRef(null);
+
+ return (
+
+ {scheduledMessages.length > 0 && (
+ }
+ onChange={({ target }) => setSearchQuery(target.value)}
+ placeholder={t('scheduledMessages.search', 'Search')}
+ ref={searchInputRef}
+ trailing={
+ {
+ setSearchQuery('');
+ searchInputRef.current?.focus();
+ }}
+ size='xs'
+ variant='secondary'
+ >
+
+
+ }
+ type='search'
+ value={searchQuery}
+ />
+ )}
+
+
+ );
+};
+
+/**
+ * The scheduled messages of a channel or thread in a modal, with a search field. The banner's
+ * "View all" opens it; an override can show the list elsewhere, such as in a channel detail view,
+ * or search differently with `useScheduledMessagesSearch`'s `filter`.
+ */
+export const ScheduledMessageListDialog = ({
+ close,
+ onEdit,
+ open,
+ scheduledMessages,
+}: ScheduledMessageListDialogProps) => {
+ const { t } = useTranslationContext();
+ const { Modal = GlobalModal } = useComponentContext();
+
+ return (
+
+
+ {open && (
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessagePreview.tsx b/src/components/ScheduledMessages/ScheduledMessagePreview.tsx
new file mode 100644
index 0000000000..2a8ffabaa4
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessagePreview.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import { useTranslationContext } from '../../context';
+import { QuotedMessagePreviewUI } from '../MessageComposer/QuotedMessagePreview';
+import { useScheduledMessagePreview } from './hooks/useScheduledMessagePreview';
+import { isFailedScheduledMessage } from './utils';
+
+export type ScheduledMessagePreviewProps = {
+ onCancel: () => void;
+ scheduledMessage: ScheduledMessageResponse;
+};
+
+/** Above the composer while a scheduled message is edited, or a failed one scheduled again. */
+export const ScheduledMessagePreview = ({
+ onCancel,
+ scheduledMessage,
+}: ScheduledMessagePreviewProps) => {
+ const { t } = useTranslationContext();
+ const previewMessage = useScheduledMessagePreview(scheduledMessage);
+ return (
+
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessagesBanner.tsx b/src/components/ScheduledMessages/ScheduledMessagesBanner.tsx
new file mode 100644
index 0000000000..b156a8beaa
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessagesBanner.tsx
@@ -0,0 +1,150 @@
+import clsx from 'clsx';
+import React, { useEffect, useRef, useState } from 'react';
+import { nsToDate, Thread } from 'stream-chat';
+import type {
+ MessageComposerState,
+ PaginatorState,
+ ScheduledMessageResponse,
+} from 'stream-chat';
+
+import {
+ useComponentContext,
+ useComponentContextIcons,
+ useTranslationContext,
+} from '../../context';
+import { useStateStore } from '../../store';
+import { useAriaLiveAnnouncer } from '../Accessibility';
+import { useChannelCapabilities } from '../Channel/hooks/useChannelCapabilities';
+import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
+import { SCHEDULE_MESSAGE_CAPABILITY, useScheduledMessages } from './hooks';
+import { ScheduledMessageListDialog as DefaultScheduledMessageListDialog } from './ScheduledMessageListDialog';
+import { isFailedScheduledMessage } from './utils';
+
+const selectIsLoaded = ({ items }: PaginatorState) => ({
+ isLoaded: items !== undefined,
+});
+
+// hidden for a failed scheduled message open in the composer too
+const selectIsEditing = ({
+ editedMessage,
+ editedScheduledMessage,
+}: MessageComposerState) => ({
+ isEditing: !!editedMessage || !!editedScheduledMessage,
+});
+
+/**
+ * Above the composer: when the only scheduled message in the channel (or thread) is sent, how many
+ * are scheduled, or how many failed. "View all" opens the `ScheduledMessageListDialog` slot. Hidden
+ * while the composer edits a message; its changes are announced to screen readers.
+ */
+export const ScheduledMessagesBanner = () => {
+ const { t } = useTranslationContext();
+ const { IconClock, IconExclamationMark } = useComponentContextIcons();
+ const { ScheduledMessageListDialog = DefaultScheduledMessageListDialog } =
+ useComponentContext();
+ const messageComposer = useMessageComposerController();
+ const { channel, compositionContext } = messageComposer;
+ // a thread's composer lists the thread's scheduled replies
+ const view =
+ compositionContext instanceof Thread
+ ? compositionContext.scheduledMessages
+ : channel.scheduledMessages;
+ const { parentId } = view;
+ const canSchedule = useChannelCapabilities({ cid: channel.cid }).has(
+ SCHEDULE_MESSAGE_CAPABILITY,
+ );
+ const { scheduledMessages } = useScheduledMessages(view);
+ const { isLoaded } = useStateStore(view.paginator.state, selectIsLoaded);
+ const { isEditing } = useStateStore(messageComposer.state, selectIsEditing);
+ const announce = useAriaLiveAnnouncer();
+ const announcedLabelRef = useRef(undefined);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ const failedCount = scheduledMessages.filter(isFailedScheduledMessage).length;
+ const isVisible =
+ !isEditing && (failedCount > 0 || (canSchedule && scheduledMessages.length > 0));
+ const [onlyScheduledMessage] = scheduledMessages.length === 1 ? scheduledMessages : [];
+
+ const label = failedCount
+ ? t('scheduledMessages.banner.failedToSend', {
+ count: failedCount,
+ defaultValue_one: '{{ count }} failed to send',
+ defaultValue_other: '{{ count }} failed to send',
+ })
+ : onlyScheduledMessage
+ ? t('scheduledMessages.banner.scheduledFor', 'Scheduled for {{ time }}', {
+ time: t('timestamp.ScheduledMessageConfirmation', {
+ timestamp: nsToDate(onlyScheduledMessage.scheduled_at),
+ }),
+ })
+ : parentId
+ ? t('scheduledMessages.banner.scheduledReplies', {
+ count: scheduledMessages.length,
+ defaultValue_one: '{{ count }} scheduled reply',
+ defaultValue_other: '{{ count }} scheduled replies',
+ })
+ : t('scheduledMessages.banner.scheduledMessages', {
+ count: scheduledMessages.length,
+ defaultValue_one: '{{ count }} scheduled message',
+ defaultValue_other: '{{ count }} scheduled messages',
+ });
+ const announcedLabel = isVisible ? label : undefined;
+
+ // a change, such as a message just scheduled or failed; not the banner the list loaded with
+ useEffect(() => {
+ if (!isLoaded) {
+ announcedLabelRef.current = undefined;
+ return;
+ }
+ const previousLabel = announcedLabelRef.current;
+ announcedLabelRef.current = announcedLabel ?? '';
+ if (
+ previousLabel === undefined ||
+ !announcedLabel ||
+ previousLabel === announcedLabel
+ )
+ return;
+ announce(announcedLabel);
+ }, [announce, announcedLabel, isLoaded]);
+
+ if (!isVisible && !isDialogOpen) return null;
+
+ const closeDialog = () => setIsDialogOpen(false);
+ const editScheduledMessage = (scheduledMessage: ScheduledMessageResponse) => {
+ closeDialog();
+ messageComposer.editScheduledMessage(scheduledMessage);
+ };
+
+ const Icon = failedCount ? IconExclamationMark : IconClock;
+
+ return (
+ <>
+ {isVisible && (
+ 0,
+ })}
+ data-testid='scheduled-messages-banner'
+ >
+
+ {label}
+ setIsDialogOpen(true)}
+ type='button'
+ >
+ {onlyScheduledMessage && !failedCount
+ ? t('scheduledMessages.view', 'View')
+ : t('scheduledMessages.viewAll', 'View all')}
+
+
+ )}
+
+ >
+ );
+};
diff --git a/src/components/ScheduledMessages/ScheduledMessagesNotice.tsx b/src/components/ScheduledMessages/ScheduledMessagesNotice.tsx
new file mode 100644
index 0000000000..f2b3e34f82
--- /dev/null
+++ b/src/components/ScheduledMessages/ScheduledMessagesNotice.tsx
@@ -0,0 +1,33 @@
+import clsx from 'clsx';
+import React from 'react';
+
+import { useComponentContextIcons } from '../../context';
+
+export type ScheduledMessagesNoticeProps = {
+ children: string;
+ className?: string;
+ isError?: boolean;
+};
+
+/** A tinted line of information or error, as the scheduling dialogs and the list show it. */
+export const ScheduledMessagesNotice = ({
+ children,
+ className,
+ isError,
+}: ScheduledMessagesNoticeProps) => {
+ const { IconExclamationCircleFill, IconInfo } = useComponentContextIcons();
+ const Icon = isError ? IconExclamationCircleFill : IconInfo;
+ return (
+
+
+ {children}
+
+ );
+};
diff --git a/src/components/ScheduledMessages/__tests__/EditScheduledMessage.test.tsx b/src/components/ScheduledMessages/__tests__/EditScheduledMessage.test.tsx
new file mode 100644
index 0000000000..1da1337d63
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/EditScheduledMessage.test.tsx
@@ -0,0 +1,155 @@
+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.editScheduledMessage(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, failedMessage, startSchedulingAgain } = await renderComposer();
+ vi.spyOn(channel, 'sendMessage').mockResolvedValue(
+ fromPartial({ message: { id: 'sent-id', text: 'Launch moved to Monday' } }),
+ );
+ const deleteScheduledMessage = vi
+ .spyOn(channel, '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 0000000000..4695f96f24
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx
@@ -0,0 +1,451 @@
+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();
+ });
+
+ // the banner confirms it
+ it('schedules at the picked preset without a notification', 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(screen.queryByTestId('schedule-message-menu')).not.toBeInTheDocument(),
+ );
+ expect(getNotifications(client)).toEqual([]);
+ });
+
+ 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('reports a failure once: in the dialog, not also as a notification', async () => {
+ const { channel, client } = await renderComposer();
+ const { messageComposer } = channel;
+ // as the composer does, notifying of its own failure
+ mockSchedule(channel).mockImplementation(() => {
+ const error = new Error('failed');
+ client.notifications.addError({
+ message: 'Failed to schedule the message',
+ origin: { context: { composer: messageComposer }, emitter: 'MessageComposer' },
+ });
+ return Promise.resolve({ error, 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(getNotifications(client)).toEqual([]);
+ });
+
+ it.each(['blocked', 'nothing-to-send'] as const)(
+ 'keeps the dialog open when the message is %s',
+ async (status) => {
+ const { channel } = await renderComposer();
+ mockSchedule(channel).mockResolvedValue({ status });
+
+ 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();
+ },
+ );
+
+ it('opens the picker from anywhere in the field, its icons included', async () => {
+ await renderComposer();
+
+ const dialog = await openDialog();
+ const time = screen.getByLabelText('Time') as HTMLInputElement;
+ const showPicker = vi.fn();
+ time.showPicker = showPicker;
+ const caret = time
+ .closest('.str-chat__form-text-input__wrapper')
+ ?.querySelector('.str-chat__form-text-input__trailing');
+ fireEvent.click(caret ?? dialog);
+
+ expect(showPicker).toHaveBeenCalledTimes(1);
+ });
+
+ 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('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 0000000000..fbe51ab5d7
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx
@@ -0,0 +1,405 @@
+import React from 'react';
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} 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,
+ useChatContext,
+} from '../../../context';
+import { WithComponents } from '../../../context/WithComponents';
+import { Streami18n } from '../../../i18n';
+import {
+ convertDateToTimestamp,
+ generateScheduledMessage,
+ getTestClientWithUser,
+ hydrateScheduledMessages,
+ mockChatContext,
+} from '../../../mock-builders';
+import { getChannelByCid, SCHEDULED_MESSAGE_NOTIFICATION_TYPE } from '../utils';
+import { ScheduledMessageList } from '../ScheduledMessageList';
+import type { EditScheduledMessage } from '../types';
+
+const cid = 'messaging:general';
+
+const ChannelScheduledMessageList = ({ onEdit }: { onEdit?: EditScheduledMessage }) => {
+ const { client } = useChatContext();
+ return (
+
+ );
+};
+
+const getChannel = (client: StreamChat) => client.channel('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]);
+ client
+ .channel('messaging', 'general')
+ .state.partialNext({ ownCapabilities: ['send-message', 'schedule-message'] });
+
+ 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']);
+ });
+
+ describe('in a channel that allows neither sending nor scheduling', () => {
+ // as a frozen channel does
+ const removeComposeCapabilities = (client: StreamChat) =>
+ act(() =>
+ client.channel('messaging', 'general').state.partialNext({ ownCapabilities: [] }),
+ );
+
+ it('disables Edit, keeping it focusable and saying why', async () => {
+ const onEdit = vi.fn();
+ const { client } = await renderActions({ onEdit });
+ removeComposeCapabilities(client);
+
+ const edit = screen.getByTestId('scheduled-message-edit-button');
+ fireEvent.click(edit);
+
+ expect(edit).toHaveAttribute('aria-disabled', 'true');
+ expect(edit).not.toBeDisabled();
+ expect(edit).toHaveAccessibleDescription(
+ "Unavailable: you can't send messages in this channel",
+ );
+ expect(onEdit).not.toHaveBeenCalled();
+ });
+
+ it('disables Edit & reschedule of a failed message', async () => {
+ const onEdit = vi.fn();
+ const { client } = await renderActions({
+ onEdit,
+ scheduledMessage: generateScheduledMessage({
+ cid,
+ failure_reason: SCHEDULED_MESSAGE_FAILURE_REASON.ChannelFrozen,
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ }),
+ });
+ removeComposeCapabilities(client);
+
+ await selectAction('edit');
+
+ const editAndReschedule = screen.getByTestId('scheduled-message-action-edit');
+ expect(editAndReschedule).toHaveAttribute('aria-disabled', 'true');
+ expect(editAndReschedule).toHaveAccessibleDescription(
+ "Unavailable: you can't send messages in this channel",
+ );
+ expect(onEdit).not.toHaveBeenCalled();
+ // the menu stays open on the unavailable item
+ expect(screen.getByTestId('scheduled-message-actions-menu')).toBeInTheDocument();
+ });
+ });
+
+ 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']);
+ // Delete stands apart in a two-item menu too
+ expect(screen.getByRole('separator')).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(getChannel(client), 'sendScheduledMessageNow')
+ .mockResolvedValue(scheduledMessage);
+
+ await selectAction('send_now');
+
+ expect(sendNow).toHaveBeenCalledWith(scheduledMessage.id);
+ await waitFor(() =>
+ expect(getNotificationTypes(client)).toContain(
+ SCHEDULED_MESSAGE_NOTIFICATION_TYPE.sendNowSucceeded,
+ ),
+ );
+ });
+
+ it('reports a send-now failure with its error', async () => {
+ const { client } = await renderActions();
+ vi.spyOn(getChannel(client), 'sendScheduledMessageNow').mockRejectedValue(
+ notFoundError,
+ );
+
+ await selectAction('send_now');
+
+ await waitFor(() =>
+ expect(client.notifications.store.getLatestValue().notifications).toEqual([
+ expect.objectContaining({
+ // the error's own copy, given where the notification is made
+ message: 'Already sent',
+ originalError: notFoundError,
+ type: SCHEDULED_MESSAGE_NOTIFICATION_TYPE.sendNowFailed,
+ }),
+ ]),
+ );
+ });
+
+ it('deletes only after the deletion is confirmed', async () => {
+ const { client, scheduledMessage } = await renderActions();
+ const deleteScheduledMessage = vi
+ .spyOn(getChannel(client), '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(getChannel(client), '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(getChannel(client), '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(getChannel(client), 'updateScheduledMessage').mockRejectedValue(
+ notFoundError,
+ );
+
+ await selectAction('reschedule');
+ await screen.findByTestId('schedule-message-dialog');
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Save' })));
+
+ // the error's own copy: the message was sent meanwhile
+ expect(await screen.findByText('Already sent')).toBeInTheDocument();
+ // the dialog reports it, so it is not repeated as a notification
+ expect(getNotificationTypes(client)).toEqual([]);
+ });
+
+ it('reports a reschedule failure without a specific reason generically', async () => {
+ const { client } = await renderActions();
+ vi.spyOn(getChannel(client), 'updateScheduledMessage').mockRejectedValue(
+ new Error('Network Error'),
+ );
+
+ 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();
+ });
+
+ it('explains in place of the time zone why a reschedule is too soon', async () => {
+ await renderActions();
+
+ await selectAction('reschedule');
+ const dialog = await screen.findByTestId('schedule-message-dialog');
+ fireEvent.change(screen.getByLabelText('Time'), { target: { value: '00:00' } });
+ fireEvent.change(screen.getByLabelText('Date'), { target: { value: '2020-01-01' } });
+
+ expect(within(dialog).getByRole('alert')).toHaveTextContent(
+ "Pick a later time. Editing can't send a message right away.",
+ );
+ expect(dialog).not.toHaveTextContent('Sent in your time zone');
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+ });
+
+ 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('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 0000000000..f3cbe216eb
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx
@@ -0,0 +1,573 @@
+import React, { useEffect } from 'react';
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ 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,
+ useChatContext,
+} from '../../../context';
+import { WithComponents } from '../../../context/WithComponents';
+import { Streami18n } from '../../../i18n';
+import {
+ convertDateToTimestamp,
+ generatePoll,
+ generateScheduledMessage,
+ getScheduledMessagesPaginator,
+ getTestClientWithUser,
+ hydrateScheduledMessages,
+ mockChatContext,
+} from '../../../mock-builders';
+import type { ScheduledMessageSearchFilter } from '../hooks';
+import { useScheduledMessagesSearch } from '../hooks';
+import { getChannelByCid } from '../utils';
+import type { ScheduledMessageListProps } from '../ScheduledMessageList';
+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 = {
+ client?: StreamChat;
+ components?: Parameters[0]['overrides'];
+ onEdit?: EditScheduledMessage;
+ filter?: ScheduledMessageSearchFilter;
+ listProps?: Partial;
+ scheduledMessages?: ScheduledMessageResponse[];
+ searchQuery?: string;
+};
+
+/** The list as the dialog renders it: searched with `useScheduledMessagesSearch`. */
+const SearchedList = ({
+ filter,
+ listProps,
+ onEdit,
+ searchQuery = '',
+}: Pick) => {
+ const { client } = useChatContext();
+ const { filter: searchFilter, setSearchQuery } = useScheduledMessagesSearch({ filter });
+ useEffect(() => setSearchQuery(searchQuery), [searchQuery, setSearchQuery]);
+ return (
+
+ );
+};
+
+const renderList = async ({
+ client: clientArg,
+ components = {},
+ filter,
+ listProps,
+ onEdit,
+ scheduledMessages = [],
+ searchQuery,
+}: RenderListOptions = {}) => {
+ const client = clientArg ?? (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 getListedTexts = () =>
+ screen
+ .getAllByTestId('scheduled-message-item')
+ .map(
+ (item) => item.querySelector('.str-chat__summarized-message-preview')?.textContent,
+ );
+
+const getPaginator = (client: StreamChat) => getScheduledMessagesPaginator(client, 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();
+ });
+
+ describe('in a channel that takes no scheduled messages', () => {
+ const setCapabilities = (
+ client: StreamChat,
+ ownCapabilities: string[],
+ frozen = false,
+ ) =>
+ act(() => {
+ const channel = client.channel('messaging', 'general');
+ channel.data = { ...channel.data, frozen };
+ channel.state.partialNext({ ownCapabilities });
+ });
+
+ it('says a frozen channel will not send them', async () => {
+ const { client } = await renderList({
+ scheduledMessages: [generateScheduledMessage({ cid })],
+ });
+
+ setCapabilities(client, [], true);
+
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ "This channel is frozen. Scheduled messages won't be sent",
+ );
+ });
+
+ it('says the user cannot schedule here, even with nothing scheduled', async () => {
+ const { client } = await renderList();
+
+ setCapabilities(client, ['send-message']);
+
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ "You can't schedule messages in this channel",
+ );
+ expect(screen.getByText('No scheduled messages')).toBeInTheDocument();
+ });
+
+ it('shows nothing while the channel can schedule', async () => {
+ const { client } = await renderList();
+
+ setCapabilities(client, ['send-message', 'schedule-message']);
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+ });
+
+ 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('loads further pages while the search matches nothing loaded', async () => {
+ const { client } = await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({ cid, message: { custom: {}, text: 'Lunch at noon' } }),
+ ],
+ searchQuery: 'friday',
+ });
+ const query = vi.spyOn(client, 'queryScheduledMessages').mockResolvedValue({
+ duration: '',
+ scheduled_messages: [
+ generateScheduledMessage({ cid, message: { custom: {}, text: 'Friday demo' } }),
+ ],
+ } as never);
+
+ setNextCursor(client);
+
+ expect(await screen.findByText('Friday demo')).toBeInTheDocument();
+ expect(query).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops loading further pages when one fails', async () => {
+ const { client } = await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({ cid, message: { custom: {}, text: 'Lunch at noon' } }),
+ ],
+ searchQuery: 'friday',
+ });
+ const query = vi
+ .spyOn(client, 'queryScheduledMessages')
+ .mockRejectedValue(new Error('offline'));
+
+ setNextCursor(client);
+
+ expect(await screen.findByText('No messages found')).toBeInTheDocument();
+ expect(query).toHaveBeenCalledTimes(1);
+ });
+
+ // a list-state key overridden for the channel list (`EmptyListIndicator` takes no props) must not
+ // turn up here
+ it('renders the list states passed as props, not the shared list-state keys', async () => {
+ const { client } = await renderList({
+ components: {
+ EmptyListIndicator: () => No channels
,
+ FirstPageLoadingIndicator: () => Loading channels
,
+ },
+ listProps: {
+ EmptyListIndicator: () => Nothing scheduled
,
+ FirstPageLoadingIndicator: () => Loading scheduled
,
+ },
+ });
+
+ expect(screen.getByText('Nothing scheduled')).toBeInTheDocument();
+ expect(screen.queryByText('No channels')).not.toBeInTheDocument();
+
+ setPaginatorState(client, { isLoading: true, items: undefined });
+
+ expect(screen.getByText('Loading scheduled')).toBeInTheDocument();
+ });
+
+ it('renders a custom empty search results indicator', async () => {
+ await renderList({
+ listProps: { EmptySearchResultsIndicator: () => No match
},
+ scheduledMessages: [
+ generateScheduledMessage({ cid, message: { custom: {}, text: 'Lunch at noon' } }),
+ ],
+ searchQuery: 'friday',
+ });
+
+ expect(screen.getByText('No match')).toBeInTheDocument();
+ });
+
+ it('searches with a custom filter, such as by date', async () => {
+ const onFriday: ScheduledMessageSearchFilter = ({ scheduled_at }) =>
+ scheduled_at >= atLocalTime(30, 0) && scheduled_at < atLocalTime(31, 0);
+ await renderList({
+ filter: onFriday,
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Thursday standup' },
+ scheduled_at: atLocalTime(29, 9),
+ }),
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Friday demo' },
+ scheduled_at: atLocalTime(30, 9),
+ }),
+ ],
+ });
+
+ expect(screen.getByText('Friday demo')).toBeInTheDocument();
+ expect(screen.queryByText('Thursday standup')).not.toBeInTheDocument();
+ });
+
+ // a failed message failed at its send time, so ordering by time alone lists it first
+ it('lists the messages earliest first, failed ones included, without grouping', async () => {
+ await renderList({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Friday plans' },
+ scheduled_at: atLocalTime(30, 13),
+ }),
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Tomorrow plans' },
+ scheduled_at: atLocalTime(29, 9),
+ }),
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Failed earlier' },
+ scheduled_at: atLocalTime(28, 9),
+ status: SCHEDULED_MESSAGE_STATUS.Failed,
+ }),
+ ],
+ });
+
+ expect(getListedTexts()).toEqual([
+ expect.stringContaining('Failed earlier'),
+ expect.stringContaining('Tomorrow plans'),
+ expect.stringContaining('Friday plans'),
+ ]);
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ });
+
+ 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();
+ });
+
+ describe('a scheduled poll', () => {
+ const scheduledPoll = () =>
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, poll_id: 'poll-1', text: '' },
+ });
+
+ // the row reads "Poll", as a channel preview does; the composer preview shows the question
+ it('loads the poll it carries only the ID of, and shows it as a poll', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const getPoll = vi.spyOn(client, 'getPoll').mockResolvedValue({
+ duration: '',
+ poll: generatePoll({
+ created_by: { id: 'user-id' },
+ id: 'poll-1',
+ name: 'Where do we eat?',
+ vote_count: 0,
+ }),
+ } as never);
+
+ await renderList({ client, scheduledMessages: [scheduledPoll()] });
+
+ expect(await screen.findByText('Poll')).toBeInTheDocument();
+ expect(getPoll).toHaveBeenCalledWith({ poll_id: 'poll-1' });
+ });
+
+ it('takes a poll the client already holds without loading it', async () => {
+ const client = await getTestClientWithUser({ id: 'user-id' });
+ const getPoll = vi.spyOn(client, 'getPoll').mockResolvedValue({
+ duration: '',
+ poll: generatePoll({ id: 'poll-1', name: 'Pizza or pasta?' }),
+ } as never);
+ await client.polls.getPoll('poll-1');
+ getPoll.mockClear();
+
+ await renderList({ client, scheduledMessages: [scheduledPoll()] });
+
+ expect(screen.getByText('Poll')).toBeInTheDocument();
+ expect(getPoll).not.toHaveBeenCalled();
+ });
+ });
+
+ 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 the next page when scrolled to the end', async () => {
+ const { client } = await renderList({
+ scheduledMessages: [generateScheduledMessage({ cid })],
+ });
+ const query = vi
+ .spyOn(client, 'queryScheduledMessages')
+ .mockResolvedValue({ duration: '', scheduled_messages: [] } as never);
+ setNextCursor(client);
+ const list = screen.getByTestId('scheduled-message-list');
+ Object.defineProperties(list, {
+ clientHeight: { configurable: true, value: 100 },
+ offsetParent: { configurable: true, value: document.body },
+ scrollTop: { configurable: true, value: 0, writable: true },
+ });
+ Object.defineProperty(list.firstElementChild, 'scrollHeight', {
+ configurable: true,
+ value: 1000,
+ });
+
+ list.scrollTop = 900;
+ fireEvent.scroll(list);
+
+ await waitFor(() => expect(query).toHaveBeenCalledTimes(1));
+ });
+
+ 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 0000000000..8ba0175541
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx
@@ -0,0 +1,432 @@
+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 type { ScheduledMessageListDialogProps } from '../ScheduledMessageListDialog';
+
+import { axe } from '../../../../axe-helper';
+import { AriaLiveAnnouncerContext } from '../../Accessibility';
+import type { AriaLiveAnnounce } from '../../Accessibility';
+import { Channel } from '../../Channel';
+import { Chat } from '../../Chat';
+import { WithComponents } from '../../../context/WithComponents';
+import { MessageComposer } from '../../MessageComposer';
+import { ThreadProvider } from '../../Threads';
+import {
+ convertDateToTimestamp,
+ dispatchScheduledMessageEvent,
+ generateMessage,
+ generateScheduledMessage,
+ generateUser,
+ getScheduledMessagesPaginator,
+ hydrateScheduledMessages,
+ 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 = {
+ announce?: AriaLiveAnnounce;
+ awaitsScheduledMessages?: boolean;
+ canSchedule?: boolean;
+ components?: Parameters[0]['overrides'];
+ inThread?: boolean;
+ scheduledMessages?: ScheduledMessageResponse[];
+};
+
+const renderComposer = async ({
+ announce,
+ 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(getScheduledMessagesPaginator(client, cid).hasLoadedPage).toBe(true),
+ );
+ }
+
+ return { channel, client };
+};
+
+const AnnouncerScope = ({
+ announce,
+ children,
+}: PropsWithChildren<{ announce?: AriaLiveAnnounce }>) =>
+ announce ? (
+
+ {children}
+
+ ) : (
+ children
+ );
+
+const queryBanner = () => screen.queryByTestId('scheduled-messages-banner');
+
+const ON_JANUARY_15 = convertDateToTimestamp(new Date(2030, 0, 15, 9, 0));
+
+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('shows when the only scheduled message is sent, with View', async () => {
+ await renderComposer({
+ scheduledMessages: [generateScheduledMessage({ cid, scheduled_at: ON_JANUARY_15 })],
+ });
+
+ expect(queryBanner()).toHaveTextContent('Scheduled for Tue, Jan 15 at 09:00');
+ expect(screen.getByRole('button', { name: 'View' })).toBeInTheDocument();
+ });
+
+ it('is hidden while the composer edits a scheduled message', async () => {
+ const scheduledMessage = generateScheduledMessage({ cid });
+ const { channel } = await renderComposer({ scheduledMessages: [scheduledMessage] });
+
+ await act(() => channel.messageComposer.editScheduledMessage(scheduledMessage));
+
+ expect(queryBanner()).not.toBeInTheDocument();
+ });
+
+ it('announces a change, but not the banner the channel opens with', async () => {
+ const announce = vi.fn(() => () => undefined);
+ const scheduledMessage = generateScheduledMessage({
+ cid,
+ scheduled_at: ON_JANUARY_15,
+ });
+ const { client } = await renderComposer({
+ announce,
+ scheduledMessages: [scheduledMessage],
+ });
+
+ expect(announce).not.toHaveBeenCalled();
+
+ act(() =>
+ dispatchScheduledMessageEvent({
+ client,
+ scheduledMessage: { ...scheduledMessage, status: 'failed' },
+ type: ScheduledMessageEventType.Failed,
+ }),
+ );
+
+ await waitFor(() => expect(announce).toHaveBeenCalledWith('1 failed to send'));
+ });
+
+ 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(() => getScheduledMessagesPaginator(client, cid).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(() => getScheduledMessagesPaginator(client, cid).resetState());
+ act(() =>
+ hydrateScheduledMessages(client, 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(),
+ threadReply(),
+ ],
+ });
+
+ expect(queryBanner()).toHaveTextContent('2 scheduled replies');
+ });
+
+ 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('Scheduled for'));
+
+ act(() =>
+ dispatchScheduledMessageEvent({
+ client,
+ scheduledMessage: { ...scheduledMessage, status: 'sent' },
+ type: ScheduledMessageEventType.Sent,
+ }),
+ );
+
+ await waitFor(() => expect(queryBanner()).not.toBeInTheDocument());
+ });
+
+ it('opens the scheduled message list from View', async () => {
+ await renderComposer({
+ scheduledMessages: [
+ generateScheduledMessage({
+ cid,
+ message: { custom: {}, text: 'Launch moved to Monday' },
+ }),
+ ],
+ });
+
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'View' })));
+
+ 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');
+ const search = within(dialog).getByRole('searchbox', { name: 'Search' });
+ fireEvent.change(search, { target: { value: 'launch' } });
+
+ expect(dialog).toHaveTextContent('Launch moved to Monday');
+ expect(dialog).not.toHaveTextContent('Lunch at noon');
+
+ // a button, so the keyboard reaches it
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Clear search' }));
+
+ expect(search).toHaveValue('');
+ expect(search).toHaveFocus();
+ // kept in place so the field keeps its height, hidden from pointer and keyboard
+ expect(screen.getByRole('button', { name: 'Clear search' })).toHaveClass(
+ 'str-chat__form-text-input__clear-button--hidden',
+ );
+ expect(dialog).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' })));
+
+ expect(await screen.findByTestId('scheduled-message-list-dialog')).toHaveTextContent(
+ 'Scheduled replies',
+ );
+ });
+
+ it('opens what the ScheduledMessageListDialog slot renders', async () => {
+ const scheduledMessage = generateScheduledMessage({ cid });
+ const ScheduledMessageListDialog = vi.fn(
+ ({ close, onEdit, open }: ScheduledMessageListDialogProps) =>
+ open ? (
+
+
+ Close list
+
+ onEdit?.(scheduledMessage)} type='button'>
+ Edit it
+
+
+ ) : null,
+ );
+ const { channel } = await renderComposer({
+ components: { ScheduledMessageListDialog },
+ scheduledMessages: [scheduledMessage],
+ });
+
+ expect(screen.queryByTestId('custom-list')).not.toBeInTheDocument();
+
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'View' })));
+
+ expect(screen.getByTestId('custom-list')).toBeInTheDocument();
+ expect(ScheduledMessageListDialog).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ open: true,
+ scheduledMessages: channel.scheduledMessages,
+ }),
+ undefined,
+ );
+
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Close list' })));
+
+ expect(screen.queryByTestId('custom-list')).not.toBeInTheDocument();
+
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'View' })));
+ await act(() => fireEvent.click(screen.getByRole('button', { name: 'Edit it' })));
+
+ // editing closes the list and opens the message in the composer
+ expect(screen.queryByTestId('custom-list')).not.toBeInTheDocument();
+ expect(channel.messageComposer.editedScheduledMessage?.id).toBe(scheduledMessage.id);
+ });
+
+ 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/__tests__/utils.test.ts b/src/components/ScheduledMessages/__tests__/utils.test.ts
new file mode 100644
index 0000000000..a350254976
--- /dev/null
+++ b/src/components/ScheduledMessages/__tests__/utils.test.ts
@@ -0,0 +1,166 @@
+import { SCHEDULED_MESSAGE_FAILURE_REASON } from 'stream-chat';
+import type { Channel } from 'stream-chat';
+
+import { generateScheduledMessage } from '../../../mock-builders';
+import {
+ formatTimeZoneOffset,
+ fromInputValues,
+ getDefaultScheduledAt,
+ getScheduledMessageActions,
+ getSchedulePresets,
+ getScheduleTimeBounds,
+ getScheduleTimeError,
+ matchesScheduledMessageText,
+ translateScheduledMessageFailureReason,
+} from '../utils';
+
+const at = (day: number, hour: number) => new Date(2026, 9, day, hour, 0);
+const withLimits = (minLeadTimeSeconds: number, maxLeadTimeSeconds: number) =>
+ ({
+ serverConfig: {
+ scheduled_messages_max_lead_time_seconds: maxLeadTimeSeconds,
+ scheduled_messages_max_per_channel: 10,
+ scheduled_messages_max_per_user: 100,
+ scheduled_messages_min_lead_time_seconds: minLeadTimeSeconds,
+ },
+ }) as unknown as Channel;
+
+describe('scheduled message utils', () => {
+ it('searches the text case-insensitively and ignores a blank query', () => {
+ const lunch = generateScheduledMessage({
+ message: { custom: {}, text: 'Lunch at noon' },
+ });
+ const launch = generateScheduledMessage({ message: { custom: {}, text: 'Launch' } });
+
+ expect(matchesScheduledMessageText(lunch, ' LUNCH ')).toBe(true);
+ expect(matchesScheduledMessageText(launch, ' LUNCH ')).toBe(false);
+ expect(matchesScheduledMessageText(launch, ' ')).toBe(true);
+ });
+
+ describe('time bounds', () => {
+ it('starts a minute after the minimum lead time and ends at the maximum', () => {
+ const now = at(28, 10);
+
+ const { earliest, latest } = getScheduleTimeBounds(withLimits(60, 3600), now);
+
+ expect(earliest).toEqual(new Date(now.getTime() + 2 * 60 * 1000));
+ expect(latest).toEqual(new Date(now.getTime() + 3600 * 1000));
+ });
+
+ it('leaves the latest time open for a channel without limits', () => {
+ expect(getScheduleTimeBounds({} as Channel, at(28, 10)).latest).toBeUndefined();
+ });
+
+ it('reports a time outside the bounds', () => {
+ const bounds = { earliest: at(28, 10), latest: at(29, 10) };
+
+ expect(getScheduleTimeError(at(28, 9), bounds)).toBe('tooSoon');
+ expect(getScheduleTimeError(at(30, 9), bounds)).toBe('tooFar');
+ expect(getScheduleTimeError(at(28, 12), bounds)).toBeUndefined();
+ });
+
+ it('defaults to the next full hour after the earliest time', () => {
+ expect(getDefaultScheduledAt({ earliest: new Date(2026, 9, 28, 10, 25) })).toEqual(
+ at(28, 11),
+ );
+ });
+ });
+
+ describe('presets', () => {
+ const open = { earliest: new Date(0) };
+
+ it('offers tomorrow morning and afternoon and next Monday morning', () => {
+ // a Wednesday
+ expect(getSchedulePresets(at(28, 10), open)).toEqual([
+ at(29, 9),
+ at(29, 13),
+ new Date(2026, 10, 2, 9, 0),
+ ]);
+ });
+
+ it('leaves out next Monday when it is tomorrow', () => {
+ // a Sunday
+ expect(getSchedulePresets(new Date(2026, 10, 1, 10, 0), open)).toHaveLength(2);
+ });
+
+ it('leaves out a preset outside the bounds', () => {
+ expect(getSchedulePresets(at(28, 10), { earliest: at(29, 12) })).toEqual([
+ at(29, 13),
+ new Date(2026, 10, 2, 9, 0),
+ ]);
+ });
+ });
+
+ describe('actions', () => {
+ it('offers every action for a pending message, without edit when it cannot open', () => {
+ const pending = generateScheduledMessage();
+
+ expect(getScheduledMessageActions(pending, true)).toEqual([
+ 'edit',
+ 'reschedule',
+ 'send_now',
+ 'delete',
+ ]);
+ expect(getScheduledMessageActions(pending, false)).not.toContain('edit');
+ });
+
+ it('offers nothing while the message is being sent', () => {
+ expect(
+ getScheduledMessageActions(generateScheduledMessage({ status: 'sending' }), true),
+ ).toEqual([]);
+ });
+
+ it('offers copy and delete for a message whose channel was deleted', () => {
+ const failed = generateScheduledMessage({
+ failure_reason: SCHEDULED_MESSAGE_FAILURE_REASON.ChannelDeleted,
+ status: 'failed',
+ });
+
+ expect(getScheduledMessageActions(failed, true)).toEqual(['copy', 'delete']);
+ });
+ });
+
+ it('explains a failure, except when the user was deleted', () => {
+ const t = ((_key: string, defaultValue: string) => defaultValue) as never;
+
+ expect(
+ translateScheduledMessageFailureReason(
+ t,
+ SCHEDULED_MESSAGE_FAILURE_REASON.ChannelFrozen,
+ ),
+ ).toBe('This channel is frozen');
+ expect(
+ translateScheduledMessageFailureReason(
+ t,
+ SCHEDULED_MESSAGE_FAILURE_REASON.UserDeleted,
+ ),
+ ).toBeUndefined();
+ expect(translateScheduledMessageFailureReason(t)).toBe("Couldn't send. Try again");
+ expect(
+ translateScheduledMessageFailureReason(
+ t,
+ SCHEDULED_MESSAGE_FAILURE_REASON.InternalError,
+ ),
+ ).toBe('Something went wrong. Try again');
+ expect(
+ translateScheduledMessageFailureReason(
+ t,
+ SCHEDULED_MESSAGE_FAILURE_REASON.PendingNotSupported,
+ ),
+ ).toBe('This channel now needs approval. Schedule it again');
+ });
+
+ it('reads the date and time inputs, rejecting an invalid value', () => {
+ expect(fromInputValues('2026-10-28', '14:30')).toEqual(new Date(2026, 9, 28, 14, 30));
+ expect(fromInputValues('2026-10-28')).toEqual(at(28, 0));
+ expect(fromInputValues('', '14:30')).toBeUndefined();
+ expect(fromInputValues('not-a-date', '14:30')).toBeUndefined();
+ });
+
+ it('formats the time zone offset', () => {
+ const date = new Date();
+ vi.spyOn(date, 'getTimezoneOffset').mockReturnValue(-330);
+
+ expect(formatTimeZoneOffset(date)).toBe('GMT+5:30');
+ });
+});
diff --git a/src/components/ScheduledMessages/hooks/index.ts b/src/components/ScheduledMessages/hooks/index.ts
new file mode 100644
index 0000000000..7c390e1153
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useCanScheduleMessage';
+export * from './useScheduledMessages';
+export * from './useScheduledMessagesSearch';
diff --git a/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts
new file mode 100644
index 0000000000..ad6f9122ff
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts
@@ -0,0 +1,56 @@
+import { useMemo } from 'react';
+import type {
+ MessageComposerState,
+ ScheduledMessagePaginatorsManagerState,
+} from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+import { useChannelCapabilities } from '../../Channel/hooks/useChannelCapabilities';
+import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController';
+import { isComposerEditing } from '../utils';
+
+export const SCHEDULE_MESSAGE_CAPABILITY = 'schedule-message';
+
+const selectIsDisabled = ({ isDisabled }: ScheduledMessagePaginatorsManagerState) => ({
+ isDisabled,
+});
+
+const selectIsEditing = (state: MessageComposerState) => ({
+ isEditing: isComposerEditing(state),
+});
+
+/**
+ * Whether the composition can be scheduled: the channel allows it, the feature is on, nothing is
+ * being edited and the composition holds nothing that cannot be scheduled (a command, a poll, a
+ * location). `scheduleBlockReason` reads three stores, so each is subscribed to.
+ */
+export const useCanScheduleMessage = () => {
+ const { client } = useChatContext();
+ const messageComposer = useMessageComposerController();
+ const { channel, locationComposer, textComposer } = messageComposer;
+ const channelCapabilities = useChannelCapabilities({ cid: channel.cid });
+
+ const selectBlockReason = useMemo(
+ () => () => ({ scheduleBlockReason: messageComposer.scheduleBlockReason }),
+ [messageComposer],
+ );
+
+ const { isDisabled } = useStateStore(
+ client.scheduledMessagePaginators.state,
+ selectIsDisabled,
+ );
+ const { isEditing } = useStateStore(messageComposer.state, selectIsEditing);
+ const blockReasons = [
+ useStateStore(messageComposer.state, selectBlockReason).scheduleBlockReason,
+ useStateStore(textComposer.state, selectBlockReason).scheduleBlockReason,
+ useStateStore(locationComposer.state, selectBlockReason).scheduleBlockReason,
+ ];
+
+ return (
+ channelCapabilities.has(SCHEDULE_MESSAGE_CAPABILITY) &&
+ !isDisabled &&
+ !isEditing &&
+ !blockReasons.some(Boolean)
+ );
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts b/src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts
new file mode 100644
index 0000000000..8c94534344
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts
@@ -0,0 +1,55 @@
+import { useEffect, useMemo, useState } from 'react';
+import { toScheduledMessagePreview } from 'stream-chat';
+import type {
+ LocalMessage,
+ Poll,
+ PollState,
+ ScheduledMessageResponse,
+} from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+
+const selectPollPreview = ({ created_by, name, vote_count }: PollState) => ({
+ created_by,
+ name,
+ vote_count,
+});
+
+/**
+ * The scheduled message as a message to preview. A scheduled poll carries only its ID, so the poll
+ * is taken from `client.polls`, loaded once when it is not there yet.
+ */
+export const useScheduledMessagePreview = (
+ scheduledMessage: ScheduledMessageResponse,
+): LocalMessage => {
+ const { client } = useChatContext();
+ const pollId = scheduledMessage.message.poll_id;
+ const [loadedPoll, setLoadedPoll] = useState();
+ const poll = pollId
+ ? (client.polls.fromState(pollId) ??
+ (loadedPoll?.id === pollId ? loadedPoll : undefined))
+ : undefined;
+
+ useEffect(() => {
+ if (!pollId || client.polls.fromState(pollId)) return;
+ let isActive = true;
+ client.polls
+ .getPoll(pollId)
+ .then((fetched) => {
+ if (isActive && fetched) setLoadedPoll(fetched);
+ })
+ .catch(() => undefined);
+ return () => {
+ isActive = false;
+ };
+ }, [client, pollId]);
+
+ const pollPreview = useStateStore(poll?.state, selectPollPreview);
+
+ return useMemo(() => {
+ const preview = toScheduledMessagePreview(scheduledMessage);
+ if (!pollId || !pollPreview) return preview;
+ return { ...preview, poll: { ...pollPreview, id: pollId } as LocalMessage['poll'] };
+ }, [pollId, pollPreview, scheduledMessage]);
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessages.ts b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts
new file mode 100644
index 0000000000..7150a4c872
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts
@@ -0,0 +1,49 @@
+import { useEffect, useMemo } from 'react';
+import type {
+ PaginatorState,
+ ScheduledMessageResponse,
+ ScheduledMessagesView,
+ ScheduledMessagesViewState,
+} from 'stream-chat';
+
+import { useStateStore } from '../../../store';
+
+const NO_SCHEDULED_MESSAGES: ScheduledMessageResponse[] = [];
+
+const selectItems = ({ items }: ScheduledMessagesViewState) => ({ items });
+
+const createAwaitsFirstPageSelector =
+ (view: ScheduledMessagesView) =>
+ ({ isLoading, lastQueryError }: PaginatorState) => ({
+ awaitsFirstPage: !view.paginator.hasLoadedPage && !isLoading && !lastQueryError,
+ });
+
+/**
+ * The messages of an SDK scheduled-message view: `channel.scheduledMessages`,
+ * `thread.scheduledMessages`, or a view of a list registered with
+ * `client.scheduledMessagePaginators`. Keeps the view following its paginator while mounted, and
+ * loads the first page unless one is loaded, as `ChannelList` does.
+ */
+export const useScheduledMessages = (view: ScheduledMessagesView) => {
+ useEffect(() => {
+ view.registerSubscriptions();
+ return () => {
+ view.unregisterSubscriptions();
+ };
+ }, [view]);
+
+ const awaitsFirstPageSelector = useMemo(
+ () => createAwaitsFirstPageSelector(view),
+ [view],
+ );
+ const { awaitsFirstPage } = useStateStore(
+ view.paginator.state,
+ awaitsFirstPageSelector,
+ );
+ useEffect(() => {
+ if (awaitsFirstPage) view.toTailDebounced();
+ }, [awaitsFirstPage, view]);
+
+ const { items } = useStateStore(view.state, selectItems);
+ return { scheduledMessages: items ?? NO_SCHEDULED_MESSAGES };
+};
diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessagesSearch.ts b/src/components/ScheduledMessages/hooks/useScheduledMessagesSearch.ts
new file mode 100644
index 0000000000..86522cc621
--- /dev/null
+++ b/src/components/ScheduledMessages/hooks/useScheduledMessagesSearch.ts
@@ -0,0 +1,35 @@
+import { useCallback, useState } from 'react';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import { matchesScheduledMessageText } from '../utils';
+
+export { matchesScheduledMessageText };
+
+/** Whether a scheduled message matches the search; `searchQuery` is the search field's value. */
+export type ScheduledMessageSearchFilter = (
+ scheduledMessage: ScheduledMessageResponse,
+ searchQuery: string,
+) => boolean;
+
+export type UseScheduledMessagesSearchParams = {
+ /**
+ * Replaces the default text search, for example to search by date. It receives the query, so it
+ * can still combine with `matchesScheduledMessageText`. Keep it stable, e.g. with `useCallback`.
+ */
+ filter?: ScheduledMessageSearchFilter;
+};
+
+/**
+ * The search field's state, and the `filter` for `ScheduledMessageList` it makes of it. The list
+ * loads further pages while none of the loaded messages match, so the matches need not be loaded.
+ */
+export const useScheduledMessagesSearch = ({
+ filter = matchesScheduledMessageText,
+}: UseScheduledMessagesSearchParams = {}) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const searchFilter = useCallback(
+ (scheduledMessage: ScheduledMessageResponse) => filter(scheduledMessage, searchQuery),
+ [filter, searchQuery],
+ );
+ return { filter: searchFilter, searchQuery, setSearchQuery };
+};
diff --git a/src/components/ScheduledMessages/index.ts b/src/components/ScheduledMessages/index.ts
new file mode 100644
index 0000000000..44e5465a4a
--- /dev/null
+++ b/src/components/ScheduledMessages/index.ts
@@ -0,0 +1,8 @@
+export * from './hooks';
+export * from './ScheduledMessageItem';
+export * from './ScheduledMessageList';
+export * from './ScheduledMessageListDialog';
+export * from './ScheduledMessagePreview';
+export * from './ScheduledMessagesBanner';
+export * from './ScheduleMessageButton';
+export * from './ScheduleTimeDialog';
diff --git a/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss b/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss
new file mode 100644
index 0000000000..4baa454cad
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss
@@ -0,0 +1,50 @@
+@use '../../../styling/utils';
+
+$caret-size: var(--str-chat__button-visual-height-xs);
+
+.str-chat {
+ .str-chat__split-send-button {
+ position: relative;
+ display: inline-flex;
+
+ .str-chat__send-button.str-chat__button--circular {
+ aspect-ratio: auto;
+ // the shrunk icon would leave the button 28px tall
+ min-height: var(--str-chat__button-visual-height-sm);
+
+ .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) + #{$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 {
+ padding: var(--str-chat__spacing-xs);
+ }
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss b/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss
new file mode 100644
index 0000000000..80bbe888fd
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss
@@ -0,0 +1,74 @@
+@use '../../../styling/utils';
+
+.str-chat {
+ .str-chat__schedule-message-dialog {
+ width: 100%;
+ max-width: 504px;
+
+ .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);
+ }
+
+ 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);
+ }
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/ScheduledMessageActions.scss b/src/components/ScheduledMessages/styling/ScheduledMessageActions.scss
new file mode 100644
index 0000000000..aec8d04f71
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduledMessageActions.scss
@@ -0,0 +1,31 @@
+@use '../../../styling/utils';
+
+.str-chat {
+ .str-chat__scheduled-message-list__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;
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/ScheduledMessageList.scss b/src/components/ScheduledMessages/styling/ScheduledMessageList.scss
new file mode 100644
index 0000000000..ea47e07066
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduledMessageList.scss
@@ -0,0 +1,163 @@
+@use '../../../styling/utils';
+
+// The modal's dialog wrapper is a flex item sized to its content, so without a limit of its own the
+// dialog's `max-width: 100%` measures against its own 520px and overflows a narrow screen. Top-level,
+// as in `Modal.scss`: the modal is portaled and is itself the `.str-chat` element, with no such
+// ancestor.
+.str-chat__scheduled-message-list-modal > .str-chat__modal__dialog {
+ max-width: calc(100% - 2 * var(--str-chat__spacing-md));
+}
+
+.str-chat {
+ .str-chat__scheduled-message-list-dialog {
+ width: 520px;
+ max-width: 100%;
+ // A fixed height, as ChannelDetail's: sized to its content, the dialog would grow and shrink
+ // with every search keystroke and page loaded.
+ height: min(640px, calc(100dvh - 2 * var(--str-chat__spacing-md)));
+
+ // the list scrolls, so reaching its end loads the next page
+ .str-chat__scheduled-message-list-dialog__body {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+
+ // rows run to the dialog's edges; on the list, as its own overflow would clip them
+ .str-chat__scheduled-message-list {
+ margin-inline: calc(-1 * var(--str-chat__spacing-xl));
+ }
+ }
+ }
+
+ // the pill of the ChannelDetail search, as the design has it
+ .str-chat__scheduled-message-list-dialog__search {
+ margin-block-end: var(--str-chat__spacing-xs);
+
+ .str-chat__form-text-input__wrapper--outline {
+ border-radius: var(--str-chat__radius-max);
+ }
+ }
+
+ .str-chat__scheduled-message-list {
+ flex: 1;
+ min-height: 0;
+ }
+
+ .str-chat__scheduled-message-list-notice {
+ flex-shrink: 0;
+ margin-block-end: var(--str-chat__spacing-xs);
+ }
+
+ .str-chat__scheduled-message-list__items {
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ .str-chat__scheduled-message-list__item {
+ padding: var(--str-chat__spacing-md) var(--str-chat__spacing-xl);
+ border-block-end: 1px solid var(--str-chat__border-core-subtle);
+
+ &: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);
+ }
+ }
+
+ // fills the dialog or view it is in, centred both ways
+ .str-chat__scheduled-messages-empty-list {
+ @include utils.flex-col-center;
+ @include utils.empty-theme('scheduled-messages');
+ flex: 1;
+ gap: var(--str-chat__spacing-xxs);
+ padding-block: var(--str-chat__spacing-3xl);
+ padding-inline: var(--str-chat__spacing-md);
+
+ .str-chat__scheduled-messages-empty-list__icon {
+ 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-message-list-loading {
+ @include utils.flex-row-center;
+ flex: 1;
+ padding-block: var(--str-chat__spacing-3xl);
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/ScheduledMessagesBanner.scss b/src/components/ScheduledMessages/styling/ScheduledMessagesBanner.scss
new file mode 100644
index 0000000000..c70e88e786
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduledMessagesBanner.scss
@@ -0,0 +1,47 @@
+@use '../../../styling/utils';
+
+.str-chat {
+ .str-chat__scheduled-messages-banner {
+ display: flex;
+ align-items: center;
+ gap: var(--str-chat__spacing-sm);
+ width: 100%;
+ 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/ScheduledMessagesNotice.scss b/src/components/ScheduledMessages/styling/ScheduledMessagesNotice.scss
new file mode 100644
index 0000000000..4b1bcd639e
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/ScheduledMessagesNotice.scss
@@ -0,0 +1,29 @@
+.str-chat {
+ // tinted with its accent: the primary one, or the error one
+ .str-chat__scheduled-messages-notice {
+ --str-chat__scheduled-messages-notice-accent: var(--str-chat__accent-primary);
+ display: flex;
+ align-items: center;
+ gap: var(--str-chat__spacing-xs);
+ padding: var(--str-chat__spacing-sm);
+ border-radius: var(--str-chat__radius-md);
+ background-color: color-mix(
+ in srgb,
+ var(--str-chat__scheduled-messages-notice-accent) 5%,
+ transparent
+ );
+ color: var(--str-chat__text-primary);
+ font: var(--str-chat__font-metadata-emphasis);
+
+ .str-chat__scheduled-messages-notice__icon {
+ flex-shrink: 0;
+ height: var(--str-chat__icon-size-sm);
+ width: var(--str-chat__icon-size-sm);
+ color: var(--str-chat__scheduled-messages-notice-accent);
+ }
+ }
+
+ .str-chat__scheduled-messages-notice--error {
+ --str-chat__scheduled-messages-notice-accent: var(--str-chat__accent-error);
+ }
+}
diff --git a/src/components/ScheduledMessages/styling/index.scss b/src/components/ScheduledMessages/styling/index.scss
new file mode 100644
index 0000000000..6e20b67fea
--- /dev/null
+++ b/src/components/ScheduledMessages/styling/index.scss
@@ -0,0 +1,6 @@
+@use 'ScheduledMessageActions';
+@use 'ScheduledMessageList';
+@use 'ScheduledMessagesBanner';
+@use 'ScheduledMessagesNotice';
+@use 'ScheduleMessageButton';
+@use 'ScheduleMessageDialog';
diff --git a/src/components/ScheduledMessages/utils.ts b/src/components/ScheduledMessages/utils.ts
new file mode 100644
index 0000000000..99a4c63b2b
--- /dev/null
+++ b/src/components/ScheduledMessages/utils.ts
@@ -0,0 +1,276 @@
+import {
+ getScheduledMessageErrorReason,
+ getScheduledMessageLimits,
+ isScheduledMessageNotFoundError,
+ SCHEDULED_MESSAGE_FAILURE_REASON,
+ SCHEDULED_MESSAGE_STATUS,
+ ScheduledMessageErrorReason,
+ StreamAPIError,
+} from 'stream-chat';
+import type {
+ Channel,
+ MessageComposerState,
+ ScheduledMessageFailureReason,
+ ScheduledMessageResponse,
+ StreamChat,
+} from 'stream-chat';
+
+import type { StreamTFunction } from '../../i18n/types';
+
+const MS_IN_SECOND = 1000;
+/** Keeps the earliest offered time ahead of the server's minimum despite clock skew. */
+const CLOCK_SKEW_BUFFER_MS = 60 * MS_IN_SECOND;
+const MORNING_HOUR = 9;
+const AFTERNOON_HOUR = 13;
+
+/** Notifications the UI emits; the composer's own come from `CORE_NOTIFICATION_TYPE`. */
+export const SCHEDULED_MESSAGE_NOTIFICATION_TYPE = {
+ deleteFailed: 'api:scheduled-message:delete:failed',
+ sendNowFailed: 'api:scheduled-message:send-now:failed',
+ sendNowSucceeded: 'api:scheduled-message:send-now:success',
+} as const;
+
+export const SCHEDULED_MESSAGES_EMITTER = 'ScheduledMessages';
+
+export type ScheduleTimeBounds = { earliest: Date; latest?: Date };
+
+export type ScheduledMessageAction =
+ | 'copy'
+ | 'delete'
+ | 'edit'
+ | 'reschedule'
+ | 'send_now';
+
+export const getChannelByCid = (client: StreamChat, cid: string) => {
+ const [type, id] = cid.split(':');
+ return client.channelManager.get(cid) ?? client.channel(type, id);
+};
+
+export const isFailedScheduledMessage = ({ status }: ScheduledMessageResponse) =>
+ status === SCHEDULED_MESSAGE_STATUS.Failed;
+
+/** A channel's list holds its replies too: a thread keeps its own, a channel its top-level ones. */
+/** The default search: the text contains the query, ignoring case; a blank query matches all. */
+export const matchesScheduledMessageText = (
+ { message }: ScheduledMessageResponse,
+ searchQuery: string,
+) => {
+ const query = searchQuery.trim().toLocaleLowerCase();
+ return !query || !!message.text?.toLocaleLowerCase().includes(query);
+};
+
+export const toDateInputValue = (date: Date) =>
+ [
+ date.getFullYear(),
+ String(date.getMonth() + 1).padStart(2, '0'),
+ String(date.getDate()).padStart(2, '0'),
+ ].join('-');
+
+export const toTimeInputValue = (date: Date) =>
+ `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
+
+/** The local date of ` ` and time of ` `, if both are valid. */
+export const fromInputValues = (dateValue: string, timeValue = '00:00') => {
+ if (!dateValue || !timeValue) return undefined;
+ const date = new Date(`${dateValue}T${timeValue}`);
+ return Number.isNaN(date.getTime()) ? undefined : date;
+};
+
+/** The times the channel accepts; no `latest` when it reports no limits. */
+export const getScheduleTimeBounds = (channel: Channel, now = new Date()) => {
+ const limits = getScheduledMessageLimits(channel);
+ const nowMs = now.getTime();
+ const bounds: ScheduleTimeBounds = {
+ earliest: new Date(
+ nowMs + (limits?.minLeadTimeSeconds ?? 0) * MS_IN_SECOND + CLOCK_SKEW_BUFFER_MS,
+ ),
+ latest: limits && new Date(nowMs + limits.maxLeadTimeSeconds * MS_IN_SECOND),
+ };
+ return bounds;
+};
+
+export const getScheduleTimeError = (
+ scheduledAt: Date,
+ { earliest, latest }: ScheduleTimeBounds,
+) => {
+ if (scheduledAt < earliest) return 'tooSoon' as const;
+ if (latest && scheduledAt > latest) return 'tooFar' as const;
+ return undefined;
+};
+
+/** The next full hour after the earliest allowed time. */
+export const getDefaultScheduledAt = ({ earliest }: ScheduleTimeBounds) => {
+ const scheduledAt = new Date(earliest);
+ scheduledAt.setHours(scheduledAt.getHours() + 1, 0, 0, 0);
+ return scheduledAt;
+};
+
+const atLocalHour = (date: Date, dayOffset: number, hour: number) => {
+ const localDate = new Date(date);
+ localDate.setDate(date.getDate() + dayOffset);
+ localDate.setHours(hour, 0, 0, 0);
+ return localDate;
+};
+
+/** Tomorrow morning and afternoon and next Monday morning, within `bounds`. */
+export const getSchedulePresets = (now: Date, bounds: ScheduleTimeBounds) => {
+ const daysUntilMonday = (8 - now.getDay()) % 7 || 7;
+ const presets = [
+ atLocalHour(now, 1, MORNING_HOUR),
+ atLocalHour(now, 1, AFTERNOON_HOUR),
+ ...(daysUntilMonday === 1 ? [] : [atLocalHour(now, daysUntilMonday, MORNING_HOUR)]),
+ ];
+ return presets.filter((preset) => !getScheduleTimeError(preset, bounds));
+};
+
+export const formatTimeZoneOffset = (date: Date) => {
+ const offsetMinutes = -date.getTimezoneOffset();
+ if (!offsetMinutes) return 'GMT';
+ const sign = offsetMinutes < 0 ? '-' : '+';
+ const hours = Math.floor(Math.abs(offsetMinutes) / 60);
+ const minutes = Math.abs(offsetMinutes) % 60;
+ return `GMT${sign}${hours}${minutes ? `:${String(minutes).padStart(2, '0')}` : ''}`;
+};
+
+/** What the user may do with a scheduled message; `edit` only where the list can open it. */
+export const getScheduledMessageActions = (
+ { failure_reason, status }: ScheduledMessageResponse,
+ canEdit: boolean,
+): ScheduledMessageAction[] => {
+ if (status === SCHEDULED_MESSAGE_STATUS.Sending) return [];
+ const actions: ScheduledMessageAction[] =
+ status !== SCHEDULED_MESSAGE_STATUS.Failed
+ ? ['edit', 'reschedule', 'send_now', 'delete']
+ : failure_reason === SCHEDULED_MESSAGE_FAILURE_REASON.ChannelDeleted
+ ? ['copy', 'delete']
+ : ['edit', 'delete'];
+ return canEdit ? actions : actions.filter((action) => action !== 'edit');
+};
+
+/**
+ * Whether the composer edits a message: a message, or a pending scheduled one. A failed scheduled
+ * message open in it is not edited but sent or scheduled again, so the composer works as usual.
+ */
+export const isComposerEditing = ({
+ editedMessage,
+ editedScheduledMessage,
+}: Pick) =>
+ !!editedMessage ||
+ (!!editedScheduledMessage && !isFailedScheduledMessage(editedScheduledMessage));
+
+/**
+ * After a conflict (the message changed elsewhere), reloads the lists that may hold the stale copy:
+ * the channel's own and the ones registered with the client.
+ */
+export const reloadOnScheduledMessageConflict = (
+ client: StreamChat,
+ cid: string,
+ error: unknown,
+) => {
+ if (getScheduledMessageErrorReason(error) !== ScheduledMessageErrorReason.Conflict)
+ return;
+ const { scheduledMessagesPaginator } = getChannelByCid(client, cid);
+ if (scheduledMessagesPaginator.hasLoadedPage) {
+ scheduledMessagesPaginator.reload().catch(() => undefined);
+ }
+ void client.scheduledMessagePaginators.recover();
+};
+
+/** Why a scheduled message failed; `undefined` when the reason needs no explanation. */
+export const translateScheduledMessageFailureReason = (
+ t: StreamTFunction,
+ reason?: ScheduledMessageFailureReason,
+) => {
+ switch (reason) {
+ 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.thisChannelNowNeedsApprovalScheduleItAgain',
+ 'This channel now needs approval. Schedule it again',
+ );
+ case SCHEDULED_MESSAGE_FAILURE_REASON.InternalError:
+ return t(
+ 'scheduledMessages.somethingWentWrongTryAgain',
+ 'Something went wrong. Try again',
+ );
+ default:
+ return t('scheduledMessages.couldntSendTryAgain', "Couldn't send. Try again");
+ }
+};
+
+const FORBIDDEN_STATUS = 403;
+
+/** What a failed scheduled-message request tells the user, when its error says something specific. */
+export const translateScheduledMessageError = (t: StreamTFunction, error: unknown) => {
+ switch (getScheduledMessageErrorReason(error)) {
+ case ScheduledMessageErrorReason.Disabled:
+ return t(
+ 'scheduledMessages.notification.disabled',
+ 'Scheduled messages are turned off',
+ );
+ case ScheduledMessageErrorReason.TooSoon:
+ return t('scheduledMessages.chooseALaterTime', 'Choose a later time');
+ case ScheduledMessageErrorReason.TooFar:
+ return t('scheduledMessages.chooseAnEarlierTime', 'Choose an earlier time');
+ case ScheduledMessageErrorReason.ChannelLimit:
+ return t(
+ 'scheduledMessages.notification.channelLimit',
+ "You've reached the limit of scheduled messages in this channel",
+ );
+ case ScheduledMessageErrorReason.UserLimit:
+ return t(
+ 'scheduledMessages.notification.userLimit',
+ "You've reached the limit of scheduled messages",
+ );
+ case ScheduledMessageErrorReason.Sending:
+ return t('scheduledMessages.notification.sending', 'This message is being sent');
+ case ScheduledMessageErrorReason.Conflict:
+ return t('scheduledMessages.notification.conflict', 'Updated elsewhere. Try again');
+ case ScheduledMessageErrorReason.NotEditable:
+ return t(
+ 'scheduledMessages.notification.notEditable',
+ "Failed messages can't be edited",
+ );
+ default:
+ break;
+ }
+ if (isScheduledMessageNotFoundError(error)) {
+ return t('scheduledMessages.notification.alreadySent', 'Already sent');
+ }
+ if (error instanceof StreamAPIError && error.status === FORBIDDEN_STATUS) {
+ return t(
+ 'scheduledMessages.notification.forbidden',
+ "You can't schedule messages here",
+ );
+ }
+ return undefined;
+};
diff --git a/src/components/index.ts b/src/components/index.ts
index 64061386f3..9f1f998c3b 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 45195702fe..45ff125bf7 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -78,6 +78,12 @@ 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 {
+ ScheduledMessageItemProps,
+ ScheduledMessageListDialogProps,
+ ScheduledMessageListProps,
+ ScheduledMessagePreviewProps,
+} from '../components/ScheduledMessages';
export type ComponentContextValue = {
/** Progress of an attachment whose upload is still in flight. */
@@ -307,6 +313,21 @@ export type ComponentContextValue = {
RecordingPermissionDeniedNotification?: React.ComponentType;
/** Message reminder information in the message UI. */
ReminderNotification?: React.ComponentType;
+ /** One item of `ScheduledMessageList`. */
+ ScheduledMessageItem?: React.ComponentType;
+ /** Scheduled messages in the banner's dialog and the Channel Detail section. */
+ ScheduledMessageList?: React.ComponentType;
+ /**
+ * What the banner's "View all" opens: a modal with the channel's (or thread's) scheduled messages
+ * by default. Receives `open` and `close`, so an override can show the list elsewhere.
+ */
+ ScheduledMessageListDialog?: React.ComponentType;
+ /** Above the composer while a scheduled message is edited or a failed one scheduled again. */
+ ScheduledMessagePreview?: 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;
/** Translation indicator, shown when the message has `i18n`. */
MessageTranslationIndicator?: React.ComponentType;
/**
diff --git a/src/i18n/TranslationBuilder/notifications/translators.ts b/src/i18n/TranslationBuilder/notifications/translators.ts
index c138371222..f22f48d063 100644
--- a/src/i18n/TranslationBuilder/notifications/translators.ts
+++ b/src/i18n/TranslationBuilder/notifications/translators.ts
@@ -1,5 +1,10 @@
+import {
+ CORE_NOTIFICATION_TYPE,
+ ScheduledMessageCompositionEndReason,
+} from 'stream-chat';
import type { Notification } from 'stream-chat';
+import { translateScheduledMessageError } from '../../../components/ScheduledMessages/utils';
import type { NotificationTranslatorOptions } from './types';
import type { Translator } from '../../index';
@@ -94,3 +99,34 @@ export const translateCommandDisabled: Translator
return t('notification.commandDisabled', 'Command not available');
};
+
+/** A failed request to schedule or edit a scheduled message, both emitted by the composer. */
+export const translateScheduledMessageRequestFailed: Translator<
+ NotificationTranslatorOptions
+> = ({ options: { notification }, t }) => {
+ const specific = translateScheduledMessageError(t, notification?.originalError);
+ if (specific) return specific;
+ switch (notification?.type) {
+ case CORE_NOTIFICATION_TYPE.scheduledMessageCreateFailed:
+ return t(
+ 'scheduledMessages.couldntScheduleMessageTryAgain',
+ "Couldn't schedule message. Try again",
+ );
+ default:
+ return t(
+ 'scheduledMessages.notification.updateFailed',
+ "Couldn't update the scheduled message. Try again",
+ );
+ }
+};
+
+/** A scheduled message open in the composer was sent, deleted or failed meanwhile. */
+export const translateScheduledMessageCompositionEnded: Translator<
+ NotificationTranslatorOptions
+> = ({ options: { notification }, t }) =>
+ notification?.metadata?.reason === ScheduledMessageCompositionEndReason.Sent
+ ? t('scheduledMessages.notification.alreadySentEdit', 'This message was already sent')
+ : 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 a713b7fa91..8195e22380 100644
--- a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts
+++ b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts
@@ -9,6 +9,8 @@ import {
translateCommandDisabled,
translatePollCreateFailed,
translatePollEndFailed,
+ translateScheduledMessageCompositionEnded,
+ translateScheduledMessageRequestFailed,
} from './translators';
import type { Translator } from '../../index';
@@ -51,6 +53,15 @@ const coreNotificationTranslators: Record
+ t('scheduledMessages.notification.blocked', "This message can't be scheduled"),
+ [CORE_NOTIFICATION_TYPE.scheduledMessageCompositionEnded]:
+ translateScheduledMessageCompositionEnded,
+ [CORE_NOTIFICATION_TYPE.scheduledMessageCreateFailed]:
+ translateScheduledMessageRequestFailed,
+ [CORE_NOTIFICATION_TYPE.scheduledMessageUpdateFailed]:
+ translateScheduledMessageRequestFailed,
};
/**
diff --git a/src/i18n/__tests__/Streami18n.test.ts b/src/i18n/__tests__/Streami18n.test.ts
index a6f991b6da..8685aec33a 100644
--- a/src/i18n/__tests__/Streami18n.test.ts
+++ b/src/i18n/__tests__/Streami18n.test.ts
@@ -266,12 +266,15 @@ 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.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 a7a179b20a..194ccfdd2d 100644
--- a/src/i18n/__tests__/catalog.fixture.json
+++ b/src/i18n/__tests__/catalog.fixture.json
@@ -197,6 +197,12 @@
"channelDetail.channelMembersView.browseChannelMembers.description": "Browse channel members",
"channelDetail.channelMembersView.members.title_one": "{{ count }} member",
"channelDetail.channelMembersView.members.title_other": "{{ count }} members",
+ "channelDetail.navButton.channelInfo.title": "Channel info",
+ "channelDetail.navButton.files.title": "Files",
+ "channelDetail.navButton.media.title": "Photos & videos",
+ "channelDetail.navButton.members.title": "Members",
+ "channelDetail.navButton.pinnedMessages.title": "Pinned messages",
+ "channelDetail.navButton.scheduledMessages.title": "Scheduled",
"channelDetail.pinnedMessagesEmpty.noPinnedMessages.text": "No pinned messages",
"channelDetail.pinnedMessagesEmpty.pinMessageSee.text": "Pin a message to see it here",
"channelDetail.pinnedMessagesView.browsePinnedMessages.description": "Browse pinned messages",
@@ -589,6 +595,86 @@
"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.scheduledFor": "Scheduled for {{ time }}",
+ "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.cantSendInThisChannel": "Unavailable: you can't send messages in this channel",
+ "scheduledMessages.channelFrozenNotice": "This channel is frozen. Scheduled messages won't be sent",
+ "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.loaded": "Scheduled messages loaded",
+ "scheduledMessages.loadingScheduledMessages": "Loading scheduled messages",
+ "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.conflict": "Updated elsewhere. Try again",
+ "scheduledMessages.notification.disabled": "Scheduled messages are turned off",
+ "scheduledMessages.notification.endedEdit": "This scheduled message can no longer be edited",
+ "scheduledMessages.notification.forbidden": "You can't schedule messages here",
+ "scheduledMessages.notification.notEditable": "Failed messages can't be edited",
+ "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.pickALaterTimeEditingCantSend": "Pick a later time. Editing can't send a message right away.",
+ "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.schedulingUnavailableNotice": "You can't schedule messages in this channel",
+ "scheduledMessages.search": "Search",
+ "scheduledMessages.sendNow": "Send now",
+ "scheduledMessages.sendTime": "Send {{ time }}",
+ "scheduledMessages.sending": "Sending...",
+ "scheduledMessages.sentInYourTimeZoneTimezone": "Sent in your time zone ({{ timeZone }})",
+ "scheduledMessages.somethingWentWrongTryAgain": "Something went wrong. Try again",
+ "scheduledMessages.theMessageNoLongerMeetsTheAppsLimits": "The message no longer meets the app's limits. Edit it",
+ "scheduledMessages.thisChannelIsFrozen": "This channel is frozen",
+ "scheduledMessages.thisChannelNowNeedsApprovalScheduleItAgain": "This channel now needs 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.view": "View",
+ "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 +725,10 @@
"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.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 0000000000..83ef969241
--- /dev/null
+++ b/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts
@@ -0,0 +1,183 @@
+import { fromPartial } from '@total-typescript/shoehorn';
+import { StreamAPIError } from 'stream-chat';
+import type { Notification } from 'stream-chat';
+
+import {
+ translateScheduledMessageCompositionEnded,
+ translateScheduledMessageRequestFailed,
+} 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) =>
+ translateScheduledMessageRequestFailed({
+ key: '',
+ options: {
+ notification: fromPartial({
+ message: 'Failed to schedule the message',
+ originalError,
+ type: 'api:scheduled-message:create:failed',
+ }),
+ },
+ t,
+ value: '',
+ });
+
+describe('translateScheduledMessageRequestFailed', () => {
+ 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) =>
+ translatorsByNotificationType['validation:scheduled-message:blocked']({
+ key: '',
+ options: {
+ notification: fromPartial({
+ metadata: reason ? { reason } : undefined,
+ type: 'validation:scheduled-message:blocked',
+ }),
+ },
+ t,
+ value: '',
+ });
+
+describe('the scheduled message blocked notification', () => {
+ // the schedule button is hidden whenever a block applies, so one line covers the race
+ it.each(['command', 'poll', 'shared_location', undefined])(
+ 'reads the same for any reason (%s)',
+ (reason) => {
+ expect(translateBlocked(reason)).toBe("This message can't be scheduled");
+ },
+ );
+});
+
+const translateAction = (type: string, originalError?: Error) =>
+ translateScheduledMessageRequestFailed({
+ key: '',
+ options: {
+ notification: fromPartial({ originalError, type }),
+ },
+ t,
+ value: '',
+ });
+
+describe('translateScheduledMessageRequestFailed', () => {
+ it('reports a message the server no longer has as already sent', () => {
+ expect(
+ translateAction('api:scheduled-message:update: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('falls back to the generic copy of an edit', () => {
+ expect(
+ translateAction('api:scheduled-message:update:failed', new Error('network')),
+ ).toBe("Couldn't update the scheduled message. Try again");
+ });
+});
+
+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 can no longer be edited'],
+ ['failed', 'This scheduled message can no longer be edited'],
+ [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', () => {
+ // the React notifications carry their own translated message, the error's specific copy included
+ it.each([
+ 'api:scheduled-message:send:failed',
+ 'api:scheduled-message:reschedule:failed',
+ 'api:scheduled-message:send-now:failed',
+ 'api:scheduled-message:delete:failed',
+ ])('registers no translator for %s', (type) => {
+ expect(translatorsByNotificationType[type]).toBeUndefined();
+ });
+
+ it('registers translators for the composer notifications', () => {
+ expect(translatorsByNotificationType['api:scheduled-message:create:failed']).toBe(
+ translateScheduledMessageRequestFailed,
+ );
+ expect(translatorsByNotificationType['api:scheduled-message:update:failed']).toBe(
+ translateScheduledMessageRequestFailed,
+ );
+ expect(translatorsByNotificationType).not.toHaveProperty(
+ 'validation:scheduled-message:command',
+ );
+ });
+});
diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts
index d4e3249c29..70a8ce2cb1 100644
--- a/src/i18n/keys.ts
+++ b/src/i18n/keys.ts
@@ -208,6 +208,12 @@ export type TranslationCatalog = {
'channelDetail.channelMembersView.browseChannelMembers.description': 'Browse channel members';
'channelDetail.channelMembersView.members.title_one': '{{ count }} member';
'channelDetail.channelMembersView.members.title_other': '{{ count }} members';
+ 'channelDetail.navButton.channelInfo.title': 'Channel info';
+ 'channelDetail.navButton.files.title': 'Files';
+ 'channelDetail.navButton.media.title': 'Photos & videos';
+ 'channelDetail.navButton.members.title': 'Members';
+ 'channelDetail.navButton.pinnedMessages.title': 'Pinned messages';
+ 'channelDetail.navButton.scheduledMessages.title': 'Scheduled';
'channelDetail.pinnedMessagesEmpty.noPinnedMessages.text': 'No pinned messages';
'channelDetail.pinnedMessagesEmpty.pinMessageSee.text': 'Pin a message to see it here';
'channelDetail.pinnedMessagesView.browsePinnedMessages.description': 'Browse pinned messages';
@@ -600,6 +606,86 @@ 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.scheduledFor': 'Scheduled for {{ time }}';
+ '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.cantSendInThisChannel': "Unavailable: you can't send messages in this channel";
+ 'scheduledMessages.channelFrozenNotice': "This channel is frozen. Scheduled messages won't be sent";
+ '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.loaded': 'Scheduled messages loaded';
+ 'scheduledMessages.loadingScheduledMessages': 'Loading scheduled messages';
+ '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.conflict': 'Updated elsewhere. Try again';
+ 'scheduledMessages.notification.disabled': 'Scheduled messages are turned off';
+ 'scheduledMessages.notification.endedEdit': 'This scheduled message can no longer be edited';
+ 'scheduledMessages.notification.forbidden': "You can't schedule messages here";
+ 'scheduledMessages.notification.notEditable': "Failed messages can't be edited";
+ '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.pickALaterTimeEditingCantSend': "Pick a later time. Editing can't send a message right away.";
+ '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.schedulingUnavailableNotice': "You can't schedule messages in this channel";
+ 'scheduledMessages.search': 'Search';
+ 'scheduledMessages.sendNow': 'Send now';
+ 'scheduledMessages.sendTime': 'Send {{ time }}';
+ 'scheduledMessages.sending': 'Sending...';
+ 'scheduledMessages.sentInYourTimeZoneTimezone': 'Sent in your time zone ({{ timeZone }})';
+ 'scheduledMessages.somethingWentWrongTryAgain': 'Something went wrong. Try again';
+ 'scheduledMessages.theMessageNoLongerMeetsTheAppsLimits': "The message no longer meets the app's limits. Edit it";
+ 'scheduledMessages.thisChannelIsFrozen': 'This channel is frozen';
+ 'scheduledMessages.thisChannelNowNeedsApprovalScheduleItAgain': 'This channel now needs 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.view': 'View';
+ '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 +736,10 @@ 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.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 f8eb368ed6..58690acbd6 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
+ * Seven `timestamp.*` entries (`DateSeparator`, `ReminderNotification`, `ChannelPreviewTimestamp`,
+ * `ChannelDetailPinnedMessageTimestamp`, `ScheduledMessageConfirmation`,
+ * `ScheduledMessageSendTime`, `SchedulePreset`) hold English words in their formats, which
+ * `dayjsLocaleConfigForLanguage` does not reach, so integrators override the key. Adding an eighth
* fails a guard in `__tests__/Streami18n.test.ts` that keeps the migration guide's list in sync.
*/
export const runtimeDefaults = {
@@ -32,6 +33,14 @@ 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.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 d98a4e19cd..8f35be0d09 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 0000000000..63e4d16cb7
--- /dev/null
+++ b/src/mock-builders/api/scheduledMessages.ts
@@ -0,0 +1,50 @@
+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 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 044c9abf13..4789aea2f1 100644
--- a/src/mock-builders/api/utils.ts
+++ b/src/mock-builders/api/utils.ts
@@ -1,4 +1,4 @@
-type HttpMethod = 'get' | 'post' | 'put' | 'delete';
+type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
export interface MockedApiResponse {
response: {
diff --git a/src/mock-builders/event/index.ts b/src/mock-builders/event/index.ts
index 934339b1e9..5364349129 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 0000000000..6fd3b4d3a3
--- /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 cc91443b20..ba211dde9b 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 8c5edd6268..e87c95305f 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 0000000000..4f82050a93
--- /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 88f4ee0f76..8398a0258d 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 7541725215..247ffc1bf7 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,18 @@ export const initClientWithChannels = async ({
return { channels, client };
};
+
+/** The channel's own scheduled messages list. */
+export const getScheduledMessagesPaginator = (client: StreamChat, cid: string) => {
+ const [type, id] = cid.split(':');
+ const channel = client.channelManager.get(cid) ?? client.channel(type, id);
+
+ return channel.scheduledMessagesPaginator;
+};
+
+/** Fills the channel's scheduled messages list, as a channel query with `include_scheduled_messages` does. */
+export const hydrateScheduledMessages = (
+ client: StreamChat,
+ cid: string,
+ scheduledMessages: ScheduledMessageResponse[] = [],
+) => getScheduledMessagesPaginator(client, cid).hydrate(scheduledMessages);
diff --git a/src/plugins/ChannelDetail/ChannelDetail.tsx b/src/plugins/ChannelDetail/ChannelDetail.tsx
index eaca06b337..2381638b8c 100644
--- a/src/plugins/ChannelDetail/ChannelDetail.tsx
+++ b/src/plugins/ChannelDetail/ChannelDetail.tsx
@@ -20,8 +20,9 @@ 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 { useComponentContextIcons, useTranslationContext } from '../../context';
import { useStableCallback } from '../../utils';
const ChannelManagementNavButtonIcon = () => {
@@ -48,51 +49,82 @@ const ChannelMediaNavButtonIcon = () => {
return ;
};
+const ScheduledMessagesNavButtonIcon = () => {
+ const { IconClock } = useComponentContextIcons();
+ return ;
+};
+
const ChannelFilesNavButtonIcon = () => {
const { IconFolder } = useComponentContextIcons();
return ;
};
-export const ChannelManagementNavButton = (props: SectionNavigatorNavButtonProps) => (
-
-);
-
-export const ChannelMembersNavButton = (props: SectionNavigatorNavButtonProps) => (
-
-);
-
-export const PinnedMessagesNavButton = (props: SectionNavigatorNavButtonProps) => (
-
-);
-
-export const ChannelMediaNavButton = (props: SectionNavigatorNavButtonProps) => (
-
-);
-
-export const ChannelFilesNavButton = (props: SectionNavigatorNavButtonProps) => (
-
-);
+export const ChannelManagementNavButton = (props: SectionNavigatorNavButtonProps) => {
+ const { t } = useTranslationContext();
+ return (
+
+ );
+};
+
+export const ChannelMembersNavButton = (props: SectionNavigatorNavButtonProps) => {
+ const { t } = useTranslationContext();
+ return (
+
+ );
+};
+
+export const PinnedMessagesNavButton = (props: SectionNavigatorNavButtonProps) => {
+ const { t } = useTranslationContext();
+ return (
+
+ );
+};
+
+export const ScheduledMessagesNavButton = (props: SectionNavigatorNavButtonProps) => {
+ const { t } = useTranslationContext();
+ return (
+
+ );
+};
+
+export const ChannelMediaNavButton = (props: SectionNavigatorNavButtonProps) => {
+ const { t } = useTranslationContext();
+ return (
+
+ );
+};
+
+export const ChannelFilesNavButton = (props: SectionNavigatorNavButtonProps) => {
+ const { t } = useTranslationContext();
+ return (
+
+ );
+};
export const defaultChannelDetailSections: SectionNavigatorSection[] = [
{
@@ -110,6 +142,11 @@ export const defaultChannelDetailSections: SectionNavigatorSection[] = [
NavButton: PinnedMessagesNavButton,
SectionContent: PinnedMessagesView,
},
+ {
+ id: 'scheduled-messages',
+ NavButton: ScheduledMessagesNavButton,
+ SectionContent: ScheduledMessagesView,
+ },
{
id: 'channel-media',
NavButton: ChannelMediaNavButton,
diff --git a/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx b/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx
index 9e988b6446..8a4c57e0c0 100644
--- a/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx
+++ b/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx
@@ -1,6 +1,8 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import clsx from 'clsx';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useComponentContextIcons, useTranslationContext } from '../../context';
+import { Button } from '../../components/Button';
import { TextInput } from '../../components/Form';
export type ChannelDetailSearchInputProps = {
@@ -11,8 +13,9 @@ export type ChannelDetailSearchInputProps = {
export const ChannelDetailSearchInput = React.memo(
({ autoFocus, onSearchChange, resetKey }: ChannelDetailSearchInputProps) => {
- const { IconSearch } = useComponentContextIcons();
+ const { IconSearch, IconXCircle } = useComponentContextIcons();
const { t } = useTranslationContext();
+ const inputRef = useRef(null);
const [searchInput, setSearchInput] = useState('');
useEffect(() => {
@@ -28,6 +31,12 @@ export const ChannelDetailSearchInput = React.memo(
[onSearchChange],
);
+ const clearSearch = () => {
+ setSearchInput('');
+ onSearchChange('');
+ inputRef.current?.focus();
+ };
+
return (
}
onChange={handleSearchChange}
placeholder={t('common.search.ariaLabel', 'Search')}
+ ref={inputRef}
+ trailing={
+
+
+
+ }
type='search'
value={searchInput}
/>
diff --git a/src/plugins/ChannelDetail/Views/ScheduledMessagesView/ScheduledMessagesView.tsx b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/ScheduledMessagesView.tsx
new file mode 100644
index 0000000000..69ce88f805
--- /dev/null
+++ b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/ScheduledMessagesView.tsx
@@ -0,0 +1,54 @@
+import React, { useCallback } from 'react';
+import type { ScheduledMessageResponse } from 'stream-chat';
+
+import {
+ useComponentContext,
+ useModalContext,
+ useTranslationContext,
+} from '../../../../context';
+import { Prompt } from '../../../../components/Dialog';
+import {
+ ScheduledMessageList as DefaultScheduledMessageList,
+ useScheduledMessages,
+ useScheduledMessagesSearch,
+} from '../../../../components/ScheduledMessages';
+import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput';
+import { useChannelDetailContext } from '../../ChannelDetailContext';
+import { SectionNavigatorHeader } from '../../SectionNavigator';
+
+export const ScheduledMessagesView = () => {
+ const { t } = useTranslationContext();
+ const { ScheduledMessageList = DefaultScheduledMessageList } = useComponentContext();
+ const { close } = useModalContext();
+ const { channel } = useChannelDetailContext();
+
+ const { scheduledMessages } = useScheduledMessages(channel.scheduledMessages);
+ const { filter, setSearchQuery } = useScheduledMessagesSearch();
+
+ const editScheduledMessage = useCallback(
+ (scheduledMessage: ScheduledMessageResponse) => {
+ close();
+ channel.messageComposer.editScheduledMessage(scheduledMessage);
+ },
+ [channel, close],
+ );
+
+ return (
+
+
+
+ {scheduledMessages.length > 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 0000000000..f5c61d82fe
--- /dev/null
+++ b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/__tests__/ScheduledMessagesView.test.tsx
@@ -0,0 +1,168 @@
+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';
+import type { ScheduledMessageListProps } from '../../../../../components/ScheduledMessages';
+
+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' } }),
+ ]);
+
+ const search = screen.getByRole('searchbox', { name: 'Search' });
+ fireEvent.change(search, { target: { value: 'lunch' } });
+
+ expect(screen.getByText('Lunch at noon')).toBeInTheDocument();
+ expect(screen.queryByText('Launch moved to Monday')).not.toBeInTheDocument();
+
+ // a button, so the keyboard reaches it
+ fireEvent.click(screen.getByRole('button', { name: 'Clear search' }));
+
+ expect(search).toHaveValue('');
+ expect(search).toHaveFocus();
+ // kept in place so the field keeps its height, hidden from pointer and keyboard
+ expect(screen.getByRole('button', { name: 'Clear search' })).toHaveClass(
+ 'str-chat__form-text-input__clear-button--hidden',
+ );
+ expect(screen.getByText('Launch moved to Monday')).toBeInTheDocument();
+ });
+
+ it('renders the list through the ScheduledMessageList slot', async () => {
+ const CustomList = ({ scheduledMessages }: ScheduledMessageListProps) => (
+
+ {scheduledMessages.scope.type}:{scheduledMessages.paginator.cid}
+
+ );
+
+ await renderView([], { ScheduledMessageList: CustomList });
+
+ expect(screen.getByTestId('custom-scheduled-message-list')).toHaveTextContent(
+ `channel:${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 0000000000..81ab402742
--- /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 8d1a469d0f..078f7cc8c2 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 0000000000..97af480003
--- /dev/null
+++ b/src/plugins/ChannelDetail/styling/ScheduledMessagesView.scss
@@ -0,0 +1,20 @@
+.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: hidden;
+}
+
+// aligned with the search input, which the view pads in the same way
+.str-chat__channel-detail__scheduled-messages-view__body
+ .str-chat__scheduled-message-list-notice {
+ margin-inline: var(--str-chat__spacing-xl);
+}
diff --git a/src/plugins/ChannelDetail/styling/index.scss b/src/plugins/ChannelDetail/styling/index.scss
index db73bf5d10..da4a7e965a 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 0504b424d8..c68777f088 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;