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/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 47fe52282..67625b890 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? @@ -41,6 +44,11 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let onSendCash: () -> Void 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. @@ -59,6 +67,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onOpenURL = onOpenURL 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 @@ -76,6 +86,14 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onOpenURL = onOpenURL 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 @@ -91,6 +109,28 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { context.coordinator.lastMessageID = newLastMessageID } + /// 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 + /// 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 + ) -> (String, MessageCapability) -> Void { + { [weak screen] stableID, action in + handler(stableID, action) + switch action { + case .edit: + screen?.beginEditSpotlight(for: stableID) + 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 } } } @@ -108,6 +148,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..3886093b5 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -0,0 +1,93 @@ +// +// 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 } + + /// 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 { + 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 { + 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..31c82fedd 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,13 +41,21 @@ 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 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, @@ -66,7 +69,7 @@ struct ConversationBottomBar: View { ) } if chatExists { - ConversationComposer(conversationID: conversationID, model: model) + ConversationComposer(conversationID: conversationID, model: model, composer: composer) .transition(.opacity) } } @@ -74,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 @@ -85,12 +89,13 @@ 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? @Bindable var model: ConversationBarModel + @Bindable var composer: ComposerModel @Environment(ConversationController.self) private var conversationController @FocusState private var isFocused: Bool @@ -100,7 +105,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,32 +114,35 @@ struct ConversationComposer: View { .frame(maxWidth: .infinity, alignment: .leading) .frame(minHeight: BarMetrics.fieldMinHeight) - if model.canSend { - Button(action: send) { - Image(systemName: "arrow.up") + if showsSubmit { + Button(action: submit) { + 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("Send") + .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: model.canSend) + .animation(Self.sendButtonSpring, value: showsSubmit) + + return field .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) + // 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. @@ -144,22 +152,67 @@ 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() { + /// 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 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) } + + // 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 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 { + 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)) + } + .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 4cad5e027..f176a6a2d 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? @@ -208,6 +209,7 @@ struct ConversationScreen: View { onOpenURL: openLink, onContactAction: openContactCard, onProfileTap: profileTapAction, + onMessageAction: handleMessageAction, showsSendCash: sendTarget != nil, chatExists: chatExists, conversationID: conversationID, @@ -215,6 +217,8 @@ struct ConversationScreen: View { onSendCash: sendCash, conversationController: conversationController, barModel: barModel, + composer: composer, + editingStableID: composer.editingStableID, focusOnAppear: openKeyboard, isTipDm: tipCounterpart != nil ) @@ -265,6 +269,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. @@ -360,6 +369,48 @@ 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 } + + // WhatsApp's sheet offers "Delete for everyone" beside "Delete for me"; we have no + // 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" + ) { + DialogAction.destructive("Delete For Everyone") { + Task { await conversationController.delete(messageID: messageID, in: conversationID) } + } + DialogAction.cancel() + } + } + private func sendCash() { guard let sendTarget else { return } let context: AddMoneyContext = switch sendTarget { 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/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) + } } 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/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..33261846a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -42,6 +42,16 @@ 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 + /// 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 /// - Parameters: /// - bar: pinned to the bottom of the view; rides the keyboard. @@ -88,6 +98,16 @@ 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 } + set { transcript.onMessageAction = newValue } + } + public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(Color.backgroundMain) @@ -117,6 +137,85 @@ public final class ChatScreenViewController: UIViewController { barHeightConstraint = constraints.height 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 + /// 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 + /// 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] 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] animator in + guard let self else { return } + backdrop.dismiss(animator: animator) + guard composerHeldKeyboardUnderMenu else { return } + composerHeldKeyboardUnderMenu = false + _ = bar.firstTextInputResponder?.becomeFirstResponder() + } + } + + /// 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. + 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 @@ -190,13 +289,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 @@ -213,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 58b024ca0..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)? @@ -46,6 +49,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 @@ -87,6 +94,26 @@ 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 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 + /// 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 + + /// 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] = [] public init() { super.init(collectionViewLayout: chatLayout) @@ -363,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 { @@ -386,8 +414,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. @@ -405,18 +436,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 @@ -425,9 +459,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 @@ -504,27 +538,13 @@ 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 } + guard let menu = contextMenu(forItemAt: indexPath) 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 - } // 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 +554,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? { @@ -550,20 +593,46 @@ 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?(animator) + } + /// 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?(animator) + 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) } + let held = pendingAfterContextMenu + pendingAfterContextMenu = [] + for work in held { work() } } if let animator { animator.addCompletion(resume) @@ -572,6 +641,39 @@ 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() } + // 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 /// whole side-hugging cell as a plain rectangle. Mirrors ChatLayout's `preview(for:)`. private func preview(for configuration: UIContextMenuConfiguration) -> UITargetedPreview? { @@ -628,3 +730,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 + } + } +} 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 diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index 7ba5a9220..310953182 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -67,6 +67,14 @@ 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" + /// The bare glyph, for a confirm affordance that carries its own backing shape. + case checkmark = "checkmark" case speakerSlash = "speaker.slash.fill" 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