Repository navigation
feat: add scheduled messages for v9 (CHA-5934) - #1951
NikolaGoranNaumovski wants to merge 41 commits into
Conversation
Add scheduled_at to sendMessage options, the update/delete/send_now/query scheduled message endpoints, the scheduled_message.* events and a ScheduledMessageManager (client.scheduledMessages) that keeps the current user's scheduled messages in sync from the query, channel queries with include_scheduled_messages, and events. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Messages awaiting a send retry report status sending, pending: false is accepted with scheduled_at, and the scheduled send response hydrates mentioned_users. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Ignore an upsert older (by updated_at) than the stored version, so the send-now response or its updated event cannot revert a failed message to scheduled. - load() and channel query hydration no longer undo changes that arrived while they were in flight. - Drop responses to requests made before disconnectUser(), so a new user never sees the previous user's scheduled messages. - getForChannel accepts parentId: null for messages outside threads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pass include_scheduled_messages through queryChannels and replace each returned channel's entries in the scheduled messages store from its scheduled_messages field. An absent field leaves the store untouched so the app can fall back to queryScheduledMessages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add getScheduledMessageErrorReason(), which reads exception_fields.reason
and falls back to the error code, and register codes 123
(ScheduledMessageAlreadySentError) and 124 (ScheduledMessageCanceledError).
Type the scheduled_messages_* channel config fields and add
getScheduledMessageLimits() with DEFAULT_SCHEDULED_MESSAGE_LIMITS, so a
missing field falls back to the backend default.
Address review feedback:
- load and loadMore share one query helper and snapshot token; loadMore
updates the store once per page
- one subscribeToEvents helper serves both event subscriptions
- order by Date.parse, tie-break with localeCompare
- ScheduledMessageSort is { scheduled_at: 1 }
- drop the ScheduledMessageId alias and the comments this PR added
Add getScheduledMessageErrorReason(), which reads exception_fields.reason
from an ErrorFromResponse and falls back to the error code. Register
codes 123 (ScheduledMessageAlreadySentError) and 124
(ScheduledMessageCanceledError), and make 122 non-retryable.
Type the scheduled_messages_* channel config fields and add
getScheduledMessageLimits() with DEFAULT_SCHEDULED_MESSAGE_LIMITS, so a
missing field falls back to the backend default.
Export ScheduledMessageStatus, ScheduledMessageFailureReason,
ScheduledMessageErrorReason and ScheduledMessageEventType as const
objects with same-named types.
Rework ScheduledMessageManager:
- set isDisabled when load fails with code 114; a successful load
clears it
- getForChannel returns channel-level messages only; add getForThread
and getCountForChannel
- drop the snapshot token (epoch/seq), hasMore and
upsertResponseToState; keep the updated_at check and tombstones
- load and loadMore share one query helper; loadMore updates the store
once per page
- one subscribeToEvents helper for both event groups
- sort by Date.parse, tie-break with localeCompare
- ScheduledMessageSort is { scheduled_at: 1 }
- name every inline type, order members private-first and drop the
comments this PR added
…ypes.ts Collect the client-side types (manager state, options, event, handlers, ScheduledMessageAPIError and ScheduledMessageLimits) in the folder's own types.ts, like notifications/ and search/. src/types.ts keeps the API shapes. Every type is still exported from the package root.
Add the scheduled message types and const objects (ScheduledMessageStatus, ScheduledMessageFailureReason, ScheduledMessageErrorReason, ScheduledMessageEventType), the scheduled_at and include_scheduled_messages options, the scheduled_messages_* channel config fields and APIErrorResponse.exception_fields. Add the queryScheduledMessages, updateScheduledMessage, deleteScheduledMessage and sendScheduledMessageNow client methods, the scheduled_message.* events, and error codes 114-124. Only 122 is not marked retryable either: a conflict should be reloaded, not resent. Add getScheduledMessageErrorReason(), which reads exception_fields.reason and falls back to the error code, and getScheduledMessageLimits() with the backend defaults. channel.sendMessage with scheduled_at skips the offline queue, so a replayed scheduled send can't land in the channel as a sent message.
…4-scheduled-messages Update the scheduled message manager and API tests to the merged API layer.
Add MessageComposer.schedule(at). It waits for pending uploads, composes the message and sends it with scheduled_at. On success it deletes the draft and clears the composer. Add an edit mode for scheduled messages: editScheduledMessage, updateScheduledMessage and cancelEditScheduledMessage. While a scheduled message is being edited, drafts are neither created nor deleted. When the edit ends, the composer goes back to what it held before, or to the newest draft if the draft changed during the edit.
- Disable commands while a scheduled message is edited through getCommandDisabledReason, and share the command lookup of compositionValidation as getComposedCommand. - Check the composition before scheduling or updating: commands, polls and shared locations are rejected with a validation:scheduled-message:blocked warning carrying the reason (ScheduledMessageBlockReason). Polls skipped validation through the poll-only middleware before. - Wait for uploads only when sending with pending uploads; the default attachments middleware already refuses while uploads run. - Fill the composer through initState when editing a scheduled message. - Build the update payload's server fields on RESERVED_UPDATED_MESSAGE_FIELDS. - Let clear() and the composer subscriptions delete the draft after scheduling, as after a normal send. Refs: REACT-1385
Replace the partial offline DB casts with the MockOfflineDB fixture and drop inline comments from the scheduled message tests. Refs: REACT-1385
… a predicate applySnapshot only ever received two predicates: one matching every scheduled message (load) and one matching a single channel's cid (hydrateChannelState). Replace the predicate parameter with an optional cid and move the check into a module-level isInSnapshotScope helper.
…manager applySnapshot only ever received two predicates: one matching every scheduled message (load) and one matching a single channel's cid (hydrateChannelState). Replace the predicate parameter with an optional cid and move the check into a module-level isInSnapshotScope helper. Subscribe to scheduled message events with the SDK's EventHandler type through dedicated handleUpsertEvent and handleRemoveEvent handlers. Remove the ScheduledMessagePredicate and ScheduledMessageHandler types.
Update, delete and send-now on a scheduled message that was already sent or deleted return a plain 404 (code 16) rather than an error reason, so getScheduledMessageErrorReason cannot classify it. Expose a helper that detects it, so the state layer can drop the stale item and UIs can say "Already sent".
…4-scheduled-messages
…r has it updateScheduledMessage, deleteScheduledMessage and sendScheduledMessageNow remove the item from the store when the request fails with a 404, then rethrow. This happens when the message was sent or deleted elsewhere and the matching event never reached this client; the item would otherwise stay listed until the next reload.
…heduled-messages-composer
- Move the slash command / poll / shared location check into a composition
middleware (stream-io/message-composer-middleware/scheduled-message),
registered before poll-only so poll compositions reach it. It runs when
sendOptions.scheduled_at is set or a scheduled message is being edited,
and keeps the validation:scheduled-message:blocked warning and reasons.
- compose() accepts { sendOptions }, and schedule() passes scheduled_at
through the chain instead of adding it after composing.
- Drop getComposedCommand and restore the validation middleware.
- Move the mention and quoted-message lookups for the scheduled edit out of
MessageComposer into module helpers.
…4-scheduled-messages
load() had no concurrency guard: a reload on connection.recovered and a load started by the app could resolve out of order, and the first one to finish set isLoading to false while the other was still running. load() now runs through withCancellation with a tag per manager. A newer load aborts the older one: the older result is not applied, its state changes are skipped, and it resolves to undefined. Loads run one after the other, so the latest snapshot always wins.
- queryChannels no longer hydrates scheduled messages for channels listed in stateOptions.skipInitialization, matching how their other state is left alone - a snapshot that leaves the store as it was no longer emits a new scheduledMessages map, so subscribers are not notified for nothing
SCHEDULED_MESSAGES_LOAD_LIMIT, ScheduledMessageMap and ScheduledMessageQueryResultHandler are only used inside ScheduledMessageManager, so they are no longer exported. ScheduledMessageManagerState.scheduledMessages is typed as Map<string, ScheduledMessageResponse> directly.
…heduled-messages-composer
Nothing ended a scheduled message edit when the message was sent at its time, sent now or deleted on another device, or failed: saving then failed with "already sent", and the payload was built from a stale stored copy. MessageComposer now listens for scheduled_message events while subscribed: - sent, deleted or failed for the message being edited ends the edit, restores the earlier composition and adds a validation:scheduled-message:composition:ended warning with the reason (ScheduledMessageCompositionEndReason: sent, deleted, failed) - deleted for a failed message being scheduled again ends that too - a newer scheduled_message.updated refreshes the stored copy the update payload is built from
A channel query or queryChannels response with a draft (for example after a reconnect) re-initialized the composer from that draft, which silently dropped a scheduled message edit or a failed message being scheduled again and left a stale restore point. While a scheduled message is in the composer, initStateFromChannelResponse now only updates the restore point with the newer draft, the same way the draft.updated event does.
With pending uploads, schedule() waits for them before composing. If the user cancelled scheduling a failed message again during that wait, the restored draft was scheduled and the failed message was still deleted. After the wait, schedule() now stops when an edit started or the failed message being scheduled again changed.
- the composer-owned fields of a scheduled message are listed once; the draft is built from that list instead of repeating it - getCurrentCommand in commandUtils replaces the lookup duplicated in the validation and scheduled message middleware - the default middleware chain creates one CommandSearchSource and passes it to both middleware
initState accepts stateOverrides that are merged into the new composer state. Editing a scheduled message or scheduling a failed one again now initializes the composition with its scheduled message, draftId, quoted message and show_in_channel in one update, instead of writing the scheduled state before initState, having initState reset it, and writing it again.
createPoll, sendLocation, schedule and updateScheduledMessage reported failures with the same addError block. They now use one private notifyApiError(message, type, error); the notifications are unchanged.
ScheduledMessageEditRestore and ScheduledMessageCompositionState are only used by private MessageComposer members, so they are no longer exported.
1679755 to
b4a398e
Compare
| typeof ScheduledMessageStatus.Canceled | typeof ScheduledMessageStatus.Sent | ||
| >; | ||
|
|
||
| export const ScheduledMessageFailureReason = { |
There was a problem hiding this comment.
Maybe we could just have a union type for the strings or even just declare the union inline in ScheduledMessageResponse.failure_reason?
| next?: string; | ||
| }; | ||
|
|
||
| export const ScheduledMessageStatus = { |
There was a problem hiding this comment.
Could ScheduledMessageStatus be removed and only inline union of strings declared in ScheduledMessageResponse.status?
…cheduled-messages-api
|
|
||
| export type QueryScheduledMessagesOptions = Omit<Pager, 'prev'> & { | ||
| filter?: ScheduledMessageFilters; | ||
| sort?: ScheduledMessageSort | ScheduledMessageSort[]; |
There was a problem hiding this comment.
does it make sense to include key sort if ScheduledMessageSort allows only asc sorting by scheduled_at?
| longitude?: number; | ||
| }; | ||
|
|
||
| export type ScheduledMessageContent = CustomMessageData & { |
There was a problem hiding this comment.
Do we need to declare a new type here if we have MessageBase?
There was a problem hiding this comment.
Shouldn't this be called ScheduledMessageContentResponse as in v10 it has that name?
| export type ScheduledMessageCompositionEndReason = | ||
| (typeof ScheduledMessageCompositionEndReason)[keyof typeof ScheduledMessageCompositionEndReason]; | ||
|
|
||
| export const ScheduledMessageEventType = { |
There was a problem hiding this comment.
Could we keep this in EVENT_MAP only? Is there added value to extract them here?
| export type ScheduledMessageFailureReason = | ||
| (typeof ScheduledMessageFailureReason)[keyof typeof ScheduledMessageFailureReason]; | ||
|
|
||
| export const ScheduledMessageErrorReason = { |
There was a problem hiding this comment.
Maybe this variable could be private to the module where getScheduledMessageErrorReason resides. Integrators will probably not need to have access to this const. I am proposing this to reduce the exported surface.
| export type ScheduledMessageErrorReason = | ||
| (typeof ScheduledMessageErrorReason)[keyof typeof ScheduledMessageErrorReason]; | ||
|
|
||
| export const ScheduledMessageBlockReason = { |
There was a problem hiding this comment.
This could reside where getScheduleBlockReason resides and be private to the module (not exported).
| export type ScheduledMessageBlockReason = | ||
| (typeof ScheduledMessageBlockReason)[keyof typeof ScheduledMessageBlockReason]; | ||
|
|
||
| export const ScheduledMessageCompositionEndReason = { |
There was a problem hiding this comment.
Could this be a union type declared inline?
| next?: string; | ||
| }; | ||
|
|
||
| export const ScheduledMessageStatus = { |
There was a problem hiding this comment.
I think that in general, we should use different names for types and variables - I would apply that to all the types / variables declared in this PR.
There was a problem hiding this comment.
The reason is that you never know what you import as an integrator or at least your DX is lowered by having to distinguish btw importing the type and var.
| typeof ScheduledMessageStatus.Canceled | typeof ScheduledMessageStatus.Sent | ||
| >; | ||
|
|
||
| export const ScheduledMessageFailureReason = { |
There was a problem hiding this comment.
Do we need this variable? Can we just have a union type and remove type ScheduledMessageFailureReason? I would apply it to all the analogies in this file / PR.
There was a problem hiding this comment.
I think that we may not need ScheduledMessageFailureReason neither. Again, maybe just inline union declaration is enough.
…onse (#2010) Ticket: https://linear.app/stream/issue/REACT-1543 ## Summary The `Type test` check fails on every PR into `release-v9` (for example #2000, #2002 and #1951). There are two causes, and this PR fixes both. ### 1. `dist` is never built before the type tests `test/typescript` loads the built bundle (`dist/cjs/index.node.js`). It used to be built by the root `prepare` script during `yarn install`. Since the Yarn 4 migration (#1742), `yarn install` no longer runs `prepare` for the project itself, so the job fails with: ``` Error: Cannot find module '../../dist/cjs/index.node.js' ``` The weekly `Scheduled tests` workflow kept passing because it already has its own `yarn build` step. This PR adds the same step to `.github/workflows/type.yml`. ### 2. `two_way_block_effect_enabled` is missing from the app settings response type Once the build runs, every API call in the type test succeeds, but compiling the generated responses fails. The API now returns `two_way_block_effect_enabled` from `getAppSettings()`, which came with two-way blocking (CHA-6283): ``` test/typescript/data.ts(72,79536): error TS2353: Object literal may only specify known properties, and '"two_way_block_effect_enabled"' does not exist in type '{ ... }' ``` This PR adds it to `AppSettingsAPIResponse['app']` as an optional boolean, the same way #1887 added `before_message_send_hook_system_messages`. It is a type-only change with no runtime effect. The update request type (`AppSettings`) is unchanged. ## Changes - `.github/workflows/type.yml`: add a `Build` step (`yarn build`) before `yarn test-types`. - `src/types.ts`: add `two_way_block_effect_enabled?: boolean` to `AppSettingsAPIResponse`. ## Notes - The `ci:` commit does not trigger a release. The `fix(types):` commit releases a v9 patch. - v10 (`master`) needs neither change, because its type tests were removed in #1870. - The type test runs against a shared live app. One run on #2000 hit `block list with name "FWord" already exists`, most likely because another run was using the same app at the same moment. Rerunning the failed job passed. ## Test plan - [x] `yarn types` - [x] `yarn lint` - [x] `yarn test`: 3242 passed, 1 skipped - [x] `yarn build`, and `dist/cjs/index.node.js` loads - [x] The same two commits are green in CI on #2000, including `Type test` - [ ] `Type test` passes on this PR 🤖 Generated with [Claude Code](https://claude.com/claude-code)
CLA
Description of the changes, What, Why and How?
Note
The backend (CHA-5924) shipped in chat
v239.72.0, behind the per-app flagchat_scheduled_messages_enabled(off by default).Scheduled messages for v9 ("send later"): the API contract, a state layer that keeps the user's scheduled messages in memory, and
MessageComposersupport for scheduling, editing and scheduling a failed message again.This PR combines the three stacked v9 PRs into one: this one (API), #1915 (state layer, by nijeesh-stream) and #1956 (composer). It is up to date with
release-v9. The v10 counterpart is #1990. React v14 builds on it in GetStream/stream-chat-react#3353.Tickets
1. API
Sending
channel.sendMessage(message, { scheduled_at })schedules the message. The response carriesscheduled_message, and the message is not added to the channel (it arrives later throughmessage.new).scheduled_atsends the message now.Client methods
queryScheduledMessages({ filter?, sort?, limit?, next?, user_id? })POST /scheduled_messages/queryupdateScheduledMessage(id, { message?, scheduled_at? })PATCH /scheduled_messages/{id}deleteScheduledMessage(id)DELETE /scheduled_messages/{id}sendScheduledMessageNow(id)POST /scheduled_messages/{id}/send_nowChannel queries:
include_scheduled_messagesonchannel.queryandclient.queryChannelsreturns each channel's scheduled messages for the user.Events:
scheduled_message.created,.updated,.deleted,.sentand.failedinEVENT_MAP, withEvent.scheduled_messagetyped. They are sent to the scheduling user only.Errors: codes 114–124 in
APIErrorCodes, none retryable.Helpers
getScheduledMessageErrorReason(error)readsexception_fields.reason, falling back to the code.isScheduledMessageNotFoundError(error)detects the plain 404 that update, delete and send-now return for a message that was already sent or deleted.getScheduledMessageLimits(channel)reads the fourscheduled_messages_*channel config fields, with defaults.Types and constants:
ScheduledMessageStatus,ScheduledMessageFailureReason,ScheduledMessageErrorReason,ScheduledMessageEventType,ScheduledMessageResponseand the request/response types.2. State:
client.scheduledMessages(ScheduledMessageManager)It's built like
client.reminders.State:
scheduledMessages(a map of the user'sscheduled/sending/failedmessages),isLoaded,isLoading,isDisabled(error 114),lastQueryErrorandnext.Selectors:
getFromState(id),getForChannel(cid)(outside threads),getForThread(parentId)andgetCountForChannel(cid). Lists are sorted byscheduled_at.Loading:
load()(up to 100) andloadMore(). Overlapping loads are cancelled so only the latest applies.Events (opt-in through
registerSubscriptions())created,updatedandfailedupsert.sentanddeletedremove.connection.recoveredreloads.Actions:
updateScheduledMessage,deleteScheduledMessageandsendScheduledMessageNowupdate the store after the request. A 404 removes the item and rethrows.Filled from elsewhere: a scheduled
sendMessageresponse, channel queries withinclude_scheduled_messages, and a reset ondisconnectUser().Ordering rules
updated_atwins.disconnectUser().3. Composer
Scheduling:
schedule(at)runs the normalcompose()pipeline withsendOptions.scheduled_at.ScheduledMessageResponse.api:scheduled-message:create:failednotification.What can't be scheduled: the
scheduled-messagecomposition middleware blocks slash commands, polls and shared locations with avalidation:scheduled-message:blockedwarning.Editing:
editScheduledMessage,updateScheduledMessageandcancelEditScheduledMessage, with theeditedScheduledMessagestate.parent_id.Scheduling a failed message again:
rescheduleFailedMessageandcancelRescheduleFailedMessage, with thefailedScheduledMessagestate. Scheduling it uses a new id and then deletes the failed message.Changes made elsewhere: if the message being edited is sent, deleted or fails, the edit ends with a
validation:scheduled-message:composition:endedwarning. Drafts are paused during a scheduled composition.Additive changes
compose({ sendOptions })andinitState({ stateOverrides }).editedScheduledMessageandfailedScheduledMessage.Tests
include_scheduled_messagesandsendMessagewithscheduled_at(including the offline queue);yarn types,yarn lintandyarn buildpass, andyarn testpasses (3370 passed, 1 skipped).End-to-end
Run against the scheduled messages test app with this branch's build: 42/42 checks pass.
sendMessage, with the roundedscheduled_at; nothing added to the channel; another session of the same user gets the events.getForThreadreturns them, andgetForChannelleaves them out.load(),queryScheduledMessagesfiltered bycid, and hydration throughinclude_scheduled_messages.channel_frozenwhile another one sends on time;rescheduleFailedMessage+schedule()creates a new message and deletes the failed one.disconnectUser()resets the store.The same flows in the React v14 UI (GetStream/stream-chat-react#3353) with this branch's build:
scheduled-messages-v9-v14.mp4
Backend behaviour found in end-to-end testing
scheduled_atless than 60 seconds ahead is not rejected with 115 (scheduled_at_too_soon). The server moves it to the next whole minute at least 60 seconds out. In practice 115 can't be triggered with a future time.queryScheduledMessagesrequiresfilter.statusto be an array (the typedScheduledMessageFiltersalready says so).Changelog
scheduled_atonchannel.sendMessage,client.queryScheduledMessages/updateScheduledMessage/deleteScheduledMessage/sendScheduledMessageNow,include_scheduled_messageson channel queries, thescheduled_message.*events, and error codes 114–124.getScheduledMessageErrorReason,isScheduledMessageNotFoundErrorandgetScheduledMessageLimits.client.scheduledMessages(ScheduledMessageManager), kept in sync from events and channel queries.MessageComposer.schedule, editing of scheduled messages, scheduling a failed message again, and thescheduled-messagecomposition middleware.🤖 Generated with Claude Code