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)',