mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): address offline state review feedback
Ensure interrupted authentication completes, fully reload channels after terminal recovery, avoid exposing raw connection errors, and distinguish auth rejection in cached banners. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
b1dbe3e40b
commit
336a1a0b7a
@@ -79,7 +79,9 @@ class _DetailConnectionBanner extends StatelessWidget {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Connection lost — Retry',
|
||||
state.lastError is RelayAuthRejectedException
|
||||
? 'Authentication rejected — Retry'
|
||||
: 'Connection lost — Retry',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
|
||||
@@ -125,12 +125,16 @@ class _ConnectionBanner extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _ConnectionLostBanner extends StatelessWidget {
|
||||
final Object? error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _ConnectionLostBanner({required this.onRetry});
|
||||
const _ConnectionLostBanner({required this.error, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final message = error is RelayAuthRejectedException
|
||||
? 'Authentication rejected — Retry'
|
||||
: 'Connection lost — Retry';
|
||||
return Material(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
child: InkWell(
|
||||
@@ -139,7 +143,7 @@ class _ConnectionLostBanner extends StatelessWidget {
|
||||
height: _kBannerHeight,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Connection lost — Retry',
|
||||
message,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
@@ -169,11 +173,9 @@ class _OfflineView extends StatelessWidget {
|
||||
? 'Authentication rejected by the relay'
|
||||
: "Can't reach your workspace";
|
||||
final body = authRejected
|
||||
? (error as RelayAuthRejectedException).message
|
||||
? 'Check your workspace access or credentials, then retry.'
|
||||
: 'Check your network or VPN (e.g. Cloudflare WARP), then retry.';
|
||||
final detail = authRejected
|
||||
? relayHost
|
||||
: '$relayHost\n${error ?? 'Connection failed'}';
|
||||
final detail = relayHost;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
|
||||
@@ -57,7 +57,10 @@ class _ChannelsBody extends StatelessWidget {
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: connectionLost
|
||||
? _ConnectionLostBanner(onRetry: onRetryConnection)
|
||||
? _ConnectionLostBanner(
|
||||
error: sessionState.lastError,
|
||||
onRetry: onRetryConnection,
|
||||
)
|
||||
: showConnectionBanner
|
||||
? _ConnectionBanner(state: sessionState)
|
||||
: const SizedBox.shrink(),
|
||||
|
||||
@@ -75,7 +75,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
return;
|
||||
}
|
||||
if (next.status != SessionStatus.connected) return;
|
||||
if (initiallyFailed) {
|
||||
if (initiallyFailed ||
|
||||
(waitingForInitialConnection &&
|
||||
connected.isCompleted &&
|
||||
!_hasLoaded)) {
|
||||
ref.invalidateSelf();
|
||||
} else if (waitingForInitialConnection &&
|
||||
!_hasLoaded &&
|
||||
|
||||
@@ -156,6 +156,7 @@ class RelaySocket {
|
||||
}
|
||||
|
||||
void _resetConnection() {
|
||||
_failAuth(null);
|
||||
_state = SocketState.disconnected;
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
|
||||
@@ -207,7 +207,10 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Authentication rejected by the relay'), findsOneWidget);
|
||||
expect(find.text('access revoked'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Check your workspace access or credentials, then retry.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('Cloudflare WARP'), findsNothing);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
@@ -264,6 +264,54 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'fails a pending initial build and fully reloads after recovery',
|
||||
() async {
|
||||
final error = Exception('relay unavailable');
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [_membership(_channelA, myPk)],
|
||||
metadata: [_meta(id: _channelA, name: 'general')],
|
||||
initialState: const SessionState(status: SessionStatus.connecting),
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final pending = container.read(channelsProvider.future);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
session.setStatus(SessionStatus.failed, lastError: error);
|
||||
await expectLater(pending, throwsA(same(error)));
|
||||
|
||||
session.setStatus(SessionStatus.connected);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final recovered = await container.read(channelsProvider.future);
|
||||
expect(recovered.single.name, 'general');
|
||||
expect(session.subscribeFilters, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test('fully reloads after being built in a failed state', () async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [_membership(_channelA, myPk)],
|
||||
metadata: [_meta(id: _channelA, name: 'general')],
|
||||
initialState: SessionState(
|
||||
status: SessionStatus.failed,
|
||||
lastError: Exception('relay unavailable'),
|
||||
),
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await expectLater(container.read(channelsProvider.future), throwsException);
|
||||
|
||||
session.setStatus(SessionStatus.connected);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final recovered = await container.read(channelsProvider.future);
|
||||
expect(recovered.single.name, 'general');
|
||||
expect(session.subscribeFilters, hasLength(1));
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps cached channels and live subscriptions during reconnect',
|
||||
() async {
|
||||
|
||||
@@ -5,6 +5,28 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
void main() {
|
||||
test('disconnect completes a pending authentication attempt', () async {
|
||||
final channel = _ReadyWebSocketChannel();
|
||||
final socket = RelaySocket(
|
||||
wsUrl: 'wss://relay.example',
|
||||
nsec: null,
|
||||
onMessage: (_) {},
|
||||
onConnected: () => fail('socket must not connect'),
|
||||
onDisconnected: (_) {},
|
||||
channelFactory: (_) => channel,
|
||||
);
|
||||
|
||||
final connecting = socket.connect();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(socket.state, SocketState.authenticating);
|
||||
|
||||
await socket.disconnect();
|
||||
await connecting.timeout(const Duration(seconds: 1));
|
||||
|
||||
expect(socket.state, SocketState.disconnected);
|
||||
expect(channel.closeCount, 1);
|
||||
});
|
||||
|
||||
test('hung handshake times out, closes, and reports disconnection', () async {
|
||||
final channel = _HungWebSocketChannel();
|
||||
final disconnected = Completer<Object?>();
|
||||
@@ -26,6 +48,35 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
class _ReadyWebSocketChannel implements WebSocketChannel {
|
||||
final _controller = StreamController<dynamic>();
|
||||
int closeCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> get ready => Future.value();
|
||||
|
||||
@override
|
||||
String? get protocol => null;
|
||||
|
||||
@override
|
||||
int? get closeCode => null;
|
||||
|
||||
@override
|
||||
String? get closeReason => null;
|
||||
|
||||
@override
|
||||
Stream<dynamic> get stream => _controller.stream;
|
||||
|
||||
@override
|
||||
late final WebSocketSink sink = _RecordingWebSocketSink(
|
||||
_controller.sink,
|
||||
() => closeCount++,
|
||||
);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _HungWebSocketChannel implements WebSocketChannel {
|
||||
final _controller = StreamController<dynamic>();
|
||||
final _ready = Completer<void>();
|
||||
|
||||
Reference in New Issue
Block a user