From 4b6a0f17a94e8cb3003a5a3e32783c8d2a942b05 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Tue, 3 Nov 2026 16:55:45 +0100 Subject: [PATCH 01/12] feat(Icons): add IconCalendar, IconChevronUp and IconCaretDown The schedule menu, the time dialog and the banner use them. Icon slots derive from icons.tsx. --- src/components/Icons/icons.tsx | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index d88a22a2f..9bdfeb68b 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', Date: Tue, 3 Nov 2026 16:55:51 +0100 Subject: [PATCH 02/12] test(mock-builders): add scheduled message generators, events and API mocks The mock client answers queryScheduledMessages with an empty page so existing tests do not hit the network once Chat loads scheduled messages. --- src/mock-builders/api/index.ts | 1 + src/mock-builders/api/scheduledMessages.ts | 60 +++++++++++++++++++ src/mock-builders/api/utils.ts | 2 +- src/mock-builders/event/index.ts | 1 + src/mock-builders/event/scheduledMessage.ts | 29 +++++++++ src/mock-builders/generator/index.ts | 1 + .../generator/scheduledMessage.ts | 28 +++++++++ src/mock-builders/index.ts | 9 +++ 8 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/mock-builders/api/scheduledMessages.ts create mode 100644 src/mock-builders/event/scheduledMessage.ts create mode 100644 src/mock-builders/generator/scheduledMessage.ts diff --git a/src/mock-builders/api/index.ts b/src/mock-builders/api/index.ts index d98a4e19c..8f35be0d0 100644 --- a/src/mock-builders/api/index.ts +++ b/src/mock-builders/api/index.ts @@ -111,3 +111,4 @@ export * from './markRead'; export * from './threadReplies'; export * from './sendMessage'; export * from './error'; +export * from './scheduledMessages'; diff --git a/src/mock-builders/api/scheduledMessages.ts b/src/mock-builders/api/scheduledMessages.ts new file mode 100644 index 000000000..2fcd33dd7 --- /dev/null +++ b/src/mock-builders/api/scheduledMessages.ts @@ -0,0 +1,60 @@ +import type { + MessageResponse, + ScheduledMessageErrorReason, + ScheduledMessageResponse, +} from 'stream-chat'; + +import { mockedApiResponse } from './utils'; + +type ScheduledMessageRequestMethod = 'delete' | 'patch' | 'post'; + +const SCHEDULED_MESSAGE_ERROR_STATUS: Record = { + 121: 409, + 122: 409, +}; + +export const queryScheduledMessagesApi = ( + scheduledMessages: ScheduledMessageResponse[] = [], + next?: string, +) => mockedApiResponse({ next, scheduled_messages: scheduledMessages }, 'post'); + +export const scheduledMessageApi = ( + scheduledMessage: ScheduledMessageResponse, + method: ScheduledMessageRequestMethod = 'patch', +) => mockedApiResponse({ scheduled_message: scheduledMessage }, method); + +export const sendScheduledMessageApi = ( + scheduledMessage: ScheduledMessageResponse, + message: Partial = {}, +) => + mockedApiResponse( + { + message: { + id: scheduledMessage.id, + text: scheduledMessage.message.text, + ...message, + }, + scheduled_message: scheduledMessage, + }, + 'post', + ); + +export const erroredScheduledMessageApi = ( + code: number, + reason: ScheduledMessageErrorReason, + method: ScheduledMessageRequestMethod = 'post', +) => { + const status = SCHEDULED_MESSAGE_ERROR_STATUS[code] ?? 400; + + const error = { + code, + details: [], + duration: '0.01', + exception_fields: { reason }, + message: `scheduled message request failed: ${reason}`, + more_info: '', + status_code: status, + }; + + return mockedApiResponse(error, method, status); +}; diff --git a/src/mock-builders/api/utils.ts b/src/mock-builders/api/utils.ts index 044c9abf1..4789aea2f 100644 --- a/src/mock-builders/api/utils.ts +++ b/src/mock-builders/api/utils.ts @@ -1,4 +1,4 @@ -type HttpMethod = 'get' | 'post' | 'put' | 'delete'; +type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; export interface MockedApiResponse { response: { diff --git a/src/mock-builders/event/index.ts b/src/mock-builders/event/index.ts index 934339b1e..536434912 100644 --- a/src/mock-builders/event/index.ts +++ b/src/mock-builders/event/index.ts @@ -1,3 +1,4 @@ +export * from './scheduledMessage'; export { default as dispatchChannelDeletedEvent } from './channelDeleted'; export { default as dispatchChannelHiddenEvent } from './channelHidden'; export { default as dispatchChannelTruncatedEvent } from './channelTruncated'; diff --git a/src/mock-builders/event/scheduledMessage.ts b/src/mock-builders/event/scheduledMessage.ts new file mode 100644 index 000000000..6fd3b4d3a --- /dev/null +++ b/src/mock-builders/event/scheduledMessage.ts @@ -0,0 +1,29 @@ +import { nowNs } from 'stream-chat'; +import type { + ScheduledMessageEventType, + ScheduledMessageResponse, + StreamChat, +} from 'stream-chat'; + +export type DispatchScheduledMessageEventParams = { + client: StreamChat; + scheduledMessage: ScheduledMessageResponse; + type: ScheduledMessageEventType; +}; + +export const dispatchScheduledMessageEvent = ({ + client, + scheduledMessage, + type, +}: DispatchScheduledMessageEventParams) => { + const { cid, user } = scheduledMessage; + + client.dispatchEvent({ + cid, + created_at: nowNs(), + custom: {}, + scheduled_message: scheduledMessage, + type, + user, + } as Parameters[0]); +}; diff --git a/src/mock-builders/generator/index.ts b/src/mock-builders/generator/index.ts index 8c5edd626..e87c95305 100644 --- a/src/mock-builders/generator/index.ts +++ b/src/mock-builders/generator/index.ts @@ -7,6 +7,7 @@ export * from './messageDraft'; export * from './poll'; export * from './reaction'; export * from './reminder'; +export * from './scheduledMessage'; export * from './sharedLocation'; export * from './time'; export * from './user'; diff --git a/src/mock-builders/generator/scheduledMessage.ts b/src/mock-builders/generator/scheduledMessage.ts new file mode 100644 index 000000000..4f82050a9 --- /dev/null +++ b/src/mock-builders/generator/scheduledMessage.ts @@ -0,0 +1,28 @@ +import { nanoid } from 'nanoid'; +import type { ScheduledMessageResponse, UserResponse } from 'stream-chat'; + +import { convertDateToTimestamp } from './time'; + +const ONE_HOUR_MS = 60 * 60 * 1000; + +export const generateScheduledMessage = ( + data: Partial = {}, +): ScheduledMessageResponse => { + const now = new Date(); + const createdAt = convertDateToTimestamp(now); + const scheduledAt = convertDateToTimestamp(new Date(now.getTime() + ONE_HOUR_MS)); + + const scheduledMessage: ScheduledMessageResponse = { + cid: 'messaging:general', + created_at: createdAt, + id: nanoid(), + message: { custom: {}, text: nanoid() }, + scheduled_at: scheduledAt, + status: 'scheduled', + updated_at: createdAt, + user: { id: 'user-id' } as UserResponse, + ...data, + }; + + return scheduledMessage; +}; diff --git a/src/mock-builders/index.ts b/src/mock-builders/index.ts index 88f4ee0f7..8398a0258 100644 --- a/src/mock-builders/index.ts +++ b/src/mock-builders/index.ts @@ -29,6 +29,7 @@ const connectUser = (client: StreamChat, user: Partial) => interface MockClientOverrides { getAppSettings?: StreamChat['getAppSettings']; queryReactions?: StreamChat['queryReactions']; + queryScheduledMessages?: StreamChat['queryScheduledMessages']; } function mockClient(client: StreamChat, mocks: MockClientOverrides = {}) { @@ -41,6 +42,14 @@ function mockClient(client: StreamChat, mocks: MockClientOverrides = {}) { mocks.queryReactions ?? ((() => Promise.resolve({})) as unknown as StreamChat['queryReactions']), ); + vi.spyOn(client, 'queryScheduledMessages').mockImplementation( + mocks.queryScheduledMessages ?? + ((() => + Promise.resolve({ + duration: '', + scheduled_messages: [], + })) as unknown as StreamChat['queryScheduledMessages']), + ); client.tokenManager = fromPartial({ getToken: vi.fn(() => token), tokenReady: vi.fn(() => true), From eb28cd12ecef924b88a537fefb2d860f9ef70d8a Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Tue, 3 Nov 2026 16:55:55 +0100 Subject: [PATCH 03/12] feat(i18n): add the scheduled message copy and date formats Adds the scheduledMessages.* keys and five timestamp.* runtime defaults (ScheduleDate, SchedulePreset, ScheduledMessageConfirmation, ScheduledMessageGroup, ScheduledMessageSendTime). The English-words guard and the v15 i18n docs list them. --- ai-docs/ai-migration-v14-v15.md | 2 +- ai-docs/i18n-v15-migration.md | 10 ++- src/i18n/__tests__/Streami18n.test.ts | 6 +- src/i18n/__tests__/catalog.fixture.json | 90 +++++++++++++++++++++++++ src/i18n/keys.ts | 90 +++++++++++++++++++++++++ src/i18n/runtimeDefaults.ts | 17 ++++- 6 files changed, 207 insertions(+), 8 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index a74472d63..097ae4e94 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2799,7 +2799,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 4f0cf9858..b18e65f97 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -151,7 +151,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. @@ -217,7 +217,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` | @@ -226,8 +226,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/src/i18n/__tests__/Streami18n.test.ts b/src/i18n/__tests__/Streami18n.test.ts index a6f991b6d..b64897ef6 100644 --- a/src/i18n/__tests__/Streami18n.test.ts +++ b/src/i18n/__tests__/Streami18n.test.ts @@ -266,12 +266,16 @@ describe('Streami18n - the calendar keys that carry English words', () => { // dayjs takes the calendar wording in the format string, so a few `timestamp.*` values embed // English day words. A per-key `calendarFormats` replaces the locale's calendar, so // `dayjsLocaleConfigForLanguage` cannot translate them; only overriding the key can. The - // migration guide names these four, and this keeps that list honest. + // migration guide names these eight, and this keeps that list honest. const KEYS_WITH_ENGLISH_WORDS = [ 'timestamp.ChannelDetailPinnedMessageTimestamp', 'timestamp.ChannelPreviewTimestamp', 'timestamp.DateSeparator', 'timestamp.ReminderNotification', + 'timestamp.SchedulePreset', + 'timestamp.ScheduledMessageConfirmation', + 'timestamp.ScheduledMessageGroup', + 'timestamp.ScheduledMessageSendTime', ]; it('is exactly the set the migration guide documents', () => { diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json index 0f6be7550..3fa15ab47 100644 --- a/src/i18n/__tests__/catalog.fixture.json +++ b/src/i18n/__tests__/catalog.fixture.json @@ -545,6 +545,91 @@ "reactions.messageReactionsDetail.reactions.text_other": "{{ count }} reactions", "reactions.messageReactionsDetail.tapRemove.ariaLabel": "Tap to remove: {{ reactionName }}", "reactions.messageReactionsDetail.tapRemove.text": "Tap to remove", + "scheduledMessages.banner.failedToSend_one": "{{ count }} failed to send", + "scheduledMessages.banner.failedToSend_other": "{{ count }} failed to send", + "scheduledMessages.banner.scheduledMessages_one": "{{ count }} scheduled message", + "scheduledMessages.banner.scheduledMessages_other": "{{ count }} scheduled messages", + "scheduledMessages.banner.scheduledReplies_one": "{{ count }} scheduled reply", + "scheduledMessages.banner.scheduledReplies_other": "{{ count }} scheduled replies", + "scheduledMessages.blockedByModeration": "Blocked by moderation", + "scheduledMessages.cancel": "Cancel", + "scheduledMessages.chooseALaterTime": "Choose a later time", + "scheduledMessages.chooseAnEarlierTime": "Choose an earlier time", + "scheduledMessages.clickTheArrowNextToTheSendButton": "Click the arrow next to the send button in a channel to schedule a message.", + "scheduledMessages.copyText": "Copy text", + "scheduledMessages.couldntDeleteTheScheduledMessageTryAgain": "Couldn't delete the scheduled message. Try again", + "scheduledMessages.couldntLoadScheduledMessages": "Couldn't load scheduled messages", + "scheduledMessages.couldntRescheduleTheMessageTryAgain": "Couldn't reschedule the message. Try again", + "scheduledMessages.couldntScheduleMessageTryAgain": "Couldn't schedule message. Try again", + "scheduledMessages.couldntSendTheMessageTryAgain": "Couldn't send the message. Try again", + "scheduledMessages.couldntSendTryAgain": "Couldn't send. Try again", + "scheduledMessages.customTime": "Custom time", + "scheduledMessages.date": "Date", + "scheduledMessages.delete": "Delete", + "scheduledMessages.deleteScheduledMessage": "Delete scheduled message?", + "scheduledMessages.edit": "Edit", + "scheduledMessages.editReschedule": "Edit & reschedule", + "scheduledMessages.editScheduledMessage": "Edit scheduled message", + "scheduledMessages.failed": "Failed", + "scheduledMessages.loadMore": "Load more", + "scheduledMessages.loadingScheduledMessages": "Loading scheduled messages", + "scheduledMessages.messageScheduledForTime": "Message scheduled for {{ time }}", + "scheduledMessages.messageToName": "Message to {{ name }}", + "scheduledMessages.noMessagesFound": "No messages found", + "scheduledMessages.noScheduledMessages": "No scheduled messages", + "scheduledMessages.notSentTheServiceWasDelayed": "Not sent: the service was delayed", + "scheduledMessages.notification.alreadySent": "Already sent", + "scheduledMessages.notification.alreadySentEdit": "This message was already sent", + "scheduledMessages.notification.blocked": "This message can't be scheduled", + "scheduledMessages.notification.channelLimit": "You've reached the limit of scheduled messages in this channel", + "scheduledMessages.notification.commandBlocked": "Slash commands can't be scheduled", + "scheduledMessages.notification.conflict": "Updated elsewhere. Try again", + "scheduledMessages.notification.deleteFailed": "Couldn't delete the scheduled message. Try again", + "scheduledMessages.notification.deletedEdit": "This scheduled message was deleted", + "scheduledMessages.notification.disabled": "Scheduled messages are turned off", + "scheduledMessages.notification.endedEdit": "This scheduled message can no longer be edited", + "scheduledMessages.notification.failedEdit": "This scheduled message failed to send", + "scheduledMessages.notification.forbidden": "You can't schedule messages here", + "scheduledMessages.notification.locationBlocked": "Locations can't be scheduled", + "scheduledMessages.notification.notEditable": "Failed messages can't be edited", + "scheduledMessages.notification.notSent": "Scheduled message not sent", + "scheduledMessages.notification.notSentWithReason": "Scheduled message not sent: {{ reason }}", + "scheduledMessages.notification.pollBlocked": "Polls can't be scheduled", + "scheduledMessages.notification.sendNowFailed": "Couldn't send the message. Try again", + "scheduledMessages.notification.sending": "This message is being sent", + "scheduledMessages.notification.updateFailed": "Couldn't update the scheduled message. Try again", + "scheduledMessages.notification.userLimit": "You've reached the limit of scheduled messages", + "scheduledMessages.openScheduleMessageMenu.ariaLabel": "Open schedule message menu", + "scheduledMessages.openScheduledMessageActions.ariaLabel": "Open scheduled message actions", + "scheduledMessages.rejectedByTheApp": "Rejected by the app", + "scheduledMessages.reschedule": "Reschedule", + "scheduledMessages.rescheduleMessage": "Reschedule message", + "scheduledMessages.save": "Save", + "scheduledMessages.schedule": "Schedule", + "scheduledMessages.scheduleAgain": "Schedule again", + "scheduledMessages.scheduleMessage": "Schedule message", + "scheduledMessages.scheduledForTime": "Scheduled for {{ time }}", + "scheduledMessages.scheduledMessageActions": "Scheduled message actions", + "scheduledMessages.scheduledMessageSent": "Scheduled message sent", + "scheduledMessages.scheduledMessages": "Scheduled messages", + "scheduledMessages.scheduledReplies": "Scheduled replies", + "scheduledMessages.search": "Search", + "scheduledMessages.sendNow": "Send now", + "scheduledMessages.sendTime": "Send {{ time }}", + "scheduledMessages.sending": "Sending...", + "scheduledMessages.sentInYourTimeZoneTimezone": "Sent in your time zone ({{ timeZone }})", + "scheduledMessages.theMessageNoLongerMeetsTheAppsLimits": "The message no longer meets the app's limits. Edit it", + "scheduledMessages.thisChannelIsFrozen": "This channel is frozen", + "scheduledMessages.thisChannelNowHoldsMessagesForApprovalSchedule": "This channel now holds messages for approval. Schedule it again", + "scheduledMessages.thisChannelWasDeleted": "This channel was deleted", + "scheduledMessages.thisMessageToNameWontBeSentThis": "This message to {{ name }} won't be sent. This can't be undone.", + "scheduledMessages.thisMessageWontBeSentThisCantBe": "This message won't be sent. This can't be undone.", + "scheduledMessages.time": "Time", + "scheduledMessages.tryAgain": "Try again", + "scheduledMessages.viewAll": "View all", + "scheduledMessages.youCantSendMessagesInThisChannel": "You can't send messages in this channel", + "scheduledMessages.youreNoLongerAllowedToPostHere": "You're no longer allowed to post here", + "scheduledMessages.youreOfflineReconnectToScheduleThisMessage": "You're offline. Reconnect to schedule this message.", "search.bar.clearSearch.ariaLabel": "Clear search", "search.bar.exitSearch.ariaLabel": "Exit search", "search.resultItem.selectUserChannel.ariaLabel": "Select User Channel: {{ name }}", @@ -593,6 +678,11 @@ "timestamp.PollVote": "{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactWeekRounding: ceil) }}", "timestamp.PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp.ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp.ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, MMM D, YYYY) }}", + "timestamp.SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"dddd [at] HH:mm\" }) }}", + "timestamp.ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, MMM D [at] HH:mm\", \"lastDay\": \"ddd, MMM D [at] HH:mm\", \"nextWeek\": \"ddd, MMM D [at] HH:mm\", \"lastWeek\": \"ddd, MMM D [at] HH:mm\", \"sameElse\": \"ddd, MMM D [at] HH:mm\" }) }}", + "timestamp.ScheduledMessageGroup": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today]\", \"nextDay\": \"[Tomorrow]\", \"lastDay\": \"[Yesterday]\", \"nextWeek\": \"dddd\", \"lastWeek\": \"[Last] dddd\", \"sameElse\": \"ddd, D MMM\" }) }}", + "timestamp.ScheduledMessageSendTime": "{{ timestamp | timestampFormatter(format: ddd, MMM D [at] HH:mm) }}", "timestamp.SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "translationBuilderTopic.notification": "{{value, notification}}", "typing.manyUsers_one": "{{ count }} person is typing", diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index 362e51269..0aef711a8 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -556,6 +556,91 @@ export type TranslationCatalog = { 'reactions.messageReactionsDetail.reactions.text_other': '{{ count }} reactions'; 'reactions.messageReactionsDetail.tapRemove.ariaLabel': 'Tap to remove: {{ reactionName }}'; 'reactions.messageReactionsDetail.tapRemove.text': 'Tap to remove'; + 'scheduledMessages.banner.failedToSend_one': '{{ count }} failed to send'; + 'scheduledMessages.banner.failedToSend_other': '{{ count }} failed to send'; + 'scheduledMessages.banner.scheduledMessages_one': '{{ count }} scheduled message'; + 'scheduledMessages.banner.scheduledMessages_other': '{{ count }} scheduled messages'; + 'scheduledMessages.banner.scheduledReplies_one': '{{ count }} scheduled reply'; + 'scheduledMessages.banner.scheduledReplies_other': '{{ count }} scheduled replies'; + 'scheduledMessages.blockedByModeration': 'Blocked by moderation'; + 'scheduledMessages.cancel': 'Cancel'; + 'scheduledMessages.chooseALaterTime': 'Choose a later time'; + 'scheduledMessages.chooseAnEarlierTime': 'Choose an earlier time'; + 'scheduledMessages.clickTheArrowNextToTheSendButton': 'Click the arrow next to the send button in a channel to schedule a message.'; + 'scheduledMessages.copyText': 'Copy text'; + 'scheduledMessages.couldntDeleteTheScheduledMessageTryAgain': "Couldn't delete the scheduled message. Try again"; + 'scheduledMessages.couldntLoadScheduledMessages': "Couldn't load scheduled messages"; + 'scheduledMessages.couldntRescheduleTheMessageTryAgain': "Couldn't reschedule the message. Try again"; + 'scheduledMessages.couldntScheduleMessageTryAgain': "Couldn't schedule message. Try again"; + 'scheduledMessages.couldntSendTheMessageTryAgain': "Couldn't send the message. Try again"; + 'scheduledMessages.couldntSendTryAgain': "Couldn't send. Try again"; + 'scheduledMessages.customTime': 'Custom time'; + 'scheduledMessages.date': 'Date'; + 'scheduledMessages.delete': 'Delete'; + 'scheduledMessages.deleteScheduledMessage': 'Delete scheduled message?'; + 'scheduledMessages.edit': 'Edit'; + 'scheduledMessages.editReschedule': 'Edit & reschedule'; + 'scheduledMessages.editScheduledMessage': 'Edit scheduled message'; + 'scheduledMessages.failed': 'Failed'; + 'scheduledMessages.loadMore': 'Load more'; + 'scheduledMessages.loadingScheduledMessages': 'Loading scheduled messages'; + 'scheduledMessages.messageScheduledForTime': 'Message scheduled for {{ time }}'; + 'scheduledMessages.messageToName': 'Message to {{ name }}'; + 'scheduledMessages.noMessagesFound': 'No messages found'; + 'scheduledMessages.noScheduledMessages': 'No scheduled messages'; + 'scheduledMessages.notSentTheServiceWasDelayed': 'Not sent: the service was delayed'; + 'scheduledMessages.notification.alreadySent': 'Already sent'; + 'scheduledMessages.notification.alreadySentEdit': 'This message was already sent'; + 'scheduledMessages.notification.blocked': "This message can't be scheduled"; + 'scheduledMessages.notification.channelLimit': "You've reached the limit of scheduled messages in this channel"; + 'scheduledMessages.notification.commandBlocked': "Slash commands can't be scheduled"; + 'scheduledMessages.notification.conflict': 'Updated elsewhere. Try again'; + 'scheduledMessages.notification.deleteFailed': "Couldn't delete the scheduled message. Try again"; + 'scheduledMessages.notification.deletedEdit': 'This scheduled message was deleted'; + 'scheduledMessages.notification.disabled': 'Scheduled messages are turned off'; + 'scheduledMessages.notification.endedEdit': 'This scheduled message can no longer be edited'; + 'scheduledMessages.notification.failedEdit': 'This scheduled message failed to send'; + 'scheduledMessages.notification.forbidden': "You can't schedule messages here"; + 'scheduledMessages.notification.locationBlocked': "Locations can't be scheduled"; + 'scheduledMessages.notification.notEditable': "Failed messages can't be edited"; + 'scheduledMessages.notification.notSent': 'Scheduled message not sent'; + 'scheduledMessages.notification.notSentWithReason': 'Scheduled message not sent: {{ reason }}'; + 'scheduledMessages.notification.pollBlocked': "Polls can't be scheduled"; + 'scheduledMessages.notification.sendNowFailed': "Couldn't send the message. Try again"; + 'scheduledMessages.notification.sending': 'This message is being sent'; + 'scheduledMessages.notification.updateFailed': "Couldn't update the scheduled message. Try again"; + 'scheduledMessages.notification.userLimit': "You've reached the limit of scheduled messages"; + 'scheduledMessages.openScheduleMessageMenu.ariaLabel': 'Open schedule message menu'; + 'scheduledMessages.openScheduledMessageActions.ariaLabel': 'Open scheduled message actions'; + 'scheduledMessages.rejectedByTheApp': 'Rejected by the app'; + 'scheduledMessages.reschedule': 'Reschedule'; + 'scheduledMessages.rescheduleMessage': 'Reschedule message'; + 'scheduledMessages.save': 'Save'; + 'scheduledMessages.schedule': 'Schedule'; + 'scheduledMessages.scheduleAgain': 'Schedule again'; + 'scheduledMessages.scheduleMessage': 'Schedule message'; + 'scheduledMessages.scheduledForTime': 'Scheduled for {{ time }}'; + 'scheduledMessages.scheduledMessageActions': 'Scheduled message actions'; + 'scheduledMessages.scheduledMessageSent': 'Scheduled message sent'; + 'scheduledMessages.scheduledMessages': 'Scheduled messages'; + 'scheduledMessages.scheduledReplies': 'Scheduled replies'; + 'scheduledMessages.search': 'Search'; + 'scheduledMessages.sendNow': 'Send now'; + 'scheduledMessages.sendTime': 'Send {{ time }}'; + 'scheduledMessages.sending': 'Sending...'; + 'scheduledMessages.sentInYourTimeZoneTimezone': 'Sent in your time zone ({{ timeZone }})'; + 'scheduledMessages.theMessageNoLongerMeetsTheAppsLimits': "The message no longer meets the app's limits. Edit it"; + 'scheduledMessages.thisChannelIsFrozen': 'This channel is frozen'; + 'scheduledMessages.thisChannelNowHoldsMessagesForApprovalSchedule': 'This channel now holds messages for approval. Schedule it again'; + 'scheduledMessages.thisChannelWasDeleted': 'This channel was deleted'; + 'scheduledMessages.thisMessageToNameWontBeSentThis': "This message to {{ name }} won't be sent. This can't be undone."; + 'scheduledMessages.thisMessageWontBeSentThisCantBe': "This message won't be sent. This can't be undone."; + 'scheduledMessages.time': 'Time'; + 'scheduledMessages.tryAgain': 'Try again'; + 'scheduledMessages.viewAll': 'View all'; + 'scheduledMessages.youCantSendMessagesInThisChannel': "You can't send messages in this channel"; + 'scheduledMessages.youreNoLongerAllowedToPostHere': "You're no longer allowed to post here"; + 'scheduledMessages.youreOfflineReconnectToScheduleThisMessage': "You're offline. Reconnect to schedule this message."; 'search.bar.clearSearch.ariaLabel': 'Clear search'; 'search.bar.exitSearch.ariaLabel': 'Exit search'; 'search.resultItem.selectUserChannel.ariaLabel': 'Select User Channel: {{ name }}'; @@ -604,6 +689,11 @@ export type TranslationCatalog = { 'timestamp.PollVote': '{{ timestamp | timestampFormatter(relativeCompact: true; relativeCompactWeekRounding: ceil) }}'; 'timestamp.PollVoteTooltip': '{{ timestamp | timestampFormatter(calendar: true) }}'; 'timestamp.ReminderNotification': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today] [at] HH:mm", "nextDay": "[Tomorrow] [at] HH:mm", "lastDay": "[Yesterday] [at] HH:mm", "nextWeek": "dddd [at] HH:mm", "lastWeek": "[Last] dddd [at] HH:mm", "sameElse": "ddd, D MMM [at] HH:mm" }) }}'; + 'timestamp.ScheduleDate': '{{ timestamp | timestampFormatter(format: ddd, MMM D, YYYY) }}'; + 'timestamp.SchedulePreset': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today] [at] HH:mm", "nextDay": "[Tomorrow] [at] HH:mm", "lastDay": "[Yesterday] [at] HH:mm", "nextWeek": "dddd [at] HH:mm", "lastWeek": "[Last] dddd [at] HH:mm", "sameElse": "dddd [at] HH:mm" }) }}'; + 'timestamp.ScheduledMessageConfirmation': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "HH:mm", "nextDay": "ddd, MMM D [at] HH:mm", "lastDay": "ddd, MMM D [at] HH:mm", "nextWeek": "ddd, MMM D [at] HH:mm", "lastWeek": "ddd, MMM D [at] HH:mm", "sameElse": "ddd, MMM D [at] HH:mm" }) }}'; + 'timestamp.ScheduledMessageGroup': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today]", "nextDay": "[Tomorrow]", "lastDay": "[Yesterday]", "nextWeek": "dddd", "lastWeek": "[Last] dddd", "sameElse": "ddd, D MMM" }) }}'; + 'timestamp.ScheduledMessageSendTime': '{{ timestamp | timestampFormatter(format: ddd, MMM D [at] HH:mm) }}'; 'timestamp.SystemMessage': '{{ timestamp | timestampFormatter(format: dddd L) }}'; 'translationBuilderTopic.notification': '{{value, notification}}'; 'typing.manyUsers_one': '{{ count }} person is typing'; diff --git a/src/i18n/runtimeDefaults.ts b/src/i18n/runtimeDefaults.ts index f8eb368ed..83fc74cc6 100644 --- a/src/i18n/runtimeDefaults.ts +++ b/src/i18n/runtimeDefaults.ts @@ -4,9 +4,10 @@ * `translationBuilderTopic.*` (an i18next postProcessor directive). The `language.*` names come * from `stream-chat`. * - * Four `timestamp.*` entries (`DateSeparator`, `ReminderNotification`, `ChannelPreviewTimestamp`, - * `ChannelDetailPinnedMessageTimestamp`) hold English day words in `calendarFormats`, which - * `dayjsLocaleConfigForLanguage` does not reach, so integrators override the key. Adding a fifth + * Eight `timestamp.*` entries (`DateSeparator`, `ReminderNotification`, `ChannelPreviewTimestamp`, + * `ChannelDetailPinnedMessageTimestamp`, `ScheduledMessageConfirmation`, `ScheduledMessageGroup`, + * `ScheduledMessageSendTime`, `SchedulePreset`) hold English words in their formats, which + * `dayjsLocaleConfigForLanguage` does not reach, so integrators override the key. Adding a ninth * fails a guard in `__tests__/Streami18n.test.ts` that keeps the migration guide's list in sync. */ export const runtimeDefaults = { @@ -32,6 +33,16 @@ export const runtimeDefaults = { 'timestamp.PollVoteTooltip': '{{ timestamp | timestampFormatter(calendar: true) }}', 'timestamp.ReminderNotification': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today] [at] HH:mm", "nextDay": "[Tomorrow] [at] HH:mm", "lastDay": "[Yesterday] [at] HH:mm", "nextWeek": "dddd [at] HH:mm", "lastWeek": "[Last] dddd [at] HH:mm", "sameElse": "ddd, D MMM [at] HH:mm" }) }}', + 'timestamp.ScheduleDate': + '{{ timestamp | timestampFormatter(format: ddd, MMM D, YYYY) }}', + 'timestamp.ScheduledMessageConfirmation': + '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "HH:mm", "nextDay": "ddd, MMM D [at] HH:mm", "lastDay": "ddd, MMM D [at] HH:mm", "nextWeek": "ddd, MMM D [at] HH:mm", "lastWeek": "ddd, MMM D [at] HH:mm", "sameElse": "ddd, MMM D [at] HH:mm" }) }}', + 'timestamp.ScheduledMessageGroup': + '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today]", "nextDay": "[Tomorrow]", "lastDay": "[Yesterday]", "nextWeek": "dddd", "lastWeek": "[Last] dddd", "sameElse": "ddd, D MMM" }) }}', + 'timestamp.ScheduledMessageSendTime': + '{{ timestamp | timestampFormatter(format: ddd, MMM D [at] HH:mm) }}', + 'timestamp.SchedulePreset': + '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today] [at] HH:mm", "nextDay": "[Tomorrow] [at] HH:mm", "lastDay": "[Yesterday] [at] HH:mm", "nextWeek": "dddd [at] HH:mm", "lastWeek": "[Last] dddd [at] HH:mm", "sameElse": "dddd [at] HH:mm" }) }}', 'timestamp.SystemMessage': '{{ timestamp | timestampFormatter(format: dddd L) }}', 'translationBuilderTopic.notification': '{{value, notification}}', }; From 96ca0d7d5bdc2ba062ccec509c577d84b9d01674 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Tue, 3 Nov 2026 16:55:59 +0100 Subject: [PATCH 04/12] feat(ScheduledMessages): add the scheduled message components, hooks and utilities - Split send button with presets and a custom time dialog. - Banner above the composer, list dialog with search and grouped rows. - Row actions: edit, reschedule, send now and delete. - Previews for an edited and a failed scheduled message. - Overridable through ComponentContext slots. useCanScheduleMessage reads the channel capabilities and messageComposer.scheduleBlockReason and only re-renders when the block reason changes. --- .../DeleteScheduledMessageAlert.tsx | 60 +++ .../EditedScheduledMessagePreview.tsx | 26 ++ .../FailedScheduledMessagePreview.tsx | 26 ++ .../RescheduleMessageDialog.tsx | 83 ++++ .../ScheduleMessageButton.tsx | 115 +++++ .../ScheduleMessageDialog.tsx | 29 ++ .../ScheduleMessageDialogNotice.tsx | 29 ++ .../ScheduledMessages/ScheduleMessageMenu.tsx | 51 +++ .../ScheduleMessageMenuHeader.tsx | 14 + .../ScheduleMessagePresetButton.tsx | 25 + .../ScheduledMessages/ScheduleTimeDialog.tsx | 173 +++++++ .../ScheduledMessageActions.tsx | 175 +++++++ .../ScheduledMessageActionsMenu.tsx | 98 ++++ .../ScheduledMessageGroupHeader.tsx | 20 + .../ScheduledMessageItem.tsx | 83 ++++ .../ScheduledMessageItemStatus.tsx | 35 ++ .../ScheduledMessageList.tsx | 82 ++++ .../ScheduledMessageListDialog.tsx | 66 +++ .../ScheduledMessageListLoading.tsx | 43 ++ .../ScheduledMessagesBanner.tsx | 113 +++++ .../ScheduledMessagesEmptyList.tsx | 20 + .../ScheduledMessagesEmptyState.tsx | 21 + .../ScheduledMessagesLoadError.tsx | 33 ++ .../ScheduledMessagesNoResults.tsx | 16 + .../__tests__/EditScheduledMessage.test.tsx | 156 +++++++ .../__tests__/ScheduleMessageButton.test.tsx | 433 ++++++++++++++++++ .../ScheduledMessageActions.test.tsx | 322 +++++++++++++ .../__tests__/ScheduledMessageList.test.tsx | 336 ++++++++++++++ .../ScheduledMessagesBanner.test.tsx | 281 ++++++++++++ src/components/ScheduledMessages/constants.ts | 76 +++ .../__tests__/scheduleMessageHooks.test.tsx | 260 +++++++++++ .../scheduledMessageListHooks.test.tsx | 172 +++++++ .../__tests__/useCanScheduleMessage.test.tsx | 194 ++++++++ .../useScheduledMessageCount.test.tsx | 128 ++++++ .../__tests__/useScheduledMessages.test.tsx | 136 ++++++ .../ScheduledMessages/hooks/index.ts | 15 + .../hooks/useCanScheduleMessage.ts | 66 +++ .../hooks/useFailedScheduledMessageCount.ts | 38 ++ .../hooks/useScheduleMessage.ts | 61 +++ .../hooks/useScheduleMessageDialog.ts | 43 ++ .../hooks/useScheduleMessageSubmit.ts | 10 + .../hooks/useSchedulePresets.ts | 20 + .../hooks/useScheduleTimeForm.ts | 52 +++ .../hooks/useScheduleTimeSubmit.ts | 40 ++ .../useScheduledMessageActionHandlers.ts | 102 +++++ .../hooks/useScheduledMessageCount.ts | 49 ++ .../hooks/useScheduledMessageGroups.ts | 29 ++ .../hooks/useScheduledMessagePreview.ts | 40 ++ .../hooks/useScheduledMessages.ts | 76 +++ .../hooks/useScheduledMessagesLoadState.ts | 36 ++ .../hooks/useScheduledMessagesPagination.ts | 32 ++ src/components/ScheduledMessages/index.ts | 33 ++ .../styling/ScheduleMessageButton.scss | 61 +++ .../styling/ScheduleMessageDialog.scss | 122 +++++ .../styling/ScheduledMessageActions.scss | 40 ++ .../styling/ScheduledMessageList.scss | 246 ++++++++++ .../styling/ScheduledMessagesBanner.scss | 48 ++ .../ScheduledMessages/styling/index.scss | 5 + src/components/ScheduledMessages/types.ts | 215 +++++++++ .../__tests__/dateTimeInputValues.test.ts | 47 ++ .../utils/__tests__/schedulePresets.test.ts | 141 ++++++ .../__tests__/scheduleTimeBounds.test.ts | 91 ++++ .../__tests__/scheduledMessageActions.test.ts | 65 +++ .../scheduledMessageComposer.test.ts | 49 ++ .../scheduledMessageFailureReason.test.ts | 46 ++ .../__tests__/scheduledMessageGroups.test.ts | 89 ++++ .../__tests__/scheduledMessageHelpers.test.ts | 103 +++++ .../__tests__/scheduledMessageSearch.test.ts | 29 ++ .../utils/__tests__/timeZone.test.ts | 21 + .../utils/channelContainer.ts | 3 + .../utils/dateTimeInputValues.ts | 40 ++ .../ScheduledMessages/utils/index.ts | 14 + .../ScheduledMessages/utils/nativePicker.ts | 9 + .../utils/schedulePresets.ts | 57 +++ .../utils/scheduleTimeBounds.ts | 51 +++ .../utils/scheduledMessageActions.ts | 52 +++ .../utils/scheduledMessageChannel.ts | 14 + .../utils/scheduledMessageComposer.ts | 14 + .../utils/scheduledMessageErrors.ts | 16 + .../utils/scheduledMessageFailureReason.ts | 48 ++ .../utils/scheduledMessageGroups.ts | 61 +++ .../utils/scheduledMessagePreview.ts | 31 ++ .../utils/scheduledMessageSearch.ts | 20 + .../ScheduledMessages/utils/timeZone.ts | 18 + src/components/index.ts | 1 + src/context/ComponentContext.tsx | 30 ++ src/styling/index.scss | 1 + 87 files changed, 6570 insertions(+) create mode 100644 src/components/ScheduledMessages/DeleteScheduledMessageAlert.tsx create mode 100644 src/components/ScheduledMessages/EditedScheduledMessagePreview.tsx create mode 100644 src/components/ScheduledMessages/FailedScheduledMessagePreview.tsx create mode 100644 src/components/ScheduledMessages/RescheduleMessageDialog.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageButton.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageDialog.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageMenu.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx create mode 100644 src/components/ScheduledMessages/ScheduleTimeDialog.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageActions.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageActionsMenu.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageGroupHeader.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageItem.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageItemStatus.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageList.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageListDialog.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessageListLoading.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessagesBanner.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessagesEmptyList.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessagesEmptyState.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessagesLoadError.tsx create mode 100644 src/components/ScheduledMessages/ScheduledMessagesNoResults.tsx create mode 100644 src/components/ScheduledMessages/__tests__/EditScheduledMessage.test.tsx create mode 100644 src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx create mode 100644 src/components/ScheduledMessages/__tests__/ScheduledMessageActions.test.tsx create mode 100644 src/components/ScheduledMessages/__tests__/ScheduledMessageList.test.tsx create mode 100644 src/components/ScheduledMessages/__tests__/ScheduledMessagesBanner.test.tsx create mode 100644 src/components/ScheduledMessages/constants.ts create mode 100644 src/components/ScheduledMessages/hooks/__tests__/scheduleMessageHooks.test.tsx create mode 100644 src/components/ScheduledMessages/hooks/__tests__/scheduledMessageListHooks.test.tsx create mode 100644 src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx create mode 100644 src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx create mode 100644 src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx create mode 100644 src/components/ScheduledMessages/hooks/index.ts create mode 100644 src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts create mode 100644 src/components/ScheduledMessages/hooks/useFailedScheduledMessageCount.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduleMessage.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduleMessageDialog.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduleMessageSubmit.ts create mode 100644 src/components/ScheduledMessages/hooks/useSchedulePresets.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduleTimeForm.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduleTimeSubmit.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessageActionHandlers.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessageGroups.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessages.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessagesLoadState.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessagesPagination.ts create mode 100644 src/components/ScheduledMessages/index.ts create mode 100644 src/components/ScheduledMessages/styling/ScheduleMessageButton.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduledMessageActions.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduledMessageList.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduledMessagesBanner.scss create mode 100644 src/components/ScheduledMessages/styling/index.scss create mode 100644 src/components/ScheduledMessages/types.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/schedulePresets.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/scheduleTimeBounds.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/scheduledMessageActions.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/scheduledMessageComposer.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/scheduledMessageFailureReason.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/scheduledMessageGroups.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/scheduledMessageHelpers.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/scheduledMessageSearch.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/timeZone.test.ts create mode 100644 src/components/ScheduledMessages/utils/channelContainer.ts create mode 100644 src/components/ScheduledMessages/utils/dateTimeInputValues.ts create mode 100644 src/components/ScheduledMessages/utils/index.ts create mode 100644 src/components/ScheduledMessages/utils/nativePicker.ts create mode 100644 src/components/ScheduledMessages/utils/schedulePresets.ts create mode 100644 src/components/ScheduledMessages/utils/scheduleTimeBounds.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessageActions.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessageChannel.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessageComposer.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessageErrors.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessageFailureReason.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessageGroups.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessagePreview.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessageSearch.ts create mode 100644 src/components/ScheduledMessages/utils/timeZone.ts 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..612bd4a8e --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleTimeDialog.tsx @@ -0,0 +1,173 @@ +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 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={toDateInputValue(bounds.latest)} + 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..52885299d --- /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(); + const { hasLoadFailed, isFirstLoad, reload } = useScheduledMessagesLoadState(); + + 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) => ( +