fix(mobile): stop asking for an API token when the channel list is refused (#9327)

## 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):

<img
src="https://github.com/user-attachments/assets/feebc324-a4cf-49ca-876a-1ae026bda753"
alt="Channel list error: This community couldn't authenticate the
request. Try again. With a Retry button" width="360">


🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Johnson
2026-11-11 17:58:14 -05:00
committed by GitHub
parent 75d38b6c2d
commit 8f80b172ea
2 changed files with 109 additions and 10 deletions

View File

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

View File

@@ -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<void> 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<List<Channel>> build() => Future.error('Connection refused');
}
class _UnauthorizedNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() =>
Future.error(RelayException(401, 'authentication required\n'));
}
class _LoadingNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() => Completer<List<Channel>>().future;