From f04fa0904c99df3e7c0a1ba29faf945cbce3c6e1 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 3 Nov 2026 21:06:19 -0800 Subject: [PATCH] fix(mobile): close the community switcher when the org relay refuses the key (#9042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to #9028 (merged). #9028 closes the community switcher before content loads when organization sign-in is needed, but it checks the session status once. When the session is signed in and the relay then refuses the paired key (`AccountFailure.denied`, which #9001 now saves on purpose), the Home banner offers **Retry** and **Use a different account**, yet the switcher keeps covering it until the 20s "taking longer" timeout. - `organizationSignInPromptVisible()` in `shared/hosted/organization_sign_in.dart` is now the single rule for when the banner has an action. The banner uses it, and so does the switcher. - The switcher listens to the sign-in session and relay auth while it waits for content, and closes as soon as the rule turns true. Listeners are removed on every exit, including the timeout, and the content wait stops listening once the prompt wins. - Relay failures stay scoped to the new community's relay and pubkey, so a stale failure from the previous community can't close the switcher. - `checking` no longer closes the switcher early (the banner is hidden then); a later transition to signed out still does. ### Related issue Related to #9001, #9009 and #9028. No duplicate PR found. ### Testing - New widget test: signed in, switch to an organization relay with content pending, then the relay refuses the key, and the switcher closes. It fails with #9028's one-shot check. - `flutter analyze` is clean. The full mobile suite passes except 10 push and notification tests, which fail the same way on plain `main` on this Mac (local environment; CI covers them). - Pushed with `--no-verify` because of those 10 local failures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../channels_page/community_switcher.dart | 65 ++++++++++---- .../organization_sign_in_banner.dart | 17 ++-- .../shared/hosted/organization_sign_in.dart | 32 +++++++ .../features/channels/channels_page_test.dart | 90 +++++++++++++++++++ 4 files changed, 177 insertions(+), 27 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart index 33fce502aa..6f959638b9 100644 --- a/mobile/lib/features/channels/channels_page/community_switcher.dart +++ b/mobile/lib/features/channels/channels_page/community_switcher.dart @@ -137,8 +137,14 @@ class CommunitySwitcherPage extends HookConsumerWidget { MediaQuery.disableAnimationsOf(context) || FocusManager.instance.highlightMode == FocusHighlightMode.traditional; - Future waitForContent() async { + Future waitForContent({Future? abandon}) async { final ready = Completer(); + // Stop listening once the caller no longer needs the content. + unawaited( + abandon?.then((_) { + if (!ready.isCompleted) ready.complete(); + }), + ); final subscription = ref.listenManual(_communityContentReadyProvider, ( _, next, @@ -192,25 +198,54 @@ class CommunitySwitcherPage extends HookConsumerWidget { if (!context.mounted) return; await ref.read(activeCommunityProvider.future); if (!context.mounted) return; + // Organization sign-in can be needed before content loads, or only + // once the relay refuses this key. Close for either, so the Home + // banner's action is reachable instead of covered until the timeout. final signIn = ref.read(organizationSignInProvider); - if (signIn != null && - signIn.status != OrganizationSessionStatus.signedIn && - ref - .read(hostedRelayAuthProvider) - .organizationProtects(ref.read(relayConfigProvider))) { + final auth = ref.read(hostedRelayAuthProvider); + // The picker can close while this waits, so check before using ref. + bool promptVisible() => + context.mounted && + organizationSignInPromptVisible( + signIn, + auth, + ref.read(relayConfigProvider), + ); + if (promptVisible()) { Navigator.of(context).pop(); return; } - // Refresh this scope before awaiting its content. ChannelsNotifier - // fences cached snapshots to the destination relay and identity. - if (!arriving) ref.invalidate(channelsProvider); - await (() async { - await waitForContent(); - if (!context.mounted) return; - final bounds = await prepareLanding(); - if (context.mounted) landingBounds.value = bounds; - })().timeout(const Duration(seconds: 20)); + final prompt = Completer(); + void checkPrompt() { + if (!prompt.isCompleted && promptVisible()) prompt.complete(true); + } + + signIn?.addListener(checkPrompt); + auth.addListener(checkPrompt); + final bool prompted; + try { + // Refresh this scope before awaiting its content. ChannelsNotifier + // fences cached snapshots to the destination relay and identity. + if (!arriving) ref.invalidate(channelsProvider); + prompted = await Future.any([ + prompt.future, + (() async { + await waitForContent(abandon: prompt.future); + if (!context.mounted || prompt.isCompleted) return false; + final bounds = await prepareLanding(); + if (context.mounted) landingBounds.value = bounds; + return false; + })(), + ]).timeout(const Duration(seconds: 20)); + } finally { + signIn?.removeListener(checkPrompt); + auth.removeListener(checkPrompt); + } if (!context.mounted) return; + if (prompted) { + Navigator.of(context).pop(); + return; + } if (animate) { await controller.forward(from: 0).orCancel; if (!context.mounted) return; diff --git a/mobile/lib/features/onboarding/organization_sign_in_banner.dart b/mobile/lib/features/onboarding/organization_sign_in_banner.dart index b8b3a2fb82..789aab8904 100644 --- a/mobile/lib/features/onboarding/organization_sign_in_banner.dart +++ b/mobile/lib/features/onboarding/organization_sign_in_banner.dart @@ -24,18 +24,16 @@ class OrganizationSignInBanner extends HookConsumerWidget { useListenable(signIn); useListenable(auth); final config = ref.watch(relayConfigProvider); - if (signIn == null || !auth.organizationProtects(config)) { + if (signIn == null || + !organizationSignInPromptVisible(signIn, auth, config)) { return const SizedBox.shrink(); } - final relayFailure = - auth.failedRelay == config.baseUrl && - auth.failedPubkey == pubkeyFromNsec(config.nsec) - ? auth.failure - : null; + final relayFailure = organizationRelayFailure(auth, config); final String message; final List actions; switch (signIn.status) { case OrganizationSessionStatus.checking: + // Unreachable: organizationSignInPromptVisible hides checking. return const SizedBox.shrink(); case OrganizationSessionStatus.signingIn: message = 'Finishing organization sign-in…'; @@ -60,12 +58,7 @@ class OrganizationSignInBanner extends HookConsumerWidget { ), ]; case OrganizationSessionStatus.signedIn: - // Signed out is handled above; unavailable keeps reconnecting. - if (relayFailure == null || - relayFailure == AccountFailure.signedOut || - relayFailure == AccountFailure.unavailable) { - return const SizedBox.shrink(); - } + // The check above hides signed out and unavailable, which reconnects. message = relayFailure == AccountFailure.denied ? 'Your organization account cannot use this community with this key.' : 'Could not reconnect with your organization account.'; diff --git a/mobile/lib/shared/hosted/organization_sign_in.dart b/mobile/lib/shared/hosted/organization_sign_in.dart index a12ce5ca60..3dd85533fa 100644 --- a/mobile/lib/shared/hosted/organization_sign_in.dart +++ b/mobile/lib/shared/hosted/organization_sign_in.dart @@ -192,3 +192,35 @@ class OrganizationSignIn extends ChangeNotifier { super.dispose(); } } + +/// The relay's refusal for this community and key, if any. +AccountFailure? organizationRelayFailure( + HostedRelayAuth auth, + RelayConfig config, +) => + auth.failedRelay == config.baseUrl && + auth.failedPubkey == pubkeyFromNsec(config.nsec) + ? auth.failure + : null; + +/// Whether the organization sign-in banner offers an action for [config]. The +/// community switcher closes early on this so the banner is reachable. +bool organizationSignInPromptVisible( + OrganizationSignIn? signIn, + HostedRelayAuth auth, + RelayConfig config, +) { + if (signIn == null || !auth.organizationProtects(config)) return false; + return switch (signIn.status) { + OrganizationSessionStatus.checking => false, + OrganizationSessionStatus.signingIn || + OrganizationSessionStatus.signedOut => true, + OrganizationSessionStatus.signedIn => switch (organizationRelayFailure( + auth, + config, + )) { + null || AccountFailure.signedOut || AccountFailure.unavailable => false, + _ => true, + }, + }; +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index e4f5e14158..071b856145 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -2637,6 +2637,96 @@ void main() { pending.complete(testChannels); }); + testWidgets( + 'a refused organization key closes the switcher before content loads', + (tester) async { + final communities = [ + Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ), + Community( + id: 'bravo', + name: 'Bravo', + relayUrl: 'wss://bravo.example.com', + addedAt: DateTime(2025), + ), + ]; + final notifier = _FakeCommunityListNotifier(communities); + final pending = Completer>(); + final account = BuilderlabAccountClient(); + final signIn = OrganizationSignIn( + account: account, + browser: BrowserAuth(), + reconnect: () async {}, + )..status = OrganizationSessionStatus.signedIn; + final auth = HostedRelayAuth( + BuilderlabAccountClient(), + organization: account, + organizationRelay: (uri) => uri.host == 'bravo.example.com', + ); + addTearDown(() { + signIn.dispose(); + auth.dispose(); + account.dispose(); + }); + await tester.pumpWidget( + buildTestable( + overrides: [ + organizationSignInProvider.overrideWithValue(signIn), + hostedRelayAuthProvider.overrideWithValue(auth), + channelsProvider.overrideWith( + () => _FakeNotifier( + testChannels, + load: () => notifier.activeId == 'alpha' + ? Future.value(testChannels) + : pending.future, + ), + ), + communityListProvider.overrideWith(() => notifier), + activeCommunityProvider.overrideWith((ref) async { + await ref.watch(communityListProvider.future); + return communities.firstWhere( + (community) => community.id == notifier.activeId, + ); + }), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Community settings', + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-switcher-row-bravo'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(notifier.switchedIds, ['bravo']); + // Signed in with no refusal yet: keep waiting for content. + expect(find.byKey(const Key('community-switcher-page')), findsOneWidget); + final container = ProviderScope.containerOf( + tester.element(find.byKey(const Key('community-switcher-page'))), + ); + auth.rejected( + container.read(relayConfigProvider), + 'Bearer refused', + terminal: true, + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(find.textContaining('taking longer to load'), findsNothing); + pending.complete(testChannels); + }, + ); + for (final timeout in [false, true]) { testWidgets( 'community loading failure can retry the active selection (timeout: $timeout)',