Skip to content

feat: schedule and edit scheduled messages from MessageComposer - #1956

Merged
NikolaGoranNaumovski merged 15 commits into
feat/cha-5934-scheduled-messages-apifrom
feat/cha-5934-scheduled-messages-composer
Nov 9, 2026
Merged

NikolaGoranNaumovski merged 15 commits into
feat/cha-5934-scheduled-messages-apifrom
feat/cha-5934-scheduled-messages-composer

Conversation

@NikolaGoranNaumovski

@NikolaGoranNaumovski NikolaGoranNaumovski commented Oct 28, 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-1385
Parent: https://linear.app/stream/issue/REACT-1347

Stacked on #1915 (scheduled message state), which is stacked on #1951 (API layer). Review those first.

This PR lets the MessageComposer schedule a message, edit a scheduled one, and schedule a failed one again. It reuses the existing compose() pipeline, so text, attachments, mentions, quotes and custom data behave the same as a normal send.

Scheduling

schedule(scheduledAt: Date):

  • Does nothing while a message or a scheduled message is being edited.
  • When the composer sends with pending uploads (createSendWithPendingUploadsAttachmentsMiddleware), waits for them to finish, because the composer performs this send itself. If an edit starts or scheduling a failed message again is cancelled during that wait, it stops without sending. With the default attachments middleware, uploads in progress block the composition as for a normal send.
  • Runs compose({ sendOptions: { scheduled_at } }) and sends the result through channel.sendMessage. It does not go through the offline DB queue.
  • On success, clears the composer (the draft is deleted by the composer subscriptions, as after a normal send) and returns the stored ScheduledMessageResponse. A scheduled_at that the server treats as past sends the message now; then nothing is stored and undefined is returned.
  • On failure, keeps the composition, adds an api:scheduled-message:create:failed notification and rethrows.

compose() now accepts { sendOptions }, so middleware can see that a composition is being scheduled.

What cannot be scheduled

A new composition middleware, stream-io/message-composer-middleware/scheduled-message, discards the composition and adds a validation:scheduled-message:blocked warning (metadata.reason: ScheduledMessageBlockReason.Command, Poll or SharedLocation) when it carries:

  • a slash command (a selected command, or text starting with a channel command),
  • a poll,
  • a shared location.

It runs only when sendOptions.scheduled_at is set or a scheduled message is being edited, so normal sends are unaffected. It is registered before poll-only, because that middleware completes the chain for poll compositions. Integrations can reorder or replace it like any other middleware.

The command lookup is shared with the validation middleware (getCurrentCommand in commandUtils), and the default chain gives both one CommandSearchSource.

Editing a scheduled message

  • editScheduledMessage(scheduledMessage):
    • Fills the composer from the scheduled message.
    • Resolves mentioned users from client state, and falls back to queryUsers for any it doesn't have.
    • Resolves the quoted message from channel or thread state, and falls back to getMessage.
    • Saves a restore point of the current composition. Only the first edit saves one, so switching to another scheduled message still restores the original composition.
  • updateScheduledMessage():
    • Composes the message (blocked compositions are rejected by the middleware above) and sends the whole message to client.scheduledMessages.updateScheduledMessage.
    • The payload takes every field the composer owns from the new composition, so content removed in the composer is removed on the server.
    • It keeps custom fields from the stored message and the stored parent_id, and drops server-only fields (html, id, mml, shared_location, type).
    • On success the edit ends and the earlier composition comes back. On failure the edit stays open and an api:scheduled-message:update:failed notification is added.
  • cancelEditScheduledMessage() ends the edit and restores the previous composition.
  • editedScheduledMessage (state field and getter) is the scheduled message being edited. Commands report the editing disabled reason during the edit.

Scheduling a failed message again

A failed scheduled message cannot be edited (the server answers 120), and sending its message.id again returns the failed row.

  • rescheduleFailedMessage(failedMessage) fills the composer from a failed message the same way as an edit, but as a new composition with a fresh id and without edit mode, so schedule() can send it. It ignores messages whose status is not failed.
  • A successful schedule() then deletes the failed message through client.scheduledMessages.deleteScheduledMessage (a failed delete is logged, not thrown) and restores the composition from before.
  • cancelRescheduleFailedMessage() restores the previous composition.
  • failedScheduledMessage (state field and getter) is the failed message being scheduled again.

Changes made elsewhere while a scheduled message is in the composer

The composer listens for scheduled_message.* events while it is 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 metadata.reason (ScheduledMessageCompositionEndReason: sent, deleted, failed). Without this, saving failed with "already sent" and built the payload from a stale copy.
  • 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; the text being edited is kept.

A channel query or queryChannels response with a draft (for example after a reconnect) no longer re-initializes the composer during a scheduled composition. It updates the restore point instead, like the draft events below.

Drafts while a scheduled message is in the composer

createDraft, deleteDraft and getDraft are skipped while a scheduled message is being edited or a failed one is being scheduled again, as they already were for edited messages. If a draft.updated or draft.deleted event arrives meanwhile, the composer isn't interrupted. The restore point is updated instead, so afterwards the composer shows the newest draft, or clears if the draft was deleted.

The payload mapping and the lookups live in src/messageComposer/scheduledMessage.ts (toScheduledMessageComposition, toScheduledMessagePayload, resolveMentionedUsers, resolveQuotedMessage).

Compatibility

All changes are additive.

  • MessageComposerState gains editedScheduledMessage and failedScheduledMessage (both ScheduledMessageResponse | null). Code that builds or deep-compares the whole composer state needs the new keys; three existing tests in messageComposer.test.ts were updated for this reason.
  • The default composition middleware chain gains the scheduled-message middleware. Integrations that set an explicit order with setOrder should include its id.
  • compose() takes an optional options object; calls without arguments are unchanged.
  • initState() accepts optional stateOverrides that are merged into the new composer state; the scheduled composition is set in one state update with it.
  • createPoll, sendLocation and the scheduling methods report failures through one private helper; the notifications are unchanged.

Tests

test/unit/MessageComposer/scheduledMessages.test.ts:

  • Scheduling: the payload, including parent_id from a thread composer and send options passed through compose(); clearing the composer and deleting the draft; bypassing the offline DB; waiting for pending uploads; the past-time response; the failure path; the guard while editing.
  • Blocking: typed and selected slash commands, polls (also through compose() directly) and shared locations are rejected when scheduling; normal compositions with a poll are left alone.
  • Edit mode: pre-filling the composer; the update payload, including removed content; blocked content during the edit; restoring after update and after cancel; nested edits; draft events during an edit.
  • Failed messages: pre-filling as a new composition; ignoring non-failed messages; scheduling again deletes the failed message and restores the earlier composition; a failed schedule keeps both; a failed delete is logged; cancel; cancel during an upload wait; drafts paused.
  • Changed elsewhere: the edit ends on sent, deleted and failed with the matching reason; other messages' events are ignored; the newest stored copy is kept; a failed message being scheduled again ends only on deletion; a channel response with a draft keeps the scheduled composition.

Also checked against a live app with the feature enabled (schedule, edit, send now, delete, blocked commands and polls, a message that failed because its channel was frozen and was then scheduled again, and an edit that ended when another session sent the message now).

Changelog

  • Add MessageComposer.schedule(at) to send a message later.
  • Add MessageComposer.editScheduledMessage, updateScheduledMessage and cancelEditScheduledMessage, and the editedScheduledMessage composer state.
  • Add MessageComposer.rescheduleFailedMessage and cancelRescheduleFailedMessage, and the failedScheduledMessage composer state.
  • Add the scheduled-message composition middleware, which keeps slash commands, polls and shared locations from being scheduled.
  • MessageComposer.compose() accepts { sendOptions }, and initState() accepts { stateOverrides }.
  • A scheduled message edit ends with a validation:scheduled-message:composition:ended warning when the message is sent, deleted or fails elsewhere.

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
- 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.
A failed scheduled message cannot be edited (the server answers 120), and
re-sending its message.id returns the failed row. rescheduleFailedMessage
fills the composer from the failed message (mentions and quoted message
resolved like an edit) as a new composition with a fresh id, without
entering edit mode, so schedule() can send it again.

- failedScheduledMessage (state field and getter) holds the failed message
- a successful schedule() deletes the failed message (a failed delete is
  logged) and restores the composition from before
- cancelRescheduleFailedMessage() restores that composition
- drafts are paused and draft events update the restore point, as during a
  scheduled edit
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.
@NikolaGoranNaumovski
NikolaGoranNaumovski force-pushed the feat/cha-5934-scheduled-messages branch from 62d7d9c to 070e050 Compare November 3, 2026 09:07
@NikolaGoranNaumovski
NikolaGoranNaumovski force-pushed the feat/cha-5934-scheduled-messages-composer branch from a6e473e to 896e5e0 Compare November 3, 2026 09:07
Base automatically changed from feat/cha-5934-scheduled-messages to feat/cha-5934-scheduled-messages-api November 9, 2026 12:40
@NikolaGoranNaumovski
NikolaGoranNaumovski merged commit 11d6c5f into feat/cha-5934-scheduled-messages-api Nov 9, 2026
4 checks passed
@NikolaGoranNaumovski
NikolaGoranNaumovski deleted the feat/cha-5934-scheduled-messages-composer branch November 9, 2026 12:40
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.

1 participant