diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 013e8db5a..13ca0d591 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -3601,7 +3601,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 04d88c399..aaa19d80e 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -177,7 +177,7 @@ These v14 keys (from `src/i18n/en.json` at `v14.10.0`, plural suffixes folded) h That writes `en.json` in the repo root with the 619 translatable keys. The 14 `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 + 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 633-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/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index bb82ec832..4f1345f5f 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -96,6 +96,10 @@ import { ConfigurableMessageActions } from './CustomMessageActions'; import { InlineEditableMessage } from './InlineEditMessage'; import { ThreadListHeaderWithSidebarToggle } from './Sidebar/HeaderContent.tsx'; import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx'; +import { + EventInviteAwareAttachmentPreviewList, + EventInviteMessageAttachment, +} from './EventInvite'; import { ConfigurableComposerPreviews, ConfigurableStartRecordingAudioButton, @@ -274,8 +278,14 @@ const chatViewLayouts = [ { id: 'threads' as const, slots: [MAIN_THREAD_SLOT, OPTIONAL_THREAD_SLOT] }, ]; +// The SDK renders every attachment type it does not know with `UnsupportedAttachment`, which is +// where this app's `event` attachments (EventInvite/) get their UI. +const AppAttachment = (props: AttachmentProps) => ( + +); + const CustomAttachmentWithActions = (props: AttachmentProps) => ( - + ); const APP_TITLE = 'Stream Chat React'; @@ -695,6 +705,8 @@ const App = () => { Search: CustomChannelSearch, ThreadListHeader: ThreadListHeaderWithSidebarToggle, MessageActions: ConfigurableMessageActions, + Attachment: AppAttachment, + AttachmentPreviewList: EventInviteAwareAttachmentPreviewList, AttachmentSelector: CommandModeAttachmentSelector, MessageBubbleContent: ConfigurableMessageBubbleContent, MessageComposerPreviews: ConfigurableComposerPreviews, diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index f33414f95..7ed5ef42a 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -120,6 +120,11 @@ export type ComposerSettingsState = { * also removes the composer's way into it (e.g. `attachments` drops the selector's File option). */ disabledPreviewSections: string[]; + /** + * Adds an Event action to the attachment selector, which attaches an event invite: a custom + * attachment type (`event`) the SDK does not ship. See `EventInvite/`. + */ + eventInvites: boolean; /** * The composer's preview sections in render order. A default preview missing from it is * appended (see `resolvePreviewSectionOrder`). @@ -210,6 +215,7 @@ const defaultAppSettingsState: AppSettingsState = { composer: { customCdnUrl: '', disabledPreviewSections: [], + eventInvites: false, failUploads: 'off', previewSectionOrder: defaultMessageComposerPreviewSet.map(({ type }) => type), sendMessagesWithPendingUploads: false, diff --git a/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx b/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx index 8fd49cb42..cbfefce4f 100644 --- a/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx @@ -26,6 +26,7 @@ export const ComposerTab = ({ close }: ComposerTabProps) => { composer, composer: { customCdnUrl, + eventInvites, failUploads, sendMessagesWithPendingUploads, slowUploadMs, @@ -61,6 +62,26 @@ export const ComposerTab = ({ close }: ComposerTabProps) => { +
+
Custom attachment type
+ + appSettingsStore.partialNext({ + composer: { ...composer, eventInvites: event.target.checked }, + }) + } + title='Event invites' + /> +
+ Adds an Event action to the attachment menu. It attaches an + invite of a custom type (event), which the message list renders + with an Add to calendar button. Built from public extension + points only; see EventInvite/. Needs the Files preview section. +
+
+
Pending uploads
({ command }); @@ -18,18 +19,20 @@ export const CommandModeAttachmentSelector = (props: AttachmentSelectorProps) => messageComposer.textComposer.state, textComposerCommandSelector, ); - const { disabledPreviewSections } = useAppSettingsSelector((state) => state.composer); + const { disabledPreviewSections, eventInvites } = useAppSettingsSelector( + (state) => state.composer, + ); const filesDisabled = disabledPreviewSections.includes('attachments'); const attachmentSelectorActionSet = useMemo(() => { const actionSet = props.attachmentSelectorActionSet ?? defaultAttachmentSelectorActionSet; - // A composer without the Files preview offers no File option. Polls and location are gated - // through the composer config instead (see the setup function in App.tsx), which the SDK's - // own selector filter already reads. - return filesDisabled - ? actionSet.filter(({ type }) => type !== 'uploadFile') - : actionSet; - }, [filesDisabled, props.attachmentSelectorActionSet]); + // A composer without the Files preview offers no File option, and no Event option either, + // since that preview is where the invite shows. Polls and location are gated through the + // composer config instead (see the setup function in App.tsx), which the SDK's own selector + // filter already reads. + if (filesDisabled) return actionSet.filter(({ type }) => type !== 'uploadFile'); + return eventInvites ? withEventInviteAction(actionSet) : actionSet; + }, [eventInvites, filesDisabled, props.attachmentSelectorActionSet]); return ( toIntlLocale(useTranslationContext().userLanguage); + +/** The start day as a calendar tile: month above day. Decorative: the schedule line says it. */ +const EventInviteDateTile = ({ event }: { event: EventInviteData }) => { + const locale = useEventInviteLocale(); + const start = new Date(event.starts_at); + return ( +
+ + {new Intl.DateTimeFormat(locale, { month: 'short' }).format(start)} + + + {new Intl.DateTimeFormat(locale, { day: 'numeric' }).format(start)} + +
+ ); +}; + +/** Title, schedule and place of an invite. A meeting link opens in a new tab. */ +const EventInviteDetails = ({ + attachment, + titleId, +}: { + attachment: EventInviteAttachment; + titleId?: string; +}) => { + const { t } = useTranslationContext(); + const locale = useEventInviteLocale(); + const { event } = attachment.custom; + const link = getEventInviteLink(event); + return ( +
+
+ {attachment.title} +
+ + {event.location && ( + + )} +
+ ); +}; + +/** + * Renders an `event` attachment in a message: the `UnsupportedAttachment` of the app's + * `Attachment`, since the SDK hands every type it does not know to that component. Any other + * unknown type keeps the SDK's UI. + */ +export const EventInviteMessageAttachment = ({ + attachment, +}: UnsupportedAttachmentProps) => { + const { t } = useTranslationContext(); + const titleId = useId(); + + if (!isEventInviteAttachment(attachment)) return ; + + return ( +
+
+ + +
+ +
+ ); +}; + +/** + * Previews an `event` attachment in the composer, with a button that removes it from the message. + * Any other unknown type keeps the SDK's preview. + */ +export const EventInviteAttachmentPreview = ( + props: UnsupportedAttachmentPreviewProps, +) => { + const { t } = useTranslationContext(); + const { attachment, removeAttachments } = props; + + if (!isEventInviteAttachment(attachment)) + return ; + + return ( +
+ + + removeAttachments([attachment.localMetadata.id])} + /> +
+ ); +}; + +/** The composer's attachment previews, with `event` attachments previewed as invites. */ +export const EventInviteAwareAttachmentPreviewList = () => ( + +); diff --git a/examples/vite/src/EventInvite/EventInviteDialog.tsx b/examples/vite/src/EventInvite/EventInviteDialog.tsx new file mode 100644 index 000000000..5d37e08e6 --- /dev/null +++ b/examples/vite/src/EventInvite/EventInviteDialog.tsx @@ -0,0 +1,208 @@ +import { type FormEvent, useEffect, useId, useMemo, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { + asDynamicKey, + type AttachmentSelectorModalContentProps, + Button, + Prompt, + TextInput, + useAriaLiveAnnouncer, + useMessageComposerContext, + useMessageComposerController, + useTranslationContext, +} from 'stream-chat-react'; + +import { + EVENT_INVITE_ATTACHMENT_TYPE, + type EventInviteData, + generateEventInviteUid, +} from './eventInvite'; + +const DURATIONS_MINUTES = [15, 30, 60, 120]; +const DEFAULT_DURATION_MINUTES = 60; + +const padded = (value: number) => String(value).padStart(2, '0'); + +/** A local date as the value of a date input: `2026-11-14`. */ +const toDateInputValue = (date: Date) => + `${date.getFullYear()}-${padded(date.getMonth() + 1)}-${padded(date.getDate())}`; + +/** + * Today, the earliest date the picker offers, and the next full hour in local time, as the + * values of a date and a time input. + */ +const getDefaultStart = () => { + const now = new Date(); + const start = new Date(now); + start.setHours(start.getHours() + 1, 0, 0, 0); + return { + date: toDateInputValue(start), + minDate: toDateInputValue(now), + time: `${padded(start.getHours())}:${padded(start.getMinutes())}`, + }; +}; + +/** + * The picker of the attachment selector's Event action. It adds an `event` attachment to the + * message being composed, so the invite goes out with the message's text once it is sent. + */ +export const EventInviteDialog = ({ close }: AttachmentSelectorModalContentProps) => { + const { t } = useTranslationContext(); + const messageComposer = useMessageComposerController(); + const { textareaRef } = useMessageComposerContext(); + const announce = useAriaLiveAnnouncer(); + const durationLabelId = useId(); + const defaultStart = useMemo(getDefaultStart, []); + const [title, setTitle] = useState(''); + const [date, setDate] = useState(defaultStart.date); + const [time, setTime] = useState(defaultStart.time); + const [durationMinutes, setDurationMinutes] = useState(DEFAULT_DURATION_MINUTES); + const [location, setLocation] = useState(''); + + // The dialog surface takes focus on open, and Enter moves it to the Title field + // (`data-autofocus`). Said out loud, since nothing on screen says so. Assertive: a polite + // message made around the dialog's own announcement is dropped. + useEffect( + () => + announce( + `${t(asDynamicKey('viteExample.eventInvite.dialog.opened.ariaLabel'), 'Create event dialog opened')}. ${t( + 'a11y.interactionAnnouncements.pressEnterStartTyping.ariaLabel', + 'Press Enter to start typing', + )}.`, + { dedupeMs: 1000, priority: 'assertive' }, + ), + [announce, t], + ); + + // An ISO date and time without an offset is read as local time. + const start = new Date(`${date}T${time}`); + const canSubmit = !!title.trim() && !Number.isNaN(start.getTime()); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + const end = new Date(start.getTime() + durationMinutes * 60 * 1000); + const eventData: EventInviteData = { + ends_at: end.toISOString(), + starts_at: start.toISOString(), + uid: generateEventInviteUid(), + ...(location.trim() && { location: location.trim() }), + }; + // Nothing to upload: the attachment manager gives it a local id, and an attachment with no + // file is sent as it is. + messageComposer.attachmentManager.upsertAttachments([ + { + custom: { event: eventData }, + title: title.trim(), + type: EVENT_INVITE_ATTACHMENT_TYPE, + }, + ]); + // The dialog closes and the textarea takes focus in one synchronous turn, so the message can + // be written straight away. The modal and the attachment selector restore focus to the menu + // button only when it fell to , which it no longer does. + flushSync(() => close()); + textareaRef?.current?.focus(); + }; + + return ( + + +
+ + setTitle(event.target.value)} + placeholder={t( + asDynamicKey('viteExample.eventInvite.title.placeholder'), + 'Team lunch', + )} + required + value={title} + /> +
+ setDate(event.target.value)} + required + type='date' + value={date} + /> + setTime(event.target.value)} + required + type='time' + value={time} + /> +
+
+
+ {t(asDynamicKey('viteExample.eventInvite.duration.label'), 'Duration')} +
+
+ {DURATIONS_MINUTES.map((minutes) => ( + + ))} +
+
+ setLocation(event.target.value)} + placeholder={t( + asDynamicKey('viteExample.eventInvite.location.placeholder'), + 'https://meet.example.com/…', + )} + value={location} + /> +
+ + + + {t('common.cancel.label', 'Cancel')} + + + {t(asDynamicKey('viteExample.eventInvite.dialog.submit.text'), 'Add event')} + + + +
+
+ ); +}; diff --git a/examples/vite/src/EventInvite/eventInvite.ts b/examples/vite/src/EventInvite/eventInvite.ts new file mode 100644 index 000000000..157a0a04b --- /dev/null +++ b/examples/vite/src/EventInvite/eventInvite.ts @@ -0,0 +1,203 @@ +import type { Attachment, CustomAttachmentData } from 'stream-chat'; + +/** The attachment `type` of an event invite. The SDK has no renderer for it of its own. */ +export const EVENT_INVITE_ATTACHMENT_TYPE = 'event'; + +export type EventInviteData = NonNullable; + +export type EventInviteAttachment = Attachment & { + custom: CustomAttachmentData & { event: EventInviteData }; + title: string; + type: typeof EVENT_INVITE_ATTACHMENT_TYPE; +}; + +const isValidTimestamp = (value: unknown): value is string => + typeof value === 'string' && !Number.isNaN(Date.parse(value)); + +const isOptionalString = (value: unknown) => + value === undefined || typeof value === 'string'; + +/** + * An `event` attachment this app can render. Another client may send an `event` attachment of a + * different shape, which falls back to the SDK's unsupported attachment UI rather than reaching + * the card, where a non-string field would break the render. + */ +export const isEventInviteAttachment = ( + attachment: Attachment, +): attachment is EventInviteAttachment => { + const event: unknown = attachment.custom?.event; + if ( + attachment.type !== EVENT_INVITE_ATTACHMENT_TYPE || + typeof attachment.title !== 'string' || + typeof event !== 'object' || + event === null + ) + return false; + const { ends_at, location, starts_at, uid } = event as Record; + return ( + isValidTimestamp(starts_at) && + isValidTimestamp(ends_at) && + // RFC 5545 needs an event to end after it starts. + Date.parse(ends_at) > Date.parse(starts_at) && + isOptionalString(location) && + isOptionalString(uid) + ); +}; + +/** A random id for a new invite, which keeps its calendar entry apart from any other event. */ +export const generateEventInviteUid = () => + // `getRandomValues`, unlike `randomUUID`, also works outside a secure context (plain http on + // a LAN address). + Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + +/** The event's `location` as a URL when it is an http(s) link, which renders as a link. */ +export const getEventInviteLink = ({ location }: EventInviteData) => { + if (!location) return; + try { + const url = new URL(location.trim()); + return url.protocol === 'http:' || url.protocol === 'https:' ? url : undefined; + } catch { + return; + } +}; + +/** + * The SDK's language as a locale `Intl` accepts, or `undefined` for the browser's own. The + * language can be empty or a tag `Intl` rejects, and `Intl.DateTimeFormat` throws on those. + */ +export const toIntlLocale = (language: string | undefined) => { + if (!language) return; + try { + return Intl.getCanonicalLocales(language)[0]; + } catch { + return; + } +}; + +const isSameLocalDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + +/** + * When the event happens, in the viewer's language and time zone: `Fri, 14 Nov · 15:00 – 16:00`, + * or both full dates for an event that ends on another day. + */ +export const formatEventInviteSchedule = ( + { ends_at, starts_at }: EventInviteData, + locale: string | undefined, +) => { + const start = new Date(starts_at); + const end = new Date(ends_at); + const date = new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + weekday: 'short', + }); + const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }); + + if (isSameLocalDay(start, end)) { + return `${date.format(start)} · ${time.format(start)} – ${time.format(end)}`; + } + return `${date.format(start)} ${time.format(start)} – ${date.format(end)} ${time.format(end)}`; +}; + +/** A uid this app generates, or one as plain: letters, digits, dots, dashes and underscores. */ +const ICS_UID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; + +/** iCalendar UTC date-time: `20261114T150000Z`. */ +const toIcsDateTime = (date: Date) => + date + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, ''); + +/** + * Escapes iCalendar TEXT (RFC 5545 §3.3.11). Line breaks of any kind become `\n`, and the other + * control characters, which TEXT does not allow, are dropped. + */ +const escapeIcsText = (text: string) => + text + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r\n|\r|\n/g, '\\n') + // eslint-disable-next-line no-control-regex -- matching control characters is the point + .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ''); + +const utf8 = new TextEncoder(); + +/** Folds a content line into lines of at most 75 octets (RFC 5545 §3.1). */ +const foldIcsLine = (line: string) => { + const lines: string[] = []; + let current = ''; + let currentOctets = 0; + for (const character of line) { + const octets = utf8.encode(character).length; + // Continuation lines start with a space, which counts toward their 75 octets. + const limit = lines.length ? 74 : 75; + if (currentOctets + octets > limit) { + lines.push(current); + current = ''; + currentOctets = 0; + } + current += character; + currentOctets += octets; + } + lines.push(current); + return lines.join('\r\n '); +}; + +/** + * The invite as an iCalendar file that a calendar app imports. The UID is the invite's own, so + * importing the same invite twice updates the entry instead of adding another. An invite without + * a plain one (sent by another client) gets a UID derived from its start and title. + */ +export const buildEventInviteIcs = (attachment: EventInviteAttachment) => { + const { ends_at, location, starts_at, uid } = attachment.custom.event; + const start = new Date(starts_at); + const link = getEventInviteLink(attachment.custom.event); + // The uid comes from whoever sent the invite and goes into the file unescaped, so only a plain + // token is used: a line break in it would add lines of the sender's choosing. + const icsUid = `${uid && ICS_UID_PATTERN.test(uid) ? uid : `${toIcsDateTime(start)}-${encodeURIComponent(attachment.title)}`}@stream-chat-react.example`; + + return [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Stream//stream-chat-react vite example//EN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + `UID:${icsUid}`, + `DTSTAMP:${toIcsDateTime(new Date())}`, + `DTSTART:${toIcsDateTime(start)}`, + `DTEND:${toIcsDateTime(new Date(ends_at))}`, + `SUMMARY:${escapeIcsText(attachment.title)}`, + location && `LOCATION:${escapeIcsText(location)}`, + link && `URL:${link.href}`, + 'END:VEVENT', + 'END:VCALENDAR', + ] + .filter((line): line is string => !!line) + .map(foldIcsLine) + .join('\r\n') + .concat('\r\n'); +}; + +/** Saves the invite as an `.ics` file, which the browser hands to the calendar app. */ +export const downloadEventInviteIcs = (attachment: EventInviteAttachment) => { + const blob = new Blob([buildEventInviteIcs(attachment)], { + type: 'text/calendar;charset=utf-8', + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${attachment.title.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') || 'event'}.ics`; + // Attached for the click, which some browsers ignore on a detached anchor, and the URL is kept + // for a while: some browsers fail a download whose URL is revoked straight away. + document.body.append(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); +}; diff --git a/examples/vite/src/EventInvite/eventInviteAction.tsx b/examples/vite/src/EventInvite/eventInviteAction.tsx new file mode 100644 index 000000000..34160e750 --- /dev/null +++ b/examples/vite/src/EventInvite/eventInviteAction.tsx @@ -0,0 +1,54 @@ +import { + asDynamicKey, + type AttachmentSelectorAction, + type AttachmentSelectorActionProps, + ContextMenuButton, + useContextMenuContext, + useTranslationContext, +} from 'stream-chat-react'; + +import { IconCalendar } from '../icons'; +import { EventInviteDialog } from './EventInviteDialog'; + +/** + * The selector action's `type`. Not one of the SDK's built-in types, so the selector offers the + * action without its availability rules. + */ +export const EVENT_INVITE_ACTION_TYPE = 'createEventInvite'; + +const EventInviteActionButton = ({ + openModalForAction, +}: AttachmentSelectorActionProps) => { + const { t } = useTranslationContext(); + const { closeMenu } = useContextMenuContext(); + return ( + { + openModalForAction(EVENT_INVITE_ACTION_TYPE); + closeMenu(); + }} + > + {t(asDynamicKey('viteExample.eventInvite.action.text'), 'Event')} + + ); +}; + +/** The attachment selector's Event action: its menu item opens `EventInviteDialog`. */ +export const eventInviteAction: AttachmentSelectorAction = { + ActionButton: EventInviteActionButton, + ModalContent: EventInviteDialog, + type: EVENT_INVITE_ACTION_TYPE, +}; + +/** Adds the Event action above Commands, or last when the set has no Commands action. */ +export const withEventInviteAction = (actionSet: AttachmentSelectorAction[]) => { + const commandsIndex = actionSet.findIndex(({ type }) => type === 'selectCommand'); + const insertAt = commandsIndex === -1 ? actionSet.length : commandsIndex; + return [ + ...actionSet.slice(0, insertAt), + eventInviteAction, + ...actionSet.slice(insertAt), + ]; +}; diff --git a/examples/vite/src/EventInvite/index.ts b/examples/vite/src/EventInvite/index.ts new file mode 100644 index 000000000..56a3bd738 --- /dev/null +++ b/examples/vite/src/EventInvite/index.ts @@ -0,0 +1,2 @@ +export * from './EventInviteAttachment'; +export * from './eventInviteAction'; diff --git a/examples/vite/src/icons.tsx b/examples/vite/src/icons.tsx index cc97e568e..2f9aeb3e7 100644 --- a/examples/vite/src/icons.tsx +++ b/examples/vite/src/icons.tsx @@ -95,3 +95,16 @@ export const IconCollapse = createIcon( strokeWidth='1.5' />, ); + +/** A calendar page, for the event invite attachment (EventInvite/). */ +export const IconCalendar = createIcon( + 'IconCalendar', + , +); diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index a63afc1da..694a54649 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -11,6 +11,7 @@ layer(stream-app-overrides); @import url('./InlineEditMessage/InlineEditMessage.scss') layer(stream-app-overrides); @import url('./ComposerPreviews/PollPreview.scss') layer(stream-app-overrides); +@import url('./EventInvite/EventInvite.scss') layer(stream-app-overrides); @import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides); @import url('./AccessibilityNavigation/ReturnToSkipNavigation.scss') layer(stream-app-overrides); diff --git a/examples/vite/src/stream-chat-custom-data.d.ts b/examples/vite/src/stream-chat-custom-data.d.ts index 535449bd7..0128dfb09 100644 --- a/examples/vite/src/stream-chat-custom-data.d.ts +++ b/examples/vite/src/stream-chat-custom-data.d.ts @@ -10,4 +10,21 @@ declare module 'stream-chat' { /** Used by this demo's simulated users and member lists as a fallback display name. */ username?: string; } + + interface CustomAttachmentData { + /** + * The schedule and place of this demo's `event` attachments (see `EventInvite/`). The + * invite's name is the attachment's `title`. + */ + event?: { + /** End, as an ISO 8601 timestamp in UTC. */ + ends_at: string; + /** Where it happens: a place, or an http(s) meeting link that renders as a link. */ + location?: string; + /** Start, as an ISO 8601 timestamp in UTC. */ + starts_at: string; + /** The invite's id, the UID of its calendar entry. */ + uid?: string; + }; + } } diff --git a/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx b/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx new file mode 100644 index 000000000..8485149f3 --- /dev/null +++ b/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx @@ -0,0 +1,148 @@ +import React, { StrictMode } from 'react'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +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; +}; + +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.scheduledMessages.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 upsertToState = vi.spyOn(client.scheduledMessages, 'upsertToState'); + + await renderChat(client, { strict: true }); + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage, + type: 'scheduled_message.created', + }); + }); + + await waitFor(() => expect(upsertToState).toHaveBeenCalledTimes(1)); + }); + + it('keeps the store in sync from events while mounted', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const scheduledMessage = generateScheduledMessage(); + await renderChat(client); + + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage, + type: 'scheduled_message.created', + }); + }); + + await waitFor(() => + expect(client.scheduledMessages.getFromState(scheduledMessage.id)).toEqual( + scheduledMessage, + ), + ); + }); + + it('reports a scheduled message that failed to send', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const scheduledMessage = generateScheduledMessage({ + failure_reason: 'channel_frozen', + status: 'failed', + }); + await renderChat(client); + + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage, + type: 'scheduled_message.failed', + }); + }); + + expect(client.notifications.store.getLatestValue().notifications).toEqual([ + expect.objectContaining({ + metadata: { reason: 'channel_frozen', scheduledMessageId: scheduledMessage.id }, + severity: 'error', + type: 'api:scheduled-message:send:failed', + }), + ]); + }); + + it('stops reporting failures after unmount', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const { unmount } = await renderChat(client); + + unmount(); + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage: generateScheduledMessage({ 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 { unmount } = await renderChat(client); + + unmount(); + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage, + type: 'scheduled_message.created', + }); + }); + await client.scheduledMessages.pipelines.get('scheduled_message.created')?.drain(); + + expect(client.scheduledMessages.hasSubscriptions).toBe(false); + expect(client.scheduledMessages.getFromState(scheduledMessage.id)).toBeUndefined(); + }); +}); diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 207ea6433..1f8b1c855 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -6,6 +6,7 @@ import type { StreamChat, UserMuteResponse, } from 'stream-chat'; +import { useScheduledMessageFailureNotifications } from './useScheduledMessageFailureNotifications'; export type UseChatParams = { client: StreamChat; @@ -43,6 +44,7 @@ export const useChat = ({ client }: UseChatParams) => { client.polls.registerSubscriptions(); client.reminders.registerSubscriptions(); client.reminders.initTimers(); + client.scheduledMessages.registerSubscriptions(); return () => { releaseChannelManager(); @@ -50,6 +52,7 @@ export const useChat = ({ client }: UseChatParams) => { client.polls.unregisterSubscriptions(); client.reminders.unregisterSubscriptions(); client.reminders.clearTimers(); + client.scheduledMessages.unregisterSubscriptions(); }; }, [client]); @@ -65,6 +68,8 @@ export const useChat = ({ client }: UseChatParams) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [clientMutes?.length]); + useScheduledMessageFailureNotifications(client); + return { getAppSettings, mutes, diff --git a/src/components/Chat/hooks/useScheduledMessageFailureNotifications.ts b/src/components/Chat/hooks/useScheduledMessageFailureNotifications.ts new file mode 100644 index 000000000..7e0e0e619 --- /dev/null +++ b/src/components/Chat/hooks/useScheduledMessageFailureNotifications.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; +import type { StreamChat } from 'stream-chat'; + +import { + SCHEDULED_MESSAGES_EMITTER, + ScheduledMessageNotificationType, +} from '../../ScheduledMessages/constants'; + +export const useScheduledMessageFailureNotifications = (client: StreamChat) => { + useEffect(() => { + const subscription = client.on('scheduled_message.failed', (event) => { + const { scheduled_message: scheduledMessage } = event; + + if (!scheduledMessage) return; + + const { failure_reason: reason, id } = scheduledMessage; + + client.notifications.addError({ + message: 'Scheduled message not sent', + options: { + metadata: { reason, scheduledMessageId: id }, + type: ScheduledMessageNotificationType.SendFailed, + }, + origin: { emitter: SCHEDULED_MESSAGES_EMITTER }, + }); + }); + + return subscription.unsubscribe; + }, [client]); +}; diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index c0687d94f..abb09eadf 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', ({ +const messageComposerStateSelector = ({ editedMessage, + editedScheduledMessage, +}: MessageComposerState) => ({ + isEditing: !!editedMessage || !!editedScheduledMessage, }); const textComposerStateSelector = ({ command, text }: TextComposerState) => ({ @@ -41,11 +46,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 +82,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 +104,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 c880b5b30..958f62d61 100644 --- a/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx +++ b/src/components/MessageComposer/MessageComposerPreviews.defaults.tsx @@ -20,11 +20,21 @@ import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList'; import type { MessageComposerPreviewSetItem } from './MessageComposerPreviews'; import { restorePreEditSnapshot } from './preEditSnapshot'; import { QuotedMessagePreview as DefaultQuotedMessagePreview } from './QuotedMessagePreview'; +import { EditedScheduledMessagePreview as DefaultEditedScheduledMessagePreview } from '../ScheduledMessages/EditedScheduledMessagePreview'; +import { FailedScheduledMessagePreview as DefaultFailedScheduledMessagePreview } from '../ScheduledMessages/FailedScheduledMessagePreview'; const editedMessageSelector = ({ editedMessage }: MessageComposerState) => ({ editedMessage, }); +const scheduledMessageSelector = ({ + editedScheduledMessage, + failedScheduledMessage, +}: MessageComposerState) => ({ + editedScheduledMessage, + failedScheduledMessage, +}); + const locationSelector = ({ location }: LocationComposerState) => ({ location }); const DefaultMessageComposerPreviewComponents = { @@ -44,6 +54,42 @@ const DefaultMessageComposerPreviewComponents = { /> ); }, + EditedScheduledMessage() { + const { EditedScheduledMessagePreview = DefaultEditedScheduledMessagePreview } = + useComponentContext(); + const messageComposer = useMessageComposerController(); + const { editedScheduledMessage } = useStateStore( + messageComposer.state, + scheduledMessageSelector, + ); + + if (!editedScheduledMessage) return null; + + return ( + + ); + }, + FailedScheduledMessage() { + const { FailedScheduledMessagePreview = DefaultFailedScheduledMessagePreview } = + useComponentContext(); + const messageComposer = useMessageComposerController(); + const { failedScheduledMessage } = useStateStore( + messageComposer.state, + scheduledMessageSelector, + ); + + if (!failedScheduledMessage) return null; + + return ( + + ); + }, QuotedMessage() { const { QuotedMessagePreview = DefaultQuotedMessagePreview } = useComponentContext(); return ; @@ -92,10 +138,29 @@ export const defaultMessageComposerPreviewSet: MessageComposerPreviewSetItem[] = isVisible: ({ editedMessage }) => !!editedMessage, type: 'editedMessage', }, + { + Component: DefaultMessageComposerPreviewComponents.EditedScheduledMessage, + isVisible: ({ editedScheduledMessage }) => !!editedScheduledMessage, + type: 'editedScheduledMessage', + }, + { + Component: DefaultMessageComposerPreviewComponents.FailedScheduledMessage, + isVisible: ({ failedScheduledMessage }) => !!failedScheduledMessage, + type: 'failedScheduledMessage', + }, { Component: DefaultMessageComposerPreviewComponents.QuotedMessage, // The edited message preview takes the quoted message's place while editing. - isVisible: ({ editedMessage, quotedMessage }) => !editedMessage && !!quotedMessage, + isVisible: ({ + editedMessage, + editedScheduledMessage, + failedScheduledMessage, + quotedMessage, + }) => + !editedMessage && + !editedScheduledMessage && + !failedScheduledMessage && + !!quotedMessage, type: 'quotedMessage', }, { diff --git a/src/components/MessageComposer/MessageComposerPreviews.tsx b/src/components/MessageComposer/MessageComposerPreviews.tsx index 218fd3e71..cdc73f09b 100644 --- a/src/components/MessageComposer/MessageComposerPreviews.tsx +++ b/src/components/MessageComposer/MessageComposerPreviews.tsx @@ -11,6 +11,8 @@ import { defaultMessageComposerPreviewSet } from './MessageComposerPreviews.defa export type DefaultMessageComposerPreviewType = | 'attachments' | 'editedMessage' + | 'editedScheduledMessage' + | 'failedScheduledMessage' | 'linkPreviews' | 'location' | 'quotedMessage' diff --git a/src/components/MessageComposer/MessageComposerUI.tsx b/src/components/MessageComposer/MessageComposerUI.tsx index dfad78ad7..30fdfc758 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, + failedScheduledMessage, pollId, quotedMessage, }); @@ -47,6 +51,10 @@ export type MessageComposerPreviewVisibilityFilterParams = { /** The composer's custom data (`messageComposer.customDataManager`). */ customData: CustomDataManagerState['custom']; editedMessage: MessageComposerState['editedMessage']; + /** The scheduled message being edited. */ + editedScheduledMessage: MessageComposerState['editedScheduledMessage']; + /** The failed scheduled message loaded to be scheduled or sent again. */ + failedScheduledMessage: MessageComposerState['failedScheduledMessage']; /** Link previews that are loading or loaded. */ linkPreviews: LinkPreview[]; location: LocationComposerState['location']; @@ -65,10 +73,13 @@ export type MessageComposerPreviewVisibilityFilterParams = { export const useMessageComposerPreviewState = (): MessageComposerPreviewVisibilityFilterParams => { const messageComposer = useMessageComposerController(); - const { editedMessage, pollId, quotedMessage } = useStateStore( - messageComposer.state, - messageComposerStateSelector, - ); + const { + editedMessage, + editedScheduledMessage, + failedScheduledMessage, + pollId, + quotedMessage, + } = useStateStore(messageComposer.state, messageComposerStateSelector); const { attachments } = useStateStore( messageComposer.attachmentManager.state, attachmentManagerStateSelector, @@ -101,6 +112,8 @@ export const useMessageComposerPreviewState = attachments, customData, editedMessage, + editedScheduledMessage, + failedScheduledMessage, linkPreviews, location, pollId, @@ -110,6 +123,8 @@ export const useMessageComposerPreviewState = attachments, customData, editedMessage, + editedScheduledMessage, + failedScheduledMessage, linkPreviews, location, pollId, diff --git a/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts b/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts index b2c59665c..bf8ebf9fc 100644 --- a/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts +++ b/src/components/MessageComposer/hooks/useMessageComposerSubmitFn.ts @@ -1,20 +1,35 @@ import { useCallback } from 'react'; +import { useChatContext } from '../../../context'; +import { reloadOnScheduledMessageConflict } from '../../ScheduledMessages/utils/scheduledMessageErrors'; 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], - ); + const updateScheduledMessage = useCallback(async () => { + const result = await messageComposer.updateScheduledMessage(); + + if (result.status === 'failed') { + reloadOnScheduledMessageConflict(client.scheduledMessages, result.error); + } + + return result; + }, [client, messageComposer]); + + return useCallback(() => { + if (messageComposer.editedScheduledMessage) return updateScheduledMessage(); + + return messageComposer.editedMessage + ? messageComposer.update() + : messageComposer.send(); + }, [messageComposer, updateScheduledMessage]); }; diff --git a/src/components/ScheduledMessages/DeleteScheduledMessageAlert.tsx b/src/components/ScheduledMessages/DeleteScheduledMessageAlert.tsx new file mode 100644 index 000000000..3c3e6b61e --- /dev/null +++ b/src/components/ScheduledMessages/DeleteScheduledMessageAlert.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import { useTranslationContext } from '../../context'; +import { Button } from '../Button'; +import { Alert } from '../Dialog'; +import type { DeleteScheduledMessageAlertProps } from './types'; + +export const DeleteScheduledMessageAlert = ({ + channelName, + onCancel, + onDelete, +}: DeleteScheduledMessageAlertProps) => { + const { t } = useTranslationContext(); + + const description = channelName + ? t( + 'scheduledMessages.thisMessageToNameWontBeSentThis', + "This message to {{ name }} won't be sent. This can't be undone.", + { + name: channelName, + }, + ) + : t( + 'scheduledMessages.thisMessageWontBeSentThisCantBe', + "This message won't be sent. This can't be undone.", + ); + + return ( + + + + + + + + ); +}; diff --git a/src/components/ScheduledMessages/EditedScheduledMessagePreview.tsx b/src/components/ScheduledMessages/EditedScheduledMessagePreview.tsx new file mode 100644 index 000000000..56f902765 --- /dev/null +++ b/src/components/ScheduledMessages/EditedScheduledMessagePreview.tsx @@ -0,0 +1,26 @@ +import React, { useMemo } from 'react'; + +import { useTranslationContext } from '../../context'; +import { QuotedMessagePreviewUI } from '../MessageComposer/QuotedMessagePreview'; +import type { EditedScheduledMessagePreviewProps } from './types'; +import { toStoredScheduledMessagePreview } from './utils'; + +export const EditedScheduledMessagePreview = ({ + onCancel, + scheduledMessage, +}: EditedScheduledMessagePreviewProps) => { + const { t } = useTranslationContext(); + + const previewMessage = useMemo( + () => toStoredScheduledMessagePreview(scheduledMessage), + [scheduledMessage], + ); + + return ( + + ); +}; diff --git a/src/components/ScheduledMessages/FailedScheduledMessagePreview.tsx b/src/components/ScheduledMessages/FailedScheduledMessagePreview.tsx new file mode 100644 index 000000000..28ba1548d --- /dev/null +++ b/src/components/ScheduledMessages/FailedScheduledMessagePreview.tsx @@ -0,0 +1,26 @@ +import React, { useMemo } from 'react'; + +import { useTranslationContext } from '../../context'; +import { QuotedMessagePreviewUI } from '../MessageComposer/QuotedMessagePreview'; +import type { FailedScheduledMessagePreviewProps } from './types'; +import { toStoredScheduledMessagePreview } from './utils'; + +export const FailedScheduledMessagePreview = ({ + onCancel, + scheduledMessage, +}: FailedScheduledMessagePreviewProps) => { + const { t } = useTranslationContext(); + + const previewMessage = useMemo( + () => toStoredScheduledMessagePreview(scheduledMessage), + [scheduledMessage], + ); + + return ( + + ); +}; diff --git a/src/components/ScheduledMessages/RescheduleMessageDialog.tsx b/src/components/ScheduledMessages/RescheduleMessageDialog.tsx new file mode 100644 index 000000000..b84bda6f6 --- /dev/null +++ b/src/components/ScheduledMessages/RescheduleMessageDialog.tsx @@ -0,0 +1,83 @@ +import { nsToDate } from 'stream-chat'; +import React, { useCallback, useMemo } from 'react'; + +import { useChatContext, useTranslationContext } from '../../context'; +import { useNotificationApi } from '../Notifications'; +import { + SCHEDULED_MESSAGE_ACTIONS_EMITTER, + ScheduledMessageNotificationType, +} from './constants'; +import { ScheduleTimeDialog } from './ScheduleTimeDialog'; +import type { RescheduleMessageDialogProps } from './types'; +import { + getScheduledMessageChannel, + reloadOnScheduledMessageConflict, + toStoredScheduledMessagePreview, +} from './utils'; + +export const RescheduleMessageDialog = ({ + close, + scheduledMessage, +}: RescheduleMessageDialogProps) => { + const { t } = useTranslationContext(); + const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); + + const { id, scheduled_at } = scheduledMessage; + const { scheduledMessages: manager } = client; + + const channel = useMemo( + () => getScheduledMessageChannel(client, scheduledMessage), + [client, scheduledMessage], + ); + const previewMessage = useMemo( + () => toStoredScheduledMessagePreview(scheduledMessage), + [scheduledMessage], + ); + const initialScheduledAt = nsToDate(scheduled_at); + const errorMessage = t( + 'scheduledMessages.couldntRescheduleTheMessageTryAgain', + "Couldn't reschedule the message. Try again", + ); + const scheduledTime = t('timestamp.ScheduledMessageSendTime', { + timestamp: initialScheduledAt, + }); + + const reschedule = useCallback( + async (scheduledAt: Date) => { + try { + await manager.updateScheduledMessage(id, { + scheduled_at: scheduledAt, + }); + } catch (error) { + reloadOnScheduledMessageConflict(manager, error); + addNotification({ + emitter: SCHEDULED_MESSAGE_ACTIONS_EMITTER, + error: error as Error, + message: errorMessage, + severity: 'error', + type: ScheduledMessageNotificationType.RescheduleFailed, + }); + + throw error; + } + }, + [addNotification, errorMessage, id, manager], + ); + + return ( + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessageButton.tsx b/src/components/ScheduledMessages/ScheduleMessageButton.tsx new file mode 100644 index 000000000..9d6e60fa5 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageButton.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useRef } from 'react'; + +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; +import { Button } from '../Button'; +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'; +import { getChannelContainer } from './utils'; + +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(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 ( + <> + + + + + + + {isDialogOpen && } + + + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessageDialog.tsx b/src/components/ScheduledMessages/ScheduleMessageDialog.tsx new file mode 100644 index 000000000..edd377827 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageDialog.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { useTranslationContext } from '../../context'; +import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController'; +import { useScheduledMessagePreview, useScheduleMessage } from './hooks'; +import { ScheduleTimeDialog } from './ScheduleTimeDialog'; +import type { ScheduleMessageDialogProps } from './types'; + +export const ScheduleMessageDialog = ({ close }: ScheduleMessageDialogProps) => { + const { t } = useTranslationContext(); + + const { channel } = useMessageComposerController(); + const previewMessage = useScheduledMessagePreview(); + const scheduleMessage = useScheduleMessage(); + + return ( + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx b/src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx new file mode 100644 index 000000000..ae75be05f --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx @@ -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 ( +
+ + {children} +
+ ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessageMenu.tsx b/src/components/ScheduledMessages/ScheduleMessageMenu.tsx new file mode 100644 index 000000000..76bee864d --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageMenu.tsx @@ -0,0 +1,51 @@ +import React, { useCallback } from 'react'; + +import { useComponentContextIcons, useTranslationContext } from '../../context'; +import { ContextMenuButton, useContextMenuContext } from '../Dialog'; +import { ScheduleMessagePresetButton } from './ScheduleMessagePresetButton'; +import type { ScheduleMessageMenuProps } from './types'; + +export const ScheduleMessageMenu = ({ + onCustomTimeSelect, + onPresetSelect, + presets, +}: ScheduleMessageMenuProps) => { + const { t } = useTranslationContext(); + const { IconClock } = useComponentContextIcons(); + const { closeMenu } = useContextMenuContext(); + + const selectPreset = useCallback( + (scheduledAt: Date) => { + closeMenu(); + onPresetSelect(scheduledAt); + }, + [closeMenu, onPresetSelect], + ); + + const selectCustomTime = useCallback(() => { + closeMenu(); + onCustomTimeSelect(); + }, [closeMenu, onCustomTimeSelect]); + + return ( + <> + {presets.map((scheduledAt) => ( + + ))} +
+ + {t('scheduledMessages.customTime', 'Custom time')} + + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx b/src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx new file mode 100644 index 000000000..726668faa --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { useTranslationContext } from '../../context'; +import { ContextMenuHeader } from '../Dialog'; + +export const ScheduleMessageMenuHeader = () => { + const { t } = useTranslationContext(); + + return ( + + {t('scheduledMessages.scheduleMessage', 'Schedule message')} + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx b/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx new file mode 100644 index 000000000..92fbe3d11 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx @@ -0,0 +1,25 @@ +import React, { useCallback } from 'react'; + +import { useComponentContextIcons, useTranslationContext } from '../../context'; +import { ContextMenuButton } from '../Dialog'; +import type { ScheduleMessagePresetButtonProps } from './types'; + +export const ScheduleMessagePresetButton = ({ + onSelect, + scheduledAt, +}: ScheduleMessagePresetButtonProps) => { + const { t } = useTranslationContext(); + const { IconClock } = useComponentContextIcons(); + + const selectPreset = useCallback(() => onSelect(scheduledAt), [onSelect, scheduledAt]); + + return ( + + {t('timestamp.SchedulePreset', { timestamp: scheduledAt })} + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduleTimeDialog.tsx b/src/components/ScheduledMessages/ScheduleTimeDialog.tsx new file mode 100644 index 000000000..4b608c884 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleTimeDialog.tsx @@ -0,0 +1,174 @@ +import React, { useCallback } from 'react'; + +import { useComponentContextIcons, useTranslationContext } from '../../context'; +import type { StreamTFunction } from '../../i18n/types'; +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 { ScheduleMessageNoticeSeverity, ScheduleTimeError } from './constants'; +import { useScheduleTimeForm, useScheduleTimeSubmit } from './hooks'; +import { ScheduleMessageDialogNotice } from './ScheduleMessageDialogNotice'; +import type { ScheduleTimeDialogProps } from './types'; +import { + formatTimeZoneOffset, + fromDateInputValue, + openNativePicker, + toDateInputValue, +} from './utils'; + +const translateScheduleTimeError = ( + t: StreamTFunction, + timeError?: ScheduleTimeError, +) => { + if (timeError === ScheduleTimeError.TooSoon) + return t('scheduledMessages.chooseALaterTime', 'Choose a later time'); + + if (timeError === ScheduleTimeError.TooFar) + return t('scheduledMessages.chooseAnEarlierTime', 'Choose an earlier time'); + + return undefined; +}; + +export const ScheduleTimeDialog = ({ + channel, + close, + errorMessage, + initialScheduledAt, + previewLabel, + previewMessage, + submit: submitScheduledAt, + submitLabel, + title, +}: ScheduleTimeDialogProps) => { + const { t } = useTranslationContext(); + const { IconCalendar, IconCaretDown, IconClock } = useComponentContextIcons(); + + const channelDisplayName = useChannelDisplayName(channel); + const isOnline = useWSConnectionState()?.isHealthy ?? false; + const { + bounds, + dateValue, + onDateChange, + onTimeChange, + scheduledAt, + timeError, + timeValue, + } = useScheduleTimeForm({ channel, initialScheduledAt }); + const { hasFailed, isScheduling, submit } = useScheduleTimeSubmit( + submitScheduledAt, + close, + ); + + const canSchedule = !!scheduledAt && !timeError && isOnline && !isScheduling; + const timeErrorMessage = translateScheduleTimeError(t, timeError); + const timeZone = formatTimeZoneOffset(scheduledAt ?? bounds.earliest); + const latestDateValue = bounds.latest ? toDateInputValue(bounds.latest) : undefined; + const selectedDate = fromDateInputValue(dateValue); + const dateLabel = selectedDate + ? t('timestamp.ScheduleDate', { timestamp: selectedDate }) + : undefined; + const messageToLabel = channelDisplayName + ? t('scheduledMessages.messageToName', 'Message to {{ name }}', { + name: channelDisplayName, + }) + : title; + const authorLabel = previewLabel ?? messageToLabel; + + const scheduleAtPickedTime = useCallback(() => { + if (scheduledAt) submit(scheduledAt); + }, [scheduledAt, submit]); + + return ( + + + + + {!isOnline && ( + + {t( + 'scheduledMessages.youreOfflineReconnectToScheduleThisMessage', + "You're offline. Reconnect to schedule this message.", + )} + + )} + {hasFailed && ( + + {errorMessage} + + )} +
+ + + + {dateLabel} + + + } + max={latestDateValue} + min={toDateInputValue(bounds.earliest)} + onChange={onDateChange} + onClick={openNativePicker} + required + trailing={} + type='date' + value={dateValue} + /> + } + onChange={onTimeChange} + onClick={openNativePicker} + required + trailing={} + type='time' + value={timeValue} + /> +
+ + {t( + 'scheduledMessages.sentInYourTimeZoneTimezone', + 'Sent in your time zone ({{ timeZone }})', + { timeZone }, + )} + +
+ + + + {t('scheduledMessages.cancel', 'Cancel')} + + + {submitLabel ?? t('scheduledMessages.schedule', 'Schedule')} + + + +
+ ); +}; diff --git a/src/components/ScheduledMessages/ScheduledMessageActions.tsx b/src/components/ScheduledMessages/ScheduledMessageActions.tsx new file mode 100644 index 000000000..72f6ae941 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageActions.tsx @@ -0,0 +1,175 @@ +import clsx from 'clsx'; +import React, { useCallback, useMemo, useRef } from 'react'; + +import { + useChatContext, + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; +import { useChannelDisplayName } from '../ChannelListItem/hooks/useChannelDisplayName'; +import { Button } from '../Button'; +import { ContextMenu, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog'; +import { GlobalModal } from '../Modal'; +import { SCHEDULED_MESSAGE_ACTIONS_MENU_ID, ScheduledMessageAction } from './constants'; +import { DeleteScheduledMessageAlert as DefaultDeleteScheduledMessageAlert } from './DeleteScheduledMessageAlert'; +import { useScheduledMessageActionHandlers, useScheduleMessageDialog } from './hooks'; +import { RescheduleMessageDialog as DefaultRescheduleMessageDialog } from './RescheduleMessageDialog'; +import { ScheduledMessageActionsMenu } from './ScheduledMessageActionsMenu'; +import type { ScheduledMessageActionsProps } from './types'; +import { getScheduledMessageActions, hasScheduledMessageEditButton } from './utils'; + +export const ScheduledMessageActions = ({ + onEdit, + scheduledMessage, +}: ScheduledMessageActionsProps) => { + const { t } = useTranslationContext(); + const { IconEdit, IconMore } = useComponentContextIcons(); + const { + ContextMenu: ContextMenuComponent = ContextMenu, + DeleteScheduledMessageAlert = DefaultDeleteScheduledMessageAlert, + Modal = GlobalModal, + RescheduleMessageDialog = DefaultRescheduleMessageDialog, + } = useComponentContext(); + + const { client } = useChatContext(); + const channelName = useChannelDisplayName( + client.channelManager.get(scheduledMessage.cid), + ); + + const { copyText, deleteScheduledMessage, isPending, sendNow } = + useScheduledMessageActionHandlers(scheduledMessage); + const menuDialogId = `${SCHEDULED_MESSAGE_ACTIONS_MENU_ID}-${scheduledMessage.id}`; + const { dialog: menuDialog, dialogManager } = useDialogOnNearestManager({ + id: menuDialogId, + }); + const isMenuOpen = useDialogIsOpen(menuDialogId, dialogManager?.id); + + const menuButtonRef = useRef(null); + + const rescheduleDialog = useScheduleMessageDialog(menuButtonRef); + const deleteAlert = useScheduleMessageDialog(menuButtonRef); + + const actions = useMemo( + () => getScheduledMessageActions(scheduledMessage, { canEdit: !!onEdit }), + [onEdit, scheduledMessage], + ); + const hasEditButton = hasScheduledMessageEditButton(scheduledMessage, actions); + const menuActions = useMemo( + () => + hasEditButton + ? actions.filter((action) => action !== ScheduledMessageAction.Edit) + : actions, + [actions, hasEditButton], + ); + + const toggleMenu = useCallback(() => menuDialog.toggle(), [menuDialog]); + + const editScheduledMessage = useCallback( + () => onEdit?.(scheduledMessage), + [onEdit, scheduledMessage], + ); + + const sendScheduledMessageNow = useCallback(() => { + sendNow(); + }, [sendNow]); + + const confirmDelete = useCallback(() => { + deleteAlert.closeDialog(); + deleteScheduledMessage(); + }, [deleteAlert, deleteScheduledMessage]); + + return ( +
+ + {hasEditButton && ( + + )} + + + + + {rescheduleDialog.isDialogOpen && ( + + )} + + + {deleteAlert.isDialogOpen && ( + + )} + +
+ ); +}; diff --git a/src/components/ScheduledMessages/ScheduledMessageActionsMenu.tsx b/src/components/ScheduledMessages/ScheduledMessageActionsMenu.tsx new file mode 100644 index 000000000..bc3596bef --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageActionsMenu.tsx @@ -0,0 +1,98 @@ +import React, { Fragment, useMemo } from 'react'; + +import { useComponentContextIcons, useTranslationContext } from '../../context'; +import { ContextMenuButton, useContextMenuContext } from '../Dialog'; +import { ScheduledMessageAction } from './constants'; +import type { ScheduledMessageActionsMenuProps } from './types'; + +export const ScheduledMessageActionsMenu = ({ + actions, + onCopy, + onDelete, + onEdit, + onReschedule, + onSendNow, +}: ScheduledMessageActionsMenuProps) => { + const { t } = useTranslationContext(); + const { IconClock, IconCopy, IconDelete, IconEdit, IconSend } = + useComponentContextIcons(); + const { closeMenu } = useContextMenuContext(); + + const menuItems = useMemo( + () => ({ + [ScheduledMessageAction.Copy]: { + Icon: IconCopy, + label: t('scheduledMessages.copyText', 'Copy text'), + onSelect: onCopy, + }, + [ScheduledMessageAction.Delete]: { + Icon: IconDelete, + label: t('scheduledMessages.delete', 'Delete'), + onSelect: onDelete, + }, + [ScheduledMessageAction.Edit]: { + Icon: IconEdit, + label: t('scheduledMessages.editReschedule', 'Edit & reschedule'), + onSelect: onEdit, + }, + [ScheduledMessageAction.Reschedule]: { + Icon: IconClock, + label: t('scheduledMessages.reschedule', 'Reschedule'), + onSelect: onReschedule, + }, + [ScheduledMessageAction.SendNow]: { + Icon: IconSend, + label: t('scheduledMessages.sendNow', 'Send now'), + onSelect: onSendNow, + }, + }), + [ + IconClock, + IconCopy, + IconDelete, + IconEdit, + IconSend, + onCopy, + onDelete, + onEdit, + onReschedule, + onSendNow, + t, + ], + ); + + return ( + <> + {actions.map((action, index) => { + const { Icon, label, onSelect } = menuItems[action]; + const isDelete = action === ScheduledMessageAction.Delete; + const hasSeparator = isDelete && index > 1; + + const selectAction = () => { + closeMenu(); + onSelect(); + }; + + return ( + + {hasSeparator && ( +
+ )} + + {label} + + + ); + })} + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduledMessageGroupHeader.tsx b/src/components/ScheduledMessages/ScheduledMessageGroupHeader.tsx new file mode 100644 index 000000000..892c86fb8 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageGroupHeader.tsx @@ -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('scheduledMessages.failed', 'Failed') + : t('timestamp.ScheduledMessageGroup', { timestamp: day }); + + return

{title}

; +}; diff --git a/src/components/ScheduledMessages/ScheduledMessageItem.tsx b/src/components/ScheduledMessages/ScheduledMessageItem.tsx new file mode 100644 index 000000000..664fe207b --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageItem.tsx @@ -0,0 +1,83 @@ +import clsx from 'clsx'; +import { nsToDate } from 'stream-chat'; +import React, { useMemo } from 'react'; + +import { + useChatContext, + useComponentContext, + useTranslationContext, +} from '../../context'; +import { ChannelAvatar } from '../Avatar'; +import { useChannelPreviewInfo } from '../ChannelListItem/hooks/useChannelPreviewInfo'; +import { SummarizedMessagePreview } from '../SummarizedMessagePreview'; +import { ScheduledMessageActions as DefaultScheduledMessageActions } from './ScheduledMessageActions'; +import { ScheduledMessageItemStatus } from './ScheduledMessageItemStatus'; +import type { ScheduledMessageItemProps } from './types'; +import { isFailedScheduledMessage, toStoredScheduledMessagePreview } from './utils'; + +export const ScheduledMessageItem = ({ + onEdit, + scheduledMessage, +}: ScheduledMessageItemProps) => { + const { t } = useTranslationContext(); + const { client } = useChatContext(); + const { ScheduledMessageActions: ScheduledMessageActionsOverride } = + useComponentContext(); + + const { cid, scheduled_at } = scheduledMessage; + const channel = client.channelManager.get(cid); + const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ + channel, + }); + const ScheduledMessageActions = + ScheduledMessageActionsOverride === undefined + ? DefaultScheduledMessageActions + : ScheduledMessageActionsOverride; + + const previewMessage = useMemo( + () => toStoredScheduledMessagePreview(scheduledMessage), + [scheduledMessage], + ); + + const isFailed = isFailedScheduledMessage(scheduledMessage); + const sendTime = t('timestamp.ScheduledMessageSendTime', { + timestamp: nsToDate(scheduled_at), + }); + + return ( +
+ + {ScheduledMessageActions && ( + + )} +
+
+
+ ); +}; diff --git a/src/components/ScheduledMessages/ScheduledMessageItemStatus.tsx b/src/components/ScheduledMessages/ScheduledMessageItemStatus.tsx new file mode 100644 index 000000000..a6749045a --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageItemStatus.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { SCHEDULED_MESSAGE_STATUS } 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 === SCHEDULED_MESSAGE_STATUS.Failed) { + return ( + + + {translateScheduledMessageFailureReason(t, failure_reason)} + + ); + } + + if (status === SCHEDULED_MESSAGE_STATUS.Sending) { + return ( + + + {t('scheduledMessages.sending', 'Sending...')} + + ); + } + + return null; +}; diff --git a/src/components/ScheduledMessages/ScheduledMessageList.tsx b/src/components/ScheduledMessages/ScheduledMessageList.tsx new file mode 100644 index 000000000..42cb6ec2b --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageList.tsx @@ -0,0 +1,82 @@ +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, + onEdit, + parentId, + searchQuery, +}: ScheduledMessageListProps) => { + const { t } = useTranslationContext(); + const { ScheduledMessageItem = DefaultScheduledMessageItem } = useComponentContext(); + + const { groups, hasScheduledMessages } = useScheduledMessageGroups({ + cid, + parentId, + searchQuery, + }); + const { canLoadMore, isLoading, loadMore } = useScheduledMessagesPagination({ cid }); + const { hasLoadFailed, isFirstLoad, reload } = useScheduledMessagesLoadState({ cid }); + + if (!groups.length) { + if (hasScheduledMessages) return ; + + if (isFirstLoad) return ; + + if (hasLoadFailed) return ; + + return ; + } + + return ( +
+ {groups.map((group) => ( +
+ +
    + {group.scheduledMessages.map((scheduledMessage) => ( +
  • + +
  • + ))} +
+
+ ))} + {canLoadMore && ( + + )} +
+ ); +}; diff --git a/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx b/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx new file mode 100644 index 000000000..88e0d6849 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageListDialog.tsx @@ -0,0 +1,66 @@ +import React, { useCallback, useState } from 'react'; +import type { ChangeEvent } from 'react'; + +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; +import { Prompt } from '../Dialog'; +import { TextInput } from '../Form'; +import { useScheduledMessageCount } from './hooks'; +import { ScheduledMessageList as DefaultScheduledMessageList } from './ScheduledMessageList'; +import type { ScheduledMessageListDialogProps } from './types'; + +export const ScheduledMessageListDialog = ({ + cid, + close, + onEdit, + parentId, +}: ScheduledMessageListDialogProps) => { + const { t } = useTranslationContext(); + const { IconSearch } = useComponentContextIcons(); + const { ScheduledMessageList = DefaultScheduledMessageList } = useComponentContext(); + + const scheduledMessageCount = useScheduledMessageCount({ cid, parentId }); + + const [searchQuery, setSearchQuery] = useState(''); + + const updateSearchQuery = useCallback( + ({ target }: ChangeEvent) => setSearchQuery(target.value), + [], + ); + + const title = parentId + ? t('scheduledMessages.scheduledReplies', 'Scheduled replies') + : t('scheduledMessages.scheduledMessages', 'Scheduled messages'); + + return ( + + + + {scheduledMessageCount > 0 && ( + } + onChange={updateSearchQuery} + placeholder={t('scheduledMessages.search', 'Search')} + type='search' + value={searchQuery} + /> + )} + + + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduledMessageListLoading.tsx b/src/components/ScheduledMessages/ScheduledMessageListLoading.tsx new file mode 100644 index 000000000..1f709dc60 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduledMessageListLoading.tsx @@ -0,0 +1,43 @@ +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 ( +
+ {LOADING_ROW_KEYS.map((key) => ( +