From 6415bcb215c1f75d59c19067b097773f8c2a0055 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 10:09:48 -0400 Subject: [PATCH 1/2] fix(chat): keep the long-press menu off its neighbours Long-pressing any bubble slid the group's invite card at the top of the transcript down over its own reaction pills, and it stayed there until the next scroll. The menu froze the transcript's inset by switching `contentInsetAdjustmentBehavior` to `.never`, which changes the safe area UIKit hands down to the rows; the SwiftUI-hosted card under the nav bar re-laid itself out for it. The freeze now holds the bottom inset at its keyboard-up value and leaves the mode at `.always`. The shift predates #898: it has been there since the freeze landed, and only showed once pills sat under a hosted card. The strip now takes whichever side of the bubble the menu didn't. UIKit picks the menu's side, and a bubble near the bottom got the menu and strip both above it. The taller lift from #898 now applies only to bubbles too close to the top for the strip to fit above; elsewhere it left an empty band between the bubble and a menu placed above it. The lift's copy fades out as the row's bubble reappears. UIKit keeps the copy above the transcript for about 0.4s after it lands, so a bubble partly under the bar's soft edge stayed sharp until it went. --- .../Chat/ChatScreenViewController.swift | 112 +++++++++++++++--- .../FlipcashUI/Chat/ChatViewController.swift | 96 +++++++++------ 2 files changed, 152 insertions(+), 56 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index ea6fbec3e..9e144cb00 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -264,6 +264,8 @@ public final class ChatScreenViewController: UIViewController { private var reactionStripWindow: UIWindow? /// The side the live strip grows from, so it collapses back the same way. private var stripHugsTrailing = false + /// Whether the live strip sits below its bubble, so it collapses back toward it. + private var stripSitsBelowBubble = false /// The raised copy of a double-tapped bubble and the frame it returns to, while the strip is up /// without a menu. private var stripOnlyLift: (copy: UIView, home: CGRect)? @@ -337,9 +339,8 @@ public final class ChatScreenViewController: UIViewController { return entries } - /// Adds the reaction strip above the just-lifted bubble, in a window above the context menu's own - /// container — see `reactionStripWindow`. The lift already reserved the strip's room above the - /// bubble (see `ChatViewController.liftReservesStripRoom`), so UIKit has put the menu clear of it. + /// Adds the reaction strip beside the just-lifted bubble, on the side the menu isn't, in a window + /// above the context menu's own container — see `reactionStripWindow`. private func presentReactionStrip() { guard let message = transcript.contextMenuMessage, let entries = stripEntries(for: message), let scene = view.window?.windowScene, @@ -349,11 +350,28 @@ public final class ChatScreenViewController: UIViewController { let bubble = view.convert(bubbleFrame, to: nil) let strip = installStrip(entries: entries, for: message, in: host, besides: bubble) - // The lifted copy carries its final frame from the menu's first layout pass, before its - // spring has played out, so reading it one turn later lets the strip arrive with the menu. - DispatchQueue.main.async { [weak self, weak strip] in - guard let self, let strip, reactionStrip === strip else { return } - placeStrip(strip, above: transcript.liftedBubbleFrame() ?? bubble) + // UIKit picks the menu's side, and not only by the room left below the bubble, so the strip + // takes whichever side the menu didn't. The menu's views carry their final frames from the + // first layout pass, before its spring has played out, so reading them one turn later lets + // the strip arrive alongside the menu rather than after it. + let titles = Set(message.actions.map(\.title)) + DispatchQueue.main.async { [weak self, weak strip, weak window] in + guard let self, let strip, let window, reactionStrip === strip else { return } + let menu = Self.contextMenuFrame(in: scene, excluding: [view, window], titles: titles) + // UIKit may slide the lifted bubble away from its row to make room for the menu, so the + // strip goes by where the lifted bubble sits rather than where the row does. + let lifted = transcript.liftedBubbleFrame() + ?? Self.liftedPreviewFrame(in: scene, excluding: [view, window], size: bubble.size) + ?? bubble + let menuIsAbove = menu.map { $0.midY < lifted.midY } ?? false + // The same gap UIKit left on the menu's side, so the bubble sits centered between the two. + let menuGap = menu.map { menuIsAbove ? lifted.minY - $0.maxY : $0.minY - lifted.maxY } + let gap = menuGap.flatMap { (0...40).contains($0) ? $0 : nil } ?? Self.fallbackStripGap + if menuIsAbove { + placeStrip(strip, below: lifted, gap: gap) + } else { + placeStrip(strip, above: lifted, gap: gap) + } } strip.onSelect = { [weak self] emoji in self?.transcript.dismissContextMenu() @@ -478,15 +496,28 @@ public final class ChatScreenViewController: UIViewController { return strip } - /// Sits `strip` `ReactionStripView.bubbleGap` above `lifted` (window coordinates), no higher than - /// the safe area allows, and grows it out of the bubble's corner. - private func placeStrip(_ strip: ReactionStripView, above lifted: CGRect) { + /// Sits `strip` `gap` above `lifted` (window coordinates), no higher than the safe area allows, + /// and grows it out of the bubble's corner. + private func placeStrip(_ strip: ReactionStripView, above lifted: CGRect, gap: CGFloat = ReactionStripView.bubbleGap) { guard let host = strip.superview, let window = host.window else { return } let highest = window.safeAreaInsets.top + Self.stripTopMargin - let top = max(highest, lifted.minY - ReactionStripView.bubbleGap - ReactionStripView.height) + let top = max(highest, lifted.minY - gap - ReactionStripView.height) strip.topAnchor.constraint(equalTo: host.topAnchor, constant: top).isActive = true - host.layoutIfNeeded() - strip.transform = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing) + reveal(strip, sittingBelow: false) + } + + /// Sits `strip` `gap` below `lifted` (window coordinates), for a bubble the menu sits above. + private func placeStrip(_ strip: ReactionStripView, below lifted: CGRect, gap: CGFloat) { + guard let host = strip.superview else { return } + strip.topAnchor.constraint(equalTo: host.topAnchor, constant: lifted.maxY + gap).isActive = true + reveal(strip, sittingBelow: true) + } + + /// Grows a placed strip out of the bubble's corner. + private func reveal(_ strip: ReactionStripView, sittingBelow: Bool) { + strip.superview?.layoutIfNeeded() + stripSitsBelowBubble = sittingBelow + strip.transform = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing, towardTop: sittingBelow) strip.revealEntries(fromTrailing: stripHugsTrailing) UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.78, initialSpringVelocity: 0) { strip.transform = .identity @@ -494,14 +525,59 @@ public final class ChatScreenViewController: UIViewController { } } - /// The strip shrunk into its bottom corner on the bubble's side, so it grows out of the bubble. - private static func collapsedStripTransform(_ size: CGSize, towardTrailing: Bool) -> CGAffineTransform { + /// The strip shrunk into the corner nearest the bubble's side, so it grows out of the bubble. + private static func collapsedStripTransform(_ size: CGSize, towardTrailing: Bool, towardTop: Bool) -> CGAffineTransform { let scale: CGFloat = 0.3 let dx = size.width * (1 - scale) / 2 * (towardTrailing ? 1 : -1) - let dy = size.height * (1 - scale) / 2 + let dy = size.height * (1 - scale) / 2 * (towardTop ? -1 : 1) return CGAffineTransform(translationX: dx, y: dy).scaledBy(x: scale, y: scale) } + /// The strip's gap from the bubble when the menu's own gap can't be measured. + private static let fallbackStripGap: CGFloat = 16 + + /// Where the presented context menu's platter sits on screen, or `nil` if it isn't found. UIKit + /// exposes no frame for the menu, but its rows are plain `UILabel`s carrying the action titles, + /// and the platter is the outermost view around them that is still narrower than the screen. + /// `excluded` subtrees are skipped, so a message whose text matches an action title can't be + /// mistaken for the menu. + private static func contextMenuFrame(in scene: UIWindowScene, excluding excluded: [UIView], titles: Set) -> CGRect? { + var labels: CGRect? + var platter: CGRect? + var pending: [UIView] = scene.windows + while let next = pending.popLast() { + guard !excluded.contains(where: { $0 === next }) else { continue } + if let label = next as? UILabel, let text = label.text, titles.contains(text), !label.isHidden { + let rect = label.convert(label.bounds, to: nil) + labels = labels.map { $0.union(rect) } ?? rect + if platter == nil, let screen = next.window?.bounds { + var outer: UIView = label + while let parent = outer.superview, parent.bounds.width < screen.width * 0.95 { + outer = parent + } + platter = outer.convert(outer.bounds, to: nil) + } + } + pending.append(contentsOf: next.subviews) + } + guard let labels else { return nil } + return platter.map { $0.union(labels) } ?? labels + } + + /// Where UIKit put the lifted copy of the bubble, found as the view in the menu's container + /// that matches the bubble's size, or `nil` if none does. + private static func liftedPreviewFrame(in scene: UIWindowScene, excluding excluded: [UIView], size: CGSize) -> CGRect? { + var pending: [UIView] = scene.windows + while let next = pending.popLast() { + guard !excluded.contains(where: { $0 === next }) else { continue } + if !next.isHidden, abs(next.bounds.width - size.width) < 1, abs(next.bounds.height - size.height) < 1 { + return next.convert(next.bounds, to: nil) + } + pending.append(contentsOf: next.subviews) + } + return nil + } + /// The strip's distance from the screen's sides. private static let stripSideMargin: CGFloat = 16 /// The least room the strip keeps below the status bar. @@ -513,7 +589,7 @@ public final class ChatScreenViewController: UIViewController { reactionStrip = nil let window = reactionStripWindow reactionStripWindow = nil - let collapsed = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing) + let collapsed = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing, towardTop: stripSitsBelowBubble) let collapse = { strip.transform = collapsed strip.alpha = 0 diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 75ba2344b..8c270cf94 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -211,8 +211,8 @@ public final class ChatViewController: UICollectionViewController { /// The bubble a context menu has raised, held so the lift's elevation comes off the same view when /// the menu goes. Weak: the cell it belongs to can be recycled out from under the menu. private weak var liftedBubble: UIView? - /// The inset state captured when the menu opened, restored when it closes. - private var savedInsetBehavior: UIScrollView.ContentInsetAdjustmentBehavior? + /// The adjusted bottom inset held while a menu is up, and the content inset it replaced. + private var frozenBottomInset: CGFloat? private var savedContentInset: UIEdgeInsets? private var savedScrollIndicatorInsets: UIEdgeInsets? /// A transcript pushed while the menu was up, applied once it closes (so an arriving message can't @@ -350,13 +350,16 @@ public final class ChatViewController: UICollectionViewController { } public override func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { + if let frozenBottomInset, !isAdjustingBottomInset { + holdBottomInset(at: frozenBottomInset) + return + } // The system changed the adjusted inset — on-device this is the keyboard showing or hiding. // If the user was at the bottom, follow it so the newest message stays just above the // keyboard; a reader who scrolled up is left where they are. // - // While a context menu is up the inset is frozen (`freezeInset`), so this shouldn't fire for the - // keyboard — but guard anyway, since taking the inset over and handing it back each toggles the - // adjusted inset, and following those would move the content the freeze is holding in place. + // While a context menu is up the bottom inset is held (`holdBottomInset`) above; handing it back + // on close fires this once more, and following that would move the content the hold kept still. guard !isAdjustingBottomInset, !isShowingContextMenu, wasAtBottom, !needsInitialScroll, !isUpdating, !items.isEmpty else { return } // This fires inside UIKit's keyboard-adjustment animation block. Following // the bottom via ChatLayout's `restoreContentOffset` forces a layout pass @@ -907,47 +910,47 @@ public final class ChatViewController: UICollectionViewController { } } - /// Take over the inset at its current (keyboard-up) value so the keyboard leaving under the menu - /// can't shrink the adjusted inset — the keyboard's space stays reserved and the content holds its - /// exact position. + /// Holds the adjusted bottom inset at its current (keyboard-up) value for the menu's lifetime, so + /// the keyboard leaving under the menu can't shrink it — the keyboard's space stays reserved and + /// the content holds its exact position. + /// + /// The adjustment behavior stays `.always`: switching the transcript to `.never` changes how UIKit + /// hands the safe area down to its rows, and a SwiftUI-hosted card sitting under the navigation bar + /// re-lays its content for it — the card jumped onto its own reaction pills on every long press. private func freezeInset() { - guard savedInsetBehavior == nil else { return } - let frozen = collectionView.adjustedContentInset - savedInsetBehavior = collectionView.contentInsetAdjustmentBehavior + guard frozenBottomInset == nil else { return } + frozenBottomInset = collectionView.adjustedContentInset.bottom savedContentInset = collectionView.contentInset savedScrollIndicatorInsets = collectionView.verticalScrollIndicatorInsets - // Order matters: copy the inset in *before* taking the behavior over. Switching to `.never` - // first would drop the keyboard's contribution for one pass, shrinking the scrollable range - // under a transcript that sits at its bottom — UIKit clamps the offset there and then, and - // re-growing the inset does not put it back. Raising `contentInset` first only ever grows - // the adjusted inset, so nothing clamps on the way through. - collectionView.contentInset = frozen - collectionView.verticalScrollIndicatorInsets = frozen - collectionView.contentInsetAdjustmentBehavior = .never - // No `restoreContentOffset` re-anchor here: forcing ChatLayout's layout - // pass during the context-menu inset/keyboard transition aborts on - // iOS 26 (a UICollectionView bounds-change "fading" assertion), whether - // called synchronously or deferred. The inset takeover above already - // pins the adjusted inset at its keyboard-up value, so the content holds - // its position without a forced re-anchor. - } - - /// Hand the inset back to the system, which re-derives the adjusted inset from wherever the - /// keyboard is by then: back up behind the menu, in which case nothing moves, or gone, in which - /// case the transcript settles into the space it vacated. + } + + /// Re-sizes the content inset so the system's share plus ours still adds up to `bottom`. Runs from + /// the adjusted-inset callback, before the scroll view lays out, so a transcript sitting at its + /// bottom never sees the shrunken range and has nothing to clamp. + private func holdBottomInset(at bottom: CGFloat) { + let system = collectionView.adjustedContentInset.bottom - collectionView.contentInset.bottom + let own = max(0, bottom - system) + guard abs(collectionView.contentInset.bottom - own) > 0.5 else { return } + isAdjustingBottomInset = true + collectionView.contentInset.bottom = own + collectionView.verticalScrollIndicatorInsets.bottom = own + isAdjustingBottomInset = false + } + + /// Hands the bottom inset back to the system, which re-derives it from wherever the keyboard is by + /// then: back up behind the menu, in which case nothing moves, or gone, in which case the + /// transcript settles into the space it vacated. private func restoreInset() { - guard let behavior = savedInsetBehavior else { return } - collectionView.contentInsetAdjustmentBehavior = behavior + guard frozenBottomInset != nil else { return } + frozenBottomInset = nil if let inset = savedContentInset { collectionView.contentInset = inset } if let indicator = savedScrollIndicatorInsets { collectionView.verticalScrollIndicatorInsets = indicator } - savedInsetBehavior = nil savedContentInset = nil savedScrollIndicatorInsets = nil - // No `restoreContentOffset` re-anchor (see `freezeInset`): forcing the - // layout pass here aborts on iOS 26. Handing the inset behavior back lets - // the system re-grow the adjusted inset as the keyboard returns; the - // at-bottom follow in `scrollViewDidChangeAdjustedContentInset` settles - // the position through the normal path once the menu flag is cleared. + // No `restoreContentOffset` re-anchor: forcing ChatLayout's layout pass during the + // context-menu keyboard transition aborts on iOS 26 (a UICollectionView bounds-change + // "fading" assertion). The at-bottom follow in `scrollViewDidChangeAdjustedContentInset` + // settles the position through the normal path once the menu flag is cleared. } } @@ -1184,6 +1187,13 @@ extension ChatViewController { private func resumeAfterLift() { hiddenLiftSource?.alpha = 1 hiddenLiftSource = nil + // UIKit keeps the copy's platter above the transcript until its completion, about 0.4s after + // the bubble lands, so it covered the bar's soft edge in the meantime and a bubble partly + // under it sat sharp until the platter went. Fading the copy hands over to the row's bubble, + // which the edge effect blurs. + if let liftStandIn { + UIView.animate(withDuration: 0.12) { liftStandIn.alpha = 0 } + } liftContainer = nil liftStandIn = nil // Restore the inset while the flag is still set, so the behavior switch's inset change is @@ -1360,6 +1370,7 @@ extension ChatViewController { // bounds and outlasts the dismissal. The bubble-shaped shadow below is the only one. parameters.shadowPath = UIBezierPath() if let message = message(at: IndexPath(item: item, section: section)), liftReservesStripRoom?(message) == true, + lacksStripRoomAbove(cell.liftPreviewView), let preview = stripRoomPreview(for: cell, parameters: parameters, raising: raising) { return preview } @@ -1373,6 +1384,15 @@ extension ChatViewController { return UITargetedPreview(view: cell.liftPreviewView, parameters: parameters) } + /// Whether `bubble` sits too close to the top of the screen for the reaction strip to fit above + /// it. Only then does the lift carry the strip's room: elsewhere the room is free anyway, and when + /// UIKit puts the menu above the bubble the strip goes below it, leaving reserved room as a gap. + private func lacksStripRoomAbove(_ bubble: UIView) -> Bool { + guard let window = bubble.window else { return true } + let top = bubble.convert(bubble.bounds, to: nil).minY + return top < window.safeAreaInsets.top + ReactionStripView.bubbleGap + ReactionStripView.headroom + } + /// The lift for a row the strip will sit above: the bubble's copy at the bottom of a clear box /// `ReactionStripView.headroom` taller than the bubble, centered so the copy starts over the row. /// UIKit keeps the whole box on screen, which moves a bubble near the top down far enough for the From a5b34e51b50fb3859c1b7711fc90d36eefb0d112 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 10:09:49 -0400 Subject: [PATCH 2/2] docs(plans): plan a custom long-press menu for chat UIKit's context menu can't slide only the pressed bubble to a spot with the strip above and the menu below. This records what the device investigation ruled out and the shape of an overlay that can. --- .../2026-09-29-chat-custom-context-menu.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .claude/plans/2026-09-29-chat-custom-context-menu.md diff --git a/.claude/plans/2026-09-29-chat-custom-context-menu.md b/.claude/plans/2026-09-29-chat-custom-context-menu.md new file mode 100644 index 000000000..4522cece5 --- /dev/null +++ b/.claude/plans/2026-09-29-chat-custom-context-menu.md @@ -0,0 +1,60 @@ +# Chat: custom long-press menu (WhatsApp-style lift) + +## Goal +Long-pressing a bubble slides **that bubble** (not the transcript) to a spot with the reaction strip +above it and the menu below it, then slides it back into its row as the same bubble. The transcript +behind stays still and blurred. + +## Why UIKit's context menu can't do it +Findings from the 2026-09-28/29 device investigation (bmc-iphone): +- **Targeted preview (in-place lift):** UIKit picks the menu's side and places the lift itself. There's + no public way to force the menu below. A bubble near the bottom always gets the menu above. +- **Transcript pre-scroll before the lift:** UIKit still put the menu above after a 20pt scroll. It + appears to pick the side with more room, not the one that fits. The scroll back on dismiss also + raced the fly-home and showed the bubble twice. +- **`previewProvider` controller:** UIKit does move only the bubble and puts the menu below. But the + preview is a copy (image) cross-faded in and out, so it looks different from the live bubble and + doesn't slide home as the same view. `snapshotView` copies go blank because UIKit hides the source. + UIKit also draws the preview through a portal, so its frame has to be found by size on screen. +- **Inset mode:** never switch `contentInsetAdjustmentBehavior` while the menu is up. `.never` changes + the safe area handed to rows, and SwiftUI-hosted cards under the nav bar (the group invite card) + re-lay out onto their own pills. Hold the bottom inset instead (`holdBottomInset`, shipped with the + interim fix). + +## Shape +A `ChatMessageMenuOverlay` presented over the screen (a window above the keyboard, like +`reactionStripWindow` today), owned by `ChatScreenViewController`. + +1. **Trigger:** replace `UIContextMenuInteraction` on bubbles with a long-press recognizer (0.35s, + matching the system). The double-tap path (`presentStripOnly`) already does most of this. +2. **Lift:** hide the real bubble, add a live copy at the bubble's exact window frame. Build the copy + by configuring a fresh cell/bubble view with the same message, not a snapshot, so it stays sharp + and matches exactly (animations, attachments, fonts). +3. **Placement:** target y puts the strip (`ReactionStripView.height` + `bubbleGap`) above the bubble + and the menu below it, within the safe area. Tall bubbles: clamp and scale down, as WhatsApp does. + Keep the bubble's own horizontal position. +4. **Menu:** our own view listing `message.actions` (`MessageCapability`), destructive styling for + Delete. No system extras (Ask Siri, etc.). +5. **Backdrop:** blur + dim the screen behind, tap to dismiss. Composer and keyboard follow the + existing `handOffComposerFocusAroundContextMenu` logic. +6. **Dismiss:** animate the copy back to the row's *current* frame (read it again at dismiss time, + since a pushed transcript may have moved it; `afterContextMenu` queueing still applies), then show + the real bubble and remove the copy in the same frame. +7. **Haptics:** light impact on lift, selection feedback on menu hover if we support drag-to-select. +8. **Accessibility:** keep `accessibilityCustomActions` on the bubble so VoiceOver users never need + the overlay. The overlay is a modal container (`accessibilityViewIsModal`) with an escape gesture. + +## What goes away +- `UIContextMenuInteraction` delegate methods in `ChatViewController` (config, highlight/dismiss + previews, `willEndContextMenuInteraction`), `stripRoomPreview`, `liftCopy`, `hiddenLiftSource`. +- `contextMenuFrame(in:titles:)` / `liftedPreviewFrame` view-tree searches in the screen. +- `freezeInset`/`holdBottomInset`, if the overlay keeps the keyboard untouched. + +## Tests +- Placement math as a pure function (bubble frame, safe area, strip/menu heights → target frame), + covered by Swift Testing cases: top, bottom, tall bubble, keyboard up. +- Device check: top card with pills, bottom bubble, tall bubble, keyboard up, transcript push during + the menu, VoiceOver. + +## Android +Check whether Android's menu has the same same-side problem before mirroring.