Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,8 @@ class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
: ObserverConnectionState.open,
SessionStatus.connecting ||
SessionStatus.reconnecting => ObserverConnectionState.connecting,
SessionStatus.disconnected => ObserverConnectionState.idle,
SessionStatus.disconnected ||
SessionStatus.failed => ObserverConnectionState.idle,
};
}

Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,8 @@ class ChannelDetailPage extends HookConsumerWidget {
),
),
_DetailConnectionBanner(
status: ref.watch(relaySessionProvider).status,
state: ref.watch(relaySessionProvider),
onRetry: () => ref.read(relaySessionProvider.notifier).reconnect(),
),
if (!resolvedChannel.isForum && typingEntries.isNotEmpty)
_TypingIndicator(entries: typingEntries),
Expand Down
39 changes: 34 additions & 5 deletions mobile/lib/features/channels/channel_detail_page/banners.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,46 @@ class _HeaderEphemeralBadge extends StatelessWidget {
}

class _DetailConnectionBanner extends StatelessWidget {
final SessionStatus status;
final SessionState state;
final VoidCallback onRetry;

const _DetailConnectionBanner({required this.status});
const _DetailConnectionBanner({required this.state, required this.onRetry});

@override
Widget build(BuildContext context) {
if (status == SessionStatus.connected ||
status == SessionStatus.disconnected) {
if (state.status == SessionStatus.connected ||
state.status == SessionStatus.disconnected) {
return const SizedBox.shrink();
}

if (state.status == SessionStatus.failed) {
return Material(
color: context.colors.surfaceContainerHighest,
child: InkWell(
onTap: onRetry,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.quarter + 2,
),
child: Center(
child: Text(
state.lastError is RelayAuthRejectedException
? 'Authentication rejected — Retry'
: 'Connection lost — Retry',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
),
);
}

final message = state.reconnectAttempt > 0
? 'Reconnecting… (attempt ${state.reconnectAttempt})'
: 'Reconnecting…';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
Expand All @@ -86,7 +115,7 @@ class _DetailConnectionBanner extends StatelessWidget {
),
const SizedBox(width: Grid.xxs),
Text(
'Reconnecting…',
message,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
Expand Down
10 changes: 9 additions & 1 deletion mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class ChannelsPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final channelsAsync = ref.watch(channelsProvider);
final sessionState = ref.watch(relaySessionProvider);
final relayHost = Uri.parse(ref.watch(relayConfigProvider).baseUrl).host;
final currentPubkey = ref
.watch(profileProvider)
.whenData((value) => value?.pubkey)
Expand All @@ -153,6 +154,11 @@ class ChannelsPage extends HookConsumerWidget {
}
final channels = cachedChannels.value;

Future<void> retryConnection() async {
await ref.read(relaySessionProvider.notifier).reconnect();
if (context.mounted) ref.invalidate(channelsProvider);
}

Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
await Navigator.of(context).push(
Expand Down Expand Up @@ -269,10 +275,12 @@ class ChannelsPage extends HookConsumerWidget {
channels: channels,
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
sessionState: sessionState,
relayHost: relayHost,
showConnectionBanner: showConnectionBanner.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onRetryConnection: retryConnection,
onSelectChannel: openChannel,
),
);
Expand Down
116 changes: 110 additions & 6 deletions mobile/lib/features/channels/channels_page/badges.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,24 @@ class _EphemeralBadge extends StatelessWidget {
}

class _ConnectionBanner extends StatelessWidget {
final SessionStatus status;
final SessionState state;

const _ConnectionBanner({required this.status});
const _ConnectionBanner({required this.state});

@override
Widget build(BuildContext context) {
if (status == SessionStatus.connected ||
status == SessionStatus.disconnected) {
if (state.status == SessionStatus.connected ||
state.status == SessionStatus.disconnected ||
state.status == SessionStatus.failed) {
return const SizedBox.shrink();
}

final isConnecting = status == SessionStatus.connecting;
final message = isConnecting ? 'Connecting…' : 'Reconnecting…';
final isConnecting = state.status == SessionStatus.connecting;
final message = isConnecting
? 'Connecting…'
: state.reconnectAttempt > 0
? 'Reconnecting… (attempt ${state.reconnectAttempt})'
: 'Reconnecting…';

return Container(
width: double.infinity,
Expand Down Expand Up @@ -119,6 +124,105 @@ class _ConnectionBanner extends StatelessWidget {
}
}

class _ConnectionLostBanner extends StatelessWidget {
final Object? error;
final VoidCallback 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(
onTap: onRetry,
child: SizedBox(
height: _kBannerHeight,
child: Center(
child: Text(
message,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
),
);
}
}

class _OfflineView extends StatelessWidget {
final Object? error;
final String relayHost;
final VoidCallback onRetry;

const _OfflineView({
required this.error,
required this.relayHost,
required this.onRetry,
});

@override
Widget build(BuildContext context) {
final authRejected = error is RelayAuthRejectedException;
final headline = authRejected
? 'Authentication rejected by the relay'
: "Can't reach your workspace";
final body = authRejected
? 'Check your workspace access or credentials, then retry.'
: 'Check your network or VPN (e.g. Cloudflare WARP), then retry.';
final detail = relayHost;

return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.wifiOff,
size: Grid.xl,
color: context.colors.error,
),
const SizedBox(height: Grid.xs),
Text(
headline,
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: Grid.xxs),
Text(
body,
style: context.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: Grid.xxs),
Text(
detail,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: Grid.xs),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(LucideIcons.refreshCw),
label: const Text('Retry'),
),
],
),
),
);
}
}

class _ErrorView extends StatelessWidget {
final Object error;
final VoidCallback onRetry;
Expand Down
43 changes: 34 additions & 9 deletions mobile/lib/features/channels/channels_page/body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@ class _ChannelsBody extends StatelessWidget {
final List<Channel>? channels;
final AsyncValue<List<Channel>> channelsAsync;
final bool showError;
final SessionStatus sessionStatus;
final SessionState sessionState;
final String relayHost;
final bool showConnectionBanner;
final String? currentPubkey;
final Future<void> Function() onRefresh;
final Future<void> Function() onRetryConnection;
final Future<void> Function(Channel channel) onSelectChannel;

const _ChannelsBody({
required this.channels,
required this.channelsAsync,
required this.showError,
required this.sessionStatus,
required this.sessionState,
required this.relayHost,
required this.showConnectionBanner,
required this.currentPubkey,
required this.onRefresh,
required this.onRetryConnection,
required this.onSelectChannel,
});

Expand All @@ -26,6 +30,8 @@ class _ChannelsBody extends StatelessWidget {
final barHeight = frostedAppBarHeight(context);

if (channels != null) {
final connectionLost = sessionState.status == SessionStatus.failed;
final bannerVisible = connectionLost || showConnectionBanner;
return Stack(
children: [
RefreshIndicator(
Expand All @@ -34,8 +40,7 @@ class _ChannelsBody extends StatelessWidget {
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
// Extra space for the connection banner when visible.
if (showConnectionBanner)
if (bannerVisible)
const SliverToBoxAdapter(
child: SizedBox(height: _kBannerHeight),
),
Expand All @@ -51,14 +56,30 @@ class _ChannelsBody extends StatelessWidget {
top: barHeight,
left: 0,
right: 0,
child: showConnectionBanner
? _ConnectionBanner(status: sessionStatus)
child: connectionLost
? _ConnectionLostBanner(
error: sessionState.lastError,
onRetry: onRetryConnection,
)
: showConnectionBanner
? _ConnectionBanner(state: sessionState)
: const SizedBox.shrink(),
),
],
);
}

if (sessionState.status == SessionStatus.failed) {
return Padding(
padding: EdgeInsets.only(top: barHeight),
child: _OfflineView(
error: sessionState.lastError,
relayHost: relayHost,
onRetry: onRetryConnection,
),
);
}

// The error view is gated on a grace timer in the parent — see the
// useEffect in ChannelsPage. While the grace window is in flight we fall
// through to the connection banner so transient relay-cancellation errors
Expand All @@ -73,9 +94,13 @@ class _ChannelsBody extends StatelessWidget {
return Padding(
padding: EdgeInsets.only(top: barHeight),
child: _ConnectionBanner(
status: sessionStatus == SessionStatus.connected
? SessionStatus.connecting
: sessionStatus,
state: SessionState(
status: sessionState.status == SessionStatus.connected
? SessionStatus.connecting
: sessionState.status,
reconnectAttempt: sessionState.reconnectAttempt,
lastError: sessionState.lastError,
),
),
);
}
Expand Down
21 changes: 20 additions & 1 deletion mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,25 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final sessionState = ref.read(relaySessionProvider);
final waitingForInitialConnection =
sessionState.status != SessionStatus.connected;
final initiallyFailed = sessionState.status == SessionStatus.failed;
if (initiallyFailed) connected.future.ignore();
ref.listen(relaySessionProvider, (previous, next) {
if (next.status == SessionStatus.failed &&
waitingForInitialConnection &&
!_hasLoaded &&
!connected.isCompleted) {
connected.completeError(
next.lastError ?? Exception('Connection failed'),
);
return;
}
if (next.status != SessionStatus.connected) return;
if (waitingForInitialConnection &&
if (initiallyFailed ||
(waitingForInitialConnection &&
connected.isCompleted &&
!_hasLoaded)) {
ref.invalidateSelf();
} else if (waitingForInitialConnection &&
!_hasLoaded &&
!connected.isCompleted) {
connected.complete();
Expand Down Expand Up @@ -92,6 +108,9 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
if (sessionState.status != SessionStatus.connected) {
// Keep the prior community's cache visible until the new relay connects.
if (_hasLoaded) return state.value ?? const [];
if (sessionState.status == SessionStatus.failed) {
throw sessionState.lastError ?? Exception('Connection failed');
}
await connected.future;
}

Expand Down
Loading
Loading