From 8adc8584754c702e0d68dd6af85096ef2264325a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 13:09:39 -0500 Subject: [PATCH 1/3] fix(mobile): stop asking for an API token when the channel list is refused A relay 401 on the channel list said "Not authorized. Check your API token." The mobile app has no API token, so the advice could not be followed. It now says the community did not accept the sign-in, and keeps Retry: for a protected community the refused token is dropped and Retry gets a fresh one, and the shell banner takes over once the sign-in has really ended. A self-hosted relay that refuses the key also needs Retry, since no banner covers it. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/channels_page/badges.dart | 4 ++- .../features/channels/channels_page_test.dart | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index c2641a433db..b5b081aab11 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -35,8 +35,10 @@ 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, and its shell banner takes over once sign-in has ended. if (error.statusCode == 401) { - return 'Not authorized. Check your API token.'; + return "This community didn't accept your sign-in. Try again."; } if (error.statusCode == 403) { return 'Access denied.'; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 5c3a04e7f08..acd0c91893b 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -3547,6 +3547,28 @@ 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 didn't accept your sign-in. Try again."), + findsOneWidget, + ); + expect(find.textContaining('API token'), findsNothing); + expect(find.text('Retry'), findsOneWidget); + }); + testWidgets('bolds and clears unread channel labels', (tester) async { final channels = [ Channel( @@ -3966,6 +3988,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; From 2e613e53bf385373c113b7fac0582ccdd1832303 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 13:14:47 -0500 Subject: [PATCH 2/3] fix(mobile): word the channel-list 401 without guessing its cause The error view sees only the status code, and a 401 can come from a refused sign-in, a self-hosted relay that refused the key, or a proxy. Say that the community could not authenticate the request instead of claiming the sign-in was refused. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- mobile/lib/features/channels/channels_page/badges.dart | 2 +- mobile/test/features/channels/channels_page_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index b5b081aab11..4a926ba1af5 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -38,7 +38,7 @@ class _ErrorView extends StatelessWidget { // There is no API token to check. A protected community's Retry gets a // fresh token, and its shell banner takes over once sign-in has ended. if (error.statusCode == 401) { - return "This community didn't accept your sign-in. Try again."; + return "This community couldn't authenticate the request. Try again."; } if (error.statusCode == 403) { return 'Access denied.'; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index acd0c91893b..d45efb308be 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -3562,7 +3562,7 @@ void main() { await tester.pump(); expect( - find.text("This community didn't accept your sign-in. Try again."), + find.text("This community couldn't authenticate the request. Try again."), findsOneWidget, ); expect(find.textContaining('API token'), findsNothing); From 006b161c1456595524e5f1efadd259bc1271baa6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 13:19:21 -0500 Subject: [PATCH 3/3] fix(mobile): leave the channel-list action to the sign-in banner once sign-in ends When the organization sign-in has ended, the app sends channel reads unsigned, so the channel list's Retry can never succeed, while the Home banner already shows "It looks like you're signed out." with a working Sign in button. The error view now points to that prompt and hides Retry whenever the organization sign-in prompt is visible for the current community. Every other failure keeps Retry. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/channels_page/badges.dart | 36 +++++++++++----- .../features/channels/channels_page_test.dart | 41 +++++++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index 4a926ba1af5..d47eb2cd8ca 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; @@ -36,7 +36,8 @@ 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, and its shell banner takes over once sign-in has ended. + // fresh token; once the organization sign-in has ended, the shell + // banner owns the action instead (see build). if (error.statusCode == 401) { return "This community couldn't authenticate the request. Try again."; } @@ -52,7 +53,18 @@ 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); + // With the organization sign-in ended, a read goes out unsigned and Retry + // cannot succeed. The Home banner then shows the working action. + final signInOwned = organizationSignInPromptVisible( + signIn, + auth, + ref.watch(relayConfigProvider), + ); return Center( child: Padding( padding: const EdgeInsets.all(Grid.sm), @@ -67,7 +79,9 @@ class _ErrorView extends StatelessWidget { ), const SizedBox(height: Grid.xxs), Text( - _userMessage(error), + signInOwned + ? 'Use the sign-in prompt below to reconnect.' + : _userMessage(error), style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -75,12 +89,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 d45efb308be..e01fa5e6901 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -3569,6 +3569,47 @@ void main() { 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(); + }); + await tester.pumpWidget( + buildTestable( + overrides: [ + organizationSignInProvider.overrideWithValue(signIn), + hostedRelayAuthProvider.overrideWithValue(auth), + channelsProvider.overrideWith(() => _UnauthorizedNotifier()), + ], + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); + + expect(find.text('Could not load channels'), findsOneWidget); + expect( + find.text('Use the sign-in prompt below to reconnect.'), + findsOneWidget, + ); + expect(find.text('Retry'), findsNothing); + }, + ); + testWidgets('bolds and clears unread channel labels', (tester) async { final channels = [ Channel(