Skip to content

Group native multi-image uploads into a gallery (insertPendingAttachments batch)#1111

Open
svara wants to merge 1 commit into
mainfrom
native-bridge-image-gallery
Open

Group native multi-image uploads into a gallery (insertPendingAttachments batch)#1111
svara wants to merge 1 commit into
mainfrom
native-bridge-image-gallery

Conversation

@svara

@svara svara commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Regression

Before the BC5 / Lexxy migration (Trix → Lexical), selecting multiple images at once in the native iOS/Android apps grouped them into a single image gallery, matching the web. After the migration, native multi-image uploads stopped grouping — each image lands as its own standalone attachment, stacked vertically.

Why it broke

On the web, every upload entry point (drag-and-drop, paste, file picker) funnels through Uploader.for(...), which delegates to GalleryUploader whenever the batch contains more than one previewable image (GalleryUploader.isMultipleImageUpload). GalleryUploader creates one ImageGalleryNode and inserts the images into it. That is the one and only place a gallery is formed.

The native bridge never reaches that path. The host app uploads each file itself and hands the editor one pending attachment at a time via Contents#insertPendingAttachment(file), which inserts a lone ActionTextAttachmentUploadNode at the cursor. Because images arrive and are inserted individually, isMultipleImageUpload is never consulted and no gallery is ever created.

Trix had no equivalent gap: its attachmentGalleryFilter ran over the whole document and grouped adjacent image attachments regardless of how they were inserted, so even the one-at-a-time native path produced a gallery. Lexxy has no always-on grouping pass — grouping happens only at insertion time inside GalleryUploader — so the native path regressed.

Fix

Give the bridge a batch entry point that flows through the exact same Uploader/GalleryUploader path the web uses, rather than teaching Lexxy a second, parallel grouping mechanism.

New Contents#insertPendingAttachments(files):

  • builds the whole batch via Uploader.for(editorElement, files, { pending: true }), so GalleryUploader makes the grouping decision with the same isMultipleImageUpload / selection rules as the web — multiple images → one gallery, a single image → standalone, non-image files placed after the gallery;
  • runs in a new { pending: true } mode so the upload nodes are built without an uploadUrl ($createPendingUploadNode) and the editor does not kick off a web DirectUpload — the host app still owns the upload;
  • returns one handle per file, in input order, so the host drives each attachment's setUploadProgress / setAttributes (materialize) / remove as it settles.

The pending-attachment handle is extracted into a shared #pendingAttachmentHandle and reused by both the singular insertPendingAttachment and the new batch method.

Reuses the existing gallery logic — no new grouping code

GalleryUploader and ImageGalleryNode are unchanged. The only addition to the uploader is a { pending: true } branch in Uploader#$createUploadNodes that swaps $createUploadNode for the uploadUrl-less $createPendingUploadNode:

#createUploadNode(file) {
  return this.options.pending
    ? this.contents.$createPendingUploadNode(file)
    : this.contents.$createUploadNode(file)
}

Galleries form, accept non-image siblings, materialize, and dissolve through the same code that already backs web drag/drop/paste — including replacing each pending node with its real attachment (the gallery survives) and removing the second-to-last image (the gallery dissolves).

Back-compat during rollout

The singular insertPendingAttachment is kept as-is (still no gallery) so older native builds that insert one at a time keep working. New native builds call the batch method. Once all clients are updated the singular path can be retired.

Tests

test/browser/tests/attachments/native_bridge_gallery.test.js (6 tests):

  • multiple images → one gallery
  • three images → one gallery
  • a single image → no gallery
  • images group while a non-image file is placed after the gallery
  • a handle per file, and materializing each keeps the gallery
  • removing one image via its handle dissolves the gallery

Companion change

bc3 wires the bridge to this API (insert-pending-attachments controller case + LexxyEditor#insertPendingAttachments): basecamp/bc3 branch native-bridge-image-gallery. It needs a Lexxy npm/gem release before it can bump off the local-dev pin.

Native clients (iOS/Android) currently insert each uploaded image one at a time
via insertPendingAttachment, so they never reach the web uploadFiles /
GalleryUploader path and multi-image uploads never form a gallery.

Add Contents#insertPendingAttachments(files): it routes the whole batch through
the same Uploader/GalleryUploader path the web uses, creating bridge-managed
(uploadUrl-less) upload nodes so the editor doesn't kick off a DirectUpload —
the host app still owns the upload and drives each returned handle
(setUploadProgress/setAttributes/remove) as it settles.

- $createPendingUploadNode builds the uploadUrl-less node
- Uploader gains a { pending: true } mode selecting that factory
- the pending-attachment handle is extracted and shared by the single and
  batch entry points

The single insertPendingAttachment keeps its existing no-gallery behaviour for
back-compat with the current bridge during rollout.
@svara svara requested a review from samuelpecher June 11, 2026 08:19
@svara svara marked this pull request as ready for review June 11, 2026 08:19
Copilot AI review requested due to automatic review settings June 11, 2026 08:19

Copilot AI 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.

Pull request overview

This PR restores native iOS/Android multi-image upload behavior by adding a batch pending-attachment insertion API that routes through the existing Uploader/GalleryUploader path, allowing multiple inserted images to be grouped into a single gallery (matching web behavior) without initiating web DirectUploads.

Changes:

  • Add Contents#insertPendingAttachments(files) to insert a batch of pending upload nodes and return one handle per file (in input order) for host-driven progress/materialization/removal.
  • Extend Uploader.for(...) to accept options, and add a { pending: true } mode that builds uploadUrl-less pending nodes.
  • Add Playwright coverage for native-bridge batch insertion and gallery grouping/materialization/dissolution behavior.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
test/browser/tests/attachments/native_bridge_gallery.test.js Adds browser tests asserting batch pending insertion groups images into galleries and preserves handle semantics.
src/editor/contents/uploader.js Adds options plumbing and a pending-node creation branch so pending uploads don’t start DirectUpload.
src/editor/contents.js Adds $createPendingUploadNode and new insertPendingAttachments(files) API, refactoring handle creation into #pendingAttachmentHandle.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copilot AI review requested due to automatic review settings June 11, 2026 11:54
@mbarta mbarta force-pushed the native-bridge-image-gallery branch from c117f90 to 8b2fa86 Compare June 11, 2026 11:54

Copilot AI 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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/editor/contents.js
Comment on lines +308 to +316
insertPendingAttachments(files) {
if (!this.editorElement.supportsAttachments) return []

let nodeKeys = []
this.editor.update(() => {
const uploader = Uploader.for(this.editorElement, Array.from(files), { pending: true })
uploader.$uploadFiles()
nodeKeys = (uploader.nodes ?? []).map(node => node.getKey())
}, { tag: HISTORY_MERGE_TAG })
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