Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
36 changes: 29 additions & 7 deletions mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -197,23 +197,44 @@ class ChannelsPage extends HookConsumerWidget {
}
}

// Defer the error view to absorb transient AsyncError frames caused by
// the relay session cancelling in-flight history fetches on disconnect/
// reconnect (relay_session.dart `_cancelAllHistory`). If the error clears
// (channels populate or the next _fetch succeeds) within the grace
// window, we never render the error UI.
// Only surface fetch errors while the relay is stably connected. During a
// reconnect the session owns recovery, so a cancelled in-flight query must
// not turn into a manual Retry page.
final showError = useState(false);
final hasError = channelsAsync.hasError && channels == null;
final canSurfaceError =
hasError &&
sessionState.status != SessionStatus.connecting &&
sessionState.status != SessionStatus.reconnecting;
useEffect(() {
if (!hasError) {
if (!canSurfaceError) {
showError.value = false;
return null;
}
final timer = Timer(const Duration(seconds: 2), () {
showError.value = true;
});
return timer.cancel;
}, [hasError]);
}, [canSurfaceError]);

// Match desktop's degraded-state debounce: cached content remains steady
// through brief socket flaps, and the banner appears only for a sustained
// reconnect.
final showConnectionBanner = useState(false);
final isReconnectingWithContent =
channels != null &&
(sessionState.status == SessionStatus.connecting ||
sessionState.status == SessionStatus.reconnecting);
useEffect(() {
if (!isReconnectingWithContent) {
showConnectionBanner.value = false;
return null;
}
final timer = Timer(const Duration(seconds: 2), () {
showConnectionBanner.value = true;
});
return timer.cancel;
}, [isReconnectingWithContent]);

return FrostedScaffold(
appBar: FrostedAppBar(
Expand Down Expand Up @@ -248,6 +269,7 @@ class ChannelsPage extends HookConsumerWidget {
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
showConnectionBanner: showConnectionBanner.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
Expand Down
9 changes: 6 additions & 3 deletions mobile/lib/features/channels/channels_page/body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class _ChannelsBody extends StatelessWidget {
final AsyncValue<List<Channel>> channelsAsync;
final bool showError;
final SessionStatus sessionStatus;
final bool showConnectionBanner;
final String? currentPubkey;
final Future<void> Function() onRefresh;
final Future<void> Function(Channel channel) onSelectChannel;
Expand All @@ -14,6 +15,7 @@ class _ChannelsBody extends StatelessWidget {
required this.channelsAsync,
required this.showError,
required this.sessionStatus,
required this.showConnectionBanner,
required this.currentPubkey,
required this.onRefresh,
required this.onSelectChannel,
Expand All @@ -33,8 +35,7 @@ class _ChannelsBody extends StatelessWidget {
slivers: [
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
// Extra space for the connection banner when visible.
if (sessionStatus != SessionStatus.connected &&
sessionStatus != SessionStatus.disconnected)
if (showConnectionBanner)
const SliverToBoxAdapter(
child: SizedBox(height: _kBannerHeight),
),
Expand All @@ -50,7 +51,9 @@ class _ChannelsBody extends StatelessWidget {
top: barHeight,
left: 0,
right: 0,
child: _ConnectionBanner(status: sessionStatus),
child: showConnectionBanner
? _ConnectionBanner(status: sessionStatus)
: const SizedBox.shrink(),
),
],
);
Expand Down
47 changes: 31 additions & 16 deletions mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Set<String> _participatedRootIds = {};
Set<String> _authoredRootIds = {};
String? _threadInterestPubkey;
bool _hasLoaded = false;

Map<String, int> get latestObservedByChannel =>
Map.unmodifiable(_latestObservedByChannel);
Expand All @@ -55,9 +56,22 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
});

@override
Future<List<Channel>> build() {
final sessionState = ref.watch(relaySessionProvider);
Future<List<Channel>> build() async {
ref.watch(relayConfigProvider);
final connected = Completer<void>();
final sessionState = ref.read(relaySessionProvider);
final waitingForInitialConnection =
sessionState.status != SessionStatus.connected;
ref.listen(relaySessionProvider, (previous, next) {
if (next.status != SessionStatus.connected) return;
if (waitingForInitialConnection &&
!_hasLoaded &&
!connected.isCompleted) {
connected.complete();
} else if (previous?.status != SessionStatus.connected) {
unawaited(_backstopRefresh());
}
});

// Re-fetch when the app returns to foreground so channels created on
// another device while mobile was backgrounded appear immediately.
Expand All @@ -76,27 +90,28 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
});

if (sessionState.status != SessionStatus.connected) {
_clearLiveSubscriptions();
_latestObservedByChannel.clear();
_observedUnreadEventsByChannel.clear();
// Preserve the last successfully loaded channels while reconnecting
// instead of re-entering a loading/error state. The UI will show cached
// channels with a "Reconnecting…" banner overlay, which is far better
// than a blank screen.
final previous = state.value;
if (previous != null && previous.isNotEmpty) {
return Future.value(previous);
}
if (_hasLoaded) return state.value ?? const [];
await connected.future;
}

return _fetch(
subscribeLive: sessionState.status == SessionStatus.connected,
);
return _fetch(subscribeLive: true);
}

Future<List<Channel>> _fetch({
bool subscribeLive = false,
bool fetchLastMessage = true,
}) async {
final channels = await _fetchChannels(
subscribeLive: subscribeLive,
fetchLastMessage: fetchLastMessage,
);
_hasLoaded = true;
return channels;
}

Future<List<Channel>> _fetchChannels({
bool subscribeLive = false,
bool fetchLastMessage = true,
}) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) throw StateError('No signing identity available');
Expand Down
50 changes: 4 additions & 46 deletions mobile/lib/shared/auth/auth_provider.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../relay/relay.dart';
import '../workspace/workspace.dart';
import '../workspace/workspace_provider.dart';

Expand All @@ -13,10 +12,8 @@ class AuthState {
const AuthState({required this.status, this.workspace});
}

/// Validates the active workspace on startup by opening a NIP-42-authenticated
/// websocket. A successful AUTH means the nsec is valid and the relay accepts
/// us; any other outcome falls through to offline (transient) or removes the
/// workspace (auth explicitly rejected).
/// Restores the active workspace without making connectivity load-bearing.
/// The relay session owns connection recovery after startup.
Comment thread
wesbillman marked this conversation as resolved.
class AuthNotifier extends AsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
Expand All @@ -40,42 +37,10 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
await storage.saveActiveId(active.id);
}

// Validate by attempting a NIP-42 authenticated WS connection.
final socket = RelaySocket(
wsUrl: _wsFromBase(active.relayUrl),
nsec: active.nsec,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) {},
);
try {
await socket.connect().timeout(const Duration(seconds: 8));
await socket.disconnect();
return AuthState(status: AuthStatus.authenticated, workspace: active);
} catch (e) {
final msg = e.toString();
// The relay explicitly rejected our auth — drop this workspace.
if (msg.contains('Auth rejected') ||
msg.contains('restricted') ||
msg.contains('auth-required')) {
await storage.remove(active.id);
final remaining = await storage.loadAll();
if (remaining.isNotEmpty) {
final next = remaining.first;
await storage.saveActiveId(next.id);
ref.invalidate(workspaceListProvider);
ref.invalidate(activeWorkspaceProvider);
ref.invalidateSelf();
return await future;
}
return const AuthState(status: AuthStatus.unauthenticated);
}
// Transient (timeout, network) — keep workspace, go offline.
return AuthState(status: AuthStatus.offline, workspace: active);
}
return AuthState(status: AuthStatus.authenticated, workspace: active);
Comment thread
wesbillman marked this conversation as resolved.
Outdated
}

/// Retry credential validation (e.g. after a network error).
/// Reload the active workspace after a startup error.
Future<void> retry() async {
ref.invalidateSelf();
await future;
Expand Down Expand Up @@ -126,13 +91,6 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
}
}

/// Derive the websocket URL from the workspace's HTTP base URL.
String _wsFromBase(String baseUrl) {
final uri = Uri.parse(baseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return uri.replace(scheme: scheme).toString();
}

final authProvider = AsyncNotifierProvider<AuthNotifier, AuthState>(
AuthNotifier.new,
);
43 changes: 30 additions & 13 deletions mobile/lib/shared/relay/relay_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ class RelaySessionNotifier extends Notifier<SessionState> {
int _reconnectDelayMs = _baseReconnectDelayMs;
int _subIdCounter = 0;
bool _disposed = false;
bool _paused = false;
bool _hasConnectedOnce = false;

@override
SessionState build() {
Expand Down Expand Up @@ -173,8 +175,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
final timer = Timer(timeout, () {
final sub = _historySubscriptions.remove(subId);
if (sub != null && !sub.completer.isCompleted) {
// Resolve with whatever we collected so far rather than failing.
sub.completer.complete(sub.events);
sub.completer.completeError(
TimeoutException('Relay history request timed out after $timeout'),
);
Comment thread
wesbillman marked this conversation as resolved.
}
_sendClose(subId);
});
Expand Down Expand Up @@ -266,6 +269,15 @@ class RelaySessionNotifier extends Notifier<SessionState> {
@visibleForTesting
void debugFlushEventBuffer() => _flushEventBuffer();

@visibleForTesting
void debugHandleConnected() => _handleConnected();

@visibleForTesting
void debugHandleDisconnected([Object? error]) => _handleDisconnected(error);

@visibleForTesting
void debugPauseNow() => _pauseNow();

/// Force a reconnect (e.g., returning from background).
Future<void> reconnect() async {
await _socket?.disconnect();
Expand All @@ -277,14 +289,21 @@ class RelaySessionNotifier extends Notifier<SessionState> {
/// Called by the app lifecycle provider when the app goes to background.
void onAppPaused() {
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = Timer(const Duration(seconds: 5), () {
_socket?.disconnect();
state = const SessionState(status: SessionStatus.disconnected);
});
_backgroundGraceTimer = Timer(const Duration(seconds: 5), _pauseNow);
}

void _pauseNow() {
_paused = true;
_reconnectTimer?.cancel();
_cancelAllHistory(Exception('App moved to background'));
_rejectAllPending(Exception('App moved to background'));
_socket?.disconnect();
state = const SessionState(status: SessionStatus.disconnected);
}

/// Called by the app lifecycle provider when the app returns to foreground.
void onAppResumed() {
_paused = false;
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = null;

Expand All @@ -308,7 +327,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
}

state = SessionState(
status: SessionStatus.connecting,
status: _hasConnectedOnce
? SessionStatus.reconnecting
: SessionStatus.connecting,
reconnectAttempt: state.reconnectAttempt,
);
Comment thread
wesbillman marked this conversation as resolved.

Expand All @@ -326,6 +347,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {

void _handleConnected() {
if (_disposed) return;
_hasConnectedOnce = true;
_reconnectDelayMs = _baseReconnectDelayMs;
state = const SessionState(status: SessionStatus.connected);
_replayLiveSubscriptions();
Expand All @@ -342,12 +364,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
}

void _scheduleReconnect() {
if (_disposed) return;
if (_liveSubscriptions.isEmpty) {
state = const SessionState(status: SessionStatus.disconnected);
return;
}

if (_disposed || _paused) return;
final attempt = state.reconnectAttempt + 1;
state = SessionState(
status: SessionStatus.reconnecting,
Expand Down
Loading
Loading