Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
96e7ef6
refactor(ScheduledMessages): share the channel container lookup
NikolaGoranNaumovski Oct 30, 2026
4903a92
feat(ScheduledMessages): add grouping, preview and failure copy utili…
NikolaGoranNaumovski Oct 30, 2026
04fd81c
feat(ScheduledMessages): add hooks for the scheduled message list
NikolaGoranNaumovski Oct 30, 2026
859175f
feat(ScheduledMessages): show scheduled messages above the composer
NikolaGoranNaumovski Oct 30, 2026
a85f390
feat(i18n): translate the scheduled message list and banner
NikolaGoranNaumovski Oct 30, 2026
537a4b3
feat(ChannelDetail): add a scheduled messages section
NikolaGoranNaumovski Oct 30, 2026
9db4a20
Merge branch 'feat/react-1387-scheduled-messages-schedule' into feat/…
NikolaGoranNaumovski Nov 2, 2026
9d9b6c1
feat(ScheduledMessages): add search, loading and error states to the …
NikolaGoranNaumovski Nov 2, 2026
7f51aca
Merge branch 'feat/react-1387-scheduled-messages-schedule' into feat/…
NikolaGoranNaumovski Nov 3, 2026
a127cb6
fix(ScheduledMessages): match the scheduled messages banner to the de…
NikolaGoranNaumovski Nov 3, 2026
d21cc90
fix(ScheduledMessages): match the scheduled message list to the design
NikolaGoranNaumovski Nov 3, 2026
857f093
fix(ChannelDetail): label the scheduled section as in the design
NikolaGoranNaumovski Nov 3, 2026
2055237
feat(i18n): translate the scheduled message list rows
NikolaGoranNaumovski Nov 3, 2026
cf035b8
fix(ScheduledMessages): align the scheduled message rows in the chann…
NikolaGoranNaumovski Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/MessageComposer/MessageComposerUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from 'stream-chat';
import { CommandChip as DefaultCommandChip } from './CommandChip';
import { GeolocationPreview } from './AttachmentPreviewList/GeolocationPreview';
import { ScheduledMessagesBanner as DefaultScheduledMessagesBanner } from '../ScheduledMessages/ScheduledMessagesBanner';

const messageComposerStateSelector = ({
editedMessage,
Expand Down Expand Up @@ -151,6 +152,7 @@ export const MessageComposerUI = () => {
AttachmentSelector = message ? SimpleAttachmentSelector : DefaultAttachmentSelector,
AudioRecorder = DefaultAudioRecorder,
CommandChip = DefaultCommandChip,
ScheduledMessagesBanner = DefaultScheduledMessagesBanner,
SendToChannelCheckbox = DefaultSendToChannelCheckbox,
TextareaComposer = DefaultTextareaComposer,
} = useComponentContext();
Expand All @@ -160,6 +162,7 @@ export const MessageComposerUI = () => {
className='str-chat__message-composer-container'
component='div'
>
{!message && ScheduledMessagesBanner && <ScheduledMessagesBanner />}
<div
className={clsx('str-chat__message-composer', {
'str-chat__message-composer--command-active': !!command,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
useTranslationContext,
} from '../../context';
import { Button } from '../Button';
import { CHANNEL_CONTAINER_ID } from '../Channel/constants';
import { ContextMenu, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog';
import { useMessageComposerHasSendableData } from '../MessageComposer/hooks';
import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
Expand All @@ -21,8 +20,7 @@ import {
import { ScheduleMessageDialog as DefaultScheduleMessageDialog } from './ScheduleMessageDialog';
import { ScheduleMessageMenu } from './ScheduleMessageMenu';
import { ScheduleMessageMenuHeader } from './ScheduleMessageMenuHeader';

const getChannelContainer = () => document.getElementById(CHANNEL_CONTAINER_ID);
import { getChannelContainer } from './utils';

const getMenuDialogId = (threadId?: string | null) =>
threadId ? `${SCHEDULE_MESSAGE_MENU_ID}-thread` : SCHEDULE_MESSAGE_MENU_ID;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

import { useTranslationContext } from '../../context';
import { ScheduledMessageGroupType } from './constants';
import type { ScheduledMessageGroupHeaderProps } from './types';

export const ScheduledMessageGroupHeader = ({
group,
}: ScheduledMessageGroupHeaderProps) => {
const { t } = useTranslationContext();

const { day, type } = group;

const title =
type === ScheduledMessageGroupType.Failed
? t('Failed')
: t('timestamp/ScheduledMessageGroup', { timestamp: day });

return <h3 className='str-chat__scheduled-message-group__header'>{title}</h3>;
};
62 changes: 62 additions & 0 deletions src/components/ScheduledMessages/ScheduledMessageItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import clsx from 'clsx';
import React, { useMemo } from 'react';

import { useChatContext, useTranslationContext } from '../../context';
import { ChannelAvatar } from '../Avatar';
import { useChannelPreviewInfo } from '../ChannelListItem/hooks/useChannelPreviewInfo';
import { SummarizedMessagePreview } from '../SummarizedMessagePreview';
import { ScheduledMessageItemStatus } from './ScheduledMessageItemStatus';
import type { ScheduledMessageItemProps } from './types';
import { isFailedScheduledMessage, toStoredScheduledMessagePreview } from './utils';

export const ScheduledMessageItem = ({ scheduledMessage }: ScheduledMessageItemProps) => {
const { t } = useTranslationContext();
const { client } = useChatContext();

const { cid, scheduled_at } = scheduledMessage;
const channel = client.activeChannels[cid];
const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({
channel,
});

const previewMessage = useMemo(
() => toStoredScheduledMessagePreview(scheduledMessage),
[scheduledMessage],
);

const isFailed = isFailedScheduledMessage(scheduledMessage);
const sendTime = t('timestamp/ScheduledMessageSendTime', {
timestamp: new Date(scheduled_at),
});

return (
<div
className={clsx('str-chat__scheduled-message-item', {
'str-chat__scheduled-message-item--failed': isFailed,
})}
data-testid='scheduled-message-item'
>
<time className='str-chat__scheduled-message-item__time' dateTime={scheduled_at}>
{t('Send {{ time }}', { time: sendTime })}
</time>
<div className='str-chat__scheduled-message-item__body'>
<ChannelAvatar
aria-hidden='true'
displayMembers={groupChannelDisplayInfo.members}
imageUrl={displayImage}
size='lg'
userName={displayTitle}
/>
<div className='str-chat__scheduled-message-item__content'>
{displayTitle && (
<span className='str-chat__scheduled-message-item__channel-name'>
{displayTitle}
</span>
)}
<SummarizedMessagePreview latestMessage={previewMessage} />
<ScheduledMessageItemStatus scheduledMessage={scheduledMessage} />
</div>
</div>
</div>
);
};
35 changes: 35 additions & 0 deletions src/components/ScheduledMessages/ScheduledMessageItemStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import { ScheduledMessageStatus } from 'stream-chat';

import { useComponentContextIcons, useTranslationContext } from '../../context';
import type { ScheduledMessageItemStatusProps } from './types';
import { translateScheduledMessageFailureReason } from './utils';

export const ScheduledMessageItemStatus = ({
scheduledMessage,
}: ScheduledMessageItemStatusProps) => {
const { t } = useTranslationContext();
const { IconExclamationTriangleFill, IconLoading } = useComponentContextIcons();

const { failure_reason, status } = scheduledMessage;

if (status === ScheduledMessageStatus.Failed) {
return (
<span className='str-chat__scheduled-message-item__status str-chat__scheduled-message-item__status--failed'>
<IconExclamationTriangleFill className='str-chat__scheduled-message-item__status-icon' />
{translateScheduledMessageFailureReason(t, failure_reason)}
</span>
);
}

if (status === ScheduledMessageStatus.Sending) {
return (
<span className='str-chat__scheduled-message-item__status'>
<IconLoading className='str-chat__scheduled-message-item__status-icon' />
{t('Sending...')}
</span>
);
}

return null;
};
78 changes: 78 additions & 0 deletions src/components/ScheduledMessages/ScheduledMessageList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';

import { useComponentContext, useTranslationContext } from '../../context';
import { Button } from '../Button';
import {
useScheduledMessageGroups,
useScheduledMessagesLoadState,
useScheduledMessagesPagination,
} from './hooks';
import { ScheduledMessageGroupHeader } from './ScheduledMessageGroupHeader';
import { ScheduledMessageItem as DefaultScheduledMessageItem } from './ScheduledMessageItem';
import { ScheduledMessageListLoading } from './ScheduledMessageListLoading';
import { ScheduledMessagesEmptyList } from './ScheduledMessagesEmptyList';
import { ScheduledMessagesLoadError } from './ScheduledMessagesLoadError';
import { ScheduledMessagesNoResults } from './ScheduledMessagesNoResults';
import type { ScheduledMessageListProps } from './types';

export const ScheduledMessageList = ({
cid,
parentId,
searchQuery,
}: ScheduledMessageListProps) => {
const { t } = useTranslationContext();
const { ScheduledMessageItem = DefaultScheduledMessageItem } = useComponentContext();

const { groups, hasScheduledMessages } = useScheduledMessageGroups({
cid,
parentId,
searchQuery,
});
const { canLoadMore, isLoading, loadMore } = useScheduledMessagesPagination();
const { hasLoadFailed, isFirstLoad, reload } = useScheduledMessagesLoadState();

if (!groups.length) {
if (hasScheduledMessages) return <ScheduledMessagesNoResults />;

if (isFirstLoad) return <ScheduledMessageListLoading />;

if (hasLoadFailed) return <ScheduledMessagesLoadError onRetry={reload} />;

return <ScheduledMessagesEmptyList />;
}

return (
<div
className='str-chat__scheduled-message-list'
data-testid='scheduled-message-list'
>
{groups.map((group) => (
<section className='str-chat__scheduled-message-group' key={group.id}>
<ScheduledMessageGroupHeader group={group} />
<ul className='str-chat__scheduled-message-group__items'>
{group.scheduledMessages.map((scheduledMessage) => (
<li
className='str-chat__scheduled-message-group__item'
key={scheduledMessage.id}
>
<ScheduledMessageItem scheduledMessage={scheduledMessage} />
</li>
))}
</ul>
</section>
))}
{canLoadMore && (
<Button
appearance='ghost'
className='str-chat__scheduled-message-list__load-more'
disabled={isLoading}
onClick={loadMore}
size='sm'
variant='secondary'
>
{t('Load more')}
</Button>
)}
</div>
);
};
53 changes: 53 additions & 0 deletions src/components/ScheduledMessages/ScheduledMessageListDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, { useCallback, useState } from 'react';
import type { ChangeEvent } from 'react';

import { useComponentContextIcons, useTranslationContext } from '../../context';
import { Prompt } from '../Dialog';
import { TextInput } from '../Form';
import { useScheduledMessageCount } from './hooks';
import { ScheduledMessageList } from './ScheduledMessageList';
import type { ScheduledMessageListDialogProps } from './types';

export const ScheduledMessageListDialog = ({
cid,
close,
parentId,
}: ScheduledMessageListDialogProps) => {
const { t } = useTranslationContext();
const { IconSearch } = useComponentContextIcons();

const scheduledMessageCount = useScheduledMessageCount({ cid, parentId });

const [searchQuery, setSearchQuery] = useState('');

const updateSearchQuery = useCallback(
({ target }: ChangeEvent<HTMLInputElement>) => setSearchQuery(target.value),
[],
);

const title = parentId ? t('Scheduled replies') : t('Scheduled messages');

return (
<Prompt.Root
className='str-chat__scheduled-message-list-dialog'
data-testid='scheduled-message-list-dialog'
>
<Prompt.Header close={close} title={title} />
<Prompt.Body className='str-chat__scheduled-message-list-dialog__body'>
{scheduledMessageCount > 0 && (
<TextInput
aria-label={t('Search')}
autoComplete='off'
className='str-chat__scheduled-message-list-dialog__search'
leading={<IconSearch />}
onChange={updateSearchQuery}
placeholder={t('Search')}
type='search'
value={searchQuery}
/>
)}
<ScheduledMessageList cid={cid} parentId={parentId} searchQuery={searchQuery} />
</Prompt.Body>
</Prompt.Root>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';

import { useTranslationContext } from '../../context';
import { SCHEDULED_MESSAGE_LIST_LOADING_ROW_COUNT } from './constants';

const LOADING_ROW_KEYS = Array.from(
{ length: SCHEDULED_MESSAGE_LIST_LOADING_ROW_COUNT },
(_, index) => `loading-row-${index}`,
);

export const ScheduledMessageListLoading = () => {
const { t } = useTranslationContext();

return (
<div
aria-busy='true'
aria-label={t('Loading scheduled messages')}
className='str-chat__scheduled-message-list-loading'
data-testid='scheduled-message-list-loading'
role='status'
>
{LOADING_ROW_KEYS.map((key) => (
<div
aria-hidden='true'
className='str-chat__scheduled-message-list-loading__row'
key={key}
>
<div className='str-chat__scheduled-message-list-loading__time' />
<div className='str-chat__scheduled-message-list-loading__body'>
<div className='str-chat__scheduled-message-list-loading__avatar' />
<div className='str-chat__scheduled-message-list-loading__lines'>
<div className='str-chat__scheduled-message-list-loading__title' />
<div className='str-chat__scheduled-message-list-loading__subtitle' />
</div>
</div>
</div>
))}
</div>
);
};
Loading
Loading