From d5d726117e8d64a45bbadc99c178624611f53883 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Nov 2026 13:57:53 +0100 Subject: [PATCH 1/4] feat(ScheduledMessages): scheduled messages built on existing SDK patterns A leaner take on the scheduled-messages UI: list states through the ComponentContext list slots, InfiniteScrollPaginator, GlobalModal, ListLoadErrorIndicator, one composer preview, a ScheduledMessageListDialog slot, and the fixes from the PR review and the Figma review. Co-Authored-By: Claude Opus 5.5 --- ai-docs/ai-migration-v14-v15.md | 27 +- ai-docs/i18n-v15-migration.md | 14 +- ai-docs/v15-breaking-changes.md | 4 +- examples/vite/src/App.tsx | 9 +- .../src/ChatLayout/ChannelFreezeAction.tsx | 108 ++++ .../ChatLayout/ConfiguredChannelDetail.tsx | 35 +- ...ConfigurablePollCreationDialogControls.tsx | 157 ++++-- .../ConfigurableScheduledMessageItem.tsx | 21 + .../src/ComposerPreviews/previewSections.ts | 4 + examples/vite/src/i18n/de.ts | 6 + examples/vite/src/i18n/it.ts | 6 + examples/vite/src/icons.tsx | 15 + .../__tests__/ChatScheduledMessages.test.tsx | 134 +++++ src/components/Chat/hooks/useChat.ts | 2 + .../Dialog/styling/ContextMenu.scss | 16 +- .../EmptyStateIndicator.tsx | 21 +- src/components/Form/styling/TextInput.scss | 12 + src/components/Icons/icons.tsx | 32 ++ .../Loading/ListLoadErrorIndicator.tsx | 9 +- .../MessageComposerActions.tsx | 39 +- .../MessageComposerPreviews.defaults.tsx | 31 +- .../MessageComposerPreviews.tsx | 1 + .../MessageComposer/MessageComposerUI.tsx | 3 + .../hooks/useMessageComposerPreviewState.ts | 15 +- .../hooks/useMessageComposerSubmitFn.ts | 35 +- .../ScheduleMessageButton.tsx | 214 ++++++++ .../ScheduledMessages/ScheduleTimeDialog.tsx | 203 ++++++++ .../ScheduledMessageItem.tsx | 446 ++++++++++++++++ .../ScheduledMessageList.tsx | 223 ++++++++ .../ScheduledMessageListDialog.tsx | 123 +++++ .../ScheduledMessagePreview.tsx | 32 ++ .../ScheduledMessagesBanner.tsx | 144 ++++++ .../ScheduledMessagesNotice.tsx | 33 ++ .../__tests__/EditScheduledMessage.test.tsx | 155 ++++++ .../__tests__/ScheduleMessageButton.test.tsx | 451 +++++++++++++++++ .../ScheduledMessageActions.test.tsx | 394 +++++++++++++++ .../__tests__/ScheduledMessageList.test.tsx | 478 ++++++++++++++++++ .../ScheduledMessagesBanner.test.tsx | 429 ++++++++++++++++ .../ScheduledMessages/__tests__/utils.test.ts | 182 +++++++ .../ScheduledMessages/hooks/index.ts | 2 + .../hooks/useCanScheduleMessage.ts | 56 ++ .../hooks/useScheduledMessagePreview.ts | 55 ++ .../hooks/useScheduledMessages.ts | 88 ++++ src/components/ScheduledMessages/index.ts | 8 + .../styling/ScheduleMessageButton.scss | 50 ++ .../styling/ScheduleMessageDialog.scss | 74 +++ .../styling/ScheduledMessageActions.scss | 31 ++ .../styling/ScheduledMessageList.scss | 169 +++++++ .../styling/ScheduledMessagesBanner.scss | 47 ++ .../styling/ScheduledMessagesNotice.scss | 29 ++ .../ScheduledMessages/styling/index.scss | 6 + src/components/ScheduledMessages/utils.ts | 325 ++++++++++++ src/components/index.ts | 1 + src/context/ComponentContext.tsx | 21 + .../notifications/translators.ts | 36 ++ .../translatorsByNotificationType.ts | 11 + src/i18n/__tests__/Streami18n.test.ts | 6 +- src/i18n/__tests__/catalog.fixture.json | 92 ++++ ...uledMessageNotificationTranslators.test.ts | 183 +++++++ src/i18n/keys.ts | 92 ++++ src/i18n/runtimeDefaults.ts | 17 +- src/mock-builders/api/index.ts | 1 + src/mock-builders/api/scheduledMessages.ts | 50 ++ src/mock-builders/api/utils.ts | 2 +- src/mock-builders/event/index.ts | 1 + src/mock-builders/event/scheduledMessage.ts | 29 ++ src/mock-builders/generator/channel.ts | 4 + src/mock-builders/generator/index.ts | 1 + .../generator/scheduledMessage.ts | 28 + src/mock-builders/index.ts | 9 + src/mock-builders/utils.ts | 16 + src/plugins/ChannelDetail/ChannelDetail.tsx | 117 +++-- .../ChannelDetailSearchInput.tsx | 31 +- .../ScheduledMessagesView.tsx | 54 ++ .../__tests__/ScheduledMessagesView.test.tsx | 163 ++++++ .../Views/ScheduledMessagesView/index.ts | 1 + src/plugins/ChannelDetail/index.ts | 1 + .../styling/ScheduledMessagesView.scss | 20 + src/plugins/ChannelDetail/styling/index.scss | 1 + src/styling/index.scss | 1 + 80 files changed, 6047 insertions(+), 145 deletions(-) create mode 100644 examples/vite/src/ChatLayout/ChannelFreezeAction.tsx create mode 100644 examples/vite/src/ComposerPreviews/ConfigurableScheduledMessageItem.tsx create mode 100644 src/components/Chat/__tests__/ChatScheduledMessages.test.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageButton.tsx create mode 100644 src/components/ScheduledMessages/ScheduleTimeDialog.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageItem.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageList.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageListDialog.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessagePreview.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessagesBanner.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessagesNotice.tsx create mode 100644 src/components/ScheduledMessages/__tests__/EditScheduledMessage.test.tsx create mode 100644 src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx create mode 100644 src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx create mode 100644 src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx create mode 100644 src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx create mode 100644 src/components/ScheduledMessages/__tests__/utils.test.ts create mode 100644 src/components/ScheduledMessages/hooks/index.ts create mode 100644 src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessages.ts create mode 100644 src/components/ScheduledMessages/index.ts create mode 100644 src/components/ScheduledMessages/styling/ScheduleMessageButton.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduledMessageActions.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduledMessageList.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduledMessagesBanner.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduledMessagesNotice.scss create mode 100644 src/components/ScheduledMessages/styling/index.scss create mode 100644 src/components/ScheduledMessages/utils.ts create mode 100644 src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts create mode 100644 src/mock-builders/api/scheduledMessages.ts create mode 100644 src/mock-builders/event/scheduledMessage.ts create mode 100644 src/mock-builders/generator/scheduledMessage.ts create mode 100644 src/plugins/ChannelDetail/Views/ScheduledMessagesView/ScheduledMessagesView.tsx create mode 100644 src/plugins/ChannelDetail/Views/ScheduledMessagesView/__tests__/ScheduledMessagesView.test.tsx create mode 100644 src/plugins/ChannelDetail/Views/ScheduledMessagesView/index.ts create mode 100644 src/plugins/ChannelDetail/styling/ScheduledMessagesView.scss diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 013e8db5a9..6c1f0b13f2 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1692,12 +1692,7 @@ Where you pass the props depends on who renders the header: Both headers are exported. -To keep showing the content only while something is open beside the list (a sidebar toggle, for example), make it return `null` otherwise. Read the workspace from `useWorkspaceNavigation()`: - -- `openChannels` / `openThreads` list the channels and threads that the workspace's panels were opened with. Something opened over another item in the same panel (stacked) is not in them. In a workspace whose panels open with channels and threads only, a stacked item always sits over one that is in the lists, so "something is open" is either list being non-empty; `openThreads` alone misses a thread stacked over a channel. -- A panel opened with another kind, such as a `userProfile` panel, is in neither list, even with a channel stacked over it. If your layout opens such panels, check the layout instead: `useLayoutViewState()` from `stream-chat-react/slot-layout` returns each slot's binding (`slotBindings`) and stacked layers (`slotLayers`). -- To ask whether a given channel or thread is on screen, use `isChannelActive(cid)` / `isThreadActive(threadId)`, which follow what each panel shows. -- `ChatView` (or your own `WorkspaceNavigationProvider`, [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) fills all of these. Without one the lists stay empty, so gate on your own selection state instead. +To keep the v14 behavior of showing the content only next to an open channel or thread (a sidebar toggle, for example), make the content return `null` otherwise: read `openChannels` / `openThreads` from `useWorkspaceNavigation()`. `ChatView` (or your own `WorkspaceNavigationProvider`, [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) fills these lists; without one they stay empty, so gate on your own selection state instead. ```tsx // v14 @@ -1715,18 +1710,24 @@ import { // `SidebarToggle` stands for a control of your own. const SidebarToggle = () => ; -// Shown only while a channel or thread is open beside the list. -const SidebarToggleWhileContentOpen = () => { - const { openChannels, openThreads } = useWorkspaceNavigation(); - return openChannels.length > 0 || openThreads.length > 0 ? : null; +// Shown only while a channel is open, as in v14. +const SidebarToggleWhileChannelOpen = () => { + const { openChannels } = useWorkspaceNavigation(); + return openChannels.length > 0 ? : null; +}; + +// Shown only while a thread is open, as in v14. +const SidebarToggleWhileThreadOpen = () => { + const { openThreads } = useWorkspaceNavigation(); + return openThreads.length > 0 ? : null; }; const ChannelListHeaderWithToggle = () => ( - + ); const ThreadListHeaderWithToggle = () => ( - + ); // Declared once, so WithComponents gets the same object on every render. @@ -3601,7 +3602,7 @@ export const RemoveButton = ({ label }: { label: string }) => { }; ``` -Four formatter keys carry English day words inside their `calendarFormats` (`timestamp.DateSeparator`, `timestamp.ReminderNotification`, `timestamp.ChannelPreviewTimestamp`, `timestamp.ChannelDetailPinnedMessageTimestamp`) and must be overridden to translate Today / Tomorrow / Yesterday / Last; `dayjsLocaleConfigForLanguage` does not reach them. +Eight formatter keys carry English words inside their formats (`timestamp.DateSeparator`, `timestamp.ReminderNotification`, `timestamp.ChannelPreviewTimestamp`, `timestamp.ChannelDetailPinnedMessageTimestamp`, `timestamp.ScheduledMessageConfirmation`, `timestamp.ScheduledMessageGroup`, `timestamp.ScheduledMessageSendTime`, `timestamp.SchedulePreset`) and must be overridden to translate Today / Tomorrow / Yesterday / Last / at; `dayjsLocaleConfigForLanguage` does not reach them. --- diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index 04d88c399d..96c1c09281 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -174,11 +174,11 @@ These v14 keys (from `src/i18n/en.json` at `v14.10.0`, plural suffixes folded) h yarn i18n:export ``` - That writes `en.json` in the repo root with the 619 translatable keys. The 14 `timestamp.*`, + That writes `en.json` in the repo root with the 716 translatable keys. The 20 `timestamp.*`, `duration.*` and `translationBuilderTopic.*` entries are left out on purpose — they are dayjs and i18next expressions, and a TMS that "translates" `{{value, notification}}` breaks notifications - outright. Four of them do carry English day words; those are handled by overriding the key, see - [Date and time](#date-and-time). Pass `--all` for the complete 633-key catalog. + outright. Eight of them do carry English words; those are handled by overriding the key, see + [Date and time](#date-and-time). Pass `--all` for the complete 736-key catalog. [`ai-docs/i18n-v15-key-map.json`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-key-map.json) also lists every key, alongside the v14 string it replaced. @@ -243,7 +243,7 @@ formatter expressions, not text: "timestamp.MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: false; format: HH:mm) }}" ``` -Most of them only need overriding to change _how_ a date is formatted. **Four of them also carry +Most of them only need overriding to change _how_ a date is formatted. **Eight of them also carry English words**, because dayjs takes the calendar wording as part of the format string: | Key | English baked into `calendarFormats` | @@ -252,8 +252,12 @@ English words**, because dayjs takes the calendar wording as part of the format | `timestamp.ReminderNotification` | `Today`, `Tomorrow`, `Yesterday`, `Last`, `at` | | `timestamp.ChannelPreviewTimestamp` | `Yesterday` | | `timestamp.ChannelDetailPinnedMessageTimestamp` | `Yesterday` | +| `timestamp.ScheduledMessageConfirmation` | `at` | +| `timestamp.ScheduledMessageGroup` | `Today`, `Tomorrow`, `Yesterday`, `Last` | +| `timestamp.ScheduledMessageSendTime` | `at` | +| `timestamp.SchedulePreset` | `Today`, `Tomorrow`, `Yesterday`, `Last`, `at` | -Translating those four means overriding the key itself — `dayjsLocaleConfigForLanguage` does not +Translating those eight means overriding the key itself — `dayjsLocaleConfigForLanguage` does not reach them. See [Date and time](#date-and-time) for the how and why. `relativeTime.*` ("Today", "{{ count }}d ago") is ordinary copy and translates normally, as does diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index b2203893a9..5ffe36ef95 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -2315,12 +2315,12 @@ const CustomThreadHeader = () => { - `ChannelListHeader` and `ThreadListHeader` take `StartContent` (before the title) and `EndContent` (after the title), as `ChannelHeader` / `ThreadHeader` do. Neither has a default. Types: `ChannelListHeaderProps`, `ThreadListHeaderProps`. - where the SDK renders the header, the props go through the header's override key: register a component that renders the default header with them. `ChannelListHeader` is read by `ChannelNavigation`; `ThreadListHeader` is a new key, read by `ThreadList`. `ThreadListHeader` is now exported. - where the app renders the header itself (for example `ChannelListHeader` above a standalone `ChannelList`), it passes the props directly; the override key does not reach that header. - - to show the content only while something is open beside the list, read `useWorkspaceNavigation()` in the content component and return `null` while both `openChannels` and `openThreads` are empty. The lists hold the channels and threads the workspace's panels were opened with: an item stacked over another one in a panel is not in them. Where panels open with channels and threads only, a stacked item always sits over one that is, so checking one list alone misses it (a thread stacked over a channel is not in `openThreads`). A panel opened with another kind (a `userProfile` panel, even with a channel stacked over it) is in neither list; such layouts check `useLayoutViewState()` (`slotBindings`, `slotLayers`) from `stream-chat-react/slot-layout` instead. Whether a given channel or thread is on screen is `isChannelActive(cid)` / `isThreadActive(threadId)`. `ChatView` or an app's own `WorkspaceNavigationProvider` fills these; without one the lists stay empty, so an app without either gates on its own selection state. + - to keep the v14 gate, read `useWorkspaceNavigation().openChannels` / `.openThreads` in the content component and return `null` while it is empty. `ChatView` or an app's own `WorkspaceNavigationProvider` fills these lists; without one they stay empty, so an app without either gates on its own selection state. - Evidence: - `release-v14:src/components/ChannelList/ChannelListHeader.tsx:15` `{channel && HeaderEndContent && }`; `release-v14:src/components/Threads/ThreadList/ThreadListHeader.tsx:12` `{activeThread && HeaderEndContent && }`; `release-v14:src/context/ComponentContext.tsx:303` - `master:src/components/ChannelList/ChannelListHeader.tsx` and `master:src/components/Threads/ThreadList/ThreadListHeader.tsx` (`StartContent` / `EndContent` props); `master:src/components/Threads/ThreadList/ThreadList.tsx` reads `ThreadListHeader` from `useComponentContext()`; `master:src/context/ComponentContext.tsx` `ThreadListHeader?` - ticket: REACT-1449 - - examples: `examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx` passes `EndContent={SidebarToggleWhileContentOpen}` to `ChannelListHeader`; `examples/vite/src/App.tsx` registers `ThreadListHeaderWithSidebarToggle` (`examples/vite/src/Sidebar/HeaderContent.tsx`) as `ThreadListHeader` + - examples: `examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx` passes `EndContent={SidebarToggleWhileChannelOpen}` to `ChannelListHeader`; `examples/vite/src/App.tsx` registers `ThreadListHeaderWithSidebarToggle` (`examples/vite/src/Sidebar/HeaderContent.tsx`) as `ThreadListHeader` - Docs impact: - `03-ui/03-components/01-channel-list-ui.md`, `03-ui/03-components/08-thread-list-ui.md`, `03-ui/03-components/12-overriding-components.md`, `06-component-reference/02-channel/04-componentcontext.md`, `06-component-reference/07-thread-list/01-threadlist.md`, `05-ui-cookbooks/10-layout/04-collapsible-sidebar.md`, `05-ui-cookbooks/03-search/03-mentions-inbox.md`, `08-release-guides/01-upgrade-to-v15.md` - Example needed: yes (the guide section has one) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index bb82ec8324..f905abd288 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -22,8 +22,8 @@ import { isVoiceRecordingAttachment, MessageSearchSource, POLL_ONLY_COMPOSITION_MIDDLEWARE_ID, + SCHEDULED_MESSAGE_COMPOSITION_MIDDLEWARE_ID, SearchController, - USER_DATA_INJECTION_MIDDLEWARE_ID, UserSearchSource, } from 'stream-chat'; import { @@ -101,6 +101,7 @@ import { ConfigurableStartRecordingAudioButton, } from './ComposerPreviews/ConfigurableComposerPreviews.tsx'; import { ConfigurablePollCreationDialogControls } from './ComposerPreviews/ConfigurablePollCreationDialogControls.tsx'; +import { ConfigurableScheduledMessageItem } from './ComposerPreviews/ConfigurableScheduledMessageItem.tsx'; import { ConfigurableMessageBubbleContent } from './MessageBubble/ConfigurableMessageBubbleContent.tsx'; import { applySimulatedListQueryFailures, StreamDebugHandles } from './Debug'; import { @@ -582,11 +583,12 @@ const App = () => { // While the Poll preview shows, the poll dialog attaches the poll to the message being // composed (ConfigurablePollCreationDialogControls). Without the poll-only step the poll is // sent with the text and attachments rather than as a message of its own, and the composer - // clears after the send. Restored when the preview is turned off, since this setup re-runs. + // clears after the send. Restored when the preview is turned off, since this setup re-runs: + // after the scheduling check, where the SDK puts it, so a blocked schedule still stops first. if (pollDisabled) { composer.compositionMiddlewareExecutor.insert({ middleware: [createPollOnlyCompositionMiddleware(composer)], - position: { after: USER_DATA_INJECTION_MIDDLEWARE_ID }, + position: { after: SCHEDULED_MESSAGE_COMPOSITION_MIDDLEWARE_ID }, unique: true, }); } else { @@ -699,6 +701,7 @@ const App = () => { MessageBubbleContent: ConfigurableMessageBubbleContent, MessageComposerPreviews: ConfigurableComposerPreviews, PollCreationDialogControls: ConfigurablePollCreationDialogControls, + ScheduledMessageItem: ConfigurableScheduledMessageItem, StartRecordingAudioButton: ConfigurableStartRecordingAudioButton, MessageUI: InlineEditableMessage, ...messageUiOverrides, diff --git a/examples/vite/src/ChatLayout/ChannelFreezeAction.tsx b/examples/vite/src/ChatLayout/ChannelFreezeAction.tsx new file mode 100644 index 0000000000..536b015c4a --- /dev/null +++ b/examples/vite/src/ChatLayout/ChannelFreezeAction.tsx @@ -0,0 +1,108 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { OwnCapabilitiesState } from 'stream-chat'; +import { + asDynamicKey, + ListItemLayout, + Switch, + useNotificationApi, + useStateStore, + useTranslationContext, +} from 'stream-chat-react'; +import { + type ChannelManagementActionItem, + defaultChannelManagementActionSet, + useChannelDetailContext, +} from 'stream-chat-react/channel-detail'; + +import { IconLock } from '../icons'; + +const toError = (error: unknown) => + error instanceof Error ? error : new Error('An unknown error occurred'); + +const selectCanFreeze = ({ ownCapabilities }: OwnCapabilitiesState) => ({ + canFreeze: !!ownCapabilities?.includes('freeze-channel'), +}); + +const FreezeActionIcon = () => ( + +); + +/** + * Freezes or unfreezes the channel, for a user whose role has the `UpdateChannelFrozen` + * permission. A frozen channel takes no new messages, which is also how a scheduled message + * can be made to fail with `channel_frozen`. The SDK ships no freeze action; it is demo code. + */ +const ChannelFreezeAction = () => { + const { channel } = useChannelDetailContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const { canFreeze } = useStateStore(channel.state, selectCanFreeze); + const [frozen, setFrozen] = useState(!!channel.data?.frozen); + + // follows a freeze made elsewhere, such as in the dashboard + useEffect(() => { + setFrozen(!!channel.data?.frozen); + const { unsubscribe } = channel.on('channel.updated', ({ channel: data }) => { + if (data?.frozen !== undefined) setFrozen(data.frozen); + }); + return unsubscribe; + }, [channel]); + + const TrailingSlot = useMemo(() => { + function ChannelFreezeSwitch() { + return ; + } + return ChannelFreezeSwitch; + }, [frozen]); + + if (!canFreeze) return null; + + const toggleFrozen = async () => { + const nextFrozen = !frozen; + setFrozen(nextFrozen); + try { + await channel.updatePartial({ set: { frozen: nextFrozen } }); + } catch (error) { + setFrozen(!nextFrozen); + addNotification({ + context: { channel }, + emitter: 'ChannelFreezeAction', + error: toError(error), + message: nextFrozen + ? t( + asDynamicKey('viteExample.channelFreeze.freezeError.text'), + "Couldn't freeze the channel", + ) + : t( + asDynamicKey('viteExample.channelFreeze.unfreezeError.text'), + "Couldn't unfreeze the channel", + ), + severity: 'error', + type: 'api:channel:freeze:failed', + }); + } + }; + + return ( + + ); +}; + +/** The SDK's channel actions with the freeze switch after Mute chat. */ +export const channelManagementActionSetWithFreeze: ChannelManagementActionItem[] = + defaultChannelManagementActionSet.flatMap((action) => + action.type === 'muteChannel' + ? [action, { Component: ChannelFreezeAction, type: 'freezeChannel' }] + : [action], + ); diff --git a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx index 6f3fc6d9d9..b9d1fcc7df 100644 --- a/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx +++ b/examples/vite/src/ChatLayout/ConfiguredChannelDetail.tsx @@ -5,6 +5,7 @@ import { type AvatarWithChannelDetailProps, ChannelDetail, type ChannelDetailProps, + ChannelManagementView, ChannelMembersView, type ChannelMembersViewModes, defaultChannelDetailSections, @@ -14,8 +15,16 @@ import { import { useAppSettingsSelector } from '../AppSettings/state'; import { getChannelMembersHeaderActionSet } from '../AppSettings/tabs/ChannelDetail'; +import { channelManagementActionSetWithFreeze } from './ChannelFreezeAction'; import { ChannelMembersRemoveView } from './ChannelMembersRemoveView'; +const ChannelManagementViewWithFreeze = (props: SectionNavigatorSectionContentProps) => ( + +); + const ChannelMembersRemoveTitle = () => { const { t } = useTranslationContext(); return ( @@ -43,18 +52,20 @@ const ConfiguredChannelDetail = (props: ChannelDetailProps) => { const sections = useMemo( () => [ ...defaultChannelDetailSections.map((section) => - section.id !== 'channel-members' - ? section - : { - ...section, - SectionContent: (sectionProps: SectionNavigatorSectionContentProps) => ( - - ), - }, + section.id === 'channel-info' + ? { ...section, SectionContent: ChannelManagementViewWithFreeze } + : section.id !== 'channel-members' + ? section + : { + ...section, + SectionContent: (sectionProps: SectionNavigatorSectionContentProps) => ( + + ), + }, ), ], [headerActionSet], diff --git a/examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx b/examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx index a75562ef2f..e5425e1812 100644 --- a/examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx +++ b/examples/vite/src/ComposerPreviews/ConfigurablePollCreationDialogControls.tsx @@ -1,11 +1,18 @@ +import { useMemo, useState } from 'react'; import { flushSync } from 'react-dom'; +import { dateToNs, type LocalMessage, toScheduledMessagePreview } from 'stream-chat'; import { asDynamicKey, + GlobalModal, + IconClock, IconPoll, PollCreationDialogControls, type PollCreationDialogControlsProps, Prompt, + ScheduleTimeDialog, useCanCreatePoll, + useCanScheduleMessage, + useChatContext, useMessageComposerContext, useMessageComposerController, useTranslationContext, @@ -13,50 +20,134 @@ import { import { useAppSettingsSelector } from '../AppSettings'; +/** The message being composed with the poll the dialog is creating, to preview in the time dialog. */ +const usePollMessagePreview = (): LocalMessage => { + const { client } = useChatContext(); + const messageComposer = useMessageComposerController(); + const { name } = messageComposer.pollComposer.state.getLatestValue().data; + const { text } = messageComposer.textComposer; + const { attachments } = messageComposer.attachmentManager; + return useMemo(() => { + const now = dateToNs(new Date()); + const preview = toScheduledMessagePreview({ + cid: messageComposer.channel.cid, + created_at: now, + id: messageComposer.id, + message: { attachments, custom: {}, text }, + scheduled_at: now, + status: 'scheduled', + updated_at: now, + user: { id: client.userID ?? '', ...client.user } as never, + }); + return { ...preview, poll: { name } as LocalMessage['poll'] }; + }, [attachments, client, messageComposer, name, text]); +}; + /** * Creates the poll without sending it: the poll waits in the composer (`state.pollId`), shows in - * its Poll preview, and goes out with the next message's text and attachments. + * its Poll preview, and goes out with the next message's text and attachments. Schedule does the + * same and schedules that message right away; its time is picked first and only then is the poll + * created, so cancelling the time dialog leaves nothing behind. */ const AttachPollControls = ({ close }: PollCreationDialogControlsProps) => { const { t } = useTranslationContext(); const { textareaRef } = useMessageComposerContext(); const messageComposer = useMessageComposerController(); const canCreatePoll = useCanCreatePoll(); + const canSchedule = useCanScheduleMessage(); + const [isPickingTime, setIsPickingTime] = useState(false); + const previewMessage = usePollMessagePreview(); + + // As in the default controls: close and focus the composer synchronously, so focus does not + // blip through the attachment selector's trigger. + const closeAndFocusComposer = () => { + flushSync(() => close()); + textareaRef?.current?.focus(); + }; + + // rejects to keep the time dialog open, which then says the scheduling failed + const schedulePoll = async (scheduledAt: Date) => { + // a poll made by an earlier, failed attempt is reused + if (!messageComposer.pollId) await messageComposer.createPoll(); + if (!messageComposer.pollId) throw new Error('The poll was not created'); + const { error, status } = await messageComposer.schedule(scheduledAt); + if (status !== 'scheduled' && status !== 'sent') { + throw error ?? new Error(`The poll was not scheduled: ${status}`); + } + setIsPickingTime(false); + closeAndFocusComposer(); + }; return ( - - - - {t('common.cancel.label', 'Cancel')} - - { - // As in the default controls: close and focus the composer synchronously, so focus - // does not blip through the attachment selector's trigger. - flushSync(() => close()); - textareaRef?.current?.focus(); - try { - await messageComposer.createPoll(); - } catch { - // `createPoll()` already reported the failure as a notification. - } - }} - type='submit' - > - - {t( - asDynamicKey('viteExample.pollCreationDialog.attachPoll.text'), - 'Attach poll', + <> + + + + {t('common.cancel.label', 'Cancel')} + + {canSchedule && ( + setIsPickingTime(true)} + type='button' + > + + {t( + asDynamicKey('viteExample.pollCreationDialog.schedulePoll.text'), + 'Schedule', + )} + )} - - - + { + closeAndFocusComposer(); + try { + await messageComposer.createPoll(); + } catch { + // `createPoll()` already reported the failure as a notification. + } + }} + type='submit' + > + + {t( + asDynamicKey('viteExample.pollCreationDialog.attachPoll.text'), + 'Attach poll', + )} + + + + setIsPickingTime(false)} + open={isPickingTime} + > + {isPickingTime && ( + setIsPickingTime(false)} + errorMessage={t( + asDynamicKey('viteExample.pollCreationDialog.schedulePollFailed.text'), + "Couldn't schedule the poll. Try again", + )} + previewMessage={previewMessage} + submit={schedulePoll} + title={t( + asDynamicKey('viteExample.pollCreationDialog.schedulePollTitle.text'), + 'Schedule poll', + )} + /> + )} + + ); }; diff --git a/examples/vite/src/ComposerPreviews/ConfigurableScheduledMessageItem.tsx b/examples/vite/src/ComposerPreviews/ConfigurableScheduledMessageItem.tsx new file mode 100644 index 0000000000..4f270b5a20 --- /dev/null +++ b/examples/vite/src/ComposerPreviews/ConfigurableScheduledMessageItem.tsx @@ -0,0 +1,21 @@ +import { ScheduledMessageItem, type ScheduledMessageItemProps } from 'stream-chat-react'; + +import { useAppSettingsSelector } from '../AppSettings'; + +/** + * Without the Scheduled message preview (Settings -> Composer), the composer could not show or + * cancel a scheduled message opened in it, so the list offers no Edit or Retry. + */ +export const ConfigurableScheduledMessageItem = ({ + onEdit, + ...props +}: ScheduledMessageItemProps) => { + const { disabledPreviewSections } = useAppSettingsSelector((state) => state.composer); + + return ( + + ); +}; diff --git a/examples/vite/src/ComposerPreviews/previewSections.ts b/examples/vite/src/ComposerPreviews/previewSections.ts index db97ca138e..c362971984 100644 --- a/examples/vite/src/ComposerPreviews/previewSections.ts +++ b/examples/vite/src/ComposerPreviews/previewSections.ts @@ -48,6 +48,10 @@ export const PREVIEW_SECTION_INFO: Record, ); + +/** The design system's `core/line/lock` icon, for the channel freeze switch. */ +export const IconLock = createIcon( + 'IconLock', + <> + + + , +); diff --git a/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx b/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx new file mode 100644 index 0000000000..db882690fa --- /dev/null +++ b/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx @@ -0,0 +1,134 @@ +import React, { StrictMode } from 'react'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { ScheduledMessagePaginator } from 'stream-chat'; +import type { StreamChat } from 'stream-chat'; + +import { Chat } from '..'; +import { + dispatchScheduledMessageEvent, + generateScheduledMessage, + getTestClientWithUser, + setWSConnectionStatus, +} from '../../../mock-builders'; + +const renderChat = async (client: StreamChat, { strict = false } = {}) => { + const chat = {null}; + + let result!: ReturnType; + await act(() => { + result = render(strict ? {chat} : chat); + }); + return result; +}; + +/** A list registered with the client, the only kind the scheduled message events reach. */ +const registerScheduledMessageList = (client: StreamChat) => { + const paginator = new ScheduledMessagePaginator({ client }); + client.scheduledMessagePaginators.insertPaginator({ paginator }); + return paginator; +}; + +const reconnect = async (client: StreamChat) => { + await act(() => { + setWSConnectionStatus(client, false); + setWSConnectionStatus(client, true); + }); +}; + +describe('Chat scheduled messages wiring', () => { + afterEach(() => { + cleanup(); + }); + + it('registers the scheduled message subscriptions while mounted', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + + await renderChat(client); + + expect(client.scheduledMessagePaginators.hasSubscriptions).toBe(true); + }); + + it('does not query the scheduled messages on its own', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + + await renderChat(client); + await reconnect(client); + + expect(client.queryScheduledMessages).not.toHaveBeenCalled(); + }); + + it('subscribes once under StrictMode', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const scheduledMessage = generateScheduledMessage(); + const ingest = vi.spyOn(client.scheduledMessagePaginators, 'ingest'); + + await renderChat(client, { strict: true }); + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage, + type: 'scheduled_message.created', + }); + }); + + await waitFor(() => expect(ingest).toHaveBeenCalledTimes(1)); + }); + + it('keeps the store in sync from events while mounted', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const scheduledMessage = generateScheduledMessage(); + const list = registerScheduledMessageList(client); + await renderChat(client); + + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage, + type: 'scheduled_message.created', + }); + }); + + await waitFor(() => expect(list.items).toEqual([scheduledMessage])); + }); + + // the banner turns into the failure; the send happens later, apart from any user action + it('does not notify about a scheduled message that failed to send', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + await renderChat(client); + + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage: generateScheduledMessage({ + failure_reason: 'channel_frozen', + status: 'failed', + }), + type: 'scheduled_message.failed', + }); + }); + + expect(client.notifications.store.getLatestValue().notifications).toEqual([]); + }); + + it('unsubscribes on unmount', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const scheduledMessage = generateScheduledMessage(); + const list = registerScheduledMessageList(client); + const { unmount } = await renderChat(client); + + unmount(); + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage, + type: 'scheduled_message.created', + }); + }); + await client.scheduledMessagePaginators.pipelines + .get('scheduled_message.created') + ?.drain(); + + expect(client.scheduledMessagePaginators.hasSubscriptions).toBe(false); + expect(list.items).toBeUndefined(); + }); +}); diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 207ea64337..5702e4e3e3 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -43,6 +43,7 @@ export const useChat = ({ client }: UseChatParams) => { client.polls.registerSubscriptions(); client.reminders.registerSubscriptions(); client.reminders.initTimers(); + client.scheduledMessagePaginators.registerSubscriptions(); return () => { releaseChannelManager(); @@ -50,6 +51,7 @@ export const useChat = ({ client }: UseChatParams) => { client.polls.unregisterSubscriptions(); client.reminders.unregisterSubscriptions(); client.reminders.clearTimers(); + client.scheduledMessagePaginators.unregisterSubscriptions(); }; }, [client]); diff --git a/src/components/Dialog/styling/ContextMenu.scss b/src/components/Dialog/styling/ContextMenu.scss index df8efd596c..2bf16e0b03 100644 --- a/src/components/Dialog/styling/ContextMenu.scss +++ b/src/components/Dialog/styling/ContextMenu.scss @@ -94,6 +94,14 @@ font: var(--str-chat__font-caption-emphasis); } + // full width, through the menu's padding + .str-chat__context-menu__separator { + height: 1px; + margin-block: var(--str-chat__spacing-xxs); + margin-inline: calc(-1 * var(--str-chat__spacing-xxs)); + background-color: var(--str-chat__border-core-subtle); + } + .str-chat__context-menu__back-button { @include utils.button-reset; display: flex; @@ -194,11 +202,11 @@ } } - &:hover:not(:disabled) { + &:hover:not(:disabled, [aria-disabled='true']) { background-color: var(--str-chat__dialog-menu-button-hover-background-color); } - &:active:not(:disabled) { + &:active:not(:disabled, [aria-disabled='true']) { background-color: var(--str-chat__background-utility-pressed); } @@ -206,7 +214,9 @@ background-color: transparent; } - &:disabled { + // `aria-disabled` keeps the item focusable, so its reason can be read + &:disabled, + &[aria-disabled='true'] { background-color: transparent; color: var(--str-chat__text-disabled); cursor: default; diff --git a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx index 0eccc27b81..6014bfb64f 100644 --- a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx +++ b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx @@ -6,7 +6,7 @@ import { asDynamicKey } from '../../i18n/utils'; export type EmptyStateIndicatorProps = { /** Which list is empty. A 'thread' list renders nothing. */ - listType?: 'channel' | 'message' | 'thread'; + listType?: 'channel' | 'message' | 'scheduled-message' | 'thread'; messageText?: string; }; @@ -14,7 +14,7 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { const { listType, messageText } = props; const { t } = useTranslationContext(); - const { IconMessageBubble, IconMessageBubbles } = useComponentContextIcons(); + const { IconClock, IconMessageBubble, IconMessageBubbles } = useComponentContextIcons(); if (listType === 'thread') return null; @@ -50,6 +50,23 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { ); } + if (listType === 'scheduled-message') { + return ( +
+ +

+ {t('scheduledMessages.noScheduledMessages', 'No scheduled messages')} +

+

+ {t( + 'scheduledMessages.clickTheArrowNextToTheSendButton', + 'Click the arrow next to the send button in a channel to schedule a message.', + )} +

+
+ ); + } + return

{t('emptyState.indicator.noItemsExist.text', 'No items exist')}

; }; diff --git a/src/components/Form/styling/TextInput.scss b/src/components/Form/styling/TextInput.scss index bcf0ea0d46..8e2a5a22bd 100644 --- a/src/components/Form/styling/TextInput.scss +++ b/src/components/Form/styling/TextInput.scss @@ -135,6 +135,18 @@ box-shadow: none; } +// Rendered while the field is empty too, so the field keeps its height; hidden, it can be neither +// focused nor clicked +.str-chat__form-text-input__clear-button--hidden { + visibility: hidden; +} + +// A field with its own clear button drops the native one, which the keyboard cannot reach +.str-chat__form-text-input__control-row:has(.str-chat__form-text-input__trailing) + .str-chat__form-text-input__input::-webkit-search-cancel-button { + display: none; +} + .str-chat__form-text-input.str-chat__form-text-input--disabled .str-chat__form-text-input__input { color: var(--str-chat__input-text-placeholder); diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index c0687d94f5..3e260ee0d4 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -289,6 +289,14 @@ export const IconCheckmark = createIcon( />, ); +export const IconCaretDown = createIcon( + 'IconCaretDown', + , +); + export const IconChevronDown = createIcon( 'IconChevronDown', , ); +export const IconChevronUp = createIcon( + 'IconChevronUp', + , +); + export const IconChevronLeft = createIcon( 'IconChevronLeft', , ); +export const IconCalendar = createIcon( + 'IconCalendar', + , +); + export const IconClock = createIcon( 'IconClock', ({ - editedMessage, +const messageComposerStateSelector = (state: MessageComposerState) => ({ + isEditing: isComposerEditing(state), }); const textComposerStateSelector = ({ command, text }: TextComposerState) => ({ @@ -41,11 +44,13 @@ export const MessageComposerActions = () => { const messageComposer = useMessageComposerController(); const { CooldownTimer = DefaultCooldownTimer, + ScheduleMessageButton: ScheduleMessageButtonOverride, SendButton, StopAIGenerationButton: StopAIGenerationButtonOverride, } = useComponentContext(); - const { editedMessage } = useStateStore( + const canScheduleMessage = useCanScheduleMessage(); + const { isEditing } = useStateStore( messageComposer.state, messageComposerStateSelector, ); @@ -75,6 +80,19 @@ export const MessageComposerActions = () => { // Accounts for an iOS requirement, see https://bugs.webkit.org/show_bug.cgi?id=252303 const recordingEnabled = !!(recordingController.recorder && navigator.mediaDevices); + const ScheduleMessageButton = + ScheduleMessageButtonOverride === undefined + ? DefaultScheduleMessageButton + : ScheduleMessageButtonOverride; + + const sendButton = SendButton ? ( + + ) : ( + + {isEditing || command ? : } + + ); + // One control fills the slot, first match wins. While the AI is answering, stopping it is the // only useful action, so the stop button outranks everything else, `hideSendButton` included. let content: React.ReactNode; @@ -84,16 +102,17 @@ export const MessageComposerActions = () => { return null; } else if (isCooldownActive) { content = ; - } else if (contentIsEmpty && !editedMessage && !command && recordingEnabled) { + } else if (contentIsEmpty && !isEditing && !command && recordingEnabled) { content = ; - } else if (SendButton) { - content = ; - } else { + } else if (canScheduleMessage && ScheduleMessageButton) { content = ( - - {editedMessage || command ? : } - +
+ {sendButton} + +
); + } else { + content = sendButton; } return
{content}
; diff --git a/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx index c880b5b30a..bbd1081490 100644 --- a/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx +++ b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx @@ -20,11 +20,16 @@ import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList'; import type { MessageComposerPreviewSetItem } from './MessageComposerPreviews'; import { restorePreEditSnapshot } from './preEditSnapshot'; import { QuotedMessagePreview as DefaultQuotedMessagePreview } from './QuotedMessagePreview'; +import { ScheduledMessagePreview as DefaultScheduledMessagePreview } from '../ScheduledMessages/ScheduledMessagePreview'; const editedMessageSelector = ({ editedMessage }: MessageComposerState) => ({ editedMessage, }); +const scheduledMessageSelector = ({ editedScheduledMessage }: MessageComposerState) => ({ + editedScheduledMessage, +}); + const locationSelector = ({ location }: LocationComposerState) => ({ location }); const DefaultMessageComposerPreviewComponents = { @@ -44,6 +49,24 @@ const DefaultMessageComposerPreviewComponents = { /> ); }, + ScheduledMessage() { + const { ScheduledMessagePreview = DefaultScheduledMessagePreview } = + useComponentContext(); + const messageComposer = useMessageComposerController(); + const { editedScheduledMessage } = useStateStore( + messageComposer.state, + scheduledMessageSelector, + ); + + if (!editedScheduledMessage) return null; + + return ( + + ); + }, QuotedMessage() { const { QuotedMessagePreview = DefaultQuotedMessagePreview } = useComponentContext(); return ; @@ -92,10 +115,16 @@ export const defaultMessageComposerPreviewSet: MessageComposerPreviewSetItem[] = isVisible: ({ editedMessage }) => !!editedMessage, type: 'editedMessage', }, + { + Component: DefaultMessageComposerPreviewComponents.ScheduledMessage, + isVisible: ({ editedScheduledMessage }) => !!editedScheduledMessage, + type: 'scheduledMessage', + }, { Component: DefaultMessageComposerPreviewComponents.QuotedMessage, // The edited message preview takes the quoted message's place while editing. - isVisible: ({ editedMessage, quotedMessage }) => !editedMessage && !!quotedMessage, + isVisible: ({ editedMessage, editedScheduledMessage, quotedMessage }) => + !editedMessage && !editedScheduledMessage && !!quotedMessage, type: 'quotedMessage', }, { diff --git a/src/components/MessageComposer/MessageComposerPreviews.tsx b/src/components/MessageComposer/MessageComposerPreviews.tsx index 218fd3e710..2fe85ec3b7 100644 --- a/src/components/MessageComposer/MessageComposerPreviews.tsx +++ b/src/components/MessageComposer/MessageComposerPreviews.tsx @@ -14,6 +14,7 @@ export type DefaultMessageComposerPreviewType = | 'linkPreviews' | 'location' | 'quotedMessage' + | 'scheduledMessage' | 'voiceRecording'; export type MessageComposerPreviewSetItem = { diff --git a/src/components/MessageComposer/MessageComposerUI.tsx b/src/components/MessageComposer/MessageComposerUI.tsx index dfad78ad78..30fdfc758e 100644 --- a/src/components/MessageComposer/MessageComposerUI.tsx +++ b/src/components/MessageComposer/MessageComposerUI.tsx @@ -21,6 +21,7 @@ import { useStateStore } from '../../store'; import type { TextComposerState } from 'stream-chat'; import { CommandChip as DefaultCommandChip } from './CommandChip'; import { MessageComposerPreviews as DefaultMessageComposerPreviews } from './MessageComposerPreviews'; +import { ScheduledMessagesBanner as DefaultScheduledMessagesBanner } from '../ScheduledMessages/ScheduledMessagesBanner'; const textComposerCommandSelector = ({ command }: TextComposerState) => ({ command }); @@ -40,6 +41,7 @@ export const MessageComposerUI = () => { AudioRecorder = DefaultAudioRecorder, CommandChip = DefaultCommandChip, MessageComposerPreviews = DefaultMessageComposerPreviews, + ScheduledMessagesBanner = DefaultScheduledMessagesBanner, SendToChannelCheckbox = DefaultSendToChannelCheckbox, TextareaComposer = DefaultTextareaComposer, } = useComponentContext(); @@ -49,6 +51,7 @@ export const MessageComposerUI = () => { className='str-chat__message-composer-container' component='div' > + {!message && ScheduledMessagesBanner && }
({ 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 ( + <> + + ('.str-chat__split-send-button') ?? + buttonRef.current + } + tabIndex={-1} + > + {isMenuOpen && + getSchedulePresets( + new Date(), + getScheduleTimeBounds(messageComposer.channel), + ).map((scheduledAt) => ( + { + menu.close(); + scheduleMessage(scheduledAt).catch(() => undefined); + }} + > + {t('timestamp.SchedulePreset', { timestamp: scheduledAt })} + + ))} +
+ + {t('scheduledMessages.customTime', 'Custom time')} + + + + + {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 ( +
+ +
+ + {hasEditButton && ( + + )} + + {menuActions.map((action, index) => { + const { Icon, label, select } = menuItems[action]; + const isDelete = action === 'delete'; + return ( + + {/* Delete stands apart, unless it is the only action */} + {isDelete && index > 0 && ( +
+ )} + { + if (action === 'edit' && !canEdit) return; + menu.close(); + select(); + }} + variant={isDelete ? 'destructive' : undefined} + > + {label} + + + ); + })} + +
+ {!canEdit && ( + + {t( + 'scheduledMessages.cantSendInThisChannel', + "Unavailable: you can't send messages in this channel", + )} + + )} +
+
+ + {openDialog === 'reschedule' && ( + + )} + + + {openDialog === 'delete' && ( + + + + + + + + )} + +
+ ); +}; diff --git a/src/components/ScheduledMessages/ScheduledMessageList.tsx b/src/components/ScheduledMessages/ScheduledMessageList.tsx new file mode 100644 index 0000000000..a15975f515 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageList.tsx @@ -0,0 +1,223 @@ +import React, { useEffect, useMemo } from 'react'; +import type { + ChannelDataState, + OwnCapabilitiesState, + PaginatorState, + ScheduledMessageResponse, +} 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, + groupScheduledMessages, + searchScheduledMessages, +} from './utils'; + +export type ScheduledMessageListProps = Pick & { + /** The channel whose scheduled messages to list; all of them without it. */ + cid?: string; + parentId?: string; + /** Lists only the messages whose text contains it. */ + searchQuery?: string; +}; + +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 NoSearchResults = () => { + 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", + )} + + ); +}; + +const ScheduledMessageListBody = ({ + cid, + onEdit, + parentId, + searchQuery, +}: ScheduledMessageListProps) => { + const { t } = useTranslationContext(); + const { + EmptyListIndicator = DefaultEmptyListIndicator, + FirstPageLoadingIndicator = DefaultFirstPageLoadingIndicator, + ListLoadErrorIndicator = DefaultListLoadErrorIndicator, + LoadingNextPageIndicator = DefaultLoadingNextPageIndicator, + ScheduledMessageItem = DefaultScheduledMessageItem, + } = useComponentContext(); + const { paginator, scheduledMessages } = useScheduledMessages({ cid, parentId }); + 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 groups = useMemo( + () => groupScheduledMessages(searchScheduledMessages(scheduledMessages, searchQuery)), + [scheduledMessages, searchQuery], + ); + const showsNothing = !isFirstPageLoading && !groups.length; + // stops at a failed page instead of retrying it + const loadsFurther = showsNothing && hasMoreTail && !lastQueryFailed; + + // the matches may be on a page not loaded yet + useEffect(() => { + if (loadsFurther && !isLoading) paginator.toTail(); + }, [isLoading, loadsFurther, paginator]); + + if (firstPageLoadRetry.error) { + return ( + + ); + } + if (isFirstPageLoading) return ; + if (showsNothing) { + if (loadsFurther) return ; + return scheduledMessages.length ? : ; + } + + return ( + + {groups.map(({ day, key, scheduledMessages: groupMessages }) => ( +
+

+ {day + ? t('timestamp.ScheduledMessageGroup', { timestamp: day }) + : t('scheduledMessages.failed', 'Failed')} +

+
    + {groupMessages.map((scheduledMessage) => ( +
  • + +
  • + ))} +
+
+ ))} + +
+ ); +}; + +/** + * Scheduled messages grouped by the day they are sent on, failed ones first, paginated on scroll. + * Loading, failing and empty states use the list-state components `ChannelList` uses. Scoping to a + * thread and searching filter the loaded messages, so further pages load while none of them match. + */ +export const ScheduledMessageList = (props: ScheduledMessageListProps) => ( + <> + {props.cid && } + + +); diff --git a/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx b/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx new file mode 100644 index 0000000000..a134c77eac --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx @@ -0,0 +1,123 @@ +import clsx from 'clsx'; +import React, { useRef, useState } 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 } from './hooks'; +import type { ScheduledMessageItemProps } from './ScheduledMessageItem'; +import { ScheduledMessageList as DefaultScheduledMessageList } from './ScheduledMessageList'; + +const getChannelContainer = () => document.getElementById(CHANNEL_CONTAINER_ID); + +export type ScheduledMessageListDialogProps = Pick< + ScheduledMessageItemProps, + 'onEdit' +> & { + /** The channel whose scheduled messages to list. */ + cid: string; + close: () => void; + open: boolean; + /** The thread whose scheduled replies to list. */ + parentId?: string; +}; + +/** + * 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. + */ +export const ScheduledMessageListDialog = ({ + cid, + close, + onEdit, + open, + parentId, +}: ScheduledMessageListDialogProps) => { + const { t } = useTranslationContext(); + const { IconSearch, IconXCircle } = useComponentContextIcons(); + const { Modal = GlobalModal, ScheduledMessageList = DefaultScheduledMessageList } = + useComponentContext(); + const { scheduledMessages } = useScheduledMessages({ cid, parentId }); + const searchInputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); + + const closeDialog = () => { + setSearchQuery(''); + close(); + }; + + return ( + + + {open && ( + + + + {scheduledMessages.length > 0 && ( + } + onChange={({ target }) => setSearchQuery(target.value)} + placeholder={t('scheduledMessages.search', 'Search')} + ref={searchInputRef} + trailing={ + + } + type='search' + value={searchQuery} + /> + )} + + + + )} + + + ); +}; 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..9dd7c97d98 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessagesBanner.tsx @@ -0,0 +1,144 @@ +import clsx from 'clsx'; +import React, { useEffect, useRef, useState } from 'react'; +import { nsToDate } 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 { cid } = messageComposer.channel; + const parentId = messageComposer.threadId ?? undefined; + const canSchedule = useChannelCapabilities({ cid }).has(SCHEDULE_MESSAGE_CAPABILITY); + const { paginator, scheduledMessages } = useScheduledMessages({ cid, parentId }); + const { isLoaded } = useStateStore(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} + +
+ )} + + + ); +}; 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..2a07176636 --- /dev/null +++ b/src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx @@ -0,0 +1,394 @@ +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, +} from '../../../context'; +import { WithComponents } from '../../../context/WithComponents'; +import { Streami18n } from '../../../i18n'; +import { + convertDateToTimestamp, + generateScheduledMessage, + getTestClientWithUser, + hydrateScheduledMessages, + mockChatContext, +} from '../../../mock-builders'; +import { SCHEDULED_MESSAGE_NOTIFICATION_TYPE } from '../utils'; +import { ScheduledMessageList } from '../ScheduledMessageList'; +import type { EditScheduledMessage } from '../types'; + +const cid = 'messaging:general'; + +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..1a43ece265 --- /dev/null +++ b/src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx @@ -0,0 +1,478 @@ +import React 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, +} from '../../../context'; +import { WithComponents } from '../../../context/WithComponents'; +import { Streami18n } from '../../../i18n'; +import { + convertDateToTimestamp, + generatePoll, + generateScheduledMessage, + getScheduledMessagesPaginator, + getTestClientWithUser, + hydrateScheduledMessages, + mockChatContext, +} from '../../../mock-builders'; +import { ScheduledMessageList } from '../ScheduledMessageList'; +import type { EditScheduledMessage, ScheduledMessageItemProps } from '../types'; + +const cid = 'messaging:general'; + +const WEDNESDAY_MORNING = new Date(2026, 9, 28, 10, 30); + +const atLocalTime = (day: number, hour: number) => + convertDateToTimestamp(new Date(2026, 9, day, hour, 0)); + +type RenderListOptions = { + client?: StreamChat; + components?: Parameters[0]['overrides']; + onEdit?: EditScheduledMessage; + scheduledMessages?: ScheduledMessageResponse[]; + searchQuery?: string; +}; + +const renderList = async ({ + client: clientArg, + components = {}, + 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 getGroupHeaders = () => + screen.getAllByRole('heading', { level: 3 }).map(({ textContent }) => 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); + }); + + it('renders the list states through the list-state slots', async () => { + const { client } = await renderList({ + components: { + EmptyListIndicator: () =>
Nothing here
, + FirstPageLoadingIndicator: () =>
Loading here
, + }, + }); + + expect(screen.getByText('Nothing here')).toBeInTheDocument(); + + setPaginatorState(client, { isLoading: true, items: undefined }); + + expect(screen.getByText('Loading here')).toBeInTheDocument(); + }); + + it('lists failed messages first, then one group per day', async () => { + await renderList({ + scheduledMessages: [ + generateScheduledMessage({ cid, scheduled_at: atLocalTime(29, 9) }), + generateScheduledMessage({ cid, scheduled_at: atLocalTime(30, 13) }), + generateScheduledMessage({ + cid, + scheduled_at: atLocalTime(29, 13), + status: SCHEDULED_MESSAGE_STATUS.Failed, + }), + ], + }); + + expect(getGroupHeaders()).toEqual(['Failed', 'Tomorrow', 'Friday']); + }); + + it('shows the send time and preview of each message', async () => { + await renderList({ + scheduledMessages: [ + generateScheduledMessage({ + cid, + message: { custom: {}, text: 'Launch moved to Monday' }, + scheduled_at: atLocalTime(29, 9), + }), + ], + }); + + const item = screen.getByTestId('scheduled-message-item'); + + expect(within(item).getByText('Launch moved to Monday')).toBeInTheDocument(); + expect(within(item).getByText('Send Thu, Oct 29 at 09:00')).toBeInTheDocument(); + expect(within(item).queryByText('Scheduled')).not.toBeInTheDocument(); + }); + + 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..9ee95b1aa0 --- /dev/null +++ b/src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx @@ -0,0 +1,429 @@ +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 ? ( +
+ + +
+ ) : 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({ cid, open: true, parentId: undefined }), + 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..e9fcc0aa8e --- /dev/null +++ b/src/components/ScheduledMessages/__tests__/utils.test.ts @@ -0,0 +1,182 @@ +import { SCHEDULED_MESSAGE_FAILURE_REASON } from 'stream-chat'; +import type { Channel } from 'stream-chat'; + +import { convertDateToTimestamp, generateScheduledMessage } from '../../../mock-builders'; +import { + formatTimeZoneOffset, + fromInputValues, + getDefaultScheduledAt, + getScheduledMessageActions, + getSchedulePresets, + getScheduleTimeBounds, + getScheduleTimeError, + groupScheduledMessages, + searchScheduledMessages, + translateScheduledMessageFailureReason, +} from '../utils'; + +const at = (day: number, hour: number) => new Date(2026, 9, day, hour, 0); +const scheduledAt = (day: number, hour: number) => ({ + scheduled_at: convertDateToTimestamp(at(day, hour)), +}); +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('groups pending messages by day and puts the failed ones first', () => { + const morning = generateScheduledMessage(scheduledAt(29, 9)); + const evening = generateScheduledMessage(scheduledAt(29, 18)); + const nextDay = generateScheduledMessage(scheduledAt(30, 9)); + const failed = generateScheduledMessage({ ...scheduledAt(28, 9), status: 'failed' }); + + const groups = groupScheduledMessages([morning, evening, nextDay, failed]); + + expect(groups.map(({ key }) => key)).toEqual(['failed', '2026-10-29', '2026-10-30']); + expect(groups[0].day).toBeUndefined(); + expect(groups[1].scheduledMessages).toEqual([morning, evening]); + }); + + 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(searchScheduledMessages([lunch, launch], ' LUNCH ')).toEqual([lunch]); + expect(searchScheduledMessages([lunch, launch], ' ')).toEqual([lunch, launch]); + }); + + 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..57ca671146 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useCanScheduleMessage'; +export * from './useScheduledMessages'; diff --git a/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts new file mode 100644 index 0000000000..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..8b8d80dca4 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts @@ -0,0 +1,88 @@ +import { useEffect, useMemo } from 'react'; +import { ScheduledMessagePaginator } from 'stream-chat'; +import type { PaginatorState, ScheduledMessageResponse, StreamChat } from 'stream-chat'; + +import { useChatContext } from '../../../context'; +import { useStateStore } from '../../../store'; +import { getChannelByCid, scopeScheduledMessages } from '../utils'; + +const ALL_SCHEDULED_MESSAGES_PAGINATOR_ID = 'stream-chat-react-scheduled-messages'; + +const allScheduledMessagesPaginators = new WeakMap< + StreamChat, + ScheduledMessagePaginator +>(); + +/** One list of all the user's scheduled messages per client, shared by every component. */ +const getAllScheduledMessagesPaginator = (client: StreamChat) => { + const registered = client.scheduledMessagePaginators.getPaginatorById( + ALL_SCHEDULED_MESSAGES_PAGINATOR_ID, + ); + if (registered) return registered; + let paginator = allScheduledMessagesPaginators.get(client); + if (!paginator) { + paginator = new ScheduledMessagePaginator({ + client, + id: ALL_SCHEDULED_MESSAGES_PAGINATOR_ID, + }); + allScheduledMessagesPaginators.set(client, paginator); + } + return paginator; +}; + +const NO_SCHEDULED_MESSAGES: ScheduledMessageResponse[] = []; + +const selectItems = ({ items }: PaginatorState) => ({ items }); + +const createAwaitsFirstPageSelector = + (paginator: ScheduledMessagePaginator) => + ({ isLoading, lastQueryError }: PaginatorState) => ({ + awaitsFirstPage: !paginator.hasLoadedPage && !isLoading && !lastQueryError, + }); + +export type UseScheduledMessagesParams = { + /** The channel whose scheduled messages to list; all of them without it. */ + cid?: string; + /** The thread whose scheduled replies to list. */ + parentId?: string; +}; + +/** + * The scheduled messages of a channel (its own list, filled by the channel query), a thread in it, + * or all of them (a list registered with the client, so events reach it). Loads the first page + * unless one is loaded, as `ChannelList` does. + */ +export const useScheduledMessages = ({ + cid, + parentId, +}: UseScheduledMessagesParams = {}) => { + const { client } = useChatContext(); + const paginator = cid + ? getChannelByCid(client, cid).scheduledMessagesPaginator + : getAllScheduledMessagesPaginator(client); + + // after render, as registering publishes the manager's state + useEffect(() => { + const { scheduledMessagePaginators } = client; + if (cid || scheduledMessagePaginators.getPaginatorById(paginator.id)) return; + scheduledMessagePaginators.insertPaginator({ paginator }); + }, [cid, client, paginator]); + + const awaitsFirstPageSelector = useMemo( + () => createAwaitsFirstPageSelector(paginator), + [paginator], + ); + const { awaitsFirstPage } = useStateStore(paginator.state, awaitsFirstPageSelector); + useEffect(() => { + if (awaitsFirstPage) paginator.toTailDebounced(); + }, [awaitsFirstPage, paginator]); + + const { items } = useStateStore(paginator.state, selectItems); + const scheduledMessages = useMemo( + () => + items ? scopeScheduledMessages(items, { cid, parentId }) : NO_SCHEDULED_MESSAGES, + [cid, items, parentId], + ); + + return { paginator, scheduledMessages }; +}; 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..8436e110ba --- /dev/null +++ b/src/components/ScheduledMessages/styling/ScheduledMessageActions.scss @@ -0,0 +1,31 @@ +@use '../../../styling/utils'; + +.str-chat { + .str-chat__scheduled-message-group__item { + position: relative; + + &:hover, + &:focus-within, + &:has(.str-chat__scheduled-message-actions--active) { + background-color: var(--str-chat__background-utility-hover); + + .str-chat__scheduled-message-actions { + opacity: 1; + pointer-events: auto; + } + } + } + + .str-chat__scheduled-message-actions { + display: flex; + position: absolute; + inset-block-start: var(--str-chat__spacing-xs); + inset-inline-end: var(--str-chat__spacing-md); + padding: var(--str-chat__spacing-xxs); + border-radius: var(--str-chat__radius-md); + background-color: var(--str-chat__background-core-elevation-3); + box-shadow: var(--str-chat__box-shadow-2); + opacity: 0; + pointer-events: none; + } +} diff --git a/src/components/ScheduledMessages/styling/ScheduledMessageList.scss b/src/components/ScheduledMessages/styling/ScheduledMessageList.scss new file mode 100644 index 0000000000..d48ac91624 --- /dev/null +++ b/src/components/ScheduledMessages/styling/ScheduledMessageList.scss @@ -0,0 +1,169 @@ +@use '../../../styling/utils'; + +.str-chat { + .str-chat__scheduled-message-list-dialog { + width: 520px; + max-width: 100%; + + // the list scrolls, so reaching its end loads the next page + .str-chat__scheduled-message-list-dialog__body { + display: flex; + 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-group { + display: flex; + flex-direction: column; + + .str-chat__scheduled-message-group__header { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .str-chat__scheduled-message-group__items { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; + } + + .str-chat__scheduled-message-group__item { + padding: var(--str-chat__spacing-md) var(--str-chat__spacing-xl); + border-block-end: 1px solid var(--str-chat__border-core-subtle); + } + } + + .str-chat__scheduled-message-group:last-of-type + .str-chat__scheduled-message-group__item:last-child { + border-block-end: none; + } + + .str-chat__scheduled-message-item { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-xs); + min-width: 0; + + .str-chat__scheduled-message-item__time { + color: var(--str-chat__text-tertiary); + font: var(--str-chat__font-caption-default); + } + + .str-chat__scheduled-message-item__body { + display: flex; + align-items: flex-start; + gap: var(--str-chat__spacing-sm); + min-width: 0; + } + + .str-chat__scheduled-message-item__content { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + color: var(--str-chat__text-primary); + font: var(--str-chat__font-body-default); + } + + .str-chat__scheduled-message-item__channel-name { + @include utils.ellipsis-text(); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-emphasis); + } + + .str-chat__summarized-message-preview { + min-width: 0; + } + + .str-chat__scheduled-message-item__status { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xxs); + margin-block-start: var(--str-chat__spacing-xxs); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-metadata-default); + } + + .str-chat__scheduled-message-item__status--failed { + color: var(--str-chat__accent-error); + font: var(--str-chat__font-caption-default); + } + + .str-chat__scheduled-message-item__status-icon { + flex-shrink: 0; + height: var(--str-chat__icon-size-sm); + width: var(--str-chat__icon-size-sm); + } + } + + // 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..424198bdc2 --- /dev/null +++ b/src/components/ScheduledMessages/utils.ts @@ -0,0 +1,325 @@ +import { + getChannelLevelScheduledMessages, + getScheduledMessageErrorReason, + getScheduledMessageLimits, + getThreadScheduledMessages, + isScheduledMessageNotFoundError, + nsToDate, + 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 ScheduledMessageGroup = { + /** The local day the group's messages are sent on; absent for the failed group. */ + day?: Date; + key: string; + scheduledMessages: ScheduledMessageResponse[]; +}; + +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. */ +export const scopeScheduledMessages = ( + scheduledMessages: ScheduledMessageResponse[], + { cid, parentId }: { cid?: string; parentId?: string }, +) => { + if (parentId) return getThreadScheduledMessages(scheduledMessages, parentId); + return cid ? getChannelLevelScheduledMessages(scheduledMessages) : scheduledMessages; +}; + +export const searchScheduledMessages = ( + scheduledMessages: ScheduledMessageResponse[], + searchQuery = '', +) => { + const query = searchQuery.trim().toLocaleLowerCase(); + if (!query) return scheduledMessages; + return scheduledMessages.filter(({ message }) => + 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; +}; + +/** + * Groups the list (pending by `scheduled_at`, then failed) by the local day pending messages are + * sent on, with the failed ones in one group shown first. + */ +export const groupScheduledMessages = (scheduledMessages: ScheduledMessageResponse[]) => { + const groups = scheduledMessages.reduce( + (groups, scheduledMessage) => { + const isFailed = isFailedScheduledMessage(scheduledMessage); + const day = nsToDate(scheduledMessage.scheduled_at); + const key = isFailed ? 'failed' : toDateInputValue(day); + const lastGroup = groups[groups.length - 1]; + + if (lastGroup?.key === key) lastGroup.scheduledMessages.push(scheduledMessage); + else + groups.push({ + day: isFailed ? undefined : day, + key, + scheduledMessages: [scheduledMessage], + }); + + return groups; + }, + [], + ); + const failedGroup = + groups[groups.length - 1]?.key === 'failed' ? groups.pop() : undefined; + return failedGroup ? [failedGroup, ...groups] : groups; +}; + +/** 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
)} ); diff --git a/src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx index 2a07176636..fbe51ab5d7 100644 --- a/src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx +++ b/src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx @@ -21,6 +21,7 @@ import { ChatProvider, DialogManagerProvider, TranslationProvider, + useChatContext, } from '../../../context'; import { WithComponents } from '../../../context/WithComponents'; import { Streami18n } from '../../../i18n'; @@ -31,12 +32,22 @@ import { hydrateScheduledMessages, mockChatContext, } from '../../../mock-builders'; -import { SCHEDULED_MESSAGE_NOTIFICATION_TYPE } from '../utils'; +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); @@ -78,7 +89,7 @@ const renderActions = async ({ - + diff --git a/src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx index cbcb776ec3..f3cbe216eb 100644 --- a/src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx +++ b/src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { act, cleanup, @@ -16,6 +16,7 @@ import { ChatProvider, DialogManagerProvider, TranslationProvider, + useChatContext, } from '../../../context'; import { WithComponents } from '../../../context/WithComponents'; import { Streami18n } from '../../../i18n'; @@ -28,6 +29,10 @@ import { 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'; @@ -42,13 +47,37 @@ 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, @@ -63,7 +92,12 @@ const renderList = async ({ - + @@ -280,19 +314,61 @@ describe('ScheduledMessageList', () => { expect(query).toHaveBeenCalledTimes(1); }); - it('renders the list states through the list-state slots', async () => { + // 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: () =>
Nothing here
, - FirstPageLoadingIndicator: () =>
Loading here
, + EmptyListIndicator: () =>
No channels
, + FirstPageLoadingIndicator: () =>
Loading channels
, + }, + listProps: { + EmptyListIndicator: () =>
Nothing scheduled
, + FirstPageLoadingIndicator: () =>
Loading scheduled
, }, }); - expect(screen.getByText('Nothing here')).toBeInTheDocument(); + expect(screen.getByText('Nothing scheduled')).toBeInTheDocument(); + expect(screen.queryByText('No channels')).not.toBeInTheDocument(); setPaginatorState(client, { isLoading: true, items: undefined }); - expect(screen.getByText('Loading here')).toBeInTheDocument(); + 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 diff --git a/src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx index 9ee95b1aa0..8ba0175541 100644 --- a/src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx +++ b/src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx @@ -386,7 +386,10 @@ describe('ScheduledMessagesBanner', () => { expect(screen.getByTestId('custom-list')).toBeInTheDocument(); expect(ScheduledMessageListDialog).toHaveBeenLastCalledWith( - expect.objectContaining({ cid, open: true, parentId: undefined }), + expect.objectContaining({ + open: true, + scheduledMessages: channel.scheduledMessages, + }), undefined, ); diff --git a/src/components/ScheduledMessages/__tests__/utils.test.ts b/src/components/ScheduledMessages/__tests__/utils.test.ts index 3a1076f133..a350254976 100644 --- a/src/components/ScheduledMessages/__tests__/utils.test.ts +++ b/src/components/ScheduledMessages/__tests__/utils.test.ts @@ -10,7 +10,7 @@ import { getSchedulePresets, getScheduleTimeBounds, getScheduleTimeError, - searchScheduledMessages, + matchesScheduledMessageText, translateScheduledMessageFailureReason, } from '../utils'; @@ -32,8 +32,9 @@ describe('scheduled message utils', () => { }); const launch = generateScheduledMessage({ message: { custom: {}, text: 'Launch' } }); - expect(searchScheduledMessages([lunch, launch], ' LUNCH ')).toEqual([lunch]); - expect(searchScheduledMessages([lunch, launch], ' ')).toEqual([lunch, launch]); + expect(matchesScheduledMessageText(lunch, ' LUNCH ')).toBe(true); + expect(matchesScheduledMessageText(launch, ' LUNCH ')).toBe(false); + expect(matchesScheduledMessageText(launch, ' ')).toBe(true); }); describe('time bounds', () => { diff --git a/src/components/ScheduledMessages/hooks/index.ts b/src/components/ScheduledMessages/hooks/index.ts index 57ca671146..7c390e1153 100644 --- a/src/components/ScheduledMessages/hooks/index.ts +++ b/src/components/ScheduledMessages/hooks/index.ts @@ -1,2 +1,3 @@ export * from './useCanScheduleMessage'; export * from './useScheduledMessages'; +export * from './useScheduledMessagesSearch'; diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessages.ts b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts index 8b8d80dca4..7150a4c872 100644 --- a/src/components/ScheduledMessages/hooks/useScheduledMessages.ts +++ b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts @@ -1,88 +1,49 @@ import { useEffect, useMemo } from 'react'; -import { ScheduledMessagePaginator } from 'stream-chat'; -import type { PaginatorState, ScheduledMessageResponse, StreamChat } from 'stream-chat'; +import type { + PaginatorState, + ScheduledMessageResponse, + ScheduledMessagesView, + ScheduledMessagesViewState, +} from 'stream-chat'; -import { useChatContext } from '../../../context'; import { useStateStore } from '../../../store'; -import { getChannelByCid, scopeScheduledMessages } from '../utils'; - -const ALL_SCHEDULED_MESSAGES_PAGINATOR_ID = 'stream-chat-react-scheduled-messages'; - -const allScheduledMessagesPaginators = new WeakMap< - StreamChat, - ScheduledMessagePaginator ->(); - -/** One list of all the user's scheduled messages per client, shared by every component. */ -const getAllScheduledMessagesPaginator = (client: StreamChat) => { - const registered = client.scheduledMessagePaginators.getPaginatorById( - ALL_SCHEDULED_MESSAGES_PAGINATOR_ID, - ); - if (registered) return registered; - let paginator = allScheduledMessagesPaginators.get(client); - if (!paginator) { - paginator = new ScheduledMessagePaginator({ - client, - id: ALL_SCHEDULED_MESSAGES_PAGINATOR_ID, - }); - allScheduledMessagesPaginators.set(client, paginator); - } - return paginator; -}; const NO_SCHEDULED_MESSAGES: ScheduledMessageResponse[] = []; -const selectItems = ({ items }: PaginatorState) => ({ items }); +const selectItems = ({ items }: ScheduledMessagesViewState) => ({ items }); const createAwaitsFirstPageSelector = - (paginator: ScheduledMessagePaginator) => + (view: ScheduledMessagesView) => ({ isLoading, lastQueryError }: PaginatorState) => ({ - awaitsFirstPage: !paginator.hasLoadedPage && !isLoading && !lastQueryError, + awaitsFirstPage: !view.paginator.hasLoadedPage && !isLoading && !lastQueryError, }); -export type UseScheduledMessagesParams = { - /** The channel whose scheduled messages to list; all of them without it. */ - cid?: string; - /** The thread whose scheduled replies to list. */ - parentId?: string; -}; - /** - * The scheduled messages of a channel (its own list, filled by the channel query), a thread in it, - * or all of them (a list registered with the client, so events reach it). Loads the first page - * unless one is loaded, as `ChannelList` does. + * 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 = ({ - cid, - parentId, -}: UseScheduledMessagesParams = {}) => { - const { client } = useChatContext(); - const paginator = cid - ? getChannelByCid(client, cid).scheduledMessagesPaginator - : getAllScheduledMessagesPaginator(client); - - // after render, as registering publishes the manager's state +export const useScheduledMessages = (view: ScheduledMessagesView) => { useEffect(() => { - const { scheduledMessagePaginators } = client; - if (cid || scheduledMessagePaginators.getPaginatorById(paginator.id)) return; - scheduledMessagePaginators.insertPaginator({ paginator }); - }, [cid, client, paginator]); + view.registerSubscriptions(); + return () => { + view.unregisterSubscriptions(); + }; + }, [view]); const awaitsFirstPageSelector = useMemo( - () => createAwaitsFirstPageSelector(paginator), - [paginator], + () => createAwaitsFirstPageSelector(view), + [view], ); - const { awaitsFirstPage } = useStateStore(paginator.state, awaitsFirstPageSelector); - useEffect(() => { - if (awaitsFirstPage) paginator.toTailDebounced(); - }, [awaitsFirstPage, paginator]); - - const { items } = useStateStore(paginator.state, selectItems); - const scheduledMessages = useMemo( - () => - items ? scopeScheduledMessages(items, { cid, parentId }) : NO_SCHEDULED_MESSAGES, - [cid, items, parentId], + const { awaitsFirstPage } = useStateStore( + view.paginator.state, + awaitsFirstPageSelector, ); + useEffect(() => { + if (awaitsFirstPage) view.toTailDebounced(); + }, [awaitsFirstPage, view]); - return { paginator, scheduledMessages }; + 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/utils.ts b/src/components/ScheduledMessages/utils.ts index 53498262fc..99a4c63b2b 100644 --- a/src/components/ScheduledMessages/utils.ts +++ b/src/components/ScheduledMessages/utils.ts @@ -1,8 +1,6 @@ import { - getChannelLevelScheduledMessages, getScheduledMessageErrorReason, getScheduledMessageLimits, - getThreadScheduledMessages, isScheduledMessageNotFoundError, SCHEDULED_MESSAGE_FAILURE_REASON, SCHEDULED_MESSAGE_STATUS, @@ -52,23 +50,13 @@ 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. */ -export const scopeScheduledMessages = ( - scheduledMessages: ScheduledMessageResponse[], - { cid, parentId }: { cid?: string; parentId?: string }, -) => { - if (parentId) return getThreadScheduledMessages(scheduledMessages, parentId); - return cid ? getChannelLevelScheduledMessages(scheduledMessages) : scheduledMessages; -}; - -export const searchScheduledMessages = ( - scheduledMessages: ScheduledMessageResponse[], - searchQuery = '', +/** 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(); - if (!query) return scheduledMessages; - return scheduledMessages.filter(({ message }) => - message.text?.toLocaleLowerCase().includes(query), - ); + return !query || !!message.text?.toLocaleLowerCase().includes(query); }; export const toDateInputValue = (date: Date) => diff --git a/src/plugins/ChannelDetail/Views/ScheduledMessagesView/ScheduledMessagesView.tsx b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/ScheduledMessagesView.tsx index c4d695a027..69ce88f805 100644 --- a/src/plugins/ChannelDetail/Views/ScheduledMessagesView/ScheduledMessagesView.tsx +++ b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/ScheduledMessagesView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import type { ScheduledMessageResponse } from 'stream-chat'; import { @@ -10,6 +10,7 @@ import { Prompt } from '../../../../components/Dialog'; import { ScheduledMessageList as DefaultScheduledMessageList, useScheduledMessages, + useScheduledMessagesSearch, } from '../../../../components/ScheduledMessages'; import { ChannelDetailSearchInput } from '../../ChannelDetailSearchInput'; import { useChannelDetailContext } from '../../ChannelDetailContext'; @@ -21,9 +22,8 @@ export const ScheduledMessagesView = () => { const { close } = useModalContext(); const { channel } = useChannelDetailContext(); - const { scheduledMessages } = useScheduledMessages({ cid: channel.cid }); - - const [searchQuery, setSearchQuery] = useState(''); + const { scheduledMessages } = useScheduledMessages(channel.scheduledMessages); + const { filter, setSearchQuery } = useScheduledMessagesSearch(); const editScheduledMessage = useCallback( (scheduledMessage: ScheduledMessageResponse) => { @@ -44,9 +44,9 @@ export const ScheduledMessagesView = () => { )}
diff --git a/src/plugins/ChannelDetail/Views/ScheduledMessagesView/__tests__/ScheduledMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/__tests__/ScheduledMessagesView.test.tsx index 8794b44bcc..f5c61d82fe 100644 --- a/src/plugins/ChannelDetail/Views/ScheduledMessagesView/__tests__/ScheduledMessagesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ScheduledMessagesView/__tests__/ScheduledMessagesView.test.tsx @@ -22,6 +22,7 @@ import { import { defaultChannelDetailSections } from '../../../ChannelDetail'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ScheduledMessagesView } from '../ScheduledMessagesView'; +import type { ScheduledMessageListProps } from '../../../../../components/ScheduledMessages'; const cid = 'messaging:general'; @@ -138,13 +139,17 @@ describe('ScheduledMessagesView', () => { }); it('renders the list through the ScheduledMessageList slot', async () => { - const CustomList = ({ cid: listCid }: { cid: string }) => ( -
{listCid}
+ const CustomList = ({ scheduledMessages }: ScheduledMessageListProps) => ( +
+ {scheduledMessages.scope.type}:{scheduledMessages.paginator.cid} +
); await renderView([], { ScheduledMessageList: CustomList }); - expect(screen.getByTestId('custom-scheduled-message-list')).toHaveTextContent(cid); + expect(screen.getByTestId('custom-scheduled-message-list')).toHaveTextContent( + `channel:${cid}`, + ); }); it('closes the channel detail modal from the header', async () => { From ccc40edf54dd91cead8cf63c7f16bcd31cc89c7e Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Nov 2026 17:09:53 +0100 Subject: [PATCH 4/4] fix(ScheduledMessages): keep the list dialog within narrow screens, at a fixed height - The modal's dialog wrapper sized to its content, so the dialog's max-width measured against its own 520px and overflowed screens narrower than that. The wrapper now leaves a 16px gutter. - The dialog grew and shrank with each search keystroke and page loaded. It now has ChannelDetail's fixed height, capped by the screen, and its list scrolls. Co-Authored-By: Claude Opus 5.5 --- .../styling/ScheduledMessageList.scss | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/ScheduledMessages/styling/ScheduledMessageList.scss b/src/components/ScheduledMessages/styling/ScheduledMessageList.scss index 7a1041dc74..ea47e07066 100644 --- a/src/components/ScheduledMessages/styling/ScheduledMessageList.scss +++ b/src/components/ScheduledMessages/styling/ScheduledMessageList.scss @@ -1,13 +1,25 @@ @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;