From 1c143a40b77a05917e8cfd47b60d4d5dc66f0d5b Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Mon, 2 Nov 2026 20:38:37 -0500 Subject: [PATCH] fix(mobile): show the Join prompt when an invite launches Buzz (#8970) ## Summary Opening an invite while Buzz is closed can leave the Join prompt invisible and block later attempts. Native confirmation can attach to a temporary system dialog that then closes, leaving the join result pending. This fix uses Flutter confirmation when the native presenter is occupied. If UIKit rejects presentation, it clears the pending session so the user can try again. ### Related issue Related invite handoff: https://github.com/block/buzz/pull/8601. ### Testing The native regression fails before the fix and passes afterward. It covers rejected presentation, a second attempt, and an existing temporary dialog. The isolated PR build passed a fresh-user journey from Safari consent through launching Buzz, visible confirmation, Join, starter channels and seeded history. ### Screenshots Visible Join prompt after Safari opens Buzz, from this PR build: ![Join prompt after cold launch](https://raw.githubusercontent.com/block/buzz/f4d083ffaa2b2d2d2672d2b5c9a5f9f3690d75b1/pr-8970--COLD_CONFIRMATION.png) ### Remaining work Finish Dart validation and review before readiness. Signed-off-by: John Tennant Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> --- mobile/ios/Runner/NativeCommunityEntry.swift | 12 ++++-- mobile/ios/RunnerTests/RunnerTests.swift | 41 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/mobile/ios/Runner/NativeCommunityEntry.swift b/mobile/ios/Runner/NativeCommunityEntry.swift index ea2abc2f50..a3fc6d2ad9 100644 --- a/mobile/ios/Runner/NativeCommunityEntry.swift +++ b/mobile/ios/Runner/NativeCommunityEntry.swift @@ -24,10 +24,9 @@ final class NativeCommunityEntryCoordinator: NSObject, UIAdaptivePresentationCon let root = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive }.flatMap(\.windows) .first(where: \.isKeyWindow)?.rootViewController - guard var presenter = root else { + guard let presenter = root, presenter.presentedViewController == nil else { result(FlutterError(code: "presentation_failed", message: nil, details: nil)); return } - while let presented = presenter.presentedViewController { presenter = presented } let controller = NativeCommunityEntryController(configuration: args) controller.action = { [weak self] method, payload, completion in guard let self, self.session == session else { return } @@ -47,6 +46,10 @@ final class NativeCommunityEntryCoordinator: NSObject, UIAdaptivePresentationCon self.navigation = navigation controller.configureDetents() presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled) + guard presenter.presentedViewController === navigation else { + self.complete(FlutterError(code: "presentation_failed", message: nil, details: nil)) + return + } navigation.presentationController?.delegate = self } } @@ -71,12 +74,12 @@ final class NativeCommunityEntryCoordinator: NSObject, UIAdaptivePresentationCon } } - private func complete(_ joined: Bool) { + private func complete(_ value: Any?) { let result = pending pending = nil navigation = nil session = nil - result?(joined) + result?(value) } } @@ -236,6 +239,7 @@ private final class NativeCommunityEntryController: UIViewController, UITextFiel submit = addButton("Join community", primary: true) { [weak self] in self?.request("join", [:]) } + submit?.accessibilityIdentifier = "community-invite-join" } else { for choice in choices { addButton("\(choice["label"] ?? "Saved identity")\n\(choice["npub"] ?? "")") { [weak self] in diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 846c0d1637..4a85e5b162 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -8,6 +8,39 @@ import XCTest class RunnerTests: XCTestCase { + @MainActor + func testRejectedCommunityEntryPresentationCompletesAndAllowsRetry() throws { + let messenger = NavigationTestMessenger() + let presenter = RejectedCommunityEntryPresenter() + let window = UIWindow(frame: UIScreen.main.bounds) + window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + window.rootViewController = presenter + window.makeKeyAndVisible() + defer { window.isHidden = true } + let coordinator = NativeCommunityEntryCoordinator(messenger: messenger) + for session in [1, 2] { + var reply: Any? + messenger.invoke("present", arguments: ["session": session, "name": "Local test", "invite": true]) { reply = $0 } + XCTAssertEqual((reply as? FlutterError)?.code, "presentation_failed") + XCTAssertEqual(presenter.attempts, session) + } + let root = UIViewController() + window.rootViewController = root + window.makeKeyAndVisible() + let temporary = UIViewController() + let presented = expectation(description: "Temporary native modal appeared") + root.present(temporary, animated: false) { presented.fulfill() } + wait(for: [presented], timeout: 2) + XCTAssertTrue(root.presentedViewController === temporary) + XCTAssertNotNil(temporary.view.window) + var reply: Any? + messenger.invoke("present", arguments: ["session": 3, "name": "Local test", "invite": true]) { reply = $0 } + XCTAssertEqual((reply as? FlutterError)?.code, "presentation_failed") + XCTAssertNil(temporary.presentedViewController) + root.dismiss(animated: false) + withExtendedLifetime(coordinator) {} + } + @MainActor func testCustomFlutterHeaderUsesDecorativeSoftEdge() throws { guard #available(iOS 27.0, *) else { return } @@ -2085,3 +2118,11 @@ private final class NavigationTestMessenger: NSObject, FlutterBinaryMessenger { handler?(message) { _ in } } } + +private final class RejectedCommunityEntryPresenter: UIViewController { + var attempts = 0 + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + // Reproduce UIKit declining a presentation during a scene transition. + attempts += 1 + } +}