feat(MessageComposer): add composition middleware for pending attachment uploads - #1845
Conversation
Adds the pieces a UI SDK needs to let a message be sent before its attachments finish uploading, plus the fixes that flow made necessary. - createSendWithPendingUploadsAttachmentsMiddleware: drop-in replacement for the default attachments middleware (same id) that stops discarding a composition with uploads in flight. localMessage.attachments keeps localMetadata for what is still uploading; message.attachments carries only attachments that already resolved to a URL. - MessageComposer.hasSendableDataWithPendingUploads: the matching sendability rule, so an upload in flight does not disable the send. failed and blocked attachments still do not count. - No config option turns this on: the composition it produces is not ready for the wire, so the switch belongs to the UI SDK that awaits the uploads and sends afterwards. - UploadRecord.uploadConfirmationPending marks the window between the last byte being written to the connection and the server responding, mirrored onto localMetadata. Progress hits 100% before anything is confirmed, so a UI can go indeterminate instead of claiming success. - isPendingUpload / isFinishedUpload predicates. - withoutConcurrency, withCancellation, hasPending and settled are now exported, so UI SDKs can serialise their own actions with the same primitive rather than hand-rolling promise chains. Fixes: - A cancelled upload is no longer reported as a failure. Removing an attachment mid-upload aborts the request through its AbortController; both the post-upload error middleware and the deprecated uploadAttachment path now recognise that (axios cancellations and DOMException AbortError, which is what the React Native adapter throws) and stay quiet. - removeAttachments releases the attachment's blob preview instead of leaking one URL per removed attachment. - The post-upload enrichment middleware releases a preview only while the composer still holds the attachment. Once a message renders from it, releasing would blank that message out. - channel.sendMessage strips localMetadata from outgoing attachments and drops any that never resolved, with a warning, so a UI that composes with pending uploads but does not await them cannot store an attachment pointing at nothing.
| (!composerIsKeptAsDraft && isPendingUpload(attachment)), | ||
| ); | ||
|
|
||
| const localAttachments = (state.localMessage.attachments ?? []).concat( |
There was a problem hiding this comment.
Question about the shape that gets persisted, RN stringifies message attachments straight into SQLite, so this localMetadata survives a restart and comes back as uploading with nothing in flight. normalizeSnapshotAttachment covers the composer, but nothing covers a stored message.
Is that ours to deal with or maybe we can add a normalizer here? Changes whether we need our own hydration pass (right now the implementation will stay in RN but eventually I would like to integrate it using the new middleware for consistency)
There was a problem hiding this comment.
My understanding is that this is currently covered by the RN SDK, so I would defer the introduction into the LLC to a next PR to make sure RN is not broken with changes that could be introduced in this subject.
…oads createSendWithPendingUploadsAttachmentsMiddleware wrote message.attachments even when no upload had finished, so a composition whose uploads were all still in flight carried \`attachments: []\`. On an edit the API reads that as \"remove every attachment\". The default middleware deliberately never introduces the key when it has nothing to add. Both payloads now get the key only when their list is non-empty — localMessage too, since it can be empty while message.attachments is not.
…port carries no number
… installed middleware
|
🎉 This PR is included in version 9.52.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Goal
Make it possible to compose a message while its attachment uploads are still in flight.
Today that is refused twice over: the attachments composition middleware discards any composition with uploads running (warning
Wait until all attachments have uploaded), andMessageComposer.hasSendableDatareturnsfalsefor as long asuploadsInProgressCount > 0. A UI SDK therefore cannot offer sending before every transfer has finished, however long that takes.This PR adds the composer-side half of that flow, plus the fixes it exposed in upload state, cancellation and preview lifecycle.
It is deliberately only half. The composition produced is not ready for the wire —
message.attachmentsomits every attachment that has no URL yet — so the other half belongs to whoever performs the send: await the pending uploads, write the resolved URLs into the payload, then make the request. That is why nothing here is enabled by a config option.Backwards compatible except in one respect: outgoing attachments are now sanitized on every send and every update, which strips composer-internal state that was previously forwarded to the API. Everything else takes effect only once a UI SDK installs the new middleware.
Implementation details
Composing with pending uploads
createSendWithPendingUploadsAttachmentsMiddlewarereplaces the default attachments composition middleware and shares its id, soreplace()keeps its position in the chain. It splits the two payloads instead of discarding the composition:localMessage.attachmentskeepslocalMetadatafor anything still uploading — theUploadManagerid, the file handle needed to await the upload, the local preview URI;message.attachmentscarries only attachments that already resolved to a URL.Neither key is introduced when its list is empty, matching the default middleware — an empty
attachmentsarray is not "nothing to say", it reads as "remove every attachment" on an edit.Attachments still uploading stay in the composer when the composition carries a poll. A poll composition does not clear the composer, so handing the attachment over would leave the same
localMetadata.idowned by both the message and the composer, and a secondUploadManager.uploadcall would restart a request whose in-flight entry had already been cleaned up.One switch: the middleware declares what it does
MessageCompositionMiddleware.allowsPendingUploadsis a declaration a composition middleware sets on itself, andMessageComposer.hasSendableDatareads it: while such a middleware is installed, an upload in flight is no longer a blocker and a pending attachment counts as content in its own right.failedandblockedattachments still do not count, so a message whose only attachment was rejected is not sendable.Installing the middleware is therefore the whole switch — there is no second flag for a UI SDK to keep in sync with it, and a custom middleware implementing the same contract is recognised regardless of the id it uses.
Reading the declaration needs
MiddlewareExecutor.installedMiddleware, a new read-only view of the chain (MessageComposerMiddlewareExecutornarrows it to the composition middleware type). Both the declaration andMessageComposer.allowsPendingUploadsare marked temporary: v10 moves this into the composer configuration.The window between the last byte and the response
UploadRecord.uploadConfirmationPendingmarks the gap between the request body being flushed and the server answering, and is mirrored ontolocalMetadata. Upload progress counts bytes written to the connection, not bytes acknowledged, so it reaches 100% while the file is still being ingested — long enough to matter for a large file on a slow link. The flag is lowered only by a progress report carrying a number below 100: a report with no number means the transport cannot measure this upload, not that flushed bytes were un-sent.Outgoing attachments are sanitized
sanitizeOutgoingAttachmentsstripslocalMetadataand drops any attachment that never resolved, reporting the drop throughclient.loggerandconsole.warn. It runs inchannel._sendMessageandclient._updateMessage— the two methods every path converges on, including the offline replay of a queued task, which calls them directly with a payload that a merged failed edit may have rewritten since it was stored."Resolved" means a remote
http(s)URL, not merely the presence of one. A local reference —blob:,file:,content:, an inlinedata:payload — resolves only on the device that produced it, and forwarding it would store an attachment nobody else can load. Reaching that branch means the code that composed the message has a bug, so it is logged rather than raised as a notification, which is a channel for things an end user can act on.partialUpdateMessageis deliberately not covered: itssetis a caller-authored patch that no composer output flows through.Cancellation is not a failure
Removing an attachment mid-upload aborts the request through its
AbortController.isUploadCancellationrecognises the result — an axios cancellation, or aDOMExceptionnamedAbortError, which is what a custom transport conventionally throws — and both the post-upload error middleware and the deprecateduploadAttachmentpath now stay quiet instead of raising an upload error for something the user asked for.Preview lifecycle
removeAttachmentsreleases the removed attachment'sblob:preview, which previously leaked one object URL per removal.The post-upload enrichment middleware releases a preview only while the composer still holds the attachment, which its new optional
composerargument is what tells it. The middleware runs when an upload finishes, and that can now happen after the composer was cleared — at which point something else is rendering from that preview and releasing it would blank it out.New exports
isPendingUpload/isFinishedUpload(upload-state predicates, typed againstAttachmentLoadingState),isUploadCancellation, and the tag-keyed async runnerswithoutConcurrency,withCancellation,hasPending,settled— so a UI SDK can serialise its own actions with the primitive this SDK already uses internally instead of hand-rolling promise chains.What a UI SDK has to implement
createSendWithPendingUploadsAttachmentsMiddlewareon the composers it wants this for. Sendability follows from the declaration; nothing else needs switching.UploadManager.uploadis idempotent bylocalMetadata.id, so calling it again returns the in-flight promise), write the resolved URLs intomessage.attachments, and only then make the request. Doing this in the send path rather than at submit time also covers resending a failed message, and skipping attachments that already carry a URL makes a retry re-upload only what failed.created_atis stamped when the request arrives —withoutConcurrencyis exported for serialising them per channel.uploadConfirmationPendingis available for both a composer attachment (fromlocalMetadata) and a message attachment (from the liveUploadManagerrecord) if the SDK wants to distinguish "sending" from "sent but unconfirmed".Tests
39 cases across the composition middleware,
hasSendableData,UploadManager,AttachmentManager, the post-upload middlewares,sanitizeOutgoingAttachments, and thechannel/clientsend and update paths.