Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions src/components/Icons/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,18 @@ export const IconChevronDown = createIcon(
/>,
);

export const IconChevronUp = createIcon(
'IconChevronUp',
<path
d='M3.75 12.5L10 6.25L16.25 12.5'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='1.5'
/>,
);

export const IconChevronLeft = createIcon(
'IconChevronLeft',
<path
Expand Down Expand Up @@ -389,6 +401,18 @@ export const IconXCircle = createIcon(
/>,
);

export const IconCalendar = createIcon(
'IconCalendar',
<path
d='M5.625 2.5V4.375M14.375 2.5V4.375M2.5 15.625V6.25C2.5 5.21447 3.33947 4.375 4.375 4.375H15.625C16.6605 4.375 17.5 5.21447 17.5 6.25V15.625M2.5 15.625C2.5 16.6605 3.33947 17.5 4.375 17.5H15.625C16.6605 17.5 17.5 16.6605 17.5 15.625M2.5 15.625V9.375C2.5 8.33947 3.33947 7.5 4.375 7.5H15.625C16.6605 7.5 17.5 8.33947 17.5 9.375V15.625'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='1.5'
/>,
);

export const IconClock = createIcon(
'IconClock',
<path
Expand Down
2 changes: 2 additions & 0 deletions src/components/Icons/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ export type IconSlots = Partial<
| 'IconBolt'
| 'IconBookmark'
| 'IconBookmarkRemove'
| 'IconCalendar'
| 'IconCamera'
| 'IconCheckmark'
| 'IconCheckmark1Small'
| 'IconChecks'
| 'IconChevronDown'
| 'IconChevronLeft'
| 'IconChevronRight'
| 'IconChevronUp'
| 'IconClock'
| 'IconCommand'
| 'IconCopy'
Expand Down
21 changes: 20 additions & 1 deletion src/components/MessageComposer/MessageComposerActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { useIsCooldownActive } from './hooks/useIsCooldownActive';
import type { MessageComposerState, TextComposerState } from 'stream-chat';
import { useStateStore } from '../../store';
import { useInertWhenHidden } from '../Accessibility';
import { useCanScheduleMessage } from '../ScheduledMessages/hooks/useCanScheduleMessage';
import { ScheduleMessageButton as DefaultScheduleMessageButton } from '../ScheduledMessages/ScheduleMessageButton';

const messageComposerStateSelector = ({ editedMessage }: MessageComposerState) => ({
editedMessage,
Expand All @@ -33,9 +35,11 @@ export const MessageComposerActions = () => {
const messageComposer = useMessageComposerController();
const {
CooldownTimer = DefaultCooldownTimer,
ScheduleMessageButton: ScheduleMessageButtonOverride,
SendButton,
StopAIGenerationButton: StopAIGenerationButtonOverride,
} = useComponentContext();
const canScheduleMessage = useCanScheduleMessage();

const { editedMessage } = useStateStore(
messageComposer.state,
Expand Down Expand Up @@ -70,14 +74,29 @@ export const MessageComposerActions = () => {

const recordingEnabled = !!(recordingController.recorder && navigator.mediaDevices); // account for requirement on iOS as per this bug report: https://bugs.webkit.org/show_bug.cgi?id=252303

let content = SendButton ? (
const ScheduleMessageButton =
ScheduleMessageButtonOverride === undefined
? DefaultScheduleMessageButton
: ScheduleMessageButtonOverride;

const sendButton = SendButton ? (
<SendButton sendMessage={handleSubmit} />
) : (
<DefaultSendButton sendMessage={handleSubmit}>
{editedMessage || command ? <IconCheckmark /> : <IconSend />}
</DefaultSendButton>
);

let content =
canScheduleMessage && ScheduleMessageButton ? (
<div className='str-chat__split-send-button'>
{sendButton}
<ScheduleMessageButton />
</div>
) : (
sendButton
);

if (shouldDisplayStopAIGeneration) {
content = <StopAIGenerationButton onClick={stopGenerating} />;
} else if (hideSendButton) return null;
Expand Down
116 changes: 116 additions & 0 deletions src/components/ScheduledMessages/ScheduleMessageButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import clsx from 'clsx';
import React, { useCallback, useRef } from 'react';

import {
useComponentContext,
useComponentContextIcons,
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';
import { GlobalModal } from '../Modal';
import { Portal } from '../Portal/Portal';
import { SCHEDULE_MESSAGE_MENU_ID } from './constants';
import {
useScheduleMessage,
useScheduleMessageDialog,
useSchedulePresets,
} from './hooks';
import { ScheduleMessageDialog as DefaultScheduleMessageDialog } from './ScheduleMessageDialog';
import { ScheduleMessageMenu } from './ScheduleMessageMenu';
import { ScheduleMessageMenuHeader } from './ScheduleMessageMenuHeader';

const getChannelContainer = () => document.getElementById(CHANNEL_CONTAINER_ID);

const getMenuDialogId = (threadId?: string | null) =>
threadId ? `${SCHEDULE_MESSAGE_MENU_ID}-thread` : SCHEDULE_MESSAGE_MENU_ID;

const ignoreReportedError = () => undefined;

export const ScheduleMessageButton = () => {
const { t } = useTranslationContext();
const { IconChevronUp } = useComponentContextIcons();
const {
ContextMenu: ContextMenuComponent = ContextMenu,
Modal = GlobalModal,
ScheduleMessageDialog = DefaultScheduleMessageDialog,
} = useComponentContext();

const { threadId } = useMessageComposerController();
const hasSendableData = useMessageComposerHasSendableData();
const scheduleMessage = useScheduleMessage();
const menuDialogId = getMenuDialogId(threadId);
const { dialog: menuDialog, dialogManager } = useDialogOnNearestManager({
id: menuDialogId,
});
const isMenuOpen = useDialogIsOpen(menuDialogId, dialogManager?.id);

const menuButtonRef = useRef<HTMLButtonElement>(null);

const presets = useSchedulePresets(isMenuOpen);
const { closeDialog, isDialogOpen, openDialog } =
useScheduleMessageDialog(menuButtonRef);

const toggleMenu = useCallback(() => menuDialog.toggle(), [menuDialog]);

const scheduleAtPreset = useCallback(
(scheduledAt: Date) => {
scheduleMessage(scheduledAt).catch(ignoreReportedError);
},
[scheduleMessage],
);

return (
<>
<Button
appearance='solid'
aria-expanded={isMenuOpen}
aria-haspopup='menu'
aria-label={t('aria/Open schedule message menu')}
className={clsx('str-chat__schedule-message-button', {
'str-chat__schedule-message-button--selected': isMenuOpen,
})}
data-testid='schedule-message-button'
disabled={!hasSendableData}
onClick={toggleMenu}
ref={menuButtonRef}
size='sm'
variant='primary'
>
<IconChevronUp />
</Button>
<ContextMenuComponent
allowFlip
aria-label={t('Schedule message')}
className='str-chat__schedule-message-menu'
data-testid='schedule-message-menu'
dialogManagerId={dialogManager?.id}
Header={ScheduleMessageMenuHeader}
id={menuDialogId}
onClose={menuDialog.close}
placement='top-end'
referenceElement={menuButtonRef.current}
tabIndex={-1}
>
<ScheduleMessageMenu
onCustomTimeSelect={openDialog}
onPresetSelect={scheduleAtPreset}
presets={presets}
/>
</ContextMenuComponent>
<Portal getPortalDestination={getChannelContainer} isOpen={isDialogOpen}>
<Modal
className='str-chat__schedule-message-modal'
initialFocusStrategy='dialog'
onClose={closeDialog}
open={isDialogOpen}
>
{isDialogOpen && <ScheduleMessageDialog close={closeDialog} />}
</Modal>
</Portal>
</>
);
};
131 changes: 131 additions & 0 deletions src/components/ScheduledMessages/ScheduleMessageDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React, { useCallback } from 'react';
import type { TFunction } from 'i18next';

import { useComponentContextIcons, useTranslationContext } from '../../context';
import { useChannelDisplayName } from '../ChannelListItem/hooks/useChannelDisplayName';
import { Prompt } from '../Dialog';
import { TextInput } from '../Form';
import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController';
import { QuotedMessagePreviewUI } from '../MessageComposer/QuotedMessagePreview';
import { ScheduleMessageNoticeSeverity, ScheduleTimeError } from './constants';
import {
useIsConnectionOnline,
useScheduledMessagePreview,
useScheduleMessageSubmit,
useScheduleTimeForm,
} from './hooks';
import { ScheduleMessageDialogNotice } from './ScheduleMessageDialogNotice';
import type { ScheduleMessageDialogProps } from './types';
import { formatTimeZoneOffset, openNativePicker, toDateInputValue } from './utils';

const translateScheduleTimeError = (t: TFunction, timeError?: ScheduleTimeError) => {
if (timeError === ScheduleTimeError.TooSoon) return t('Choose a later time');

if (timeError === ScheduleTimeError.TooFar) return t('Choose an earlier time');

return undefined;
};

export const ScheduleMessageDialog = ({ close }: ScheduleMessageDialogProps) => {
const { t } = useTranslationContext();
const { IconCalendar, IconClock } = useComponentContextIcons();

const { channel } = useMessageComposerController();
const channelDisplayName = useChannelDisplayName(channel);
const previewMessage = useScheduledMessagePreview();
const isOnline = useIsConnectionOnline();
const {
bounds,
dateValue,
onDateChange,
onTimeChange,
scheduledAt,
timeError,
timeValue,
} = useScheduleTimeForm();
const { hasFailed, isScheduling, submit } = useScheduleMessageSubmit(close);

const canSchedule = !!scheduledAt && !timeError && isOnline && !isScheduling;
const timeErrorMessage = translateScheduleTimeError(t, timeError);
const timeZone = formatTimeZoneOffset(scheduledAt ?? bounds.earliest);
const authorLabel = channelDisplayName
? t('Message to {{ name }}', { name: channelDisplayName })
: t('Schedule message');

const scheduleAtPickedTime = useCallback(() => {
if (scheduledAt) submit(scheduledAt);
}, [scheduledAt, submit]);

return (
<Prompt.Root
className='str-chat__schedule-message-dialog'
data-testid='schedule-message-dialog'
>
<Prompt.Header close={close} title={t('Schedule message')} />
<Prompt.Body className='str-chat__schedule-message-dialog__body'>
<QuotedMessagePreviewUI
authorLabel={authorLabel}
className='str-chat__schedule-message-dialog__preview'
quotedMessage={previewMessage}
/>
{!isOnline && (
<ScheduleMessageDialogNotice severity={ScheduleMessageNoticeSeverity.Info}>
{t("You're offline. Reconnect to schedule this message.")}
</ScheduleMessageDialogNotice>
)}
{hasFailed && (
<ScheduleMessageDialogNotice severity={ScheduleMessageNoticeSeverity.Error}>
{t("Couldn't schedule message. Try again")}
</ScheduleMessageDialogNotice>
)}
<div className='str-chat__schedule-message-dialog__fields'>
<TextInput
className='str-chat__schedule-message-dialog__date'
label={t('Date')}
leading={<IconCalendar />}
max={toDateInputValue(bounds.latest)}
min={toDateInputValue(bounds.earliest)}
onChange={onDateChange}
onClick={openNativePicker}
required
type='date'
value={dateValue}
/>
<TextInput
className='str-chat__schedule-message-dialog__time'
error={!!timeErrorMessage}
errorMessage={timeErrorMessage}
label={t('Time')}
leading={<IconClock />}
onChange={onTimeChange}
onClick={openNativePicker}
required
type='time'
value={timeValue}
/>
</div>
<ScheduleMessageDialogNotice severity={ScheduleMessageNoticeSeverity.Info}>
{t('Sent in your time zone ({{ timeZone }})', { timeZone })}
</ScheduleMessageDialogNotice>
</Prompt.Body>
<Prompt.Footer>
<Prompt.FooterControls>
<Prompt.FooterControlsButtonSecondary
className='str-chat__prompt__footer__controls-button--cancel'
onClick={close}
>
{t('Cancel')}
</Prompt.FooterControlsButtonSecondary>
<Prompt.FooterControlsButtonPrimary
className='str-chat__prompt__footer__controls-button--submit'
disabled={!canSchedule}
onClick={scheduleAtPickedTime}
type='submit'
>
{t('Schedule')}
</Prompt.FooterControlsButtonPrimary>
</Prompt.FooterControls>
</Prompt.Footer>
</Prompt.Root>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import clsx from 'clsx';
import React from 'react';

import { useComponentContextIcons } from '../../context';
import { ScheduleMessageNoticeSeverity } from './constants';
import type { ScheduleMessageDialogNoticeProps } from './types';

export const ScheduleMessageDialogNotice = ({
children,
severity,
}: ScheduleMessageDialogNoticeProps) => {
const { IconExclamationCircleFill, IconInfo } = useComponentContextIcons();

const isError = severity === ScheduleMessageNoticeSeverity.Error;
const Icon = isError ? IconExclamationCircleFill : IconInfo;

return (
<div
className={clsx(
'str-chat__schedule-message-dialog__notice',
`str-chat__schedule-message-dialog__notice--${severity}`,
)}
role={isError ? 'alert' : 'status'}
>
<Icon className='str-chat__schedule-message-dialog__notice-icon' />
<span className='str-chat__schedule-message-dialog__notice-text'>{children}</span>
</div>
);
};
Loading
Loading