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;