Repository navigation
feat: schedule and edit scheduled messages from MessageComposer - #1956
Merged
NikolaGoranNaumovski merged 15 commits intoNov 9, 2026
Conversation
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.
NikolaGoranNaumovski
requested review from
MartinCupela,
isekovanic and
oliverlaz
October 28, 2026 13:54
- 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
…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.
…heduled-messages-composer
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
This was referenced Nov 2, 2026
…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.
NikolaGoranNaumovski
force-pushed
the
feat/cha-5934-scheduled-messages
branch
from
November 3, 2026 09:07
62d7d9c to
070e050
Compare
NikolaGoranNaumovski
force-pushed
the
feat/cha-5934-scheduled-messages-composer
branch
from
November 3, 2026 09:07
a6e473e to
896e5e0
Compare
2 tasks done
Base automatically changed from
feat/cha-5934-scheduled-messages
to
feat/cha-5934-scheduled-messages-api
November 9, 2026 12:40
NikolaGoranNaumovski
merged commit Nov 9, 2026
11d6c5f
into
feat/cha-5934-scheduled-messages-api
4 checks passed
NikolaGoranNaumovski
deleted the
feat/cha-5934-scheduled-messages-composer
branch
November 9, 2026 12:40
2 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CLA
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
MessageComposerschedule a message, edit a scheduled one, and schedule a failed one again. It reuses the existingcompose()pipeline, so text, attachments, mentions, quotes and custom data behave the same as a normal send.Scheduling
schedule(scheduledAt: Date):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.compose({ sendOptions: { scheduled_at } })and sends the result throughchannel.sendMessage. It does not go through the offline DB queue.ScheduledMessageResponse. Ascheduled_atthat the server treats as past sends the message now; then nothing is stored andundefinedis returned.api:scheduled-message:create:failednotification 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 avalidation:scheduled-message:blockedwarning (metadata.reason:ScheduledMessageBlockReason.Command,PollorSharedLocation) when it carries:It runs only when
sendOptions.scheduled_atis set or a scheduled message is being edited, so normal sends are unaffected. It is registered beforepoll-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 (
getCurrentCommandincommandUtils), and the default chain gives both oneCommandSearchSource.Editing a scheduled message
editScheduledMessage(scheduledMessage):queryUsersfor any it doesn't have.getMessage.updateScheduledMessage():client.scheduledMessages.updateScheduledMessage.parent_id, and drops server-only fields (html,id,mml,shared_location,type).api:scheduled-message:update:failednotification is added.cancelEditScheduledMessage()ends the edit and restores the previous composition.editedScheduledMessage(state field and getter) is the scheduled message being edited. Commands report theeditingdisabled reason during the edit.Scheduling a failed message again
A failed scheduled message cannot be edited (the server answers 120), and sending its
message.idagain 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, soschedule()can send it. It ignores messages whose status is notfailed.schedule()then deletes the failed message throughclient.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,deletedorfailedfor the message being edited ends the edit, restores the earlier composition and adds avalidation:scheduled-message:composition:endedwarning withmetadata.reason(ScheduledMessageCompositionEndReason:sent,deleted,failed). Without this, saving failed with "already sent" and built the payload from a stale copy.deletedfor a failed message being scheduled again ends that too.scheduled_message.updatedrefreshes the stored copy the update payload is built from; the text being edited is kept.A channel query or
queryChannelsresponse 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,deleteDraftandgetDraftare skipped while a scheduled message is being edited or a failed one is being scheduled again, as they already were for edited messages. If adraft.updatedordraft.deletedevent 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.
MessageComposerStategainseditedScheduledMessageandfailedScheduledMessage(bothScheduledMessageResponse | null). Code that builds or deep-compares the whole composer state needs the new keys; three existing tests inmessageComposer.test.tswere updated for this reason.scheduled-messagemiddleware. Integrations that set an explicit order withsetOrdershould include its id.compose()takes an optional options object; calls without arguments are unchanged.initState()accepts optionalstateOverridesthat are merged into the new composer state; the scheduled composition is set in one state update with it.createPoll,sendLocationand the scheduling methods report failures through one private helper; the notifications are unchanged.Tests
test/unit/MessageComposer/scheduledMessages.test.ts:parent_idfrom a thread composer and send options passed throughcompose(); 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.compose()directly) and shared locations are rejected when scheduling; normal compositions with a poll are left alone.sent,deletedandfailedwith 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
MessageComposer.schedule(at)to send a message later.MessageComposer.editScheduledMessage,updateScheduledMessageandcancelEditScheduledMessage, and theeditedScheduledMessagecomposer state.MessageComposer.rescheduleFailedMessageandcancelRescheduleFailedMessage, and thefailedScheduledMessagecomposer state.scheduled-messagecomposition middleware, which keeps slash commands, polls and shared locations from being scheduled.MessageComposer.compose()accepts{ sendOptions }, andinitState()accepts{ stateOverrides }.validation:scheduled-message:composition:endedwarning when the message is sent, deleted or fails elsewhere.