Skip to content

feat: scheduled messages for v10 (CHA-5934) - #1990

Open
NikolaGoranNaumovski wants to merge 15 commits into
release-v10from
feat/cha-5934-scheduled-messages-v10
Open

NikolaGoranNaumovski wants to merge 15 commits into
release-v10from
feat/cha-5934-scheduled-messages-v10

Conversation

@NikolaGoranNaumovski

@NikolaGoranNaumovski NikolaGoranNaumovski commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

CLA

  • I have signed the Stream CLA (required).
  • Code changes are tested

Description of the changes, What, Why and How?

Ticket: https://linear.app/stream/issue/REACT-1347 (parent), https://linear.app/stream/issue/CHA-5934

Ports scheduled messages ("send later", CHA-5934) to v10. The v9 implementation is split across #1951 (API), #1915 (state) and #1956 (composer). v10's generated client already covers the REST calls, request/response/event types and the schedule-message capability, so this PR adds the state layer and the composer support on top of them. React v15 builds on it: GetStream/stream-chat-react#3383.

What is added

Helpers (src/scheduledMessages/)

  • Error codes 114–124 in APIErrorCodes.
  • getScheduledMessageErrorReason, isScheduledMessageError and isScheduledMessageNotFoundError, reading StreamAPIError.
  • getScheduledMessageLimits(channel), read from channel.serverConfig.
  • Runtime constants: SCHEDULED_MESSAGE_STATUS, SCHEDULED_MESSAGE_FAILURE_REASON, ScheduledMessageErrorReason, ScheduledMessageBlockReason, ScheduledMessageCompositionEndReason and ScheduledMessageEventType. The status and failure constants are upper case because the generated models already export ScheduledMessageStatus and ScheduledMessageFailureReason as types.

client.scheduledMessages (ScheduledMessageManager)

  • Holds the user's active scheduled messages in a StateStore and applies scheduled_message.* events.
  • load() / loadMore(), plus updateScheduledMessage, deleteScheduledMessage and sendScheduledMessageNow over the generated endpoints.
  • Selectors for a channel, a thread and a count.
  • Ordering is safe against out-of-order responses and events:
    • A newer updated_at wins. Timestamps are compared as nanosecond numbers.
    • Sent and deleted ids are tombstoned.
    • A snapshot keeps messages stored while its request was in flight.
    • Only the latest of overlapping loads is applied.
  • New in v10: once registered, the manager loads itself whenever the socket turns healthy for a connected, non-anonymous user. That covers both connect and reconnect. It stops loading automatically once the server reports the feature disabled (114), and it is reset on disconnectUser.

Hydration

  • queryChannelsAndHydrate and requestChannelState (query, watch, create) replace a channel's scheduled messages when the response carries scheduled_messages.
  • A scheduled sendMessage response stores its scheduled_message. The scheduled message never reaches the message paginator or the offline queue.

MessageComposer

  • schedule(at) composes the message and sends it with scheduled_at through channel.sendMessage.
  • editScheduledMessage() / updateScheduledMessage() / cancelEditScheduledMessage() edit a scheduled message in the composer. A newer update from elsewhere refreshes the edit. If the message is sent, deleted or fails elsewhere, the edit ends with a scheduledMessageCompositionEnded warning.
  • rescheduleFailedMessage() / cancelRescheduleFailedMessage() load a failed message so it can be scheduled again. The failed message is deleted once the new one goes out, and send() does this too when it is sent now.
  • Drafts pause while a scheduled message is in the composer, and the earlier composition comes back when it ends.
  • schedule() and updateScheduledMessage() never reject, like send(). They resolve a ScheduledMessageSubmitResult (scheduled, sent, nothing-to-send, blocked or failed). Unlike send(), they keep the composition on failure, because a scheduled message has no list item that could hold it.
  • A composition with a command, poll or shared location can't be scheduled:
    • scheduleBlockReason tells the UI why.
    • A composition middleware placed before poll-only discards it with a scheduledMessageBlocked warning.
  • With pending uploads enabled, schedule() waits for uploads in flight and refuses (failed) when an upload failed, instead of dropping the attachment.
  • New CORE_NOTIFICATION_TYPE entries: scheduledMessageBlocked, scheduledMessageCompositionEnded, scheduledMessageCreateFailed, scheduledMessageUpdateFailed.
  • toScheduledMessagePreview() turns a stored scheduled message into a LocalMessage for UI previews.

How it differs from the v9 PRs

  • No REST methods or wire types are added; the generated ones are used.
  • Nanosecond TimestampNS comparisons replace Date.parse.
  • The manager loads itself; on v9 the UI SDK loads it.
  • The composer methods return results instead of throwing, matching v10's send() contract.
  • Custom data is composer-owned under message.custom. A stored pin_expires is converted back to a Date in the update payload.

Commits

  1. Error codes and helpers
  2. ScheduledMessageManager
  3. Hydration from channel queries and sends
  4. MessageComposer scheduling, editing and rescheduling
  5. Export of the preview helper
  6. Guard for a queryChannels response without channels
  7. Composer test suite (66 tests)

Testing

  • yarn types, yarn lint and the full unit suite pass: 5071 tests.
  • New suites:
    • test/unit/scheduledMessages/*: errors, limits, manager and hydration.
    • test/unit/MessageComposer/scheduledMessages.test.ts
  • Checked live against the test app through the React v15 example: schedule, presets, list, edit, reschedule, send now, delete, a failed send in a frozen channel, and scheduling it again.

Changelog

  • Scheduled messages: client.scheduledMessages (ScheduledMessageManager), scheduled message error codes and helpers, and MessageComposer.schedule() with scheduled message editing and rescheduling.

Add the scheduled message API error codes 114 to 124, and the helpers
the state and UI layers build on: error reason detection on
StreamAPIError, channel limits read from the server config, and the
status, failure, error, block, composition end and event constants.
The runtime constants for statuses and failure reasons are upper-case
objects typed by the generated unions, so they do not clash with the
generated ScheduledMessageStatus and ScheduledMessageFailureReason types.
Port the scheduled message state layer to v10 as client.scheduledMessages.
The manager keeps the active scheduled messages in a StateStore, applies
scheduled_message events, loads and pages through the query endpoint, and
wraps update, delete and send-now so the store follows each response.
Ordering compares the nanosecond timestamps numerically, newer stored
versions win, sent and deleted ids stay tombstoned, and a snapshot keeps
messages stored while its request was in flight.

Unlike v9, the manager loads itself: once registered it loads whenever
the socket turns healthy for a connected, non-anonymous user, which also
covers reconnects, and it stops loading automatically once the feature
is reported disabled. It is reset on disconnectUser.
Channel responses that carry scheduled_messages, from
queryChannelsAndHydrate or from requestChannelState (query, watch and
create), replace the stored scheduled messages of that channel. Each
request captures the store version first, so a message stored while it
was in flight is kept. A sendMessage response with a scheduled_message
is stored, while the sent message never reaches the message paginator.
MessageComposer.schedule(at) composes the message and sends it with
scheduled_at through channel.sendMessage, so it never enters the message
list or the offline queue. editScheduledMessage() and
updateScheduledMessage() edit a scheduled message in the composer, and
rescheduleFailedMessage() loads a failed one to schedule or send again;
the failed message is deleted once the new one goes out, including when
it is sent now. Drafts pause while a scheduled message is in the
composer, and the earlier composition comes back when it ends.

schedule() and updateScheduledMessage() never reject. They return a
ScheduledMessageSubmitResult and, unlike send(), keep the composition on
failure, since a scheduled message has no list item that could hold it.
A composition with a command, poll or location can't be scheduled:
scheduleBlockReason exposes why, and a composition middleware placed
before poll-only discards it with a warning. An edit ends with a warning
when its message is sent, deleted or fails elsewhere, and is refreshed
by newer updates. New core notification types cover these cases.
UI SDKs render a stored scheduled message with their message preview
components, which take a LocalMessage. Export the helper that builds
one, so they do not duplicate its required fields.
queryChannelsAndHydrate iterated the response channels for scheduled
message hydration without the guard the rest of the method uses, so a
response without channels threw.
Port the v9 composer suite to v10: schedule, edit, reschedule a failed
message, the result contract that keeps the composition on failure,
blocked compositions, pending and failed uploads, draft pausing, the
restore point, scheduled message events ending or refreshing an edit,
the update payload built from the stored message, and the preview
helper. Also cover sending a failed-message composition now, which
deletes the failed scheduled message once the send succeeds.
@MartinCupela

Copy link
Copy Markdown
Contributor

Reviewed at 815a0ab2b; I read the code but didn't run it.

Background. In v10, every list of server data goes through the same few building blocks, so that each one loads, updates, recovers and can be customized in the same way:

  • BasePaginator loads a list page by page and owns its loading state, its last error, its cursor, retry and reset. It takes events in through ingestItem() / removeItem(), which keep or drop an item according to the list's filter and sort. Channel lists, message lists, threads and reminders are all paginators.
  • StoreBackedItemIndex over a shared EntityStore lets several lists show the same entity without copies: each list only records which ids it holds, and the entity itself lives once in the store. That's how several channel lists share channelStore, and messages share client.messageStore.
  • ConnectionRecoveryManager (client.connectionRecovery) reloads the loaded lists, channels and threads after a reconnect, then dispatches connection.recovered. Apps that recover state themselves turn it off.
  • MessageOperations is the single path for sending and editing messages. It settles uploads first, orders requests, runs the app's own request handlers (sendMessageRequest, updateMessageRequest), and applies the optimistic state.

Reminders are the closest example of a feature built this way: ReminderManager holds a ReminderPaginator, which is a BasePaginator with a StoreBackedItemIndex.

The hydration in this PR, saving scheduled_messages from channel queries and from a scheduled sendMessage response, follows the reminders pattern and looks right. The rest of the scheduled-message state is built separately from these blocks. It works, but it repeats their logic and misses what they give, and every later fix to paging, recovery or sending would have to be made twice. Details below.

1. Lists as paginators over a shared store

  • ScheduledMessageManager keeps its own isLoading, lastQueryError, next cursor, load() / loadMore() and "only the latest load applies" (ScheduledMessageManager.ts:396-413). BasePaginator already has all of these, including dropping a query that a later one overtook (isSuperseded).
  • Events update a Map by hand (upsertManyToState, :175), where a paginator's ingestItem() / removeItem() would do it.
  • getForChannel() / getForThread() (:324, :344) filter and sort the whole map on every call and return a new array each time. A React selector built on them re-renders a channel's scheduled list whenever any scheduled message changes, in any channel.
  • Proposal: one paginator per list (a channel's, a thread's, all of the user's), filtered by cid / parent_id and status and sorted by scheduled_at. Each gets a StoreBackedItemIndex over one shared store of scheduled messages, so a message edited in one list is the same object in the others.

2. Events routed by the manager, as in ChannelManager

  • Today the manager subscribes with client.on() and edits its own map. Paginators don't subscribe to events themselves; their manager does and routes to them. ChannelManager is the model: it keeps a handler pipeline per event type (EventHandlerPipeline), with labeled default handlers that apps can add to, replace or remove (addEventHandler(), setEventHandlers(), removeEventHandlers()), and passes each channel to every registered ChannelPaginator.
  • Proposal: ScheduledMessageManager keeps owning the scheduled_message.* subscriptions, through the same kind of pipeline, and keeps a list of its paginators (a channel's, a thread's, all of the user's), as channelManager.paginators does.
    • Created, updated, failed: the scheduled message goes to every registered paginator's ingestItem(), and each list's filter decides. Channel A's list takes a message for channel A; channel B's ignores it; a thread's list matches on parent_id.
    • Sent, deleted: the copy with the final status goes the same way, and the pending-or-failed filter drops it from every list that held it, as the pinned list drops an unpinned message.
    • Lists that already hold the message also update through the shared store; routing to every paginator is what lets a list that doesn't hold it yet add it.
    • With labeled handlers, an app can change the defaults (e.g. keep failed scheduled messages out of the lists) without forking the manager.
  • Two parts of ChannelManager don't apply: the ownership resolver (a scheduled message should show in every list it matches, e.g. the channel's and "all scheduled"), and fetching unknown entities (the event carries the whole scheduled message).

3. Stale copies: newer wins in the shared store, finished records kept

  • The manager guards against stale copies with its own removedIds set and per-id version counter (:108-110, used in applySnapshot and upsertManyToState). The concern is valid; the place is the problem.
  • The case that matters is an HTTP response racing a WebSocket event. Example: the user saves an edit seconds before the message is due. The server sends the message and scheduled_message.sent arrives. Then the response to the edit (updateScheduledMessage()) arrives, still saying "scheduled", and would put the sent message back in the list as pending.
  • Moving to StoreBackedItemIndex alone doesn't cover this: setOne() upserts whatever comes in, and remove() unlinks the item, so once no list holds it the store forgets it and the stale copy is accepted again.
  • Proposal, in the shared store rather than in each list, since several lists write the same scheduled message:
    • Accept an incoming copy only when its updated_at is newer than the stored one.
    • Treat "sent" and "deleted" as an update to the final status, not a removal. Each list's filter (pending or failed only) drops the message, and the store, created with releaseOnLastUnlink: false, keeps the finished record, so any older copy loses to it.
  • Order matters. A paginator tests the incoming copy against its filter before the store write, so a check only inside the store isn't enough: the stale "scheduled" copy of a sent message would match the pending list's filter and be added, while the store keeps "sent". So the manager resolves the winner first (incoming vs stored, by updated_at) and routes only the winner to the paginators. ChannelManager doesn't need this step, because a Channel is one live object that updates itself; a scheduled message arrives as fresh copies from events and HTTP responses.
  • With that, removedIds and the version counter go away. A load that started before a delete and returns the deleted message is covered the same way. On its own that case is rare, and it corrects itself on the next load or on a "not found".

4. Reconnects through ConnectionRecoveryManager

  • The manager reloads whenever the socket becomes healthy (loadWhenConnected, :232), outside the recovery manager.
  • So an app that turns recovery off (connectionRecovery.enabled: false) to recover state itself still gets a scheduled-messages query on every reconnect. And every app queries up to 100 scheduled messages on every connect, even if it never shows them.
  • Proposal: register the scheduled-message paginators with the recovery manager, and load each list when the UI first needs it, as the other lists do.

5. Scheduling and editing through MessageOperations

  • MessageComposer.schedule() calls channel.sendMessage() directly (messageComposer.ts:1589), and updateScheduledMessage() calls client.scheduledMessages.updateScheduledMessage() (:1661). They skip what send() and update() get from MessageOperations:
    • An app that sends through its own backend with sendMessageRequest / updateMessageRequest gets its handler for normal sends and edits, but scheduled messages go straight to Stream's API.
    • schedule() waits for uploads and checks failed ones itself (waitForUploadsInProgress(), :1563), repeating MessageOperations.withSettledUploads().
    • A schedule isn't ordered behind sends already in flight.
  • Proposal: add schedule and edit-scheduled operations to MessageOperations, with state rules that write to the scheduled-message store, never to the message list, and don't queue offline.
  • Request handlers: an app sets its handlers in config, client.config.setConfig('channel', { requestHandlers: { sendMessageRequest, updateMessageRequest, deleteMessageRequest, … } }), and MessageOperations reads them when it builds each request. The PR adds no entry for scheduled messages, so scheduling, editing, deleting and "send now" always call Stream's API directly, and an app can't wrap or replace them. Proposal: give each its own entry in the same requestHandlers group, next to sendMessageRequest, with the same (params, defaultRequest) signature. The new MessageOperations kinds read them, as send reads sendMessageRequest. Deleting a scheduled message and "send now" go through MessageOperations too, so all four requests follow the same path.

6. Configuration through client.config

The PR doesn't add anything to the declared config tree (configuration/shape.ts), so apps can't tune or turn off scheduled messages the way they do other features.

  • An enabled flag for offering scheduling. Polls, threads and reminders each have one, ANDed with the server, where a server "no" wins (e.g. userMessageReminders.enabled with the channel type's user_message_reminders). MessageComposer.schedule() checks neither the schedule-message capability nor whether the app has the feature: its only checks are a command, a poll or a location in the composer (getScheduleBlockReason()). So an app can't turn scheduling off, and on a channel or app without the feature the attempt simply fails at the server. Proposal: a flag next to the other features, ANDed with the capability.
  • Paging settings for the lists. The page size is hard-coded to 100 (SCHEDULED_MESSAGES_LOAD_LIMIT), and the load on connect can't be turned off. With the paginators from point 1, these come from the shared paginatorGroup (page size, debounce and the like), as the thread manager and reminders have their own groups, and reconnect behaviour from connectionRecovery.
  • The limits stay server-owned. scheduled_messages_max_lead_time_seconds, _max_per_channel, _max_per_user and _min_lead_time_seconds in channel.serverConfig are enforced by the server, so app config shouldn't override them. But getScheduledMessageLimits() (scheduledMessages/limits.ts) falls back to hard-coded defaults (120 days, 10 per channel, 100 per user, 60 seconds) when they're missing, and the generated types say they're "present only when scheduled messages are enabled for the app". So on an app without the feature, a UI reading the limits sees "allowed, 10 per channel". Missing limits should read as "scheduled messages are off".

@MartinCupela MartinCupela left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the PR needs to be adjusted to existing services provided by the LLC. More details at: #1990 (comment)

Loads the scheduled messages of one channel, or of all channels, page by page
over a shared store, sorted as the server returns them: pending before failed,
each by scheduled_at.
ScheduledMessageManager holds a list of all scheduled messages and one per
channel, all backed by one EntityStore, and routes events, responses and
channel hydration to every list. Lists load when the UI needs them and reload
after connection.recovered instead of on every connect.
ScheduledMessageManager runs scheduled_message.* and connection.recovered
through one EventHandlerPipeline per event type, as ChannelManager does.
The defaults are labeled, so an app can add, replace or remove them with
addEventHandler, setEventHandlers and removeEventHandlers, or pass its own
set to the constructor, without forking the manager.
The manager decides which copy of a scheduled message wins before it reaches
the lists or the store: a sent or canceled copy is final, otherwise the newer
updated_at wins. Sent and deleted events record the final status instead of
removing the message, so each list drops it through its filter and the store
keeps the finished record that any older copy loses to. This replaces the
set of removed ids.
…connect

connection.recovered now runs ScheduledMessageManager.recover(), which reloads
the loaded lists except the channel lists that a channel response hydrated
since the socket dropped. reload() still reloads every loaded list.
Adds scheduleMessageRequest, updateScheduledMessageRequest,
deleteScheduledMessageRequest and sendScheduledMessageNowRequest to the
channel request handlers, with the same (params, defaultRequest) signature as
sendMessageRequest. Scheduling goes through the new
ScheduledMessageManager.scheduleMessage(), so all four requests follow the same
path and a replaced request still stores its result.
channel.scheduledMessages.enabled is ANDed with the app having the feature,
which the scheduled message limits in the channel server config tell, and with
the schedule-message capability in channel.scheduledMessagesEnabled. The
composer blocks scheduling when it is off. Missing limits now mean the feature
is off instead of falling back to defaults, and client.scheduledMessages.paginator
sets the paging of every scheduled message list.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants