Skip to content
Draft
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
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
32 changes: 32 additions & 0 deletions src/components/Icons/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,14 @@ export const IconCheckmark = createIcon(
/>,
);

export const IconCaretDown = createIcon(
'IconCaretDown',
<path
d='M4.84 7.38H15.16C15.69 7.38 15.96 8.02 15.58 8.4L10.42 13.56C10.19 13.79 9.81 13.79 9.58 13.56L4.42 8.4C4.04 8.02 4.31 7.38 4.84 7.38Z'
fill='currentColor'
/>,
);

export const IconChevronDown = createIcon(
'IconChevronDown',
<path
Expand All @@ -317,6 +325,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 +409,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
3 changes: 3 additions & 0 deletions src/components/Icons/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ export type IconSlots = Partial<
| 'IconBolt'
| 'IconBookmark'
| 'IconBookmarkRemove'
| 'IconCalendar'
| 'IconCamera'
| 'IconCaretDown'
| '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
114 changes: 114 additions & 0 deletions src/components/ScheduledMessages/ScheduleMessageButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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')}
circular
className='str-chat__schedule-message-button'
data-testid='schedule-message-button'
disabled={!hasSendableData}
onClick={toggleMenu}
ref={menuButtonRef}
size='xs'
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>
</>
);
};
152 changes: 152 additions & 0 deletions src/components/ScheduledMessages/ScheduleMessageDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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,
fromDateInputValue,
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, IconCaretDown, 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 selectedDate = fromDateInputValue(dateValue);
const dateLabel = selectedDate
? t('timestamp/ScheduleDate', { timestamp: selectedDate })
: undefined;
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 />
<span
aria-hidden
className='str-chat__schedule-message-dialog__date-value'
>
{dateLabel}
</span>
</>
}
max={toDateInputValue(bounds.latest)}
min={toDateInputValue(bounds.earliest)}
onChange={onDateChange}
onClick={openNativePicker}
required
trailing={<IconCaretDown />}
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
trailing={<IconCaretDown />}
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>
);
};
Loading
Loading