From 99cc6fc8f16b39b17ab633be2362fb2249303f33 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 21:50:05 -0400 Subject: [PATCH 1/5] fix(chat): stop the edited message drawing over the composer The floated copy went straight into the navigation controller's view above the blur, which put it above the composer bar and the whole chat screen. The blur is height-clipped to the top of the bar; the copy was clipped to nothing, so once the keyboard raised the transcript, a copy of a low row kept its position and drew on top of the composer. Put the copy in a clipping container sized to the blur's rect and re-measured with it, so it stops where the blur does at both edges. --- .../FlipcashUI/Chat/MessageBackdrop.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index cf15cbdc9..1c2af575a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -39,6 +39,9 @@ final class MessageBackdrop { private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark) private var effectView: UIVisualEffectView? private var spotlight: UIView? + /// Holds the floated copy and clips it to the blur, so a copy of a row that has scrolled past + /// either edge can't draw over the composer or the navigation bar. + private var spotlightClip: UIView? /// The composer bar a held blur stops short of, re-measured on every layout pass. private weak var clearance: UIView? @@ -80,6 +83,13 @@ final class MessageBackdrop { } // The frame is driven by the bar from here on, so the host can no longer resize it. blur.autoresizingMask = [] + + let clip = UIView() + clip.clipsToBounds = true + clip.isUserInteractionEnabled = false + host.insertSubview(clip, aboveSubview: blur) + spotlightClip = clip + layoutHeld() blur.isUserInteractionEnabled = true @@ -93,6 +103,7 @@ final class MessageBackdrop { guard isHeld, let blur = effectView, let host = blur.superview, let bar = clearance else { return } let barTop = bar.convert(bar.bounds, to: host).minY blur.frame = CGRect(x: 0, y: 0, width: host.bounds.width, height: max(barTop, 0)) + spotlightClip?.frame = blur.frame } /// Floats `bubble` — a detached copy of the edited message — above a held blur, at `frame` in @@ -100,13 +111,14 @@ final class MessageBackdrop { /// /// A copy rather than a hole cut in the blur: a `UIVisualEffectView` renders its backdrop /// through a private layer that ignores `layer.mask`, and the real bubble can't be raised out of - /// the collection view that owns it. + /// the collection view that owns it. It goes in the clip rather than straight into the host, so + /// it stops where the blur does instead of covering the composer. func setSpotlight(_ bubble: UIView, at frame: CGRect) { - guard let blur = effectView, isHeld, let host = blur.superview else { return } + guard isHeld, let clip = spotlightClip else { return } spotlight?.removeFromSuperview() bubble.frame = frame bubble.isUserInteractionEnabled = false - host.insertSubview(bubble, aboveSubview: blur) + clip.addSubview(bubble) spotlight = bubble } @@ -145,12 +157,14 @@ final class MessageBackdrop { let bubble = spotlight spotlight = nil + let clip = spotlightClip + spotlightClip = nil UIView.animate(withDuration: Self.fallbackDuration) { blur.effect = nil bubble?.alpha = 0 } completion: { _ in blur.removeFromSuperview() - bubble?.removeFromSuperview() + clip?.removeFromSuperview() } } From 91a57ff95e307a23fbf8d0ad1eb27c68fdd287c7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 22:34:17 -0400 Subject: [PATCH 2/5] fix(chat): float the edited message and keep the toolbar sharp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were missing from the edit chrome. The floated copy of the edited message never appeared, and the navigation bar went blank instead of staying legible through the blur. The copy went missing because the snapshot was taken in the menu's dismissal completion, while UIKit still has the row's own bubble hidden behind its lifted preview. `snapshotView` on a hidden view returns a blank view rather than nil, so an empty copy was floated and, since one existed, never replaced. `bubbleSnapshot` now reports "not yet" for a hidden or empty bubble, and the screen retries over the next few runloop turns rather than waiting on a layout pass or a scroll that may not come. The bar went blank because the title and avatar faded to zero for the duration of the edit. The held blur already slides under the navigation bar, so the bar is sharp on its own — only the back button needs to change, to back out of the edit rather than out of the chat. --- .../Conversation/ConversationScreen.swift | 8 ++--- .../Chat/ChatScreenViewController.swift | 32 +++++++++++++------ .../FlipcashUI/Chat/ChatViewController.swift | 8 ++++- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index f0d3c8561..6f0aef50f 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -230,9 +230,8 @@ struct ConversationScreen: View { .background(Color.backgroundMain) .navigationTitle("") .toolbarTitleDisplayMode(.inline) - // An edit blurs the whole screen behind the message being edited, navigation bar included, - // so the back button is the only thing up there worth keeping legible — and it backs out of - // the edit rather than out of the chat. + // The edit blur slides under the navigation bar, so the bar stays sharp through an edit — + // only the back button changes, to back out of the edit rather than out of the chat. .navigationBarBackButtonHidden(composer.isEditing) .toolbar { if composer.isEditing { @@ -257,9 +256,6 @@ struct ConversationScreen: View { onTap: titleTapAction, opensProfile: profileTapAction != nil ) - .opacity(composer.isEditing ? 0 : 1) - .allowsHitTesting(!composer.isEditing) - .animation(.easeInOut(duration: 0.2), value: composer.isEditing) } } // Fetch the tip counterpart's avatar for the title and profile card. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 994e51a60..6d4a7b337 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -50,6 +50,13 @@ public final class ChatScreenViewController: UIViewController { private let backdrop = MessageBackdrop() /// The row floated above a held blur, while an edit is open on it. private var editedStableID: String? + /// Deferred attempts left at floating the edited message's copy. The menu's dismissal + /// completion lands while UIKit is still putting the row's own bubble back, so the first + /// attempt usually has nothing to copy; retrying over the next few runloop turns catches it + /// without waiting on a layout pass or a scroll that may never come. + private var spotlightAttemptsRemaining = 0 + /// How many of those attempts a single edit gets. + private static let spotlightAttempts = 8 /// Whether a measured bar height has landed yet — the first one is applied without animation. private var didMeasureBar = false @@ -182,13 +189,17 @@ public final class ChatScreenViewController: UIViewController { editedStableID = stableID backdrop.present(over: contextMenuBackdropHost, animator: nil) backdrop.hold(clearing: bar, under: hostNavigationController?.navigationBar) - transcript.afterContextMenu { [weak self] in self?.refreshEditSpotlight() } + transcript.afterContextMenu { [weak self] in + self?.spotlightAttemptsRemaining = Self.spotlightAttempts + self?.refreshEditSpotlight() + } } /// Takes the blur down once the edit is over, however it ended. public func endEditSpotlight() { guard editedStableID != nil else { return } editedStableID = nil + spotlightAttemptsRemaining = 0 backdrop.release() } @@ -197,18 +208,19 @@ public final class ChatScreenViewController: UIViewController { /// doesn't follow the cell on its own. A row scrolled out of the transcript leaves the copy at /// its last frame rather than dropping it, so the message stays on screen for the whole edit. private func refreshEditSpotlight() { + guard let editedStableID else { return } // Measured in the backdrop's own host, which is the navigation stack rather than this // screen whenever there is one to be in. - guard let editedStableID, - let frame = transcript.bubbleFrame( - forStableID: editedStableID, - in: contextMenuBackdropHost - ) else { return } - if backdrop.hasSpotlight { - backdrop.moveSpotlight(to: frame) - } else if let bubble = transcript.bubbleSnapshot(forStableID: editedStableID) { - backdrop.setSpotlight(bubble, at: frame) + if let frame = transcript.bubbleFrame(forStableID: editedStableID, in: contextMenuBackdropHost) { + if backdrop.hasSpotlight { + backdrop.moveSpotlight(to: frame) + } else if let bubble = transcript.bubbleSnapshot(forStableID: editedStableID) { + backdrop.setSpotlight(bubble, at: frame) + } } + guard !backdrop.hasSpotlight, spotlightAttemptsRemaining > 0 else { return } + spotlightAttemptsRemaining -= 1 + Task { @MainActor [weak self] in self?.refreshEditSpotlight() } } /// The navigation stack this screen is inside, if any — the SwiftUI hosting controllers this diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index eec08e458..52dfc0c1e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -657,7 +657,13 @@ extension ChatViewController { /// blur, because a `UIVisualEffectView` does not reliably honour a layer mask. func bubbleSnapshot(forStableID stableID: String) -> UIView? { guard let cell = bubbleCell(forStableID: stableID) else { return nil } - return cell.liftPreviewView.snapshotView(afterScreenUpdates: true) + let bubble = cell.liftPreviewView + // UIKit hides the row's own bubble for as long as its lifted preview is on screen and + // unhides it as the dismissal lands. Snapshotting in that window returns a view that is + // blank rather than nil, which would float an empty copy and never be retried — so report + // "not yet" and let the caller ask again. + guard !bubble.isHidden, bubble.alpha > 0, !bubble.bounds.isEmpty else { return nil } + return bubble.snapshotView(afterScreenUpdates: true) } /// Where that row's bubble currently sits, in `space`'s coordinates, or `nil` when it is not on From 55f25526bcf7624e8f3da92c78162a51632a8e70 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 23:05:51 -0400 Subject: [PATCH 3/5] fix(chat): match the held edit blur to the menu's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIKit dims the screen behind a context menu and takes that dimming away with the menu, so a blur held into an edit keeps the material but loses the dim. On the same patch of empty transcript that reads rgb 22 under the menu, the edit read 44 — visibly lighter, the state it was meant to continue. Fade a black overlay in inside the held blur as the menu dismisses, so the two cross rather than the screen brightening between them. The same patch now reads 23. --- .../FlipcashUI/Chat/MessageBackdrop.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index 1c2af575a..a94f64afe 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -28,6 +28,11 @@ final class MessageBackdrop { /// Matches the fade UIKit uses for its own dimming when no animator is supplied. private static let fallbackDuration: TimeInterval = 0.2 + /// Stands in for the dimming UIKit lays over the screen while a context menu is up, which goes + /// with the menu. Without it a held blur reads about twice as light as the one the menu had — + /// measured on the same patch of empty transcript, rgb 23 under the menu against 44 after it. + private static let heldDimAlpha: CGFloat = 0.48 + /// Called when the held blur is tapped — the way out of an edit, as tapping outside the message /// is in WhatsApp. Never fires while a context menu owns the screen: the menu's own container /// sits above the blur and takes those taps. @@ -39,6 +44,9 @@ final class MessageBackdrop { private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark) private var effectView: UIVisualEffectView? private var spotlight: UIView? + /// Replaces the menu's dimming once the menu is gone. Lives inside the blur, so it is clipped + /// and framed with it and sits under the floated copy. + private var dim: UIView? /// Holds the floated copy and clips it to the blur, so a copy of a row that has scrolled past /// either edge can't draw over the composer or the navigation bar. private var spotlightClip: UIView? @@ -84,6 +92,17 @@ final class MessageBackdrop { // The frame is driven by the bar from here on, so the host can no longer resize it. blur.autoresizingMask = [] + // Fade the stand-in dim in now, while the menu is still up: its own dimming fades out with + // the dismissal, so the two cross and the screen never brightens between the states. + let dim = UIView(frame: blur.bounds) + dim.backgroundColor = .black + dim.alpha = 0 + dim.autoresizingMask = [.flexibleWidth, .flexibleHeight] + dim.isUserInteractionEnabled = false + blur.contentView.addSubview(dim) + self.dim = dim + UIView.animate(withDuration: Self.fallbackDuration) { dim.alpha = Self.heldDimAlpha } + let clip = UIView() clip.clipsToBounds = true clip.isUserInteractionEnabled = false @@ -159,9 +178,12 @@ final class MessageBackdrop { spotlight = nil let clip = spotlightClip spotlightClip = nil + let dim = self.dim + self.dim = nil UIView.animate(withDuration: Self.fallbackDuration) { blur.effect = nil bubble?.alpha = 0 + dim?.alpha = 0 } completion: { _ in blur.removeFromSuperview() clip?.removeFromSuperview() From e73c41c6d7f54789e35371401e04d2390b4c5fcc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 23:15:01 -0400 Subject: [PATCH 4/5] fix(chat): drop the avatar from the bar during an edit An edit is about one message, not the counterpart, and the avatar next to the edit-exit chevron reads as a second thing to tap. Leave the name alone in the bar for the duration of the edit; it comes back with the avatar on exit. --- .../Conversation/ConversationScreen.swift | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 6f0aef50f..02e7ddef8 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -253,6 +253,7 @@ struct ConversationScreen: View { imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID), blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash, width: max(navBarWidth - Self.titleSideInset * 2, 0), + showsAvatar: !composer.isEditing, onTap: titleTapAction, opensProfile: profileTapAction != nil ) @@ -589,6 +590,7 @@ private struct ConversationTitleItem: View { let imageData: Data? let blurhash: String? let width: CGFloat + let showsAvatar: Bool let onTap: (() -> Void)? let opensProfile: Bool @@ -599,7 +601,8 @@ private struct ConversationTitleItem: View { conversationID: conversationID, imageData: imageData, blurhash: blurhash, - width: width + width: width, + showsAvatar: showsAvatar ) if let onTap { let hint = opensProfile ? "Opens profile" : (contact != nil ? "Opens contact card" : "Adds to Contacts") @@ -621,17 +624,21 @@ private struct ConversationTitleLabel: View { let imageData: Data? let blurhash: String? let width: CGFloat + /// Dropped during an edit, which leaves the name alone beside the back button. + let showsAvatar: Bool var body: some View { HStack(spacing: 12) { - ContactAvatarView( - id: contact?.contactId ?? conversationID?.description ?? title, - displayName: title, - imageData: imageData, - blurhash: blurhash, - size: 44 - ) - .accessibilityHidden(true) + if showsAvatar { + ContactAvatarView( + id: contact?.contactId ?? conversationID?.description ?? title, + displayName: title, + imageData: imageData, + blurhash: blurhash, + size: 44 + ) + .accessibilityHidden(true) + } Text(title) .font(.appBarButton) .foregroundStyle(Color.textMain) From 856c27546ac584ebad3eb88bff1cde94eb59326f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 23:44:53 -0400 Subject: [PATCH 5/5] fix(chat): leave only the chevron in the bar during an edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An edit is about one message, not the counterpart, so the name and avatar have nothing to say while it is open — and next to an exit chevron they read as a second thing to tap. Drop the whole title item for the duration; it comes back on exit. Supersedes the avatar-only removal, which left the name hanging beside the chevron. --- .../Conversation/ConversationScreen.swift | 56 +++++++++---------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 02e7ddef8..10a4a4772 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -230,8 +230,10 @@ struct ConversationScreen: View { .background(Color.backgroundMain) .navigationTitle("") .toolbarTitleDisplayMode(.inline) - // The edit blur slides under the navigation bar, so the bar stays sharp through an edit — - // only the back button changes, to back out of the edit rather than out of the chat. + // The edit blur slides under the navigation bar, so the bar stays sharp through an edit. + // What it holds changes: the counterpart's name and avatar go, since the edit is about one + // message rather than the person, and the back button backs out of the edit rather than the + // chat — leaving the chevron alone in the bar. .navigationBarBackButtonHidden(composer.isEditing) .toolbar { if composer.isEditing { @@ -244,19 +246,19 @@ struct ConversationScreen: View { } .accessibilityLabel("Stop editing") } - } - ToolbarItem(placement: .principal) { - ConversationTitleItem( - title: title, - contact: contact, - conversationID: conversationID, - imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID), - blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash, - width: max(navBarWidth - Self.titleSideInset * 2, 0), - showsAvatar: !composer.isEditing, - onTap: titleTapAction, - opensProfile: profileTapAction != nil - ) + } else { + ToolbarItem(placement: .principal) { + ConversationTitleItem( + title: title, + contact: contact, + conversationID: conversationID, + imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID), + blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash, + width: max(navBarWidth - Self.titleSideInset * 2, 0), + onTap: titleTapAction, + opensProfile: profileTapAction != nil + ) + } } } // Fetch the tip counterpart's avatar for the title and profile card. @@ -590,7 +592,6 @@ private struct ConversationTitleItem: View { let imageData: Data? let blurhash: String? let width: CGFloat - let showsAvatar: Bool let onTap: (() -> Void)? let opensProfile: Bool @@ -601,8 +602,7 @@ private struct ConversationTitleItem: View { conversationID: conversationID, imageData: imageData, blurhash: blurhash, - width: width, - showsAvatar: showsAvatar + width: width ) if let onTap { let hint = opensProfile ? "Opens profile" : (contact != nil ? "Opens contact card" : "Adds to Contacts") @@ -624,21 +624,17 @@ private struct ConversationTitleLabel: View { let imageData: Data? let blurhash: String? let width: CGFloat - /// Dropped during an edit, which leaves the name alone beside the back button. - let showsAvatar: Bool var body: some View { HStack(spacing: 12) { - if showsAvatar { - ContactAvatarView( - id: contact?.contactId ?? conversationID?.description ?? title, - displayName: title, - imageData: imageData, - blurhash: blurhash, - size: 44 - ) - .accessibilityHidden(true) - } + ContactAvatarView( + id: contact?.contactId ?? conversationID?.description ?? title, + displayName: title, + imageData: imageData, + blurhash: blurhash, + size: 44 + ) + .accessibilityHidden(true) Text(title) .font(.appBarButton) .foregroundStyle(Color.textMain)