From 87678cea310e302c829baff55c14d36a8d2f0daf Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 12 May 2026 14:32:06 -0400 Subject: [PATCH 1/2] fix(instant): make forced conversion sheet non-dismissible When `forceInstantUserConversion` is enabled, the sign-in sheet must be non-dismissible until the user actually adds an identifier. Previously the sheet defaulted to dismissible via swipe, tap-outside, and any Hub close message, which left customers with large populations of unconverted instant users. - BottomSheetViewController gains an `isUserDismissalDisabled` flag that disables swipe-to-dismiss and tap-outside-to-dismiss at presentation, and that the Hub's `can_touch_background_to_dismiss` message cannot re-enable. - `hideBottomSheet` and `HubViewController.hide()` honor the same lock, so Hub-dispatched `closeHubViewController`/`signOut` messages cannot close the sheet either. - InstantUsers observes the post-conversion auth-level transition and releases the lock once the user is no longer `.instant`, so the standard post-auth auto-close path still runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Rownd/Rownd.swift | 11 ++++++ .../Views/BottomSheetViewController.swift | 16 ++++++++ Sources/Rownd/Views/HubViewController.swift | 9 ++++- Sources/Rownd/framework/InstantUsers.swift | 39 ++++++++++--------- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/Sources/Rownd/Rownd.swift b/Sources/Rownd/Rownd.swift index bb65f84..0b5f448 100644 --- a/Sources/Rownd/Rownd.swift +++ b/Sources/Rownd/Rownd.swift @@ -180,6 +180,17 @@ public class Rownd: NSObject { inst.displayHub(.signIn, jsFnOptions: jsFnOptions) } + @MainActor + internal static func requestSignInForcedConversion(_ signInOptions: RowndSignInOptions?) { + inst.bottomSheetController.isUserDismissalDisabled = true + requestSignIn(signInOptions) + } + + @MainActor + internal static func releaseForcedConversionLock() { + inst.bottomSheetController.isUserDismissalDisabled = false + } + @MainActor public static func connectAuthenticator( with: RowndConnectSignInHint, completion: (() -> Void)? = nil diff --git a/Sources/Rownd/Views/BottomSheetViewController.swift b/Sources/Rownd/Views/BottomSheetViewController.swift index 4b5092b..0b533b3 100644 --- a/Sources/Rownd/Views/BottomSheetViewController.swift +++ b/Sources/Rownd/Views/BottomSheetViewController.swift @@ -24,6 +24,11 @@ class BottomSheetViewController: UIViewController { var latestTargetHeight: CGFloat = 0.9 var isKeyboardOpen = false + // When true, swipe-to-dismiss and tap-outside-to-dismiss are disabled for this presentation, + // and the Hub's `can_touch_background_to_dismiss` message cannot re-enable them. Programmatic + // dismissal (e.g. after a successful sign-in) is unaffected. Reset on viewDidDisappear. + var isUserDismissalDisabled: Bool = false + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) guard let controller = controller else { @@ -54,6 +59,9 @@ class BottomSheetViewController: UIViewController { sheetController = presentAsBottomSheet(controller, theme: theme, behavior: behavior) + if isUserDismissalDisabled { + sheetController?.setCanTouchDimmingBackgroundToDismiss(false) + } } override func viewDidLoad() { @@ -69,6 +77,7 @@ class BottomSheetViewController: UIViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.controller = nil + self.isUserDismissalDisabled = false } func updateBottomSheetHeight(_ number: CGFloat) { @@ -77,6 +86,10 @@ class BottomSheetViewController: UIViewController { } public func hideBottomSheet(_ completion: (() -> Void)? = nil) { + if isUserDismissalDisabled { + logger.debug("Ignoring hideBottomSheet: forced-conversion lock is active") + return + } sheetController?.dismiss(completion) } @@ -99,6 +112,9 @@ class BottomSheetViewController: UIViewController { } func canTouchDimmingBackgroundToDismiss(_ enable: Bool) { + if isUserDismissalDisabled && enable { + return + } if let sheetController = sheetController { sheetController.setCanTouchDimmingBackgroundToDismiss(enable) } diff --git a/Sources/Rownd/Views/HubViewController.swift b/Sources/Rownd/Views/HubViewController.swift index a391e63..66b2c5d 100644 --- a/Sources/Rownd/Views/HubViewController.swift +++ b/Sources/Rownd/Views/HubViewController.swift @@ -182,11 +182,16 @@ public class HubViewController: UIViewController, HubViewProtocol, BottomSheetHo self.dismiss(animated: true) return } - + + if bottomSheetController.isUserDismissalDisabled { + logger.debug("Ignoring HubViewController.hide(): forced-conversion lock is active") + return + } + if (isBottomSheetDismissing) { return } - + isBottomSheetDismissing = true bottomSheetController.hideBottomSheet({ self.dismiss(animated: true) diff --git a/Sources/Rownd/framework/InstantUsers.swift b/Sources/Rownd/framework/InstantUsers.swift index 63ca434..96c6e0e 100644 --- a/Sources/Rownd/framework/InstantUsers.swift +++ b/Sources/Rownd/framework/InstantUsers.swift @@ -11,6 +11,7 @@ import Combine class InstantUsers { private let context: Context private var cancellables = Set() + private var hasTriggeredConversion: Bool = false init( context: Context @@ -36,24 +37,26 @@ class InstantUsers { .removeDuplicates( by: == ) - .first { - isAuthenticated, - authLevel in - isAuthenticated && authLevel == .instant - } - .sink { - isAuthenticated, - authLevel in - - var signInOptions = RowndSignInOptions() - signInOptions.title = "Add a sign-in method" - signInOptions.subtitle = "To make sure you can always access your account, please add a sign-in method." - signInOptions.intent = .signUp - Rownd - .requestSignIn(signInOptions) - - subscriber - .unsubscribe() + .sink { [weak self] isAuthenticated, authLevel in + guard let self = self, isAuthenticated else { return } + + if !self.hasTriggeredConversion && authLevel == .instant { + self.hasTriggeredConversion = true + + var signInOptions = RowndSignInOptions() + signInOptions.title = "Add a sign-in method" + signInOptions.subtitle = "To make sure you can always access your account, please add a sign-in method." + signInOptions.intent = .signUp + Rownd.requestSignInForcedConversion(signInOptions) + return + } + + // User has converted to a non-instant auth level (verified, unverified, guest). + // Release the lock so the Hub's post-success auto-close can proceed. + if self.hasTriggeredConversion && authLevel != .instant && authLevel != .unknown { + Rownd.releaseForcedConversionLock() + subscriber.unsubscribe() + } }.store( in: &self.cancellables ) From 9ea56d0d598c0b9a0417350d6a909e8d07d02b7f Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 12 May 2026 15:14:01 -0400 Subject: [PATCH 2/2] fix(instant): apply lock live and re-trigger close on release Addresses review feedback from PR #125: - BottomSheetViewController: `isUserDismissalDisabled` now has a `didSet` that updates the live `sheetController` so the lock takes effect on an already-presented sheet. Track the Hub's last-requested dismissibility separately so releasing the lock restores it rather than unconditionally re-enabling (Qodo #1, #2). - Rownd: `releaseForcedConversionLock` now re-invokes `hide()` on the active HubViewProtocol so a post-auth auto-close that lost the race against `UserData.fetch` propagating `authLevel` still closes the sheet. Guarded on the prior locked state so unlocking a non-held lock is a no-op (avoids side effects in tests). - Add `Rownd._bottomSheetIsLocked` internal accessor for tests. - Tests: cover lock engagement on `.instant`, release on `.verified`, and the once-per-session gate after release. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Rownd/Rownd.swift | 11 ++ .../Views/BottomSheetViewController.swift | 19 ++- Tests/RowndTests/InstantUsersTests.swift | 123 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/Sources/Rownd/Rownd.swift b/Sources/Rownd/Rownd.swift index 0b5f448..4d5849e 100644 --- a/Sources/Rownd/Rownd.swift +++ b/Sources/Rownd/Rownd.swift @@ -188,7 +188,18 @@ public class Rownd: NSObject { @MainActor internal static func releaseForcedConversionLock() { + let wasLocked = inst.bottomSheetController.isUserDismissalDisabled inst.bottomSheetController.isUserDismissalDisabled = false + guard wasLocked else { return } + // The post-auth auto-close may have fired and been blocked while the lock was held + // (Hub schedules hide() 1.5s after `.authentication` but `authLevel` propagates from a + // separate UserData.fetch — the timer can lose the race). Retry the close now. + (inst.bottomSheetController.controller as? HubViewProtocol)?.hide() + } + + @MainActor + internal static var _bottomSheetIsLocked: Bool { + inst.bottomSheetController.isUserDismissalDisabled } @MainActor diff --git a/Sources/Rownd/Views/BottomSheetViewController.swift b/Sources/Rownd/Views/BottomSheetViewController.swift index 0b533b3..613be38 100644 --- a/Sources/Rownd/Views/BottomSheetViewController.swift +++ b/Sources/Rownd/Views/BottomSheetViewController.swift @@ -27,7 +27,22 @@ class BottomSheetViewController: UIViewController { // When true, swipe-to-dismiss and tap-outside-to-dismiss are disabled for this presentation, // and the Hub's `can_touch_background_to_dismiss` message cannot re-enable them. Programmatic // dismissal (e.g. after a successful sign-in) is unaffected. Reset on viewDidDisappear. - var isUserDismissalDisabled: Bool = false + // The didSet applies the change to a live sheetController, so toggling the lock after + // presentation still takes effect (and unlock restores the Hub's most recent preference). + var isUserDismissalDisabled: Bool = false { + didSet { + guard isUserDismissalDisabled != oldValue, let sheetController = sheetController else { return } + if isUserDismissalDisabled { + sheetController.setCanTouchDimmingBackgroundToDismiss(false) + } else { + sheetController.setCanTouchDimmingBackgroundToDismiss(hubRequestedCanTouchToDismiss) + } + } + } + + // Tracks the most recent Hub-requested dismissibility state so that releasing the lock + // restores whatever the Hub last asked for instead of unconditionally re-enabling. + private var hubRequestedCanTouchToDismiss: Bool = true override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -78,6 +93,7 @@ class BottomSheetViewController: UIViewController { super.viewDidDisappear(animated) self.controller = nil self.isUserDismissalDisabled = false + self.hubRequestedCanTouchToDismiss = true } func updateBottomSheetHeight(_ number: CGFloat) { @@ -112,6 +128,7 @@ class BottomSheetViewController: UIViewController { } func canTouchDimmingBackgroundToDismiss(_ enable: Bool) { + hubRequestedCanTouchToDismiss = enable if isUserDismissalDisabled && enable { return } diff --git a/Tests/RowndTests/InstantUsersTests.swift b/Tests/RowndTests/InstantUsersTests.swift index bd09073..7ed2267 100644 --- a/Tests/RowndTests/InstantUsersTests.swift +++ b/Tests/RowndTests/InstantUsersTests.swift @@ -24,6 +24,8 @@ class InstantUsersTests: XCTestCase { override func tearDown() { Rownd.config = originalConfig + // Reset the singleton lock so it does not leak between tests. + Rownd.releaseForcedConversionLock() super.tearDown() } @@ -197,4 +199,125 @@ class InstantUsersTests: XCTestCase { _ = instantUsers } + + /// Verifies that the forced-conversion lock is engaged on the singleton bottom + /// sheet when the conversion subscription fires for an instant user. + func testLockIsEngagedWhenConversionTriggers() async throws { + let store = createStore() + _ = Context(store) + + Rownd.config.forceInstantUserConversion = true + XCTAssertFalse(Rownd._bottomSheetIsLocked, "Pre-condition: lock should start cleared") + + let instantUsers = InstantUsers(context: Context.currentContext) + instantUsers.tmpForceInstantUserConversionIfRequested() + + try await Task.sleep(nanoseconds: 50_000_000) + + store.dispatch(SetAuthState(payload: AuthState( + accessToken: generateJwt(expires: Date(timeIntervalSinceNow: 3600).timeIntervalSince1970), + refreshToken: generateJwt(expires: Date(timeIntervalSinceNow: 36000).timeIntervalSince1970) + ))) + store.dispatch(SetClockSync(clockSyncState: .synced)) + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_instant_user"], + authLevel: .instant + ))) + + try await waitUntil(timeout: 2.0) { Rownd._bottomSheetIsLocked } + XCTAssertTrue(Rownd._bottomSheetIsLocked, "Lock should engage when authLevel transitions to .instant") + + _ = instantUsers + } + + /// Verifies that the lock releases once the user transitions from `.instant` + /// to a non-instant identifier auth level (e.g. `.verified`). + func testLockReleasesAfterVerifiedConversion() async throws { + let store = createStore() + _ = Context(store) + + Rownd.config.forceInstantUserConversion = true + + let instantUsers = InstantUsers(context: Context.currentContext) + instantUsers.tmpForceInstantUserConversionIfRequested() + + try await Task.sleep(nanoseconds: 50_000_000) + + store.dispatch(SetAuthState(payload: AuthState( + accessToken: generateJwt(expires: Date(timeIntervalSinceNow: 3600).timeIntervalSince1970), + refreshToken: generateJwt(expires: Date(timeIntervalSinceNow: 36000).timeIntervalSince1970) + ))) + store.dispatch(SetClockSync(clockSyncState: .synced)) + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_instant_user"], + authLevel: .instant + ))) + + try await waitUntil(timeout: 2.0) { Rownd._bottomSheetIsLocked } + + // Simulate successful conversion: user-data fetch returns with verified level. + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_verified_user", "email": "user@example.com"], + authLevel: .verified + ))) + + try await waitUntil(timeout: 2.0) { !Rownd._bottomSheetIsLocked } + XCTAssertFalse(Rownd._bottomSheetIsLocked, "Lock should release once authLevel becomes .verified") + + _ = instantUsers + } + + /// Verifies that the `hasTriggeredConversion` gate prevents the conversion + /// flow from re-triggering after a successful conversion + lock release. + /// (The customer-confirmed behavior is once-per-session.) + func testConversionDoesNotRetriggerAfterRelease() async throws { + let store = createStore() + _ = Context(store) + + Rownd.config.forceInstantUserConversion = true + + let instantUsers = InstantUsers(context: Context.currentContext) + instantUsers.tmpForceInstantUserConversionIfRequested() + + try await Task.sleep(nanoseconds: 50_000_000) + + // First .instant → lock should engage. + store.dispatch(SetAuthState(payload: AuthState( + accessToken: generateJwt(expires: Date(timeIntervalSinceNow: 3600).timeIntervalSince1970), + refreshToken: generateJwt(expires: Date(timeIntervalSinceNow: 36000).timeIntervalSince1970) + ))) + store.dispatch(SetClockSync(clockSyncState: .synced)) + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_instant_user"], + authLevel: .instant + ))) + try await waitUntil(timeout: 2.0) { Rownd._bottomSheetIsLocked } + + // Convert and release. + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_user", "email": "user@example.com"], + authLevel: .verified + ))) + try await waitUntil(timeout: 2.0) { !Rownd._bottomSheetIsLocked } + + // Drop back to .instant — should NOT re-engage the lock (once-per-session). + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_user"], + authLevel: .instant + ))) + try await Task.sleep(nanoseconds: 200_000_000) + XCTAssertFalse(Rownd._bottomSheetIsLocked, "Conversion must not re-trigger after a successful release") + + _ = instantUsers + } + + // MARK: - helpers + + private func waitUntil(timeout: TimeInterval, condition: @escaping () -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return } + try await Task.sleep(nanoseconds: 25_000_000) + } + } }