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 <jtennant@block.xyz>
Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
This commit is contained in:
John Matthew Tennant
2026-11-02 20:38:37 -05:00
committed by GitHub
parent 205ff47744
commit 1c143a40b7
2 changed files with 49 additions and 4 deletions

View File

@@ -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

View File

@@ -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
}
}