From b5ae69df4c1ecaca9171f6022ede504e8c8ff9c0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 1 Sep 2026 15:59:38 -0400 Subject: [PATCH 01/10] feat(chat): give the composer an editing mode The composer field now serves both writing a new message and revising an existing one. ComposerModel holds the draft, the mode, and the stashed new-message draft, so beginning an edit does not discard what was being typed and cancelling puts it back. An edit whose text matches the original cannot be submitted. --- .../ChatScreenRepresentable.swift | 2 + .../Screens/Conversation/ComposerModel.swift | 75 ++++++++++++++ .../Conversation/ConversationBottomBar.swift | 98 +++++++++++++------ .../Conversation/ConversationScreen.swift | 2 + FlipcashTests/Chat/ComposerModelTests.swift | 78 +++++++++++++++ .../FlipcashUI/Theme/Image+Symbols.swift | 6 ++ 6 files changed, 231 insertions(+), 30 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/ComposerModel.swift create mode 100644 FlipcashTests/Chat/ComposerModelTests.swift diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 47fe52282..d3e52d546 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -41,6 +41,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let onSendCash: () -> Void let conversationController: ConversationController let barModel: ConversationBarModel + let composer: ComposerModel /// Raise the keyboard when the screen first appears (post-tip open). The UIKit screen focuses /// the composer field in `viewDidAppear` — a hosted SwiftUI `@FocusState` never presents the /// keyboard across the hosting boundary. @@ -108,6 +109,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { symbol: symbol, onSendCash: onSendCash, model: barModel, + composer: composer, isTipDm: isTipDm ) .environment(conversationController) diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift new file mode 100644 index 000000000..10e889006 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -0,0 +1,75 @@ +// +// ComposerModel.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Observation +import FlipcashCore + +/// What the composer is writing, and the text it holds. Editing borrows the same field as a new +/// message, so the unsent draft is stashed while an edit is in progress and put back if the edit is +/// cancelled. +@MainActor +@Observable +final class ComposerModel { + + enum Mode: Equatable { + case new + /// `stableID` is the transcript row's identity, kept alongside the message id so the screen + /// can highlight the row being edited without re-deriving it. + case editing(messageID: MessageID, stableID: String) + } + + private(set) var mode: Mode = .new + var draft = "" + + /// The unsent new-message draft, held while an edit occupies the field. + @ObservationIgnored private var stashedDraft = "" + /// The text the message had when the edit began, so an unchanged edit can be refused. + @ObservationIgnored private var originalText = "" + + /// The trimmed text to submit, or `nil` if there is nothing worth submitting. An edit that + /// matches the original is nothing worth submitting. + var submission: String? { + let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + switch mode { + case .new: + return trimmed + case .editing: + return trimmed == originalText ? nil : trimmed + } + } + + var canSubmit: Bool { submission != nil } + + /// Switches the field to editing an existing message, stashing whatever was being written. + func beginEditing(messageID: MessageID, stableID: String, currentText: String) { + if case .new = mode { + stashedDraft = draft + } + mode = .editing(messageID: messageID, stableID: stableID) + originalText = currentText + draft = currentText + } + + /// Leaves editing and restores the stashed draft. + func endEditing() { + guard case .editing = mode else { return } + mode = .new + originalText = "" + draft = stashedDraft + stashedDraft = "" + } + + /// Empties the field after a successful send. + func clear() { + mode = .new + originalText = "" + stashedDraft = "" + draft = "" + } +} diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index e825624ce..b26b61705 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -9,16 +9,11 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// Shared state for the unified bottom bar: the message draft plus the -/// focus-driven `isComposing` flag that drives the Send Cash morph and the -/// screen's interactive-dismiss gate. +/// Shared state for the unified bottom bar: the focus-driven `isComposing` flag that drives the +/// Send Cash morph and the screen's interactive-dismiss gate. The draft itself lives in +/// `ComposerModel`, which also knows whether it is a new message or an edit. @MainActor @Observable final class ConversationBarModel { var isComposing = false - var draft = "" - - var canSend: Bool { - !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } } /// Single spring driving the whole bar: the button morph, the composer's @@ -46,6 +41,7 @@ struct ConversationBottomBar: View { let symbol: String let onSendCash: () -> Void let model: ConversationBarModel + let composer: ComposerModel /// Tip chats always show the compact symbol-only send button; ordinary /// chats expand to "Send €" at rest and collapse only while composing. var isTipDm: Bool = false @@ -66,7 +62,7 @@ struct ConversationBottomBar: View { ) } if chatExists { - ConversationComposer(conversationID: conversationID, model: model) + ConversationComposer(conversationID: conversationID, model: model, composer: composer) .transition(.opacity) } } @@ -91,6 +87,7 @@ struct ConversationComposer: View { let conversationID: ConversationID? @Bindable var model: ConversationBarModel + @Bindable var composer: ComposerModel @Environment(ConversationController.self) private var conversationController @FocusState private var isFocused: Bool @@ -100,7 +97,7 @@ struct ConversationComposer: View { var body: some View { let field = HStack(alignment: .bottom, spacing: 10) { - TextField("Message", text: $model.draft, axis: .vertical) + TextField("Message", text: $composer.draft, axis: .vertical) .font(.appTextMessage) .foregroundStyle(Color.textMain) .tint(.white) @@ -109,8 +106,8 @@ struct ConversationComposer: View { .frame(maxWidth: .infinity, alignment: .leading) .frame(minHeight: BarMetrics.fieldMinHeight) - if model.canSend { - Button(action: send) { + if composer.canSubmit { + Button(action: submit) { Image(systemName: "arrow.up") .font(.default(size: 16, weight: .bold)) .foregroundStyle(Color.textAction) @@ -118,23 +115,29 @@ struct ConversationComposer: View { .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) } .buttonStyle(.plain) - .accessibilityLabel("Send") + .accessibilityLabel(composer.mode == .new ? "Send" : "Save") .accessibilityIdentifier("send-message-button") // Pop from 60% + fade, so the opacity ramp actually reads // (scaling from 0 hides the fade behind a tiny speck). .transition(.scale(scale: 0.6).combined(with: .opacity)) } } - .animation(Self.sendButtonSpring, value: model.canSend) + .animation(Self.sendButtonSpring, value: composer.canSubmit) .padding(.leading, 14) .padding(.trailing, 8) .padding(.vertical, BarMetrics.fieldVerticalPadding) - return field - // Glass *behind* the field, not wrapping it: wrapping an editable - // TextField in `glassEffect` reparents its text view into the glass - // platter and breaks the text-selection grabbers. - .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + return VStack(alignment: .leading, spacing: 8) { + if case .editing = composer.mode { + EditingBanner { composer.endEditing() } + } + field + // Glass *behind* the field, not wrapping it: wrapping an editable + // TextField in `glassEffect` reparents its text view into the glass + // platter and breaks the text-selection grabbers. + .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + } + .animation(barMorphSpring, value: composer.mode) // Focus is the single source of `isComposing` — the button morph and the // screen's interactive-dismiss gate both key off it. Losing focus // (keyboard swiped down) ends composing. @@ -144,22 +147,57 @@ struct ConversationComposer: View { conversationController.stopSelfTyping(in: conversationID) } } - .onChange(of: model.draft) { _, text in + .onChange(of: composer.draft) { _, text in guard let conversationID else { return } conversationController.draftDidChange(text, in: conversationID) } } - private func send() { - guard let conversationID else { return } - let text = model.draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } - model.draft = "" - isFocused = true - // Fire-and-forget: the message inserts optimistically and resolves on its own, so the composer - // stays ready immediately — the user can keep sending (especially offline) without waiting for - // each round-trip. Clearing the draft up front makes a double-tap a no-op (empty text). - Task { await conversationController.send(text, to: conversationID) } + private func submit() { + guard let conversationID, let text = composer.submission else { return } + + // Fire-and-forget in both branches: the change applies optimistically and resolves on its own, + // so the composer stays ready immediately. Emptying the field up front makes a double-tap a + // no-op, because there is then nothing to submit. + switch composer.mode { + case .new: + composer.clear() + isFocused = true + Task { await conversationController.send(text, to: conversationID) } + case .editing(let messageID, _): + composer.endEditing() + isFocused = true + Task { await conversationController.edit(messageID: messageID, in: conversationID, to: text) } + } + } +} + +/// The strip above the field while an edit is in progress, naming what the field is doing and +/// offering a way out of it. +private struct EditingBanner: View { + + let onCancel: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: SystemSymbol.pencil.rawValue) + .font(.default(size: 13, weight: .medium)) + .foregroundStyle(Color.textSecondary) + Text("Editing message") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + Spacer(minLength: 0) + Button(action: onCancel) { + Image(systemName: SystemSymbol.close.rawValue) + .font(.default(size: 13, weight: .medium)) + .foregroundStyle(Color.textSecondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Cancel editing") + .accessibilityIdentifier("cancel-edit-button") + } + .padding(.horizontal, 14) + .transition(.opacity.combined(with: .move(edge: .bottom))) } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 4cad5e027..0c8495108 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -63,6 +63,7 @@ struct ConversationScreen: View { @State private var didInitialRead = false @State private var barModel = ConversationBarModel() + @State private var composer = ComposerModel() @State private var navBarWidth: CGFloat = 0 @State private var presentedCard: ContactCard? @State private var coordinator: ConversationLoadCoordinator? @@ -215,6 +216,7 @@ struct ConversationScreen: View { onSendCash: sendCash, conversationController: conversationController, barModel: barModel, + composer: composer, focusOnAppear: openKeyboard, isTipDm: tipCounterpart != nil ) diff --git a/FlipcashTests/Chat/ComposerModelTests.swift b/FlipcashTests/Chat/ComposerModelTests.swift new file mode 100644 index 000000000..1accfa03e --- /dev/null +++ b/FlipcashTests/Chat/ComposerModelTests.swift @@ -0,0 +1,78 @@ +// +// ComposerModelTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Composer mode") +struct ComposerModelTests { + + @Test("A fresh composer is writing a new message") + func freshComposerIsNew() { + let composer = ComposerModel() + #expect(composer.mode == .new) + #expect(composer.draft.isEmpty) + #expect(!composer.canSubmit) + } + + @Test("Beginning an edit loads the message's text and stashes the unsent draft") + func beginEditingStashesDraft() { + let composer = ComposerModel() + composer.draft = "half-typed" + composer.beginEditing(messageID: MessageID(value: 3), stableID: "3", currentText: "original") + + #expect(composer.mode == .editing(messageID: MessageID(value: 3), stableID: "3")) + #expect(composer.draft == "original") + } + + @Test("Cancelling an edit restores the stashed draft") + func cancellingRestoresDraft() { + let composer = ComposerModel() + composer.draft = "half-typed" + composer.beginEditing(messageID: MessageID(value: 3), stableID: "3", currentText: "original") + composer.draft = "changed my mind" + composer.endEditing() + + #expect(composer.mode == .new) + #expect(composer.draft == "half-typed") + } + + @Test("Submission trims whitespace and refuses an empty draft") + func submissionTrims() { + let composer = ComposerModel() + composer.draft = " hello " + #expect(composer.canSubmit) + #expect(composer.submission == "hello") + + composer.draft = " " + #expect(!composer.canSubmit) + #expect(composer.submission == nil) + } + + @Test("An edit that leaves the text unchanged cannot be submitted") + func unchangedEditCannotSubmit() { + let composer = ComposerModel() + composer.beginEditing(messageID: MessageID(value: 3), stableID: "3", currentText: "original") + #expect(!composer.canSubmit) + + composer.draft = "original edited" + #expect(composer.canSubmit) + } + + @Test("Clearing after a send empties the draft and stays in new-message mode") + func clearAfterSend() { + let composer = ComposerModel() + composer.draft = "sent" + composer.clear() + + #expect(composer.draft.isEmpty) + #expect(composer.mode == .new) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index 7ba5a9220..289109da3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -67,6 +67,12 @@ public enum SystemSymbol: String { case chevronRight = "chevron.right" case xmark = "xmark.app.fill" + /// The bare glyph, for dismiss affordances that sit inline with text and + /// would be overpowered by the filled app-square `xmark`. + case close = "xmark" + + case pencil = "pencil" + case trash = "trash" case speakerSlash = "speaker.slash.fill" From ad7c9791e592e72548185243bd17d3d1d0096ef8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 1 Sep 2026 16:03:58 -0400 Subject: [PATCH 02/10] feat(chat): build the transcript context menu from message actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu was hardcoded to Copy for text rows. It now renders whatever actions the mapper put on the message, in that order, so what a row offers is decided once by the capability model rather than again at the point of presentation. Copy still resolves inside the transcript; the rest report the row's id to the screen. Menu construction is split out of the configuration so the contents can be asserted without presenting a menu — UIContextMenuConfiguration does not expose its provider. --- FlipcashTests/Chat/ChatMessageCopyTests.swift | 18 +-- .../Chat/MessageCapabilityMenuTests.swift | 115 ++++++++++++++++++ .../Chat/ChatScreenViewController.swift | 6 + .../FlipcashUI/Chat/ChatViewController.swift | 70 +++++++---- 4 files changed, 178 insertions(+), 31 deletions(-) create mode 100644 FlipcashTests/Chat/MessageCapabilityMenuTests.swift diff --git a/FlipcashTests/Chat/ChatMessageCopyTests.swift b/FlipcashTests/Chat/ChatMessageCopyTests.swift index 91113a03d..2e943ebaf 100644 --- a/FlipcashTests/Chat/ChatMessageCopyTests.swift +++ b/FlipcashTests/Chat/ChatMessageCopyTests.swift @@ -41,7 +41,7 @@ struct ChatMessageCopyTests { @Test("A text message offers a context menu") func textMessage_offersMenu() { let controller = loadedController([ - .message(ChatMessage(id: "a", text: "Hello there", sender: .other)), + .message(ChatMessage(id: "a", text: "Hello there", sender: .other, actions: [.copy])), ]) #expect(configuration(controller, at: 0) != nil) } @@ -49,8 +49,8 @@ struct ChatMessageCopyTests { @Test("The configuration identifier encodes the section and item, so the preview can resolve the cell") func configuration_identifierEncodesIndexPath() { let controller = loadedController([ - .message(ChatMessage(id: "a", text: "first", sender: .me)), - .message(ChatMessage(id: "b", text: "second", sender: .other)), + .message(ChatMessage(id: "a", text: "first", sender: .me, actions: [.copy])), + .message(ChatMessage(id: "b", text: "second", sender: .other, actions: [.copy])), ]) #expect(configuration(controller, at: 1)?.identifier as? String == "0|1") } @@ -74,14 +74,14 @@ struct ChatMessageCopyTests { @Test("A message arriving while the menu is open is held, not applied") func openMenu_defersPushedUpdate() { - let controller = loadedController([.message(ChatMessage(id: "a", text: "Hello", sender: .me))]) + let controller = loadedController([.message(ChatMessage(id: "a", text: "Hello", sender: .me, actions: [.copy]))]) #expect(controller.collectionView.numberOfItems(inSection: 0) == 1) // Open the menu, then a new message is pushed while it's up. #expect(configuration(controller, at: 0) != nil) controller.update(items: [ - .message(ChatMessage(id: "a", text: "Hello", sender: .me)), - .message(ChatMessage(id: "b", text: "Just arrived", sender: .other)), + .message(ChatMessage(id: "a", text: "Hello", sender: .me, actions: [.copy])), + .message(ChatMessage(id: "b", text: "Just arrived", sender: .other, actions: [.copy])), ]) // Held — the transcript doesn't reflow out from under the lifted preview. @@ -90,11 +90,11 @@ struct ChatMessageCopyTests { @Test("Closing the menu applies the update that arrived while it was open") func closingMenu_appliesDeferredUpdate() { - let controller = loadedController([.message(ChatMessage(id: "a", text: "Hello", sender: .me))]) + let controller = loadedController([.message(ChatMessage(id: "a", text: "Hello", sender: .me, actions: [.copy]))]) #expect(configuration(controller, at: 0) != nil) controller.update(items: [ - .message(ChatMessage(id: "a", text: "Hello", sender: .me)), - .message(ChatMessage(id: "b", text: "Just arrived", sender: .other)), + .message(ChatMessage(id: "a", text: "Hello", sender: .me, actions: [.copy])), + .message(ChatMessage(id: "b", text: "Just arrived", sender: .other, actions: [.copy])), ]) #expect(controller.collectionView.numberOfItems(inSection: 0) == 1) // held diff --git a/FlipcashTests/Chat/MessageCapabilityMenuTests.swift b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift new file mode 100644 index 000000000..84872065a --- /dev/null +++ b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift @@ -0,0 +1,115 @@ +// +// MessageCapabilityMenuTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import FlipcashUI + +@MainActor +@Suite("ChatViewController action menu") +struct MessageCapabilityMenuTests { + + private func loadedController(_ items: [ChatItem]) -> ChatViewController { + let controller = ChatViewController() + controller.loadViewIfNeeded() + controller.update(items: items) + return controller + } + + private func configuration(_ controller: ChatViewController, at index: Int) -> UIContextMenuConfiguration? { + controller.collectionView( + controller.collectionView, + contextMenuConfigurationForItemAt: IndexPath(item: index, section: 0), + point: .zero + ) + } + + private func menu(_ controller: ChatViewController, at index: Int) -> UIMenu? { + controller.contextMenu(forItemAt: IndexPath(item: index, section: 0)) + } + + private func titles(_ menu: UIMenu?) -> [String] { + (menu?.children ?? []).compactMap { ($0 as? UIAction)?.title } + } + + @Test("The menu renders exactly the actions the message carries, in order") + func menuMatchesActions() { + let controller = loadedController([ + .message(ChatMessage(id: "1", text: "hi", sender: .me, actions: [.copy, .edit, .delete])) + ]) + #expect(titles(menu(controller, at: 0)) == ["Copy", "Edit", "Delete"]) + } + + @Test("A message with no actions offers no menu") + func noActionsMeansNoMenu() { + let controller = loadedController([ + .message(ChatMessage(id: "1", text: "hi", sender: .other, actions: [])) + ]) + #expect(configuration(controller, at: 0) == nil) + } + + @Test("Delete is marked destructive") + func deleteIsDestructive() { + let controller = loadedController([ + .message(ChatMessage(id: "1", text: "hi", sender: .me, actions: [.copy, .delete])) + ]) + guard let delete = menu(controller, at: 0)?.children + .compactMap({ $0 as? UIAction }) + .first(where: { $0.title == "Delete" }) else { + Issue.record("expected a Delete action") + return + } + #expect(delete.attributes.contains(.destructive)) + } + + @Test("Copy writes the body to the pasteboard without notifying the screen") + func copyStaysLocal() { + let controller = loadedController([ + .message(ChatMessage(id: "1", text: "copy me", sender: .me, actions: [.copy])) + ]) + var notified: [(String, MessageCapability)] = [] + controller.onMessageAction = { notified.append(($0, $1)) } + + guard let copy = menu(controller, at: 0)?.children.first as? UIAction else { + Issue.record("expected a Copy action") + return + } + copy.performWithSender(nil, target: nil) + + #expect(UIPasteboard.general.string == "copy me") + #expect(notified.isEmpty) + } + + @Test("Edit and Delete report the row's id to the screen") + func editAndDeleteNotify() { + let controller = loadedController([ + .message(ChatMessage(id: "row-7", text: "hi", sender: .me, actions: [.edit, .delete])) + ]) + var notified: [(String, MessageCapability)] = [] + controller.onMessageAction = { notified.append(($0, $1)) } + + guard let children = menu(controller, at: 0)?.children else { + Issue.record("expected a menu") + return + } + for action in children.compactMap({ $0 as? UIAction }) { + action.performWithSender(nil, target: nil) + } + + #expect(notified.map(\.0) == ["row-7", "row-7"]) + #expect(notified.map(\.1) == [.edit, .delete]) + } + + @Test("A deleted placeholder offers no menu") + func tombstoneOffersNoMenu() { + let controller = loadedController([ + .message(ChatMessage(id: "1", content: .deleted("This message was deleted"), sender: .other)) + ]) + #expect(configuration(controller, at: 0) == nil) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 0c0f70bf0..0b1d6f8e2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -88,6 +88,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onProfileTap = newValue } } + /// Forwards a chosen context-menu action, with the row's id, to whoever owns the screen. + public var onMessageAction: ((String, MessageCapability) -> Void)? { + get { transcript.onMessageAction } + set { transcript.onMessageAction = newValue } + } + public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(Color.backgroundMain) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 58b024ca0..921638e8f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -46,6 +46,10 @@ public final class ChatViewController: UICollectionViewController { /// Called when the user taps the profile card header in a tip DM; nil disables the tap. public var onProfileTap: (() -> Void)? + /// Fired when a context-menu action other than Copy is chosen, with the row's id. Copy is handled + /// here — it needs nothing the transcript does not already hold. + public var onMessageAction: ((String, MessageCapability) -> Void)? + /// The widest a bubble may grow, as a share of the collection view's width. private static let maxBubbleWidthFraction: CGFloat = 0.78 @@ -504,27 +508,12 @@ extension ChatViewController: UIGestureRecognizerDelegate { extension ChatViewController { - /// Long-pressing a text bubble offers a single "Copy" action that puts the message text on the - /// pasteboard — ChatLayout's canonical copy interaction, scoped to text messages. Cash cards and - /// date separators carry no copyable text and opt out. + /// Long-pressing a row offers exactly the actions the message carries, in the order the mapper + /// put them in. Rows with no actions — cash cards, tombstones, date separators — opt out. public override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { // Don't offer a menu mid-batch-update: the index path may not line up with the rendered cell. guard !isUpdating else { return nil } - - let body: String - switch items[indexPath.item] { - case .message(let message): - switch message.content { - case .text(let text): - body = text - case .deleted: - return nil - case .cash: - return nil - } - case .dateSeparator, .typingIndicator, .profileCard: - return nil - } + guard let menu = contextMenu(forItemAt: indexPath) else { return nil } // Freeze the inset for the menu's lifetime so presenting it (which dismisses the keyboard) // doesn't shrink the adjusted inset and reflow the content out from under the lifted preview. @@ -534,12 +523,35 @@ extension ChatViewController { // The section/item pair, encoded as an NSString, resolves the cell back in `preview(for:)`. // ChatLayout's note: a custom NSCopying identifier crashes, so a plain string is used. let identifier = "\(indexPath.section)|\(indexPath.item)" as NSString - return UIContextMenuConfiguration(identifier: identifier, previewProvider: nil) { _ in - let copy = UIAction(title: "Copy", image: UIImage(systemName: SystemSymbol.doc.rawValue)) { _ in - UIPasteboard.general.string = body + return UIContextMenuConfiguration(identifier: identifier, previewProvider: nil) { _ in menu } + } + + /// The menu a row offers, or `nil` if it offers none. Built separately from the configuration so + /// that presenting a menu and deciding what is in one stay independently answerable. + func contextMenu(forItemAt indexPath: IndexPath) -> UIMenu? { + guard indexPath.item < items.count, + case .message(let message) = items[indexPath.item], + !message.actions.isEmpty else { return nil } + + let body: String? = if case .text(let text) = message.content { text } else { nil } + let rowID = message.id + let handler = onMessageAction + + let children = message.actions.map { action in + UIAction( + title: action.title, + image: UIImage(systemName: action.menuSymbol.rawValue), + attributes: action.isDestructive ? .destructive : [] + ) { _ in + switch action { + case .copy: + if let body { UIPasteboard.general.string = body } + case .reply, .edit, .delete: + handler?(rowID, action) + } } - return UIMenu(title: "", children: [copy]) } + return UIMenu(title: "", children: children) } public override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { @@ -628,3 +640,17 @@ protocol BubbleCarrying { var liftPreviewMaskingPath: UIBezierPath? { get } } #endif + +private extension MessageCapability { + + /// The menu row's glyph. Lives here rather than on the action itself because `SystemSymbol` is + /// this module's symbol registry, and `MessageCapability` is a core model. + var menuSymbol: SystemSymbol { + switch self { + case .copy: .doc + case .reply: .arrowLeft + case .edit: .pencil + case .delete: .trash + } + } +} From 8534b0785697fdd7f831a6bbb3acf4ca2062bed6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 1 Sep 2026 16:06:37 -0400 Subject: [PATCH 03/10] feat(chat): wire message edit and delete into the conversation screen Edit loads the message into the composer; delete confirms first, because it applies for everyone in the chat. A conflict or a failure reads as "nothing happened" on the transcript, so the controller's mutation alert is surfaced as a dialog. --- .../Controllers/ConversationController.swift | 21 +++++++++ .../ChatScreenRepresentable.swift | 5 ++ .../Conversation/ConversationScreen.swift | 46 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index a0824e4c7..0ead2db85 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -854,6 +854,27 @@ final class ConversationController { let kind: Kind var id: String { "\(action.rawValue)-\(kind.rawValue)" } + + var title: String { + switch kind { + case .conflict: "Message Changed" + case .failure: + switch action { + case .edit: "Couldn't Edit Message" + case .delete: "Couldn't Delete Message" + case .copy, .reply: "Something Went Wrong" + } + } + } + + var subtitle: String { + switch kind { + case .conflict: + "This message changed somewhere else, so your change wasn't applied. The chat now shows the latest version." + case .failure: + "Check your connection and try again." + } + } } /// The transcript's bounded window with the in-memory optimistic overlay applied: every confirmed diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index d3e52d546..2423bc2b1 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -34,6 +34,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let onContactAction: () -> Void /// Fired when the user taps the profile card in a tip DM; nil disables the card tap. let onProfileTap: (() -> Void)? + /// Fired when a context-menu action is chosen on a row, with the row's stable id. Copy never + /// arrives here — the transcript puts the text on the pasteboard itself. + let onMessageAction: (String, MessageCapability) -> Void let showsSendCash: Bool let chatExists: Bool let conversationID: ConversationID? @@ -60,6 +63,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onOpenURL = onOpenURL screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap + screen.onMessageAction = onMessageAction screen.update(items: items) context.coordinator.barHost = barHost context.coordinator.screen = screen @@ -77,6 +81,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onOpenURL = onOpenURL screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap + screen.onMessageAction = onMessageAction // Scroll only when the user's *own* message was just appended — a new trailing message id // (skipping any trailing receipt) that is from me. Received messages and prepended history diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 0c8495108..08a15412c 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -209,6 +209,7 @@ struct ConversationScreen: View { onOpenURL: openLink, onContactAction: openContactCard, onProfileTap: profileTapAction, + onMessageAction: handleMessageAction, showsSendCash: sendTarget != nil, chatExists: chatExists, conversationID: conversationID, @@ -267,6 +268,11 @@ struct ConversationScreen: View { // While composing, a downward swipe should lower the keyboard — not // tear down the whole Send sheet. .interactiveDismissDisabled(barModel.isComposing) + // The transcript explains an applied edit or delete on its own; a conflict or a failure is + // only visible as "nothing happened", so it is reported here. + .onChange(of: conversationController.mutationAlert) { _, alert in + presentMutationAlert(alert) + } // Keyed on existence, not just the ID: a matched contact's chat ID is // pre-assigned, and fetching messages for a chat the server hasn't // created yet error-reports. Fires when the chat materializes. @@ -362,6 +368,46 @@ struct ConversationScreen: View { presentedCard = ContactCard.make(contact: contact, addablePhone: addableContactPhone) } + private func presentMutationAlert(_ alert: ConversationController.MutationAlert?) { + guard let alert else { return } + session.dialogItem = DialogItem.alert(title: alert.title, subtitle: alert.subtitle) { + DialogAction.okay(kind: .destructive) { + conversationController.mutationAlert = nil + } + } + } + + /// Routes a context-menu choice. Copy never arrives here — the transcript handles it locally. + private func handleMessageAction(_ stableID: String, _ action: MessageCapability) { + guard let message = coordinator?.loader.messages.first(where: { $0.stableID == stableID }) else { return } + + switch action { + case .copy: + break + case .reply: + break // Reply is a separate scope; the menu does not offer it yet. + case .edit: + guard case .text(let text) = message.content else { return } + composer.beginEditing(messageID: message.id, stableID: stableID, currentText: text) + case .delete: + confirmDelete(message.id) + } + } + + private func confirmDelete(_ messageID: MessageID) { + guard let conversationID else { return } + + session.dialogItem = DialogItem.alert( + title: "Delete Message", + subtitle: "This message will be deleted for everyone in this chat." + ) { + DialogAction.destructive("Delete") { + Task { await conversationController.delete(messageID: messageID, in: conversationID) } + } + DialogAction.cancel() + } + } + private func sendCash() { guard let sendTarget else { return } let context: AddMoneyContext = switch sendTarget { From 837322a843c95689a6ab9dbccca3925b65a574ac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 1 Sep 2026 16:08:21 -0400 Subject: [PATCH 04/10] test(chat): pin the default tombstone presentation end to end The mapper defaults to hiding deleted rows for callers that pass nothing, and the coordinator passes the policy's presentation instead. This asserts the two agree, so a change to either default fails here rather than in the transcript. --- FlipcashTests/Chat/ChatMessageMappingTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/FlipcashTests/Chat/ChatMessageMappingTests.swift b/FlipcashTests/Chat/ChatMessageMappingTests.swift index 9c6aa0900..d81330947 100644 --- a/FlipcashTests/Chat/ChatMessageMappingTests.swift +++ b/FlipcashTests/Chat/ChatMessageMappingTests.swift @@ -346,4 +346,19 @@ struct ChatMessageMappingTests { ) #expect(messageRows(items).first?.actions == [.copy, .edit, .delete]) } + + @Test("The default policy shows a placeholder, so a deleted row keeps its place") + func defaultPolicyShowsPlaceholder() { + #expect(MessagePolicy.default.deletedPresentation == .placeholder) + + let items = ChatItem.from( + [text(1, them, "hi", after: 0), deleted(2, them, deletedBy: them, after: 30)], + selfUserID: me, + deletedPresentation: MessagePolicy.default.deletedPresentation + ) + let rows = messageRows(items) + #expect(rows.count == 2) + #expect(rows[1].content == .deleted("This message was deleted")) + #expect(rows[0].isContinuedByNext) + } } From ae2f80c9c503c1281ef9b346669d0db975843185 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 13:07:57 -0400 Subject: [PATCH 05/10] feat(chat): let an edit take over the composer bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editing banner sat above the bar and pushed the transcript, so entering and leaving an edit shifted the messages. WhatsApp keeps the bar's own footprint and swaps its controls, so do that: while editing, a cancel control replaces Send Cash in the leading slot and the submit button stays up for the whole edit, its glyph crossfading arrow to checkmark. Confirming an unchanged edit exits without a request. The cancel button needs an explicit content shape — the glyph is its only drawn content, so taps landing on the glass around it were missing the button while the interactive platter lit up, leaving the edit open. The bar's stack is top-aligned now that the controls sit beside a field that grows downward. Delete's confirmation copy follows WhatsApp: "Delete message?", "This can't be undone.", "Delete for everyone". --- .../Screens/Conversation/ComposerModel.swift | 9 ++ .../Conversation/ConversationBottomBar.swift | 99 +++++++++++-------- .../Conversation/ConversationScreen.swift | 8 +- .../FlipcashUI/Theme/Image+Symbols.swift | 2 + 4 files changed, 73 insertions(+), 45 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index 10e889006..3ea937ba6 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -46,6 +46,15 @@ final class ComposerModel { var canSubmit: Bool { submission != nil } + /// Whether the field is editing an existing message rather than writing a new one. The bar + /// swaps its leading control and its confirm glyph on this. + var isEditing: Bool { + switch mode { + case .new: false + case .editing: true + } + } + /// Switches the field to editing an existing message, stashing whatever was being written. func beginEditing(messageID: MessageID, stableID: String, currentText: String) { if case .new = mode { diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index b26b61705..31c82fedd 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -47,8 +47,15 @@ struct ConversationBottomBar: View { var isTipDm: Bool = false var body: some View { - let content = HStack(alignment: .bottom, spacing: 10) { - if showsSendCash { + // Top-aligned: the field is the side that grows (a multiline draft) and it grows upward off + // a pinned bar bottom, so anchoring the control beside it to the field's top edge keeps the + // two reading as one row instead of leaving the button stranded low. + let content = HStack(alignment: .top, spacing: 10) { + // An edit takes over the bar: the leading control becomes the way out of it and Send + // Cash steps aside until it resolves, the way WhatsApp hides its accessory controls. + if composer.isEditing { + CancelEditButton { composer.endEditing() } + } else if showsSendCash { SendCashMorphButton( symbol: symbol, composing: model.isComposing, @@ -70,6 +77,7 @@ struct ConversationBottomBar: View { .padding(.top, 8) .padding(.bottom, 8) .animation(barMorphSpring, value: chatExists) + .animation(barMorphSpring, value: composer.isEditing) // No shared GlassEffectContainer: the composer's glass is a background // layer behind an editable text field, and a container composites its @@ -81,8 +89,8 @@ struct ConversationBottomBar: View { } } -/// The glass type box: a multiline field with a send button that appears once -/// there's text. Swiping the chat down lowers the keyboard and the box. +/// The glass type box: a multiline field with a confirm button — an arrow that appears once there's +/// text, a checkmark for the length of an edit. Swiping the chat down lowers the keyboard and the box. struct ConversationComposer: View { let conversationID: ConversationID? @@ -106,38 +114,35 @@ struct ConversationComposer: View { .frame(maxWidth: .infinity, alignment: .leading) .frame(minHeight: BarMetrics.fieldMinHeight) - if composer.canSubmit { + if showsSubmit { Button(action: submit) { - Image(systemName: "arrow.up") + Image(systemName: submitSymbol) .font(.default(size: 16, weight: .bold)) .foregroundStyle(Color.textAction) .frame(width: 34, height: 34) .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) + // Arrow and checkmark are the same button in two jobs, so the glyph swaps in + // place rather than the button popping out and a new one popping back. + .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) - .accessibilityLabel(composer.mode == .new ? "Send" : "Save") + .accessibilityLabel(composer.isEditing ? "Save" : "Send") .accessibilityIdentifier("send-message-button") // Pop from 60% + fade, so the opacity ramp actually reads // (scaling from 0 hides the fade behind a tiny speck). .transition(.scale(scale: 0.6).combined(with: .opacity)) } } - .animation(Self.sendButtonSpring, value: composer.canSubmit) + .animation(Self.sendButtonSpring, value: showsSubmit) + + return field .padding(.leading, 14) .padding(.trailing, 8) .padding(.vertical, BarMetrics.fieldVerticalPadding) - - return VStack(alignment: .leading, spacing: 8) { - if case .editing = composer.mode { - EditingBanner { composer.endEditing() } - } - field - // Glass *behind* the field, not wrapping it: wrapping an editable - // TextField in `glassEffect` reparents its text view into the glass - // platter and breaks the text-selection grabbers. - .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) - } - .animation(barMorphSpring, value: composer.mode) + // Glass *behind* the field, not wrapping it: wrapping an editable + // TextField in `glassEffect` reparents its text view into the glass + // platter and breaks the text-selection grabbers. + .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) // Focus is the single source of `isComposing` — the button morph and the // screen's interactive-dismiss gate both key off it. Losing focus // (keyboard swiped down) ends composing. @@ -153,51 +158,61 @@ struct ConversationComposer: View { } } + /// The confirm button is up for the whole of an edit, as it is in WhatsApp, and only once + /// there's text to send otherwise. + private var showsSubmit: Bool { composer.isEditing || composer.canSubmit } + + private var submitSymbol: String { + composer.isEditing ? SystemSymbol.checkmark.rawValue : SystemSymbol.arrowUp.rawValue + } + private func submit() { - guard let conversationID, let text = composer.submission else { return } + guard let conversationID else { return } // Fire-and-forget in both branches: the change applies optimistically and resolves on its own, // so the composer stays ready immediately. Emptying the field up front makes a double-tap a // no-op, because there is then nothing to submit. switch composer.mode { case .new: + guard let text = composer.submission else { return } composer.clear() isFocused = true Task { await conversationController.send(text, to: conversationID) } case .editing(let messageID, _): + // Confirming an edit that changed nothing leaves edit mode rather than round-tripping + // the same text — the button is always there to be pressed. + let text = composer.submission composer.endEditing() isFocused = true + guard let text else { return } Task { await conversationController.edit(messageID: messageID, in: conversationID, to: text) } } } } -/// The strip above the field while an edit is in progress, naming what the field is doing and -/// offering a way out of it. -private struct EditingBanner: View { +/// The way out of an edit: the bar's leading control while the field holds an existing message, +/// standing where Send Cash stands the rest of the time. Field-sized and glass, so the swap reads +/// as the same control changing job rather than a foreign button arriving. +private struct CancelEditButton: View { let onCancel: () -> Void var body: some View { - HStack(spacing: 10) { - Image(systemName: SystemSymbol.pencil.rawValue) - .font(.default(size: 13, weight: .medium)) - .foregroundStyle(Color.textSecondary) - Text("Editing message") - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - Spacer(minLength: 0) - Button(action: onCancel) { - Image(systemName: SystemSymbol.close.rawValue) - .font(.default(size: 13, weight: .medium)) - .foregroundStyle(Color.textSecondary) - } - .buttonStyle(.plain) - .accessibilityLabel("Cancel editing") - .accessibilityIdentifier("cancel-edit-button") + Button(action: onCancel) { + Image(systemName: SystemSymbol.close.rawValue) + .font(.default(size: 17, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + // The glyph is the only drawn content, so without a shape the taps that land on the + // glass around it miss the button — the platter lights up (it is `.interactive`) and + // the edit stays open. The shape makes the whole pill the target. + .contentShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) } - .padding(.horizontal, 14) - .transition(.opacity.combined(with: .move(edge: .bottom))) + .buttonStyle(.plain) + .glassBackground(cornerRadius: BarMetrics.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + .accessibilityLabel("Cancel editing") + .accessibilityIdentifier("cancel-edit-button") } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 08a15412c..d99b6e3a2 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -397,11 +397,13 @@ struct ConversationScreen: View { private func confirmDelete(_ messageID: MessageID) { guard let conversationID else { return } + // WhatsApp's sheet offers "Delete for everyone" beside "Delete for me"; we have no + // delete-for-me, so the one action we do have keeps its label and stands alone. session.dialogItem = DialogItem.alert( - title: "Delete Message", - subtitle: "This message will be deleted for everyone in this chat." + title: "Delete message?", + subtitle: "This can't be undone." ) { - DialogAction.destructive("Delete") { + DialogAction.destructive("Delete for everyone") { Task { await conversationController.delete(messageID: messageID, in: conversationID) } } DialogAction.cancel() diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index 289109da3..310953182 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -73,6 +73,8 @@ public enum SystemSymbol: String { case pencil = "pencil" case trash = "trash" + /// The bare glyph, for a confirm affordance that carries its own backing shape. + case checkmark = "checkmark" case speakerSlash = "speaker.slash.fill" From 8660ed51dde3e969a544eeecb838d4b46237794a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 13:08:07 -0400 Subject: [PATCH 06/10] fix(chat): hand the keyboard off around the transcript context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIKit hides the keyboard for a context menu's whole lifetime but leaves the composer first responder, so a long press left a blinking caret, no keyboard, and nothing to type into. It also lays the menu out in the space the keyboard vacated, which is why a press near the bottom of the transcript put the bubble behind the keyboard once the menu landed. Three points, each picked for a reason: - Freeze the inset when the menu is configured, before anything moves, so the keyboard's space stays reserved and the transcript does not reflow under the lifted preview. - Resign at willDisplayContextMenu, not at the long-press threshold, so the keyboard animates away alongside the menu. The resign stays out of the animator's block — the composer bar rides the keyboard's own notifications, and folding it in strands the bar at its keyboard-up position. - Re-take the responder as willEndContextMenuInteraction fires rather than in its completion; waiting for the menu to finish left a beat of empty composer before the keyboard moved. Actions follow the keyboard they need: edit focuses the composer, delete keeps it down so it does not rise behind the confirmation sheet. --- .../ChatScreenRepresentable.swift | 22 +++++- .../Chat/ChatScreenViewController.swift | 62 ++++++++++++++- .../FlipcashUI/Chat/ChatViewController.swift | 78 ++++++++++++++++--- 3 files changed, 150 insertions(+), 12 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 2423bc2b1..00554e76e 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -63,7 +63,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onOpenURL = onOpenURL screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap - screen.onMessageAction = onMessageAction + screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) screen.update(items: items) context.coordinator.barHost = barHost context.coordinator.screen = screen @@ -81,7 +81,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onOpenURL = onOpenURL screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap - screen.onMessageAction = onMessageAction + screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) // Scroll only when the user's *own* message was just appended — a new trailing message id // (skipping any trailing receipt) that is from me. Received messages and prepended history @@ -97,6 +97,24 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { context.coordinator.lastMessageID = newLastMessageID } + /// Wraps the action handler so the keyboard follows the action. The menu dismissed the keyboard + /// to present itself and the composer can't drive it back on its own — a hosted SwiftUI + /// `@FocusState` doesn't make the field first responder — so only the screen can raise it for an + /// edit, or hold it down for a delete, whose confirmation sheet it would otherwise cover. + private func keyboardFollowing( + _ handler: @escaping (String, MessageCapability) -> Void, + screen: ChatScreenViewController + ) -> (String, MessageCapability) -> Void { + { [weak screen] stableID, action in + handler(stableID, action) + switch action { + case .edit: screen?.focusComposer() + case .delete: screen?.dismissKeyboard() + case .copy, .reply: break + } + } + } + private func lastMessage(of items: [ChatItem]) -> ChatItem? { items.last { if case .message = $0 { true } else { false } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 0b1d6f8e2..87e900ac2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -42,6 +42,12 @@ public final class ChatScreenViewController: UIViewController { /// real `becomeFirstResponder` does. public var focusesComposerOnAppear = false private var didFocusComposer = false + /// Whether the composer held the keyboard when the current context menu opened, and so should get + /// it back when that menu goes. Cleared by `dismissKeyboard()` so an action handing off to a sheet + /// isn't fought by the restore. + private var composerHeldKeyboardUnderMenu = false + /// Whether a measured bar height has landed yet — the first one is applied without animation. + private var didMeasureBar = false /// - Parameters: /// - bar: pinned to the bottom of the view; rides the keyboard. @@ -123,6 +129,29 @@ public final class ChatScreenViewController: UIViewController { barHeightConstraint = constraints.height keyboardFloor = KeyboardFloor(view: view, bottomConstraint: constraints.bottom) lowerComposerOnResignActive() + handOffComposerFocusAroundContextMenu() + } + + /// Takes the keyboard down for a context menu and gives it back once the menu has gone. + /// + /// UIKit hides the keyboard for a context menu's whole lifetime but leaves the field first + /// responder, so the composer is left with a blinking caret, no keyboard, and no way to type + /// into it. Resigning as the menu comes on screen makes that an ordinary dismissal — the keyboard + /// animates away alongside the menu rather than at the long-press threshold, and the composer + /// looks as unfocused as it now is — and re-taking the responder as the menu starts to go + /// restores what the long press interrupted, whether the menu closed on an action or on a tap + /// outside. + private func handOffComposerFocusAroundContextMenu() { + transcript.onContextMenuWillPresent = { [weak self] in + guard let self, let responder = bar.firstTextInputResponder else { return } + composerHeldKeyboardUnderMenu = responder.isFirstResponder + _ = responder.resignFirstResponder() + } + transcript.onContextMenuDidDismiss = { [weak self] in + guard let self, composerHeldKeyboardUnderMenu else { return } + composerHeldKeyboardUnderMenu = false + _ = bar.firstTextInputResponder?.becomeFirstResponder() + } } /// Adds a hosted bar pinned to the view's width and bottom; returns the height constraint @@ -196,13 +225,44 @@ public final class ChatScreenViewController: UIViewController { // (app foregrounding) doesn't force the keyboard back up. guard focusesComposerOnAppear, !didFocusComposer else { return } didFocusComposer = true - bar.firstTextInputResponder?.becomeFirstResponder() + focusComposer() + } + + /// Raise the keyboard for the bar's field. A hosted SwiftUI `@FocusState` set programmatically + /// moves the caret into the field but never makes it first responder across the hosting + /// boundary, so the keyboard never arrives and the field takes no input — only this does. Waits + /// out any open context menu, which owns the screen and would refuse the responder change. + public func focusComposer() { + transcript.afterContextMenu { [weak self] in + self?.bar.firstTextInputResponder?.becomeFirstResponder() + } + } + + /// Keep the keyboard down, for a menu action that hands off to a sheet. The menu already lowered + /// it, so the work here is cancelling the restore that would otherwise put the keyboard back on + /// top of whatever the action presented; the resigns cover the callers that had no menu open. + public func dismissKeyboard() { + composerHeldKeyboardUnderMenu = false + _ = bar.firstTextInputResponder?.resignFirstResponder() + transcript.afterContextMenu { [weak self] in + _ = self?.bar.firstTextInputResponder?.resignFirstResponder() + } } /// Set the bar's height to its measured SwiftUI content height. public func setBarHeight(_ height: CGFloat) { guard barHeightConstraint != nil, barHeightConstraint.constant != height else { return } + // First measurement (or off-screen): apply it flat, so the push doesn't animate the bar in. + let isFirst = !didMeasureBar + didMeasureBar = true barHeightConstraint.constant = height + guard !isFirst, view.window != nil else { return } + // Lay out inside the animation so the transcript's inset change — `viewDidLayoutSubviews` + // feeds the new bar height to `setBottomInset` — rides the same curve and the content + // scrolls up with the bar, instead of snapping on whatever layout pass happens to run next. + UIView.animate(springDuration: ChatMotion.swap.duration, bounce: ChatMotion.swap.bounce) { + self.view.layoutIfNeeded() + } } // MARK: - Data passthrough diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 921638e8f..a23c7e105 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -91,6 +91,25 @@ public final class ChatViewController: UICollectionViewController { /// A transcript pushed while the menu was up, applied once it closes (so an arriving message can't /// reflow the content mid-preview). Mirrors ChatLayout deferring updates while `.showingPreview`. private var deferredItems: [ChatItem]? + /// A bottom inset requested while the menu had the inset frozen, applied once it closes. The bar + /// can grow from a menu action — choosing Edit opens the editing banner — and that request lands + /// during the freeze, so without holding it the transcript keeps the shorter bar's inset and the + /// banner covers the newest rows. + private var pendingBottomInset: CGFloat? + + /// Called as a context menu is presented and again once it has finished dismissing. UIKit hides + /// the keyboard for the menu's lifetime but leaves the composer first responder, so the screen + /// uses these to make that an ordinary dismissal and to put the keyboard back afterwards. + var onContextMenuWillPresent: (() -> Void)? + var onContextMenuDidDismiss: (() -> Void)? + /// Guards the lowering to once per menu — the display callback can fire for the lift and again + /// for the menu itself. + private var didLowerKeyboardForMenu = false + + /// Work handed over by a menu action to run once the menu has finished dismissing. A + /// `becomeFirstResponder` issued from a `UIAction` is rejected while the menu still owns the + /// screen, so choosing Edit parks the keyboard-raise here instead. + private var pendingAfterContextMenu: (() -> Void)? public init() { super.init(collectionViewLayout: chatLayout) @@ -390,8 +409,11 @@ public final class ChatViewController: UICollectionViewController { /// ChatLayout's own snapshot, so at-bottom stays at-bottom (content lifts above the bar) and /// scrolled-up stays put — no hand-computed offset. public func setBottomInset(_ inset: CGFloat) { - // The inset is frozen while a context menu is up; don't touch it (it's restored on close). - guard !isShowingContextMenu else { return } + // The inset is frozen while a context menu is up; hold the request for the close instead. + guard !isShowingContextMenu else { + pendingBottomInset = inset + return + } // Never change the inset mid-batch-update: ChatLayout can't account for an inset change // during `performBatchUpdates`, which is what made an append (a send) overshoot. The next // layout pass after the update re-applies it. @@ -409,18 +431,21 @@ 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. Switching `contentInsetAdjustmentBehavior` flashes a transient inset that - /// ChatLayout would re-anchor to, so the bottom-edge snapshot is restored across the switch (the - /// same primitive `setBottomInset` uses) to pin the content where it was. + /// exact position. private func freezeInset() { guard savedInsetBehavior == nil else { return } let frozen = collectionView.adjustedContentInset savedInsetBehavior = collectionView.contentInsetAdjustmentBehavior savedContentInset = collectionView.contentInset savedScrollIndicatorInsets = collectionView.verticalScrollIndicatorInsets - collectionView.contentInsetAdjustmentBehavior = .never + // 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 @@ -429,9 +454,9 @@ public final class ChatViewController: UICollectionViewController { // its position without a forced re-anchor. } - /// Hand the inset back to the system; with the keyboard sliding back in, it re-grows the adjusted - /// inset to its pre-menu value. The bottom-edge snapshot is restored across the switch so the - /// content lands exactly where it was, rather than wherever the transient inset re-anchored it. + /// 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. private func restoreInset() { guard let behavior = savedInsetBehavior else { return } collectionView.contentInsetAdjustmentBehavior = behavior @@ -515,6 +540,7 @@ extension ChatViewController { guard !isUpdating else { return nil } guard let menu = contextMenu(forItemAt: indexPath) else { return nil } + // Freeze the inset for the menu's lifetime so presenting it (which dismisses the keyboard) // doesn't shrink the adjusted inset and reflow the content out from under the lifted preview. isShowingContextMenu = true @@ -562,20 +588,47 @@ extension ChatViewController { preview(for: configuration) } + /// The menu is coming on screen — take the keyboard down now, so it leaves as the menu arrives + /// rather than the instant the long press registers. The inset was frozen when the menu was + /// configured, so its space stays reserved and the transcript holds position while it goes. + /// Deliberately not inside the animator's block: the composer bar rides the keyboard's own + /// notifications, and folding the resign into the menu's animation leaves the bar stranded at + /// its keyboard-up position. + public override func collectionView(_ collectionView: UICollectionView, willDisplayContextMenu configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + guard !didLowerKeyboardForMenu else { return } + didLowerKeyboardForMenu = true + onContextMenuWillPresent?() + } + /// The menu is closing — hand the inset back to the system (the keyboard slides back, restoring the /// content to exactly where it was), then apply any update that was pushed while it was up. A `nil` /// animator (no transition) runs immediately so the freeze can never get stuck on. public override func collectionView(_ collectionView: UICollectionView, willEndContextMenuInteraction configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + // Ask for the keyboard back as the dismissal starts, not in its completion: waiting for the + // menu to finish leaves a beat of empty composer before the keyboard moves. The inset is + // still frozen at its keyboard-up value while it rises, so nothing reflows, and by the time + // the completion hands the inset back the keyboard is where the system expects it. + didLowerKeyboardForMenu = false + onContextMenuDidDismiss?() + let resume: () -> Void = { [weak self] in guard let self else { return } // Restore the inset while the flag is still set, so the behavior switch's inset change is // suppressed (no stray scroll); then drop the flag and apply any held update. restoreInset() isShowingContextMenu = false + if let inset = pendingBottomInset { + pendingBottomInset = nil + setBottomInset(inset) + } if let pending = deferredItems { deferredItems = nil update(items: pending) } + if let work = pendingAfterContextMenu { + pendingAfterContextMenu = nil + work() + } } if let animator { animator.addCompletion(resume) @@ -584,6 +637,13 @@ extension ChatViewController { } } + /// Run `work` once no context menu is on screen — immediately if none is up, otherwise after the + /// current one finishes dismissing. + public func afterContextMenu(_ work: @escaping () -> Void) { + guard isShowingContextMenu else { return work() } + pendingAfterContextMenu = work + } + /// Builds the lift preview from the bubble alone, clipped to its shape. Without it UIKit lifts the /// whole side-hugging cell as a plain rectangle. Mirrors ChatLayout's `preview(for:)`. private func preview(for configuration: UIContextMenuConfiguration) -> UITargetedPreview? { From ce0be0ccbedd0e22b1eb0fb792f1bbda49997bff Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 13:08:13 -0400 Subject: [PATCH 07/10] docs(chat): record the shipped edit UI and add a Maestro login flow The spec described an inline "Edited" suffix and a banner above the composer; both were replaced during implementation. Update it to the corner-pinned marker with its clear reservation run and the bar-takeover composer. .maestro/login.yaml signs in through the flipcash://login deep link so simulator runs can reach a conversation without hand-driving onboarding. The access key is passed at run time (-e ACCESS_KEY=...), not stored. --- .maestro/login.yaml | 16 +++++++++++++++ .../2026-09-01-chat-message-actions-design.md | 20 +++++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 .maestro/login.yaml diff --git a/.maestro/login.yaml b/.maestro/login.yaml new file mode 100644 index 000000000..efe62a70d --- /dev/null +++ b/.maestro/login.yaml @@ -0,0 +1,16 @@ +# Signs the device into the UI-test account through the app's own login deeplink — +# the same path FlipcashUITests uses (FlipcashUITests/Support/BaseUITestCase.swift). +# The access key never lives in this file; pass it in at run time: +# +# maestro test .maestro/login.yaml -e ACCESS_KEY="$(...)" +# +# Run this first when another flow needs a signed-in app. +appId: com.flipcash.app.ios +--- +- launchApp +- openLink: "flipcash://login#e=${ACCESS_KEY}" + +# Login pulls the account down from the server, so give it room. +- extendedWaitUntil: + visible: "Wallet" + timeout: 60000 diff --git a/docs/superpowers/specs/2026-09-01-chat-message-actions-design.md b/docs/superpowers/specs/2026-09-01-chat-message-actions-design.md index 4b9559110..7639b87b9 100644 --- a/docs/superpowers/specs/2026-09-01-chat-message-actions-design.md +++ b/docs/superpowers/specs/2026-09-01-chat-message-actions-design.md @@ -225,12 +225,20 @@ placeholder as a muted italic variant with no link detection and no tap. The "Edited" marker cannot ride the receipt line, because the receipt attaches only to the latest confirmed self message (`ChatItem+Conversation.swift:65`) and most edited messages -will not have one. It is a muted suffix inside the bubble instead. - -**Composer.** `ComposerMode` drives a dismissible banner above the field. Entering -`.editing` pre-fills the field with the current text and turns send into a confirm. -Entering edit mode must stash the in-progress `.new` draft and restore it on cancel, -otherwise the user silently loses what they were typing. +will not have one. It takes WhatsApp's position by itself: a muted label pinned to the +bubble's bottom-trailing corner, on the last line of the body where there is room and on a +line of its own where there isn't. The body reserves that space with a clear-drawn copy of +the marker's own glyphs, so the gap is exactly the label's width and the reservation wraps +on its own rather than dragging the last word with it. `LinkableBubbleView` renders it from +the same builder, so an edited link message is marked too. + +**Composer.** `ComposerMode` takes over the whole bar, following WhatsApp: entering `.editing` +pre-fills the field with the current text, swaps Send Cash for a cancel control, and turns the send +arrow into a checkmark that stays up for the length of the edit. There is no banner and no +"Editing message" label — the pre-filled field and the swapped controls carry the state. Confirming +an edit that changed nothing leaves edit mode without a request. Entering edit mode must stash the +in-progress `.new` draft and restore it on cancel, otherwise the user silently loses what they were +typing. **Scroll to quote.** `scrollToMessage(id:)` scrolls when the target is already rendered. When it is persisted but outside the window, `MessageLoader` moves its anchor to the quoted From c9ea9da745bb2a83ae67546ddabd00cf272c5729 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 14:32:19 -0400 Subject: [PATCH 08/10] feat(chat): blur the screen behind the message context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIKit only dims the content behind a context menu, so every other bubble stays legible under the platter. WhatsApp blurs it, leaving the lifted bubble as the one sharp thing on screen. `ContextMenuBackdrop` fades a `UIVisualEffectView` in over the navigation stack — transcript, composer and navigation bar together — riding the menu's own animator so it lands and leaves with the platter. The transcript's present and dismiss callbacks now carry that animator. The effect is `.systemUltraThinMaterialDark`: on a near-black transcript anything heavier erases the bubbles instead of softening them. --- .../Chat/ChatScreenViewController.swift | 29 +++++++-- .../FlipcashUI/Chat/ChatViewController.swift | 13 ++-- .../FlipcashUI/Chat/ContextMenuBackdrop.swift | 63 +++++++++++++++++++ 3 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ContextMenuBackdrop.swift diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 87e900ac2..eeed4f85a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -46,6 +46,8 @@ public final class ChatScreenViewController: UIViewController { /// it back when that menu goes. Cleared by `dismissKeyboard()` so an action handing off to a sheet /// isn't fought by the restore. private var composerHeldKeyboardUnderMenu = false + /// The blur shown behind a context menu. + private let backdrop = ContextMenuBackdrop() /// Whether a measured bar height has landed yet — the first one is applied without animation. private var didMeasureBar = false @@ -132,7 +134,8 @@ public final class ChatScreenViewController: UIViewController { handOffComposerFocusAroundContextMenu() } - /// Takes the keyboard down for a context menu and gives it back once the menu has gone. + /// Blurs the screen behind a context menu, takes the keyboard down for its lifetime, and puts + /// both back once the menu has gone. /// /// UIKit hides the keyboard for a context menu's whole lifetime but leaves the field first /// responder, so the composer is left with a blinking caret, no keyboard, and no way to type @@ -142,18 +145,34 @@ public final class ChatScreenViewController: UIViewController { /// restores what the long press interrupted, whether the menu closed on an action or on a tap /// outside. private func handOffComposerFocusAroundContextMenu() { - transcript.onContextMenuWillPresent = { [weak self] in - guard let self, let responder = bar.firstTextInputResponder else { return } + transcript.onContextMenuWillPresent = { [weak self] animator in + guard let self else { return } + backdrop.present(over: contextMenuBackdropHost, animator: animator) + guard let responder = bar.firstTextInputResponder else { return } composerHeldKeyboardUnderMenu = responder.isFirstResponder _ = responder.resignFirstResponder() } - transcript.onContextMenuDidDismiss = { [weak self] in - guard let self, composerHeldKeyboardUnderMenu else { return } + transcript.onContextMenuDidDismiss = { [weak self] animator in + guard let self else { return } + backdrop.dismiss(animator: animator) + guard composerHeldKeyboardUnderMenu else { return } composerHeldKeyboardUnderMenu = false _ = bar.firstTextInputResponder?.becomeFirstResponder() } } + /// The view the context-menu blur covers: the navigation stack when this screen is inside one, + /// so the navigation bar goes soft with the transcript and the composer, and this screen's own + /// view otherwise. + private var contextMenuBackdropHost: UIView { + var ancestor = parent + while let current = ancestor { + if let navigation = current as? UINavigationController { return navigation.view } + ancestor = current.parent + } + return view + } + /// Adds a hosted bar pinned to the view's width and bottom; returns the height constraint /// (driven later by the bar's measured SwiftUI content height) and the bottom constraint /// (driven by the keyboard). diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index a23c7e105..30b572849 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -97,11 +97,12 @@ public final class ChatViewController: UICollectionViewController { /// banner covers the newest rows. private var pendingBottomInset: CGFloat? - /// Called as a context menu is presented and again once it has finished dismissing. UIKit hides + /// Called as a context menu is presented and again as it starts to dismiss, each carrying the + /// transition's animator so the screen can fade its own backdrop alongside the menu. UIKit hides /// the keyboard for the menu's lifetime but leaves the composer first responder, so the screen - /// uses these to make that an ordinary dismissal and to put the keyboard back afterwards. - var onContextMenuWillPresent: (() -> Void)? - var onContextMenuDidDismiss: (() -> Void)? + /// also uses these to make that an ordinary dismissal and to put the keyboard back afterwards. + var onContextMenuWillPresent: ((UIContextMenuInteractionAnimating?) -> Void)? + var onContextMenuDidDismiss: ((UIContextMenuInteractionAnimating?) -> Void)? /// Guards the lowering to once per menu — the display callback can fire for the lift and again /// for the menu itself. private var didLowerKeyboardForMenu = false @@ -597,7 +598,7 @@ extension ChatViewController { public override func collectionView(_ collectionView: UICollectionView, willDisplayContextMenu configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { guard !didLowerKeyboardForMenu else { return } didLowerKeyboardForMenu = true - onContextMenuWillPresent?() + onContextMenuWillPresent?(animator) } /// The menu is closing — hand the inset back to the system (the keyboard slides back, restoring the @@ -609,7 +610,7 @@ extension ChatViewController { // still frozen at its keyboard-up value while it rises, so nothing reflows, and by the time // the completion hands the inset back the keyboard is where the system expects it. didLowerKeyboardForMenu = false - onContextMenuDidDismiss?() + onContextMenuDidDismiss?(animator) let resume: () -> Void = { [weak self] in guard let self else { return } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ContextMenuBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ContextMenuBackdrop.swift new file mode 100644 index 000000000..3cd51d24a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ContextMenuBackdrop.swift @@ -0,0 +1,63 @@ +// +// ContextMenuBackdrop.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit + +/// The blur that sits behind a message context menu. +/// +/// UIKit only dims the content behind a context menu, leaving every bubble legible under the +/// platter. WhatsApp — the reference for this interaction — blurs it, so the lifted bubble is the +/// one sharp thing on screen. The blur covers whichever view it is presented over, and UIKit puts +/// the menu itself in a container above the window's root, so the platter and the lift stay sharp. +@MainActor +final class ContextMenuBackdrop { + + /// Matches the fade UIKit uses for its own dimming when no animator is supplied. + private static let fallbackDuration: TimeInterval = 0.2 + + private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark) + private var effectView: UIVisualEffectView? + + /// Fades the blur in over `host`, riding `animator` so it lands with the menu. Presenting twice + /// is a no-op: the display callback fires once for the lift and again for the menu. + func present(over host: UIView, animator: UIContextMenuInteractionAnimating?) { + guard effectView == nil else { return } + + let blur = UIVisualEffectView(effect: nil) + // Purely decorative — the menu's own container sits above this and owns every touch. + blur.isUserInteractionEnabled = false + blur.frame = host.bounds + blur.autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.addSubview(blur) + effectView = blur + + let fadeIn = { blur.effect = self.effect } + if let animator { + animator.addAnimations(fadeIn) + } else { + UIView.animate(withDuration: Self.fallbackDuration, animations: fadeIn) + } + } + + /// Fades the blur out with the menu and takes it off screen once it has gone. + func dismiss(animator: UIContextMenuInteractionAnimating?) { + guard let blur = effectView else { return } + effectView = nil + + let fadeOut = { blur.effect = nil } + if let animator { + animator.addAnimations(fadeOut) + animator.addCompletion { blur.removeFromSuperview() } + } else { + UIView.animate(withDuration: Self.fallbackDuration, animations: fadeOut) { _ in + blur.removeFromSuperview() + } + } + } +} +#endif From 9662d91a5ca6cb91f92b47cbc189cfd23e3c7441 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 14:39:14 -0400 Subject: [PATCH 09/10] fix(chat): name the delete scope on the confirmation CTA "Delete for everyone" said what happens but read as a sentence fragment next to Cancel, and the subtitle's full stop was the only one on a two-line dialog. Title-case the action to match the other destructive labels in the app ("Log Out", "Permanently Delete Account", "Block") and drop the period. --- Flipcash/Core/Screens/Conversation/ConversationScreen.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index d99b6e3a2..8d69ddda7 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -398,12 +398,12 @@ struct ConversationScreen: View { guard let conversationID else { return } // WhatsApp's sheet offers "Delete for everyone" beside "Delete for me"; we have no - // delete-for-me, so the one action we do have keeps its label and stands alone. + // delete-for-me, so the one action we do have names its scope and stands alone. session.dialogItem = DialogItem.alert( title: "Delete message?", - subtitle: "This can't be undone." + subtitle: "This can't be undone" ) { - DialogAction.destructive("Delete for everyone") { + DialogAction.destructive("Delete For Everyone") { Task { await conversationController.delete(messageID: messageID, in: conversationID) } } DialogAction.cancel() From 8c7cd093f7bdb9d3bf9f48641710c76083bff799 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 15:12:56 -0400 Subject: [PATCH 10/10] feat(chat): hold the context menu's blur through an edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp keeps the transcript blurred after Edit, with the edited message the one sharp thing above the composer. We faded the blur out with the menu and dropped straight back to a legible transcript. The menu's blur is now claimed from the Edit action itself — before `willEndContextMenuInteraction` would have faded it — reparented below the composer bar so the field stays sharp, and given a tap target, so tapping outside the message cancels the edit as well as the X does. The edited message is a detached snapshot floated above the blur rather than a hole cut in it: `UIVisualEffectView` renders its backdrop through a layer that ignores `layer.mask`, and the real bubble can't be raised out of the collection view that owns it. The copy is re-framed on scroll and on layout, so it follows the row as the keyboard and the bar reflow the transcript. `afterContextMenu` now queues its work instead of replacing it. An edit hands over two pieces of follow-up — raise the keyboard, pin the spotlight — and the second assignment was dropping the first. Verified on the simulator for a row mid-transcript and for the last row, where the copy lands directly above the composer. --- .../ChatScreenRepresentable.swift | 22 ++- .../Screens/Conversation/ComposerModel.swift | 9 ++ .../Conversation/ConversationScreen.swift | 1 + .../Chat/ChatScreenViewController.swift | 50 ++++++- .../FlipcashUI/Chat/ChatViewController.swift | 43 +++++- .../FlipcashUI/Chat/ContextMenuBackdrop.swift | 63 -------- .../FlipcashUI/Chat/MessageBackdrop.swift | 140 ++++++++++++++++++ 7 files changed, 253 insertions(+), 75 deletions(-) delete mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ContextMenuBackdrop.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 00554e76e..67625b890 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -45,6 +45,10 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let conversationController: ConversationController let barModel: ConversationBarModel let composer: ComposerModel + /// The row an edit is open on, or nil. Passed in rather than read off `composer` inside the + /// representable so that the owning view's body depends on it — which is what gets + /// `updateUIViewController` called, and the edit backdrop taken down, when the edit ends. + let editingStableID: String? /// Raise the keyboard when the screen first appears (post-tip open). The UIKit screen focuses /// the composer field in `viewDidAppear` — a hosted SwiftUI `@FocusState` never presents the /// keyboard across the hosting boundary. @@ -64,6 +68,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) + screen.onCancelEdit = { [composer] in composer.endEditing() } screen.update(items: items) context.coordinator.barHost = barHost context.coordinator.screen = screen @@ -82,6 +87,13 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) + screen.onCancelEdit = { [composer] in composer.endEditing() } + // The backdrop is raised from the menu action itself (see `keyboardFollowing`) because it + // has to claim the menu's blur before the dismissal fades it; it comes down here, whichever + // way the edit ended — cancelled, saved, or abandoned by a tap outside. + if editingStableID == nil { + screen.endEditSpotlight() + } // Scroll only when the user's *own* message was just appended — a new trailing message id // (skipping any trailing receipt) that is from me. Received messages and prepended history @@ -97,10 +109,12 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { context.coordinator.lastMessageID = newLastMessageID } - /// Wraps the action handler so the keyboard follows the action. The menu dismissed the keyboard + /// Wraps the action handler so the screen follows the action. The menu dismissed the keyboard /// to present itself and the composer can't drive it back on its own — a hosted SwiftUI /// `@FocusState` doesn't make the field first responder — so only the screen can raise it for an - /// edit, or hold it down for a delete, whose confirmation sheet it would otherwise cover. + /// edit, or hold it down for a delete, whose confirmation sheet it would otherwise cover. Edit + /// also claims the menu's blur here, while the menu is still up, so the two states share one + /// backdrop instead of fading one out and another in. private func keyboardFollowing( _ handler: @escaping (String, MessageCapability) -> Void, screen: ChatScreenViewController @@ -108,7 +122,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { { [weak screen] stableID, action in handler(stableID, action) switch action { - case .edit: screen?.focusComposer() + case .edit: + screen?.beginEditSpotlight(for: stableID) + screen?.focusComposer() case .delete: screen?.dismissKeyboard() case .copy, .reply: break } diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index 3ea937ba6..3886093b5 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -46,6 +46,15 @@ final class ComposerModel { var canSubmit: Bool { submission != nil } + /// The transcript row an edit is open on, if any. The chat screen keys its edit backdrop off + /// this, so reading it is what ties the backdrop's lifetime to the composer's mode. + var editingStableID: String? { + switch mode { + case .new: nil + case .editing(_, let stableID): stableID + } + } + /// Whether the field is editing an existing message rather than writing a new one. The bar /// swaps its leading control and its confirm glyph on this. var isEditing: Bool { diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 8d69ddda7..f176a6a2d 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -218,6 +218,7 @@ struct ConversationScreen: View { conversationController: conversationController, barModel: barModel, composer: composer, + editingStableID: composer.editingStableID, focusOnAppear: openKeyboard, isTipDm: tipCounterpart != nil ) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index eeed4f85a..33261846a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -46,8 +46,10 @@ public final class ChatScreenViewController: UIViewController { /// it back when that menu goes. Cleared by `dismissKeyboard()` so an action handing off to a sheet /// isn't fought by the restore. private var composerHeldKeyboardUnderMenu = false - /// The blur shown behind a context menu. - private let backdrop = ContextMenuBackdrop() + /// The blur shown behind a context menu, and held past it for an edit. + private let backdrop = MessageBackdrop() + /// The row floated above a held blur, while an edit is open on it. + private var editedStableID: String? /// Whether a measured bar height has landed yet — the first one is applied without animation. private var didMeasureBar = false @@ -96,6 +98,10 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onProfileTap = newValue } } + /// Called when the blur behind an open edit is tapped — WhatsApp's way out of an edit, beside + /// the composer's own cancel button. The owner ends the edit, which brings the blur down. + public var onCancelEdit: (() -> Void)? + /// Forwards a chosen context-menu action, with the row's id, to whoever owns the screen. public var onMessageAction: ((String, MessageCapability) -> Void)? { get { transcript.onMessageAction } @@ -132,6 +138,8 @@ public final class ChatScreenViewController: UIViewController { keyboardFloor = KeyboardFloor(view: view, bottomConstraint: constraints.bottom) lowerComposerOnResignActive() handOffComposerFocusAroundContextMenu() + backdrop.onTap = { [weak self] in self?.onCancelEdit?() } + transcript.onScroll = { [weak self] in self?.refreshEditSpotlight() } } /// Blurs the screen behind a context menu, takes the keyboard down for its lifetime, and puts @@ -161,6 +169,43 @@ public final class ChatScreenViewController: UIViewController { } } + /// Holds the context menu's blur into the edit it just opened, and floats the edited message + /// above it — the message being edited ends up the one sharp thing above the composer, which is + /// how WhatsApp presents an edit. + /// + /// Called from the menu action itself, which runs before the menu starts to dismiss, so the + /// blur is claimed before the dismissal would have faded it: holding the one blur is what keeps + /// the transcript from flashing back to legible between the menu and the edit. The floated copy + /// waits for the menu to finish, because until then UIKit is still holding the row's own bubble + /// as the lifted preview and the cell underneath is hidden. + public func beginEditSpotlight(for stableID: String) { + editedStableID = stableID + backdrop.present(over: contextMenuBackdropHost, animator: nil) + backdrop.hold(under: bar) + transcript.afterContextMenu { [weak self] in self?.refreshEditSpotlight() } + } + + /// Takes the blur down once the edit is over, however it ended. + public func endEditSpotlight() { + guard editedStableID != nil else { return } + editedStableID = nil + backdrop.release() + } + + /// Puts the edited message's copy where its row now sits — floating it the first time, and + /// re-framing it on every reflow after that, since the copy lives outside the transcript and + /// 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, + let frame = transcript.bubbleFrame(forStableID: editedStableID, in: view) else { return } + if backdrop.hasSpotlight { + backdrop.moveSpotlight(to: frame) + } else if let bubble = transcript.bubbleSnapshot(forStableID: editedStableID) { + backdrop.setSpotlight(bubble, at: frame) + } + } + /// The view the context-menu blur covers: the navigation stack when this screen is inside one, /// so the navigation bar goes soft with the transcript and the composer, and this screen's own /// view otherwise. @@ -298,6 +343,7 @@ public final class ChatScreenViewController: UIViewController { // has to stay opaque for; it holds until just above the title and clears over the tail. topFadeHeightConstraint.constant = view.safeAreaInsets.top + Self.topFadeTail topFade.opaqueLength = max(view.safeAreaInsets.top - 12, 0) + refreshEditSpotlight() // Reserve only the bar's own height. On-device the system already grows the collection // view's adjusted content inset by the keyboard when it's up, so adding the keyboard here // too (via the bar's risen position) double-counts it and overscrolls by a whole keyboard. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 30b572849..eec08e458 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -28,6 +28,9 @@ public final class ChatViewController: UICollectionViewController { /// repeatedly (not latched) — the owner's loader is expected to be idempotent, which is /// what keeps paging from ever getting stuck on a page. public var onReachTop: (() -> Void)? + /// Fired whenever the transcript's content moves, so an overlay pinned to a row — the edit + /// spotlight — can follow it. + public var onScroll: (() -> Void)? /// Called when the user taps a failed outgoing row to retry; the argument is the message's stable id. public var onRetry: ((String) -> Void)? @@ -107,10 +110,10 @@ public final class ChatViewController: UICollectionViewController { /// for the menu itself. private var didLowerKeyboardForMenu = false - /// Work handed over by a menu action to run once the menu has finished dismissing. A + /// Work handed over by a menu action to run, in order, once the menu has finished dismissing. A /// `becomeFirstResponder` issued from a `UIAction` is rejected while the menu still owns the /// screen, so choosing Edit parks the keyboard-raise here instead. - private var pendingAfterContextMenu: (() -> Void)? + private var pendingAfterContextMenu: [() -> Void] = [] public init() { super.init(collectionViewLayout: chatLayout) @@ -387,6 +390,7 @@ public final class ChatViewController: UICollectionViewController { } public override func scrollViewDidScroll(_ scrollView: UIScrollView) { + onScroll?() // Track "at the bottom" only from real user scrolling, so an inset change (keyboard) or // content settling doesn't flip it. if scrollView.isDragging || scrollView.isDecelerating { @@ -626,10 +630,9 @@ extension ChatViewController { deferredItems = nil update(items: pending) } - if let work = pendingAfterContextMenu { - pendingAfterContextMenu = nil - work() - } + let held = pendingAfterContextMenu + pendingAfterContextMenu = [] + for work in held { work() } } if let animator { animator.addCompletion(resume) @@ -642,7 +645,33 @@ extension ChatViewController { /// current one finishes dismissing. public func afterContextMenu(_ work: @escaping () -> Void) { guard isShowingContextMenu else { return work() } - pendingAfterContextMenu = work + // Appended, not assigned: one menu action can queue several pieces of follow-up work — an + // edit raises the keyboard *and* pins its spotlight — and an assignment would drop all but + // the last. + pendingAfterContextMenu.append(work) + } + + /// A detached copy of the bubble carried by the row with `stableID`, or `nil` when that row is + /// not on screen. The screen floats this above the backdrop blur so the message being edited + /// stays sharp while the transcript behind it goes soft — a copy rather than a hole cut in the + /// 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) + } + + /// Where that row's bubble currently sits, in `space`'s coordinates, or `nil` when it is not on + /// screen. The floated copy is re-framed from this as the keyboard and the bar reflow the + /// transcript underneath it. + func bubbleFrame(forStableID stableID: String, in space: UICoordinateSpace) -> CGRect? { + guard let cell = bubbleCell(forStableID: stableID) else { return nil } + let bubble = cell.liftPreviewView + return space.convert(bubble.bounds, from: bubble) + } + + private func bubbleCell(forStableID stableID: String) -> BubbleCarrying? { + guard let item = items.firstIndex(where: { $0.id == stableID }) else { return nil } + return collectionView.cellForItem(at: IndexPath(item: item, section: 0)) as? BubbleCarrying } /// Builds the lift preview from the bubble alone, clipped to its shape. Without it UIKit lifts the diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ContextMenuBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ContextMenuBackdrop.swift deleted file mode 100644 index 3cd51d24a..000000000 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ContextMenuBackdrop.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// ContextMenuBackdrop.swift -// FlipcashUI -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -#if canImport(UIKit) -import UIKit - -/// The blur that sits behind a message context menu. -/// -/// UIKit only dims the content behind a context menu, leaving every bubble legible under the -/// platter. WhatsApp — the reference for this interaction — blurs it, so the lifted bubble is the -/// one sharp thing on screen. The blur covers whichever view it is presented over, and UIKit puts -/// the menu itself in a container above the window's root, so the platter and the lift stay sharp. -@MainActor -final class ContextMenuBackdrop { - - /// Matches the fade UIKit uses for its own dimming when no animator is supplied. - private static let fallbackDuration: TimeInterval = 0.2 - - private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark) - private var effectView: UIVisualEffectView? - - /// Fades the blur in over `host`, riding `animator` so it lands with the menu. Presenting twice - /// is a no-op: the display callback fires once for the lift and again for the menu. - func present(over host: UIView, animator: UIContextMenuInteractionAnimating?) { - guard effectView == nil else { return } - - let blur = UIVisualEffectView(effect: nil) - // Purely decorative — the menu's own container sits above this and owns every touch. - blur.isUserInteractionEnabled = false - blur.frame = host.bounds - blur.autoresizingMask = [.flexibleWidth, .flexibleHeight] - host.addSubview(blur) - effectView = blur - - let fadeIn = { blur.effect = self.effect } - if let animator { - animator.addAnimations(fadeIn) - } else { - UIView.animate(withDuration: Self.fallbackDuration, animations: fadeIn) - } - } - - /// Fades the blur out with the menu and takes it off screen once it has gone. - func dismiss(animator: UIContextMenuInteractionAnimating?) { - guard let blur = effectView else { return } - effectView = nil - - let fadeOut = { blur.effect = nil } - if let animator { - animator.addAnimations(fadeOut) - animator.addCompletion { blur.removeFromSuperview() } - } else { - UIView.animate(withDuration: Self.fallbackDuration, animations: fadeOut) { _ in - blur.removeFromSuperview() - } - } - } -} -#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift new file mode 100644 index 000000000..e73f32ecd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -0,0 +1,140 @@ +// +// MessageBackdrop.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit + +/// The blur that sits behind a lifted message — the context menu's platter, and the edit that can +/// follow it. +/// +/// UIKit only dims the content behind a context menu, leaving every bubble legible under the +/// platter. WhatsApp — the reference for this interaction — blurs it, so the lifted bubble is the +/// one sharp thing on screen. The blur covers whichever view it is presented over, and UIKit puts +/// the menu itself in a container above the window's root, so the platter and the lift stay sharp. +/// +/// Choosing Edit holds the same blur past the menu rather than fading it and raising a second one, +/// which is what keeps the transcript from flashing back to legible between the two states. Held, +/// the blur moves below the composer bar so the field stays sharp and usable, carries a detached +/// copy of the edited bubble above itself, and takes the taps that land outside it. +@MainActor +final class MessageBackdrop { + + /// Matches the fade UIKit uses for its own dimming when no animator is supplied. + private static let fallbackDuration: TimeInterval = 0.2 + + /// 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. + var onTap: (() -> Void)? + + /// Whether the blur is being kept past the menu that raised it. + private(set) var isHeld = false + + private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark) + private var effectView: UIVisualEffectView? + private var spotlight: UIView? + + /// Fades the blur in over `host`, riding `animator` so it lands with the menu. Presenting twice + /// is a no-op: the display callback fires once for the lift and again for the menu. + func present(over host: UIView, animator: UIContextMenuInteractionAnimating?) { + guard effectView == nil else { return } + + let blur = UIVisualEffectView(effect: nil) + // Purely decorative until it is held — the menu's own container sits above this and owns + // every touch. + blur.isUserInteractionEnabled = false + blur.frame = host.bounds + blur.autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.addSubview(blur) + effectView = blur + + let fadeIn = { blur.effect = self.effect } + if let animator { + animator.addAnimations(fadeIn) + } else { + UIView.animate(withDuration: Self.fallbackDuration, animations: fadeIn) + } + } + + /// Keeps the blur up after the menu that raised it goes, moving it under `bar` so the composer + /// stays sharp, and starts taking taps. The message itself is floated separately, by + /// `setSpotlight`, once the menu has finished putting its lifted preview back. + func hold(under bar: UIView) { + guard let blur = effectView, let host = bar.superview else { return } + isHeld = true + + blur.frame = host.bounds + host.insertSubview(blur, belowSubview: bar) + + blur.isUserInteractionEnabled = true + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + blur.addGestureRecognizer(tap) + } + + /// Floats `bubble` — a detached copy of the edited message — above a held blur, at `frame` in + /// the blur's own coordinates. + /// + /// 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. + func setSpotlight(_ bubble: UIView, at frame: CGRect) { + guard let blur = effectView, isHeld, let host = blur.superview else { return } + spotlight?.removeFromSuperview() + bubble.frame = frame + bubble.isUserInteractionEnabled = false + host.insertSubview(bubble, aboveSubview: blur) + spotlight = bubble + } + + /// Whether a copy is currently floating. + var hasSpotlight: Bool { spotlight != nil } + + /// Moves the floated copy as the keyboard and the bar reflow the transcript underneath it. A + /// no-op when nothing is floating, so a layout pass before the copy exists is harmless. + func moveSpotlight(to frame: CGRect) { + spotlight?.frame = frame + } + + /// Fades the blur out with the menu and takes it off screen once it has gone. A held blur + /// ignores this: the edit it belongs to outlives the menu, and ends it with `release`. + func dismiss(animator: UIContextMenuInteractionAnimating?) { + guard !isHeld, let blur = effectView else { return } + effectView = nil + + let fadeOut = { blur.effect = nil } + if let animator { + animator.addAnimations(fadeOut) + animator.addCompletion { blur.removeFromSuperview() } + } else { + UIView.animate(withDuration: Self.fallbackDuration, animations: fadeOut) { _ in + blur.removeFromSuperview() + } + } + } + + /// Fades a held blur out, once whatever was holding it is over. + func release() { + guard isHeld, let blur = effectView else { return } + isHeld = false + effectView = nil + + let bubble = spotlight + spotlight = nil + UIView.animate(withDuration: Self.fallbackDuration) { + blur.effect = nil + bubble?.alpha = 0 + } completion: { _ in + blur.removeFromSuperview() + bubble?.removeFromSuperview() + } + } + + @objc private func handleTap() { + onTap?() + } +} +#endif