feat(mobile): one bottom sheet for page comments, not a floating card

Merged
HA
1mo ago
+2063-609

What

Replaces the anchored comment popover on apps/mobile page comments with a single bottom sheet, adds a native module for its quick replies, and makes the all-comments screen share the same composer.

BeforeAfter
Surfacefloating card placed by popoverPlacement()one formSheet route
Quick repliesicon row inside the cardUIKit buttons with SF Symbols, under the composer
Presetsa second sheet pushed from the carda native UIMenu anchored to the ellipsis
KeyboardkeyboardWillShow listeners + a 250ms withTiming settleUIKit
Dismissalan ✕ plus a pointer-down message from inside the WebViewdrag, tap-outside, swipe

Why

The card had to be placed, and placement kept losing. It started at an assumed ESTIMATED_HEIGHT and moved after onLayout, so the first frame was wrong. It modelled the keyboard by hand. And on a tall block both branches of the flip covered the block being commented on: below didn't fit, above sat on top of it.

A sheet has no placement. It's bottom-anchored at a height that fits its contents, and on iOS 26 a partial detent is Liquid Glass that floats clear of the edges, so the page stays visible behind it.

The native module

apps/mobile/AGENTS.md says a sheet body is RN + uniwind and never @expo/ui SwiftUI content. But an anchored menu is presented by UIKit outside the React Native view tree — JS can't draw one without it clipping to the sheet and hand-imitating the material, the animation and the VoiceOver semantics.

The house answer is a module of our own: alert-prompt wraps UIAlertController, composer already uses UIMenu. So this adds @superset/symbol-button — a UIButton whose content is an SF Symbol, which given items carries a UIMenu with showsMenuAsPrimaryAction. enabled maps to isEnabled, so a disabled quick reply stops taking taps rather than only dimming.

The branch's first cut used @expo/ui and was replaced (486ee6c4d) to honour the rule.

Its event is onTap, not onPress: React Native registers topPress as a direct event, and a view manager declaring the same name as bubbling makes registration throw.

Also in here

  • The page header is now glass with no title (pageScreenOptions → glassHeaderOptions). Four toolbar buttons plus a title read as compressed, and pages carry their own title in the content. The WebView's contentInset exists because of that: getBoundingClientRect() inside the document doesn't know about a native content inset, so the pin/highlight overlay starts at the same offset or every pin sits a header's height too high.
  • popoverPlacement (shared with desktop) gains a bottom clamp so a flipped card can't run off the container.
  • A bun patch drops expo-observe's podspec from Swift 6 to 5 — under Xcode 26.3 its mutable global state is a fatal diagnostic and blocked every local iOS build. Behaviour-neutral (no Swift-6-only syntax in the package), with a guard test in the house style per patches/README.md.
  • formatRelativeTime fell back to format(0), which numeric: "always" renders as "in 0s" — a freshly posted comment read as the future for its first second.
  • Three new i18n ids (quick-reply labels with a context), translated in all 17 locales.
  • The design plan is committed under plans/.

Testing

  • packages/shared and packages/i18n suites pass; tsc --noEmit, biome and check:i18n clean
  • Driven end to end on the simulator with Maestro against the production API: page → comment mode → block → typed post, thumbs-up, menu preset → pins → all comments → reply. Both native events reach JS and create threads.

Known gaps

  • A selection low on the page can sit behind the sheet. Scrolling it into view needs a smooth scroll-into-view on the frame protocol; the existing restore-scroll jumps instantly and locks scrolling for ~1s.
  • The sheet has no ✕ and no grabber, so swipe-down is the only exit — the one gesture VoiceOver and Switch Control users can't perform.
  • On a dark page, Liquid Glass shows page text through the sheet's bottom safe-area band. That band is outside the React view, so it can't be tinted from RN without a visible seam.
  • Quick replies dim once a draft exists, with no cue that clearing the field brings them back.