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