From 38b0407d92a3d16c6b8aed000b382c8a7e314d6e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 12:25:08 -0400 Subject: [PATCH] fix(chat): lower the keyboard before opening a request or join requests --- .../Conversation/ConversationScreen.swift | 23 +++++++-- .../Main/Search/ChatSearchScreen.swift | 44 ---------------- Flipcash/Utilities/KeyboardHideWaiter.swift | 50 +++++++++++++++++++ 3 files changed, 69 insertions(+), 48 deletions(-) create mode 100644 Flipcash/Utilities/KeyboardHideWaiter.swift diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index a1b426282..3d518f424 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -430,7 +430,9 @@ struct ConversationScreen: View { onReachTop: { if pagesHistory { coordinator?.reachedTop() } }, onRetry: retry, onCashCardTap: openCurrencyInfo, - onPaymentRequestTap: { paymentRequestDetails = PaymentRequestSelection(id: PaymentRequestID(data: $0)) }, + onPaymentRequestTap: { data in + afterKeyboardLowered { paymentRequestDetails = PaymentRequestSelection(id: PaymentRequestID(data: data)) } + }, onOpenURL: openLink, onMentionTap: openMention, onShareProfile: shareProfile, @@ -723,7 +725,7 @@ struct ConversationScreen: View { JoinRequestsAnchor( requests: hostRequests, avatarData: { sessionContainer.profileAvatars.data(for: $0) }, - onReview: { router.push(.joinRequests(conversationID)) } + onReview: { afterKeyboardLowered { router.push(.joinRequests(conversationID)) } } ) .padding(.horizontal, BarMetrics.edgeInset) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { anchorHeight = $0 } @@ -920,7 +922,20 @@ struct ConversationScreen: View { } private func payRequest(_ id: PaymentRequestID) { - Task { await requestPayFlow.start(id, sessionContainer: sessionContainer) } + afterKeyboardLowered { + Task { await requestPayFlow.start(id, sessionContainer: sessionContainer) } + } + } + + /// Runs `present` once the keyboard is out of sight, so a cover or push never starts over a raised + /// keyboard. The composer's field holds focus, so resigning it also stops it re-raising the keyboard + /// when the presentation closes. + private func afterKeyboardLowered(_ present: @escaping () -> Void) { + guard isKeyboardUp else { return present() } + Task { + await KeyboardHideWaiter().wait { KeyboardSuppressor.lower() } + present() + } } /// The fetches the transcript needs: gate token names, sender names, and avatars. @@ -1510,7 +1525,7 @@ struct ConversationScreen: View { switch sendTarget { case .privateGroup: // Requesting needs no balance, so the give-cash gate waits for the Send choice. - isShowingMoneyActions = true + afterKeyboardLowered { isShowingMoneyActions = true } case .contact, .tip, .group: presentSendAmount(sendTarget) } diff --git a/Flipcash/Core/Screens/Main/Search/ChatSearchScreen.swift b/Flipcash/Core/Screens/Main/Search/ChatSearchScreen.swift index ba523faf6..fb214fb3a 100644 --- a/Flipcash/Core/Screens/Main/Search/ChatSearchScreen.swift +++ b/Flipcash/Core/Screens/Main/Search/ChatSearchScreen.swift @@ -222,50 +222,6 @@ private extension String { var nilIfEmpty: String? { isEmpty ? nil : self } } -/// Runs `resign` and returns once the keyboard is out of sight. `keyboardDidHide` lands well after -/// that, since the hide is a spring whose tail runs on unseen below the screen, so this waits on -/// `keyboardWillHide` plus part of its animation instead. A hardware keyboard posts neither, so it -/// gives up after `timeout`. -@MainActor -private final class KeyboardHideWaiter { - /// The share of the hide animation after which the keyboard is visually gone. - private static let visibleShare = 0.6 - - private var continuation: CheckedContinuation? - private var observer: NSObjectProtocol? - - func wait(timeout: Duration = .milliseconds(450), resign: () -> Void) async { - await withCheckedContinuation { continuation in - self.continuation = continuation - observer = NotificationCenter.default.addObserver( - forName: UIResponder.keyboardWillHideNotification, - object: nil, - queue: .main - ) { [weak self] notification in - let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0 - MainActor.assumeIsolated { self?.finish(after: .seconds(duration * Self.visibleShare)) } - } - resign() - finish(after: timeout) - } - } - - private func finish(after delay: Duration) { - Task { [weak self] in - try? await Task.sleep(for: delay) - self?.finish() - } - } - - private func finish() { - guard let continuation else { return } - self.continuation = nil - if let observer { NotificationCenter.default.removeObserver(observer) } - observer = nil - continuation.resume() - } -} - // MARK: - Search bar - /// The bottom bar: back to Chats, the query field with its clear button, and close. diff --git a/Flipcash/Utilities/KeyboardHideWaiter.swift b/Flipcash/Utilities/KeyboardHideWaiter.swift new file mode 100644 index 000000000..ef6e4ddc3 --- /dev/null +++ b/Flipcash/Utilities/KeyboardHideWaiter.swift @@ -0,0 +1,50 @@ +// +// KeyboardHideWaiter.swift +// Flipcash +// + +import UIKit + +/// Runs `resign` and returns once the keyboard is out of sight. `keyboardDidHide` lands well after +/// that, since the hide is a spring whose tail runs on unseen below the screen, so this waits on +/// `keyboardWillHide` plus part of its animation instead. A hardware keyboard posts neither, so it +/// gives up after `timeout`. +@MainActor +final class KeyboardHideWaiter { + /// The share of the hide animation after which the keyboard is visually gone. + private static let visibleShare = 0.6 + + private var continuation: CheckedContinuation? + private var observer: NSObjectProtocol? + + func wait(timeout: Duration = .milliseconds(450), resign: () -> Void) async { + await withCheckedContinuation { continuation in + self.continuation = continuation + observer = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { [weak self] notification in + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0 + MainActor.assumeIsolated { self?.finish(after: .seconds(duration * Self.visibleShare)) } + } + resign() + finish(after: timeout) + } + } + + private func finish(after delay: Duration) { + Task { [weak self] in + try? await Task.sleep(for: delay) + self?.finish() + } + } + + private func finish() { + guard let continuation else { return } + self.continuation = nil + if let observer { NotificationCenter.default.removeObserver(observer) } + observer = nil + continuation.resume() + } +}