From 8f80b172ea0d7dc559807723f8ad4a2334d7a6fd Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 11 Nov 2026 17:58:14 -0500 Subject: [PATCH] fix(mobile): stop asking for an API token when the channel list is refused (#9327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - When the relay refused the channel-list request (HTTP 401), the mobile app said "Not authorized. Check your API token." The app has no API token, so the advice could not be followed. - The message now reads "This community couldn't authenticate the request. Try again." Retry stays: for org and hosted communities the refused token is dropped and Retry gets a fresh one, and a self-hosted relay that refuses the key has no other recovery on this screen. - One exception: when the organization sign-in has ended, the app sends reads unsigned and Retry can never succeed, while the Home banner already offers a working Sign in. In that state the channel list says "Use the sign-in prompt below to reconnect." and hides Retry. ## Details - `_ErrorView` (`mobile/lib/features/channels/channels_page/badges.dart`) only appears on a cold load with no saved channel list. It replaces the list, so Retry is the only control on that screen. - It reuses the shared `organizationSignInPromptVisible` predicate, the same one the Home banner and the community switcher use, so the error view and the banner agree on who owns the action. - The 403 "Access denied." message is unchanged. - Widget tests cover the new 401 copy with Retry, and the signed-out organization state with no Retry. ## Screenshots Android emulator, debug build, against a local test relay that refuses the channel-list read with HTTP 401 (no saved channel list, so the error view shows): Channel list error: This community couldn't authenticate the
request. Try again. With a Retry button 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .../channels/channels_page/badges.dart | 40 +++++++--- .../features/channels/channels_page_test.dart | 79 +++++++++++++++++++ 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index c2641a433d..e2ae974bc7 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -27,7 +27,7 @@ class _EphemeralBadge extends StatelessWidget { } } -class _ErrorView extends StatelessWidget { +class _ErrorView extends HookConsumerWidget { final Object error; final VoidCallback onRetry; @@ -35,8 +35,11 @@ class _ErrorView extends StatelessWidget { static String _userMessage(Object error) { if (error is RelayException) { + // There is no API token to check. A protected community's Retry gets a + // fresh token; once the organization sign-in has ended, the shell + // banner owns the action instead (see build). if (error.statusCode == 401) { - return 'Not authorized. Check your API token.'; + return "This community couldn't authenticate the request. Try again."; } if (error.statusCode == 403) { return 'Access denied.'; @@ -50,7 +53,20 @@ class _ErrorView extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final signIn = ref.watch(organizationSignInProvider); + final auth = ref.watch(hostedRelayAuthProvider); + useListenable(signIn); + useListenable(auth); + // When the relay refuses the read while the Home banner is up, the banner + // shows the working action. Other errors keep their message and Retry: a + // relay that does not require the organization sign-in can still serve + // the read. + final config = ref.watch(relayConfigProvider); + final signInOwned = + error is RelayException && + (error as RelayException).statusCode == 401 && + organizationSignInPromptVisible(signIn, auth, config); return Center( child: Padding( padding: const EdgeInsets.all(Grid.sm), @@ -65,7 +81,9 @@ class _ErrorView extends StatelessWidget { ), const SizedBox(height: Grid.xxs), Text( - _userMessage(error), + signInOwned + ? 'Use the prompt above to reconnect.' + : _userMessage(error), style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -73,12 +91,14 @@ class _ErrorView extends StatelessWidget { maxLines: 3, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: Grid.xs), - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(BuzzIcons.refreshCw), - label: const Text('Retry'), - ), + if (!signInOwned) ...[ + const SizedBox(height: Grid.xs), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(BuzzIcons.refreshCw), + label: const Text('Retry'), + ), + ], ], ), ), diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 5c3a04e7f0..e29ac9777f 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -3547,6 +3547,79 @@ void main() { expect(find.text('Retry'), findsOneWidget); }); + testWidgets('a refused sign-in names no API token and keeps retry', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _UnauthorizedNotifier()), + ], + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); + + expect( + find.text("This community couldn't authenticate the request. Try again."), + findsOneWidget, + ); + expect(find.textContaining('API token'), findsNothing); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets( + 'an ended organization sign-in leaves the action to the banner, not Retry', + (tester) async { + final account = BuilderlabAccountClient(); + final signIn = OrganizationSignIn( + account: account, + browser: BrowserAuth(), + reconnect: () async {}, + )..status = OrganizationSessionStatus.signedOut; + final auth = HostedRelayAuth( + BuilderlabAccountClient(), + organization: account, + organizationRelay: (_) => true, + ); + addTearDown(() { + signIn.dispose(); + auth.dispose(); + account.dispose(); + }); + Future pumpError(ChannelsNotifier Function() notifier) async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget( + buildTestable( + overrides: [ + organizationSignInProvider.overrideWithValue(signIn), + hostedRelayAuthProvider.overrideWithValue(auth), + channelsProvider.overrideWith(notifier), + ], + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); + } + + await pumpError(_UnauthorizedNotifier.new); + expect(find.text('Could not load channels'), findsOneWidget); + expect(find.text('Use the prompt above to reconnect.'), findsOneWidget); + expect(find.text('Retry'), findsNothing); + + // A relay that does not require the sign-in can still serve the read, + // so an error other than the refusal keeps its message and Retry. + await pumpError(_ErrorNotifier.new); + expect( + find.text('Something went wrong. Check your connection.'), + findsOneWidget, + ); + expect(find.text('Retry'), findsOneWidget); + }, + ); + testWidgets('bolds and clears unread channel labels', (tester) async { final channels = [ Channel( @@ -3966,6 +4039,12 @@ class _ErrorNotifier extends ChannelsNotifier { Future> build() => Future.error('Connection refused'); } +class _UnauthorizedNotifier extends ChannelsNotifier { + @override + Future> build() => + Future.error(RelayException(401, 'authentication required\n')); +} + class _LoadingNotifier extends ChannelsNotifier { @override Future> build() => Completer>().future;