diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 497d6937cc..f0681c0262 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -247,9 +247,9 @@ InviteJoinRecovery buildMobileInviteJoinRecovery( .read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future) .timeout(const Duration(seconds: 15)); ensureScopeCurrent(); - await ref.read(channelsProvider.notifier).refresh(fetchDirectory: true); - ensureScopeCurrent(); - final channels = await ref.read(channelsProvider.future); + final channels = await ref + .read(channelsProvider.notifier) + .fetchFresh(fetchDirectory: true); ensureScopeCurrent(); return channels; }, diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 40813895ed..8c20799b1e 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -81,6 +81,28 @@ class Channel { : null, ); + /// Inverse of [Channel.fromJson]. + Map toJson() => { + 'id': id, + 'name': name, + 'channel_type': channelType, + 'labels': labels, + 'visibility': visibility, + 'description': description, + 'topic': topic, + 'purpose': purpose, + 'created_by': createdBy, + 'created_at': createdAt.toIso8601String(), + 'member_count': memberCount, + 'last_message_at': lastMessageAt?.toIso8601String(), + 'archived_at': archivedAt?.toIso8601String(), + 'participants': participants, + 'participant_pubkeys': participantPubkeys, + 'is_member': isMember, + 'ttl_seconds': ttlSeconds, + 'ttl_deadline': ttlDeadline?.toIso8601String(), + }; + bool get isEphemeral => ttlSeconds != null || ttlDeadline != null; /// Solo presentation is carried by NIP-CL metadata, not project filing. diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index d5817d0e47..89e10abc87 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -195,15 +195,15 @@ Future _fenced(_ChannelRefreshFence fence, Future future) async { /// Lives in this part file because `channels_provider.dart` sits against the /// repository-wide 1200-line file ceiling enforced by `just file-size-check`. Future> _resolveDmDisplayNames( - RelaySessionNotifier session, + Ref ref, _ChannelRefreshFence fence, - Iterable dedupedMetas, - String myPk, -) async { + Iterable channelMetas, + String myPk, { + required bool refreshCached, +}) async { final dmParticipants = {}; final myPkLower = myPk.toLowerCase(); - for (final event in dedupedMetas) { - final data = ChannelData.fromEvent(event); + for (final data in channelMetas) { if (data.channelType != 'dm') continue; for (final pk in data.participantPubkeys) { final lower = pk.toLowerCase(); @@ -212,24 +212,44 @@ Future> _resolveDmDisplayNames( } if (dmParticipants.isEmpty) return const {}; - final profileEvents = await _fenced( + final profiles = await _fenced( fence, - session.fetchHistory(NostrFilters.profilesBatch(dmParticipants.toList())), + ref + .read(userCacheProvider.notifier) + .resolve(dmParticipants.toList(), refreshCached: refreshCached), ); final displayNames = {}; - for (final event in profileEvents) { - if (event.kind != 0) continue; - final profile = ProfileData.fromEvent(event); + for (final profile in profiles.values) { final label = profile.displayName?.trim().isNotEmpty == true ? profile.displayName!.trim() - : profile.nip05?.trim().isNotEmpty == true - ? profile.nip05!.trim() + : profile.nip05Handle?.trim().isNotEmpty == true + ? profile.nip05Handle!.trim() : shortPubkey(profile.pubkey); displayNames[profile.pubkey.toLowerCase()] = label; } return displayNames; } +List _latestChannelEvents( + Iterable events, { + required int kind, +}) { + final latestById = {}; + for (final event in events) { + if (event.kind != kind) continue; + final id = event.getTagValue('d'); + if (id == null) continue; + final current = latestById[id]; + if (current == null || + event.createdAt > current.createdAt || + (event.createdAt == current.createdAt && + event.id.compareTo(current.id) < 0)) { + latestById[id] = event; + } + } + return latestById.values.toList(); +} + Future> _fetchHiddenDmIds( RelaySessionNotifier session, String myPk, diff --git a/mobile/lib/features/channels/channel_snapshot.dart b/mobile/lib/features/channels/channel_snapshot.dart new file mode 100644 index 0000000000..456a77b051 --- /dev/null +++ b/mobile/lib/features/channels/channel_snapshot.dart @@ -0,0 +1,70 @@ +part of 'channels_provider.dart'; + +extension _ChannelSnapshots on ChannelsNotifier { + ChannelListSnapshotStorage? _snapshotStorage() => + _lifecycleRef.read(channelListSnapshotStorageProvider); + + List? _restoreSnapshot(String? pubkey, String relay) { + if (pubkey == null) return null; + final storage = _snapshotStorage(); + final snapshot = storage?.read(pubkey, relay); + if (snapshot == null) return null; + try { + final channels = snapshot.channels + .map(Channel.fromJson) + .where( + (channel) => + channel.isMember && !snapshot.hiddenDmIds.contains(channel.id), + ) + .toList(); + _hiddenDmIds = Set.unmodifiable(snapshot.hiddenDmIds); + _hasLoaded = true; + _hasCachedSnapshot = true; + _needsSnapshotRevalidation = true; + return channels; + } catch (_) { + unawaited( + storage!.remove(pubkey, relay).catchError((Object error) { + debugPrint( + '[ChannelsNotifier] invalid snapshot removal failed: $error', + ); + }), + ); + return null; + } + } + + void Function(List, Set)? _captureSnapshotWriter( + String pubkey, + String relay, + ) { + final storage = _snapshotStorage(); + if (storage == null) return null; + // Capture removal ownership before network I/O. Community removal retires + // this token even while the active provider is still being torn down. + final token = storage.capture(pubkey, relay); + return (channels, hiddenDmIds) { + final snapshot = ChannelListSnapshot( + channels: [ + for (final channel in channels) + if (channel.isMember) channel.toJson(), + ], + hiddenDmIds: hiddenDmIds, + ); + unawaited( + storage + .write(token, snapshot) + .then( + (saved) { + if (!saved) { + debugPrint('[ChannelsNotifier] snapshot was not saved'); + } + }, + onError: (Object error, StackTrace stack) { + debugPrint('[ChannelsNotifier] snapshot write failed: $error'); + }, + ), + ); + }; + } +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index b9e22e4567..f69a9b244a 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -290,17 +290,21 @@ class ChannelsPage extends HookConsumerWidget { Future prepareCommunityLanding() async { // The list snapshot precedes unread history and DM profile hydration. // Both can still change weight, labels, and sorting behind the picker. - final channels = ref.read(channelsProvider).requireValue; - final dmPubkeys = { - for (final channel in channels) - if (channel.isMember && !channel.isArchived && channel.isDm) - ...channel.participantPubkeys, - }; - await Future.wait([ - ref.read(channelsProvider.notifier).waitForUnreadCatchUp(), - if (dmPubkeys.isNotEmpty) - ref.read(userCacheProvider.notifier).preload(dmPubkeys.toList()), - ]); + // A restored list is already usable offline. Refresh its unread state + // and participant profiles behind the page instead of gating navigation. + if (!ref.read(channelsProvider.notifier).hasCachedSnapshot) { + final channels = ref.read(channelsProvider).requireValue; + final dmPubkeys = { + for (final channel in channels) + if (channel.isMember && !channel.isArchived && channel.isDm) + ...channel.participantPubkeys, + }; + await Future.wait([ + ref.read(channelsProvider.notifier).waitForUnreadCatchUp(), + if (dmPubkeys.isNotEmpty) + ref.read(userCacheProvider.notifier).preload(dmPubkeys.toList()), + ]); + } if (!context.mounted || !communityFlightActive.value) return null; // Settle Home's scale and scroll while the loading backdrop is opaque. onSettingsTransitionProgress(0); diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart index 8a258e7521..6074ccb2ef 100644 --- a/mobile/lib/features/channels/channels_page/community_switcher.dart +++ b/mobile/lib/features/channels/channels_page/community_switcher.dart @@ -9,12 +9,15 @@ const _communityRevealDuration = Duration(milliseconds: 160); const _communityCenteredAvatarSize = _communityGridAvatarSize * 1.5; // Readiness follows the data that determines the landing page's layout. Live -// messages continue to sync normally after this first usable snapshot. +// messages continue to sync normally after this first usable snapshot. The +// header's own profile avatar hydrates independently and never gates landing. +// Cached destinations use local layout/read preferences immediately. Remote +// changes may reorder the list or update badges after reveal; offline landing +// must not wait for those relay reads. final _communityContentReadyProvider = Provider.autoDispose>(( ref, ) { final channels = ref.watch(channelsProvider); - final profile = ref.watch(profileProvider); final community = ref.watch(activeCommunityProvider).unwrapPrevious().value; final sections = ref.watch(channelSectionsProvider).isReady; final stars = ref.watch(channelStarsProvider).isReady; @@ -22,6 +25,10 @@ final _communityContentReadyProvider = Provider.autoDispose>(( final mutes = ref.watch(channelMutesProvider).isReady; final readState = ref.watch(readStateProvider).isReady; final hasIdentity = ref.watch(myPubkeyProvider) != null; + // A restored snapshot lands with the local section/star/sort/mute/read state + // already applied; their remote catch-up continues after reveal. Watching + // channelsProvider above re-evaluates this when the snapshot is installed. + final cached = ref.read(channelsProvider.notifier).hasCachedSnapshot; if (!channels.isLoading && channels.hasError) { return AsyncError(channels.error!, channels.stackTrace!); } @@ -29,8 +36,9 @@ final _communityContentReadyProvider = Provider.autoDispose>(( community != null && channels.hasValue && !channels.isLoading && - !profile.isLoading && - (!hasIdentity || (sections && stars && sort && mutes && readState)), + (!hasIdentity || + cached || + (sections && stars && sort && mutes && readState)), ); }); @@ -168,21 +176,19 @@ class CommunitySwitcherPage extends HookConsumerWidget { if (!arriving) unawaited(HapticFeedback.selectionClick()); final animate = !reducedMotion && (arriving || destination != null); try { - // Relay teardown, credential changes and provider hydration must not - // compete with the grid-to-center motion on the UI isolate. - if (animate && !arriving) { - await centering.forward(from: 0).orCancel; - } else { - centering.value = 1; - } - if (!context.mounted) return; - await WidgetsBinding.instance.endOfFrame; - if (!context.mounted) return; - if (!arriving) { - await ref - .read(communityListProvider.notifier) - .switchCommunity(community.id); - } + // Storage and relay teardown are asynchronous I/O, so they overlap the + // grid-to-center motion. Both settle before any failure is handled, so + // the picker never resets while one of them is still running. + await Future.wait([ + if (animate && !arriving) + centering.forward(from: 0).orCancel + else + Future.sync(() => centering.value = 1), + if (!arriving) + ref + .read(communityListProvider.notifier) + .switchCommunity(community.id), + ]); if (!context.mounted) return; await ref.read(activeCommunityProvider.future); if (!context.mounted) return; diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 28a734f208..30ba2ec835 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/channels/channel_list_snapshot.dart'; import '../../shared/theme/theme_provider.dart'; import '../../shared/utils/string_utils.dart'; import 'channel.dart'; @@ -15,12 +16,14 @@ import 'channel_management_provider.dart' import 'channel_mutes/channel_mutes_provider.dart'; import 'huddle_channel_filter.dart'; import '../../shared/read_state/read_state_provider.dart'; +import '../../shared/profile/user_cache_provider.dart'; import 'thread_follows/thread_follows_provider.dart'; import 'unread_badge/is_high_priority_event.dart'; import 'unread_badge/observed_unread_event.dart'; import 'unread_badge/should_notify_for_event.dart'; part 'channel_directory.dart'; +part 'channel_snapshot.dart'; part 'channel_member_snapshots.dart'; part 'channels_provider_lifecycle.dart'; @@ -63,6 +66,12 @@ class ChannelsNotifier extends AsyncNotifier> { Set _authoredRootIds = {}; String? _threadInterestPubkey; bool _hasLoaded = false; + bool _hasCachedSnapshot = false; + bool _needsSnapshotRevalidation = false; + bool _backgroundRefreshInFlight = false; + bool _backgroundRefreshQueued = false; + int _buildGeneration = 0; + void Function(List, Set)? _saveSnapshot; String? _memberSnapshotRelayBaseUrl; String? _memberSnapshotPubkey; Map> _memberSnapshotsByChannelId = const {}; @@ -91,6 +100,10 @@ class ChannelsNotifier extends AsyncNotifier> { bool get hasLoaded => _hasLoaded; + /// Whether this scope has a reusable list from memory or local storage. + /// Remains true during background revalidation and later refreshes. + bool get hasCachedSnapshot => _hasCachedSnapshot; + /// Waits for the current list's initial unread history to settle. /// /// Presentation transitions can await this without blocking ordinary cached @@ -130,13 +143,19 @@ class ChannelsNotifier extends AsyncNotifier> { @override Future> build() async { + final buildGeneration = ++_buildGeneration; + _backgroundRefreshInFlight = false; + _backgroundRefreshQueued = false; + _refreshCoordinator.retireInFlight(); final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl; final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); if (_memberSnapshotRelayBaseUrl != relayBaseUrl || _memberSnapshotPubkey != pubkey) { // A cached snapshot is usable only within its relay and identity. - // A new scope must await its own connection and first channel fetch. + // Never reuse another community or identity’s in-memory content. _hasLoaded = false; + _hasCachedSnapshot = false; + _needsSnapshotRevalidation = false; _memberSnapshotRelayBaseUrl = relayBaseUrl; _memberSnapshotPubkey = pubkey; _memberSnapshotsByChannelId = const {}; @@ -146,21 +165,28 @@ class ChannelsNotifier extends AsyncNotifier> { // previous relay or identity and must not reach this scope's state. _refreshCoordinator.retireInFlight(); } + final snapshot = + (_hasLoaded ? state.value : null) ?? + _restoreSnapshot(pubkey, relayBaseUrl); + if (snapshot != null) _hasCachedSnapshot = true; + _saveSnapshot = pubkey == null + ? null + : _captureSnapshotWriter(pubkey, relayBaseUrl); final connected = Completer(); + var connectionVersion = 0; 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) { + if (previous?.status != SessionStatus.connected) connectionVersion++; + if (waitingForInitialConnection && !connected.isCompleted) { connected.complete(); } else if (previous?.status != SessionStatus.connected) { // A new authenticated connection can change relay admission policy. // Ordinary refreshes must not retry an unchanged terminal rejection. _terminallyClosedLiveChunks.clear(); - unawaited(_backstopRefresh()); + unawaited(_refreshInBackground(queueIfBusy: true)); } }); @@ -171,15 +197,20 @@ class ChannelsNotifier extends AsyncNotifier> { // Resume is published before the session hears it. Let the session // decide first: one that reconnects reconciles when it connects, so // refreshing here too would fetch the same list twice. + final versionBeforeResume = connectionVersion; scheduleMicrotask(() { if (!ref.mounted) return; - if (ref.read(relaySessionProvider).status == SessionStatus.connected) { + // A fast reconnect may already have reconciled before this callback. + if (connectionVersion == versionBeforeResume && + ref.read(relaySessionProvider).status == SessionStatus.connected) { refresh(); } }); }); ref.onDispose(() { + _buildGeneration++; + _refreshCoordinator.retireInFlight(); _clearLiveSubscriptions(); _latestObservedByChannel.clear(); _observedUnreadEventsByChannel.clear(); @@ -187,11 +218,25 @@ class ChannelsNotifier extends AsyncNotifier> { _backstopTimer = null; }); + if (snapshot != null) { + _startBackstopTimer(); + // Install local data before starting work that can notify listeners. + unawaited( + Future.microtask(() async { + if (waitingForInitialConnection) await connected.future; + if (!ref.mounted || buildGeneration != _buildGeneration) return; + await _refreshInBackground(); + }), + ); + return snapshot; + } + if (sessionState.status != SessionStatus.connected) { - // Preserve cached channels only for a reconnect within the same scope. - if (_hasLoaded) return state.value ?? const []; await connected.future; } + if (!ref.mounted || buildGeneration != _buildGeneration) { + throw const _StaleChannelRefresh(); + } return _fetch(subscribeLive: true); } @@ -218,6 +263,7 @@ class ChannelsNotifier extends AsyncNotifier> { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); _loadThreadInterestStores(myPk); + final saveSnapshot = _saveSnapshot; final session = ref.read(relaySessionProvider.notifier); @@ -229,15 +275,14 @@ class ChannelsNotifier extends AsyncNotifier> { ); // Step 1: find the channels I'm a member of via kind:39002. - final memberships = await _fenced( - fence, - _fetchChannelMemberships(session, myPk), + final memberships = _latestChannelEvents( + await _fenced(fence, _fetchChannelMemberships(session, myPk)), + kind: 39002, ); final memberChannelIds = memberships .map((e) => e.getTagValue('d')) .whereType() .toSet(); - _cacheMemberSnapshots(memberships, replaceAll: true); // Step 2: everything below the membership list depends only on it, so // send those reads together instead of one round trip after another. A @@ -249,44 +294,27 @@ class ChannelsNotifier extends AsyncNotifier> { NostrFilters.channelMetadata(memberChannelIdList), ); final hiddenDmIdsRequest = _fetchHiddenDmIds(session, myPk); - // The authoritative membership snapshots are needed to filter Huddle - // backing channels. The relay-signed kind:39000 metadata identifies the - // relay, not the channel creator; the owner role in kind:39002 is the - // canonical creator identity used to reject forged Huddle links. - final memberEventsRequest = memberChannelIds.isEmpty - ? Future.value(const []) - : session.fetchHistory( - NostrFilter( - kinds: const [39002], - tags: {'#d': memberChannelIdList}, - limit: memberChannelIdList.length, - ), - ); final huddleStartsRequest = _fetchHuddleStarts( session, memberChannelIdList, ); - final ( - memberMetas, - hiddenDmIds, - memberEvents, - huddleStarts, - ) = await _fenced(fence, () async { - // Future.wait handles every request's failure and rethrows the first - // one unchanged, as soon as it happens. - await Future.wait(eagerError: true, [ - memberMetasRequest, - hiddenDmIdsRequest, - memberEventsRequest, - huddleStartsRequest, - ]); - return ( - await memberMetasRequest, - await hiddenDmIdsRequest, - await memberEventsRequest, - await huddleStartsRequest, - ); - }()); + final (memberMetas, hiddenDmIds, huddleStarts) = await _fenced( + fence, + () async { + // Future.wait handles every request's failure and rethrows the first + // one unchanged, as soon as it happens. + await Future.wait(eagerError: true, [ + memberMetasRequest, + hiddenDmIdsRequest, + huddleStartsRequest, + ]); + return ( + await memberMetasRequest, + await hiddenDmIdsRequest, + await huddleStartsRequest, + ); + }(), + ); // Step 3: fetch the open-channel directory. The relay filters this global // kind:39000 query by the caller's access, but the client still rejects @@ -299,56 +327,73 @@ class ChannelsNotifier extends AsyncNotifier> { // Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable, // but stale revisions from before the relay's d_tag backfill can linger. - final latestMetaPerId = {}; - for (final event in [...memberMetas, ..._directoryMetas]) { - if (event.kind != 39000) continue; - final id = event.getTagValue('d'); - if (id == null) continue; - final existing = latestMetaPerId[id]; - if (existing == null || - event.createdAt > existing.createdAt || - (event.createdAt == existing.createdAt && - event.id.compareTo(existing.id) < 0)) { - latestMetaPerId[id] = event; + final dedupedMetas = _latestChannelEvents([ + ...memberMetas, + ..._directoryMetas, + ], kind: 39000); + final parsedMetas = { + for (final event in dedupedMetas) event: ChannelData.fromEvent(event), + }; + // Membership discovery already returned the full authoritative rosters. + // Their owner roles validate Huddle links without downloading them again. + final huddleBackingIds = huddleBackingChannelIds(huddleStarts, memberships); + final visibleMetas = parsedMetas.entries.where((entry) { + final data = entry.value; + final isMember = memberChannelIds.contains(data.id); + if (!isMember && + (data.visibility == 'private' || data.channelType == 'dm')) { + return false; } - } - final dedupedMetas = latestMetaPerId.values.toList(); + if (data.channelType == 'dm' && hiddenDmIds.contains(data.id)) { + return false; + } + return !(huddleBackingIds.contains(data.id) && + data.channelType == 'stream' && + data.visibility == 'private'); + }).toList(); // Resolve DM participant display names. Extracted into the part file so // `channels_provider.dart` stays under the 1200-line ceiling enforced by // `just file-size-check`. - final displayNames = await _resolveDmDisplayNames( - session, + final displayNamesRequest = _resolveDmDisplayNames( + ref, fence, - dedupedMetas, + parsedMetas.values, myPk, + refreshCached: fetchLastMessage, ); - + // Latest-message filters depend on channel identity/privacy, not names. + // Start them alongside profiles, and observe both errors immediately. + final latestEventsRequest = fetchLastMessage + ? _fetchLastMessageEvents(session, [ + for (final entry in visibleMetas) + if (memberChannelIds.contains(entry.value.id) && + !entry.value.isArchived) + entry.value, + ]) + : Future.value(const []); + final (displayNames, latestEvents) = await _fenced(fence, () async { + await Future.wait(eagerError: true, [ + displayNamesRequest, + latestEventsRequest, + ]); + return (await displayNamesRequest, await latestEventsRequest); + }()); // Name resolution may return without a fenced read; check before writing. fence.ensureCurrent(); + _cacheMemberSnapshots(memberships, replaceAll: true); _hiddenDmIds = Set.unmodifiable(hiddenDmIds); - final huddleBackingIds = huddleBackingChannelIds( - huddleStarts, - memberEvents, - ); final channels = []; - for (final event in dedupedMetas) { - final id = event.getTagValue('d'); - if (id == null) continue; + for (final entry in visibleMetas) { + final id = entry.value.id; final isMember = memberChannelIds.contains(id); final channel = _channelFromMeta( - event, + entry.key, + entry.value, isMember: isMember, displayNames: displayNames, ); - if (!isMember && (channel.isPrivate || channel.isDm)) continue; - if (channel.isDm && hiddenDmIds.contains(channel.id)) continue; - if (huddleBackingIds.contains(channel.id) && - channel.isStream && - channel.isPrivate) { - continue; - } // Ephemeral (TTL) channels are surfaced in the list with an // `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be // hidden. Desktop shows them too. Previously dropped here unconditionally, @@ -358,8 +403,7 @@ class ChannelsNotifier extends AsyncNotifier> { // Use the membership snapshots already fetched above for both Huddle // linkage validation and member-count hydration. - if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); - final memberCounts = _memberCountsByChannelId(memberEvents); + final memberCounts = _memberCountsByChannelId(memberships); for (var i = 0; i < channels.length; i++) { final count = memberCounts[channels[i].id]; if (count != null) { @@ -380,10 +424,7 @@ class ChannelsNotifier extends AsyncNotifier> { final channelById = { for (final channel in activeChannels) channel.id: channel, }; - final events = await _fenced( - fence, - _fetchLastMessageEvents(session, activeChannels), - ); + final events = latestEvents; final lastMessageMap = {}; // An unavailable batch keeps the timestamps already known rather than // installing channels whose latest message looks absent. @@ -470,6 +511,18 @@ class ChannelsNotifier extends AsyncNotifier> { // Guard the provider-state write in `retryDirectory` and `build`: the // caller assigns whatever this returns, so the last check belongs here. fence.ensureCurrent(); + if (!fetchLastMessage) { + // Backstop metadata refreshes retain live timestamps in the persisted + // snapshot as well as the visible list. + for (var i = 0; i < channels.length; i++) { + final previous = prevById[channels[i].id]?.lastMessageAt; + if (previous != null) { + channels[i] = channels[i].copyWith(lastMessageAt: previous); + } + } + } + if (fetchLastMessage) _needsSnapshotRevalidation = latestEvents == null; + saveSnapshot?.call(channels, _hiddenDmIds); return channels; } @@ -479,7 +532,7 @@ class ChannelsNotifier extends AsyncNotifier> { /// burst of websocket REQs on communities with many channels. Future?> _fetchLastMessageEvents( RelaySessionNotifier session, - List channels, + List channels, ) async { if (channels.isEmpty) return const []; @@ -490,7 +543,7 @@ class ChannelsNotifier extends AsyncNotifier> { tags: { '#h': [channel.id], }, - limit: channel.isDm ? 1 : 20, + limit: channel.channelType == 'dm' ? 1 : 20, ), ]; @@ -613,11 +666,11 @@ class ChannelsNotifier extends AsyncNotifier> { /// used to populate [Channel.participants] for DMs so [Channel.displayLabel] /// can render real names instead of the relay-canonical "DM" name. Channel _channelFromMeta( - NostrEvent event, { + NostrEvent event, + ChannelData data, { required bool isMember, Map displayNames = const {}, }) { - final data = ChannelData.fromEvent(event); final participants = data.channelType == 'dm' ? [ for (final pk in data.participantPubkeys) @@ -904,22 +957,11 @@ class ChannelsNotifier extends AsyncNotifier> { Future _backstopRefresh() async { try { final sessionState = ref.read(relaySessionProvider); - final prevChannels = state.value ?? const []; - final prevLastMessage = { - for (final c in prevChannels) - if (c.lastMessageAt != null) c.id: c.lastMessageAt, - }; final channels = await _fetch( subscribeLive: sessionState.status == SessionStatus.connected, fetchLastMessage: false, fetchDirectory: false, ); - for (var i = 0; i < channels.length; i++) { - final prev = prevLastMessage[channels[i].id]; - if (channels[i].lastMessageAt == null && prev != null) { - channels[i] = channels[i].copyWith(lastMessageAt: prev); - } - } state = AsyncData(channels); } on _StaleChannelRefresh { return; @@ -928,9 +970,34 @@ class ChannelsNotifier extends AsyncNotifier> { } } - /// Refreshes memberships and, when [fetchDirectory], the open directory. - /// Invite starter recovery opts in to converge on another identity's - /// starters instead of attempting duplicate creation from memberships. + /// Fetches current memberships for decisions that cannot use cached data. + /// + /// Unlike background refresh, failures (including disconnection or a retired + /// request) propagate. Cached UI state stays visible while the request runs. + Future> fetchFresh({bool fetchDirectory = false}) async { + final generation = _buildGeneration; + try { + await future; + } catch (_) { + // A failed initial list must not prevent an explicit network retry. + } + if (!ref.mounted || generation != _buildGeneration) { + throw const _StaleChannelRefresh(); + } + if (ref.read(relaySessionProvider).status != SessionStatus.connected) { + throw StateError('Connect to the community before refreshing channels.'); + } + final channels = await _fetch( + subscribeLive: true, + fetchDirectory: fetchDirectory, + ); + state = AsyncData(channels); + return channels; + } + + /// Best-effort refresh of memberships and, optionally, the open directory. + /// Keeps usable UI state on failure. Decisions requiring fresh membership + /// must use [fetchFresh] to receive the fetch error instead. Future refresh({bool fetchDirectory = false}) async { final sessionState = ref.read(relaySessionProvider); // Don't attempt to fetch when the session isn't connected — fetchHistory @@ -940,15 +1007,18 @@ class ChannelsNotifier extends AsyncNotifier> { // when the session transitions to connected. if (sessionState.status != SessionStatus.connected) return; try { - final channels = await _fetch( - subscribeLive: true, - fetchDirectory: fetchDirectory, - ); - state = AsyncData(channels); + await fetchFresh(fetchDirectory: fetchDirectory); } on _StaleChannelRefresh { return; } catch (error, stackTrace) { - state = AsyncError(error, stackTrace); + // A refresh failure cannot make already-fetched content disappear. + if (state.value == null) { + state = AsyncError(error, stackTrace); + } else { + debugPrint( + '[ChannelsNotifier] refresh failed; retaining cached list: $error', + ); + } } } diff --git a/mobile/lib/features/channels/channels_provider_lifecycle.dart b/mobile/lib/features/channels/channels_provider_lifecycle.dart index 4be4412a86..944bbdf462 100644 --- a/mobile/lib/features/channels/channels_provider_lifecycle.dart +++ b/mobile/lib/features/channels/channels_provider_lifecycle.dart @@ -165,20 +165,50 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier { _notifyUnreadReadinessChanged(); unawaited(_unreadCatchUp); - _backstopTimer?.cancel(); - _backstopTimer = Timer.periodic(ChannelsNotifier._backstopInterval, (_) { - // A backgrounded app polls nothing, and a reconnecting session - // reconciles when it connects. After an OS suspension the missed tick - // fires before the app hears it has resumed, so check both. + _startBackstopTimer(); + } + + // Arm recovery before the first network load succeeds. A restored snapshot + // needs one full load; subsequent reconnects keep the metadata-only backstop. + void _startBackstopTimer() { + _backstopTimer ??= Timer.periodic(ChannelsNotifier._backstopInterval, (_) { + // After suspension, a missed tick can precede the resumed lifecycle + // event. Keep recovery armed, but never poll while backgrounded. if (_lifecycleRef.read(appLifecycleProvider) == - AppLifecycleState.resumed && - _lifecycleRef.read(relaySessionProvider).status == - SessionStatus.connected) { - unawaited(_backstopRefresh()); + AppLifecycleState.resumed) { + unawaited(_refreshInBackground()); } }); } + Future _refreshInBackground({bool queueIfBusy = false}) async { + if (_lifecycleRef.read(relaySessionProvider).status != + SessionStatus.connected) { + return; + } + if (_backgroundRefreshInFlight) { + if (queueIfBusy) _backgroundRefreshQueued = true; + return; + } + final generation = _buildGeneration; + _backgroundRefreshInFlight = true; + try { + if (_needsSnapshotRevalidation) { + await refresh(); + } else { + await _backstopRefresh(); + } + } finally { + if (generation == _buildGeneration) { + _backgroundRefreshInFlight = false; + if (_backgroundRefreshQueued) { + _backgroundRefreshQueued = false; + unawaited(_refreshInBackground()); + } + } + } + } + void _handleLiveChunkClosed(String chunkKey, int generation, String message) { if (!_lifecycleRef.mounted) return; final subscription = _liveSubscriptionsByChunk[chunkKey]; diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart index f4749227de..dc979aea66 100644 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -163,8 +163,7 @@ Future _createMeChat( return intent.complete( sync, findChannel: () async { - ref.invalidate(channelsProvider); - final all = await ref.read(channelsProvider.future); + final all = await ref.read(channelsProvider.notifier).fetchFresh(); return all.where((c) => c.id == channelId).firstOrNull; }, createChannel: () => actions.createChannel( diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index d3df16d85e..7036915ce1 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -75,16 +75,36 @@ class ProfileNotifier extends AsyncNotifier { Future _patchQueue = Future.value(); @override - Future build() { + Future build() async { final config = ref.watch(relayConfigProvider); final pubkey = ref.watch(myPubkeyProvider); - ref.watch(relaySessionProvider); final context = _ProfileWriteContext( config: config, pubkey: pubkey, session: ref.read(relaySessionProvider.notifier), ); _hasHydrated = false; + if (pubkey != null) { + final connected = Completer(); + ref.listen(relaySessionProvider.select((value) => value.status), ( + previous, + next, + ) { + if (next != SessionStatus.connected) return; + if (!connected.isCompleted) { + connected.complete(); + } else if (previous != SessionStatus.connected) { + // Revalidate once per authenticated connection. Intermediate socket + // states must not enqueue duplicate profile reads or erase the avatar. + ref.invalidateSelf(); + } + }); + if (ref.read(relaySessionProvider).status == SessionStatus.connected) { + connected.complete(); + } + await connected.future; + _requireCurrentWriteContext(context); + } return _fetch(context); } diff --git a/mobile/lib/shared/channels/channel_list_snapshot.dart b/mobile/lib/shared/channels/channel_list_snapshot.dart new file mode 100644 index 0000000000..91547318eb --- /dev/null +++ b/mobile/lib/shared/channels/channel_list_snapshot.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../relay/relay_provider.dart' show RelayConfig; +import '../theme/community_theme_preference.dart'; +import '../theme/theme_provider.dart'; + +const _keyPrefix = 'buzz.channel-list.v1'; +const _version = 1; + +/// Snapshots beyond these bounds are not stored; the list loads from the relay. +const maxChannelListSnapshotChannels = 2000; +const maxChannelListSnapshotBytes = 2 * 1024 * 1024; + +/// A disposable, relay-derived channel list for one relay and identity. +class ChannelListSnapshot { + const ChannelListSnapshot({ + required this.channels, + required this.hiddenDmIds, + }); + + /// Member channel JSON in the shape of `Channel.toJson`. + final List> channels; + final Set hiddenDmIds; +} + +/// Permission to write one scope's snapshot, captured before a network fetch. +/// +/// [ChannelListSnapshotStorage.remove] retires every token captured before +/// it, so a fetch in flight during removal cannot recreate the snapshot. +class ChannelListSnapshotToken { + const ChannelListSnapshotToken._(this._key, this._epoch); + + final String _key; + final int _epoch; +} + +/// Stores the last authoritative channel list per relay and identity. +/// +/// The data is a cache derived from relay events: unreadable, unsupported or +/// oversized data is discarded rather than migrated. +class ChannelListSnapshotStorage { + ChannelListSnapshotStorage(this._prefs); + + final SharedPreferences _prefs; + + /// Retirement epochs shared by every storage over the same preferences. + static final _epochsByPrefs = Expando>(); + static final _savedValuesByPrefs = Expando>(); + + Map get _savedValues => _savedValuesByPrefs[_prefs] ??= {}; + + Map get _epochs => _epochsByPrefs[_prefs] ??= {}; + + /// The scope key. `wss://`/`ws://` origins fold to `https://`/`http://` + /// like `RelayConfig.baseUrl`, so both onboarding paths share a snapshot. + static String key(String pubkey, String relayUrl) { + final relay = normalizeCommunityRelayUrl( + RelayConfig(baseUrl: normalizeCommunityRelayUrl(relayUrl)).baseUrl, + ); + return '$_keyPrefix:${pubkey.toLowerCase()}:${Uri.encodeComponent(relay)}'; + } + + /// Returns the scope's snapshot, or null when absent or unusable. + ChannelListSnapshot? read(String pubkey, String relayUrl) { + final key = ChannelListSnapshotStorage.key(pubkey, relayUrl); + final raw = _prefs.getString(key); + if (raw == null) return null; + try { + // UTF-16 length is at most the UTF-8 length, so this skips encoding + // for the oversized case before measuring the exact bound. + if (raw.length > maxChannelListSnapshotBytes || + utf8.encode(raw).length > maxChannelListSnapshotBytes) { + throw const FormatException('size'); + } + final json = jsonDecode(raw) as Map; + if (json['v'] != _version) throw const FormatException('version'); + final channels = json['channels'] as List; + if (channels.length > maxChannelListSnapshotChannels) { + throw const FormatException('count'); + } + return ChannelListSnapshot( + channels: [ + for (final channel in channels) channel as Map, + ], + hiddenDmIds: { + for (final id in json['hidden_dm_ids'] as List) id as String, + }, + ); + } catch (_) { + // Unusable cache: drop it without retiring in-flight writers. + _savedValues.remove(key); + _prefs.remove(key).ignore(); + return null; + } + } + + /// Captures write permission for the scope before its fetch starts. + ChannelListSnapshotToken capture(String pubkey, String relayUrl) { + final key = ChannelListSnapshotStorage.key(pubkey, relayUrl); + return ChannelListSnapshotToken._(key, _epochs[key] ?? 0); + } + + /// Replaces the scope's snapshot. Returns false when [token] was retired, + /// the snapshot exceeds the size bounds, or storage rejected the write. + Future write( + ChannelListSnapshotToken token, + ChannelListSnapshot snapshot, + ) async { + if ((_epochs[token._key] ?? 0) != token._epoch) return false; + final raw = snapshot.channels.length > maxChannelListSnapshotChannels + ? null + : jsonEncode({ + 'v': _version, + 'channels': snapshot.channels, + 'hidden_dm_ids': snapshot.hiddenDmIds.toList()..sort(), + }); + if (raw == null || utf8.encode(raw).length > maxChannelListSnapshotBytes) { + // An older, smaller snapshot no longer describes this list. + _savedValues.remove(token._key); + await _prefs.remove(token._key); + return false; + } + if (_savedValues[token._key] == raw && + _prefs.getString(token._key) == raw) { + return true; + } + // The token check and SharedPreferences' synchronous in-memory update run + // without an intervening await, so a later remove always wins. + // Failed platform writes still change SharedPreferences' memory cache. + // Only skip values known to have been successfully persisted. + _savedValues.remove(token._key); + final saved = await _prefs.setString(token._key, raw); + if (saved && (_epochs[token._key] ?? 0) == token._epoch) { + _savedValues[token._key] = raw; + } + return saved; + } + + /// Deletes the scope's snapshot and retires its outstanding tokens. + /// + /// Throws on storage failure. Community sign-out treats this as best-effort; + /// orphan cleanup retries the remaining disk entry. + Future remove(String pubkey, String relayUrl) async { + await _removeKey(ChannelListSnapshotStorage.key(pubkey, relayUrl)); + } + + /// Retires and deletes snapshots without a stored community. Failed deletes + /// remain on disk for retry at the next startup or community mutation. + Future removeOrphans(Set retainedKeys) async { + final keys = {..._prefs.getKeys(), ..._epochs.keys}; + // Start every deletion before yielding: later community additions must not + // have a new writer retired by an older cleanup's delayed next iteration. + await Future.wait([ + for (final key in keys) + if (key.startsWith('$_keyPrefix:') && !retainedKeys.contains(key)) + _removeKey(key).catchError((Object error) { + debugPrint('[ChannelListSnapshotStorage] cleanup failed: $error'); + }), + ]); + } + + Future _removeKey(String key) async { + _savedValues.remove(key); + _epochs[key] = (_epochs[key] ?? 0) + 1; + if (!await _prefs.remove(key)) { + throw StateError('Channel list snapshot could not be removed.'); + } + } +} + +/// The app's snapshot storage, or null where preferences were never provided +/// (isolated tests). Null means there is no snapshot to read, write or remove. +final channelListSnapshotStorageProvider = + Provider((ref) { + final SharedPreferences prefs; + try { + prefs = ref.watch(savedPrefsProvider); + } catch (_) { + // The unprovided override throws, wrapped by Riverpod. + return null; + } + return ChannelListSnapshotStorage(prefs); + }); diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 535bc95d48..eac9f91e0b 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -7,12 +7,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import '../auth/auth_provider.dart'; +import '../channels/channel_list_snapshot.dart'; import '../hosted/community_entry_requirements.dart'; import '../push/dev_push_lease.dart'; import '../push/push_bridge.dart'; import '../push/push_lease_revocation_outbox.dart'; import '../push/push_subscription.dart'; import '../relay/signed_event_relay.dart'; +import '../relay/relay_provider.dart' show pubkeyFromNsec; import 'community.dart'; import 'community_storage.dart'; @@ -248,6 +250,7 @@ class CommunityListNotifier extends AsyncNotifier> { Future> build() async { final storage = ref.read(communityStorageProvider); final communities = await storage.loadAll(); + unawaited(_removeOrphanChannelListSnapshots(communities)); await syncCommunitySnapshot(ref, communities); return communities; } @@ -273,10 +276,15 @@ class CommunityListNotifier extends AsyncNotifier> { pubkey: community.pubkey, nsec: community.nsec, ); + if (_channelSnapshotPubkey(existing)?.toLowerCase() != + _channelSnapshotPubkey(updated)?.toLowerCase()) { + await _removeChannelListSnapshot(existing); + } await storage.save(updated); final updatedList = [...current]; updatedList[existingIndex] = updated; state = AsyncData(updatedList); + await _removeOrphanChannelListSnapshots(updatedList); await syncCommunitySnapshot(ref, updatedList); return existing.id; } @@ -284,6 +292,7 @@ class CommunityListNotifier extends AsyncNotifier> { await storage.save(community); final updatedList = [...current, community]; state = AsyncData(updatedList); + await _removeOrphanChannelListSnapshots(updatedList); await syncCommunitySnapshot(ref, updatedList); return community.id; } @@ -314,6 +323,7 @@ class CommunityListNotifier extends AsyncNotifier> { (community) => community.id == id, ); if (removedIndex >= 0) { + await _removeChannelListSnapshot(current[removedIndex]); // Persist every remote-cleanup dependency before erasing credentials. // This local transaction is the only removal prerequisite. Relay I/O // starts after the community and NSE snapshot have been removed. @@ -324,6 +334,7 @@ class CommunityListNotifier extends AsyncNotifier> { await storage.remove(id); final updatedList = current.where((w) => w.id != id).toList(); + await _removeOrphanChannelListSnapshots(updatedList); state = AsyncData(updatedList); await syncCommunitySnapshot(ref, updatedList); @@ -355,6 +366,39 @@ class CommunityListNotifier extends AsyncNotifier> { }); } + String? _channelSnapshotPubkey(Community community) => + pubkeyFromNsec(community.nsec?.trim()) ?? community.pubkey; + + Future _removeChannelListSnapshot(Community community) async { + final snapshots = ref.read(channelListSnapshotStorageProvider); + final pubkey = _channelSnapshotPubkey(community); + if (snapshots == null || pubkey == null) return; + try { + // Retire writers before the revocation journal or credential changes. + // A disposable cache must never prevent local sign-out. + await snapshots.remove(pubkey, community.relayUrl); + } catch (error, stackTrace) { + developer.log( + 'Channel snapshot deletion failed; orphan cleanup will retry', + name: 'buzz.community', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _removeOrphanChannelListSnapshots( + List communities, + ) async { + final snapshots = ref.read(channelListSnapshotStorageProvider); + if (snapshots == null) return; + await snapshots.removeOrphans({ + for (final community in communities) + if (_channelSnapshotPubkey(community) case final pubkey?) + ChannelListSnapshotStorage.key(pubkey, community.relayUrl), + }); + } + Future switchCommunity(String id) { return ref.read(communityTransitionProvider).runExclusive(() async { final storage = ref.read(communityStorageProvider); diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index b548cc6b7e..2be1d3e02a 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -53,6 +53,38 @@ class UserCacheNotifier extends Notifier> { state = {...state, profile.pubkey.toLowerCase(): profile}; } + /// Resolves profiles through verified batch admission. + /// + /// Unlike [preload], failures propagate to callers whose result requires + /// profile hydration. Set [refreshCached] for a full revalidation; otherwise + /// cached profiles remain reusable within this scope. + Future> resolve( + List pubkeys, { + bool refreshCached = false, + }) async { + final requested = pubkeys.map((pubkey) => pubkey.toLowerCase()).toSet(); + final toFetch = requested + .where((pubkey) => refreshCached || !state.containsKey(pubkey)) + .toList(); + final generation = _generation; + final session = ref.read(relaySessionProvider.notifier); + for (final batch in _profileQueryBatches(toFetch)) { + if (!_isCurrent(generation)) { + throw StateError('Profile request belongs to a retired cache'); + } + final events = await session.fetchHistory( + NostrFilters.profilesBatch(batch), + ); + if (!await _verifyAndMerge(events, generation)) { + throw StateError('Profile request belongs to a retired cache'); + } + } + if (!_isCurrent(generation)) { + throw StateError('Profile request belongs to a retired cache'); + } + return {for (final pubkey in requested) pubkey: ?state[pubkey]}; + } + /// Preload profiles for a list of pubkeys (e.g. channel members). /// Returns whether the batch completed successfully. Future preload(List pubkeys) { diff --git a/mobile/test/features/channels/channel_snapshot_test.dart b/mobile/test/features/channels/channel_snapshot_test.dart new file mode 100644 index 0000000000..3c72e4d760 --- /dev/null +++ b/mobile/test/features/channels/channel_snapshot_test.dart @@ -0,0 +1,503 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/channels/channel_list_snapshot.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('fresh read retries a failed initial list', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final session = _SnapshotSession(['alpha'])..fail = true; + final container = _container(prefs, session); + addTearDown(container.dispose); + await expectLater( + container.read(channelsProvider.future), + throwsStateError, + ); + session.fail = false; + expect( + (await container.read(channelsProvider.notifier).fetchFresh()).single.id, + 'alpha', + ); + expect(container.read(channelsProvider).requireValue.single.id, 'alpha'); + }); + + test( + 'fresh membership reads propagate failures while retaining cached UI', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final session = _SnapshotSession(['alpha']); + final container = _container(prefs, session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _settle(); + session.ids = ['alpha', 'me-chat']; + final notifier = container.read(channelsProvider.notifier); + final fresh = await notifier.fetchFresh(); + expect(fresh.map((c) => c.id), ['alpha', 'me-chat']); + session.fail = true; + await expectLater( + notifier.fetchFresh(fetchDirectory: true), + throwsStateError, + ); + expect(container.read(channelsProvider).requireValue.map((c) => c.id), [ + 'alpha', + 'me-chat', + ]); + session.disconnect(); + await expectLater(notifier.fetchFresh(), throwsStateError); + expect(container.read(channelsProvider).hasError, isFalse); + }, + ); + + test('history deadline keeps full snapshot revalidation pending', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = ChannelListSnapshotStorage(prefs); + await storage.write( + storage.capture('me', 'https://a.example'), + ChannelListSnapshot( + channels: [_channel('alpha').toJson()], + hiddenDmIds: {}, + ), + ); + fakeAsync((clock) { + final session = _SnapshotSession(['alpha'])..latestTimeout = true; + final container = _container(prefs, session); + container.listen(channelsProvider, (_, _) {}); + clock.flushMicrotasks(); + expect(container.read(channelsProvider).requireValue.single.id, 'alpha'); + expect(session.latestReads, 1); + session.latestTimeout = false; + clock.elapse(const Duration(minutes: 1)); + clock.flushMicrotasks(); + expect(session.latestReads, 2); + clock.elapse(const Duration(minutes: 1)); + clock.flushMicrotasks(); + expect(session.latestReads, 2); + container.dispose(); + expect(clock.pendingTimers, isEmpty); + }); + }); + + test( + 'reconnect during background refresh queues one non-overlapping retry', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + fakeAsync((clock) { + final session = _SnapshotSession(['alpha']); + final container = _container(prefs, session); + container.listen(channelsProvider, (_, _) {}); + clock.flushMicrotasks(); + final latestReads = session.latestReads; + final gate = session.gate = Completer(); + clock.elapse(const Duration(minutes: 1)); + clock.flushMicrotasks(); + final parkedRequests = session.membershipRequests; + session.disconnect(); + session.connect(); + clock.flushMicrotasks(); + expect(session.membershipRequests, parkedRequests); + session.gate = null; + gate.complete(); + clock.flushMicrotasks(); + expect(session.membershipRequests, parkedRequests + 1); + expect(session.latestReads, latestReads); + container.dispose(); + clock.elapse(const Duration(minutes: 2)); + expect(clock.pendingTimers, isEmpty); + }); + }, + ); + + test('same-scope rebuild keeps live timestamps ahead of disk', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final session = _SnapshotSession(['alpha']); + final container = _container(prefs, session); + addTearDown(container.dispose); + container.listen(channelsProvider, (_, _) {}); + await container.read(channelsProvider.future); + await _settle(); + final event = NostrEvent( + id: 'live', + pubkey: 'other', + createdAt: 100, + kind: 9, + tags: [ + ['h', 'alpha'], + ['p', 'me'], + ], + content: 'hello', + sig: 'sig', + ); + for (final listener in session.listeners.toList()) { + listener(event); + } + final latest = container + .read(channelsProvider) + .requireValue + .single + .lastMessageAt; + expect(latest, DateTime.fromMillisecondsSinceEpoch(100000, isUtc: true)); + expect( + ChannelListSnapshotStorage( + prefs, + ).read('me', 'https://a.example')!.channels.single['last_message_at'], + isNull, + ); + session.disconnect(); + container.invalidate(channelsProvider); + expect( + (await container.read(channelsProvider.future)).single.lastMessageAt, + latest, + ); + }); + + test( + 'cached discovery survives offline restart and failed refresh, then replaces removed channels', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final session = _SnapshotSession(['alpha', 'bravo']); + var container = _container(prefs, session); + expect((await container.read(channelsProvider.future)).map((c) => c.id), [ + 'alpha', + 'bravo', + ]); + await _settle(); + container.dispose(); + + fakeAsync((clock) { + final offline = _SnapshotSession([], connected: false); + container = _container(prefs, offline); + container.listen(channelsProvider, (_, _) {}); + clock.flushMicrotasks(); + expect(container.read(channelsProvider).requireValue.map((c) => c.id), [ + 'alpha', + 'bravo', + ]); + expect( + container.read(channelsProvider.notifier).hasCachedSnapshot, + isTrue, + ); + expect(container.read(channelsProvider.notifier).hiddenDmIds, { + 'hidden-dm', + }); + expect(offline.membershipRequests, 0); + + offline.fail = true; + offline.connect(); + clock.flushMicrotasks(); + clock.elapse(const Duration(minutes: 2)); + clock.flushMicrotasks(); + expect( + offline.membershipRequests, + 3, + ); // One bounded attempt per minute. + expect(offline.listeners, isEmpty); + // A slow attempt cannot accumulate overlapping periodic retries. + final gate = Completer(); + offline.gate = gate; + clock.elapse(const Duration(minutes: 1)); + clock.flushMicrotasks(); + final parkedRequests = offline.membershipRequests; + clock.elapse(const Duration(minutes: 2)); + clock.flushMicrotasks(); + expect(offline.membershipRequests, parkedRequests); + gate.complete(); + clock.flushMicrotasks(); + offline.gate = null; + expect(container.read(channelsProvider).hasError, isFalse); + expect(container.read(channelsProvider).requireValue, hasLength(2)); + + offline.fail = false; + offline.ids = ['bravo']; + clock.elapse(const Duration(minutes: 1)); + clock.flushMicrotasks(); + expect( + container.read(channelsProvider).requireValue.single.id, + 'bravo', + ); + expect(offline.listeners, isNotEmpty); + final latestReads = offline.latestReads; + expect(latestReads, greaterThan(0)); + // Live updates work after recovery, and ordinary reconnects stay light. + final live = offline.event(9, [ + ['h', 'bravo'], + ['p', 'me'], + ]); + for (final listener in offline.listeners.toList()) { + listener(live); + } + expect( + container.read(channelsProvider).requireValue.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true), + ); + offline.disconnect(); + offline.connect(); + clock.flushMicrotasks(); + expect(offline.latestReads, latestReads); + expect( + container.read(channelsProvider).requireValue.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true), + ); + container.dispose(); + clock.elapse(const Duration(minutes: 2)); + expect(clock.pendingTimers, isEmpty); + }); + + container = _container(prefs, _SnapshotSession([], connected: false)); + addTearDown(container.dispose); + expect( + (await container.read(channelsProvider.future)).single.id, + 'bravo', + ); + }, + ); + + test( + 'removal retires both a parked fetch and later refreshes of that build', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = ChannelListSnapshotStorage(prefs); + final session = _SnapshotSession(['alpha']); + final container = _container(prefs, session); + addTearDown(container.dispose); + container.listen(channelsProvider, (_, _) {}); + await container.read(channelsProvider.future); + await _settle(); + expect(storage.read('me', 'https://a.example'), isNotNull); + final gate = Completer(); + session.gate = gate; + final refresh = container.read(channelsProvider.notifier).refresh(); + await _settle(); + await storage.remove('me', 'https://a.example'); + gate.complete(); + await refresh; + session.gate = null; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + expect(storage.read('me', 'https://a.example'), isNull); + }, + ); + + test( + 'relay and identity isolate restored lists and retire a parked refresh', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = ChannelListSnapshotStorage(prefs); + for (final scope in [ + ('me', 'https://a.example', 'alpha'), + ('other', 'https://a.example', 'bravo'), + ('me', 'https://b.example', 'charlie'), + ]) { + await storage.write( + storage.capture(scope.$1, scope.$2), + ChannelListSnapshot( + channels: [_channel(scope.$3).toJson()], + hiddenDmIds: {}, + ), + ); + } + for (final scope in [ + ('me', 'https://a.example', 'alpha'), + ('other', 'https://a.example', 'bravo'), + ('me', 'https://b.example', 'charlie'), + ]) { + final container = _container( + prefs, + _SnapshotSession([], connected: false), + pubkey: scope.$1, + relay: scope.$2, + ); + expect( + (await container.read(channelsProvider.future)).single.id, + scope.$3, + ); + container.dispose(); + } + final session = _SnapshotSession(['late-alpha'], connected: false); + final gate = Completer(); + session.gate = gate; + final container = _container(prefs, session); + addTearDown(container.dispose); + container.listen(channelsProvider, (_, _) {}); + expect( + (await container.read(channelsProvider.future)).single.id, + 'alpha', + ); + session.connect(); + await _settle(); + expect(session.membershipRequests, 1); + session.gate = null; + session.disconnect(); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://b.example'); + expect( + (await container.read(channelsProvider.future)).single.id, + 'charlie', + ); + gate.complete(); + await _settle(); + expect( + container.read(channelsProvider).requireValue.single.id, + 'charlie', + ); + expect( + storage.read('me', 'https://a.example')!.channels.single['id'], + 'alpha', + ); + expect( + storage.read('me', 'https://b.example')!.channels.single['id'], + 'charlie', + ); + }, + ); +} + +Channel _channel(String id) => Channel( + id: id, + name: id, + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'me', + createdAt: DateTime.utc(2026), + memberCount: 1, + isMember: true, +); + +ProviderContainer _container( + SharedPreferences prefs, + _SnapshotSession session, { + String pubkey = 'me', + String relay = 'https://a.example', +}) => ProviderContainer( + retry: (_, _) => null, + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(pubkey), + relayConfigProvider.overrideWith(() => _Config(relay)), + ], +); + +class _Config extends RelayConfigNotifier { + _Config(this.relay); + final String relay; + @override + RelayConfig build() => RelayConfig(baseUrl: relay); +} + +class _SnapshotSession extends RelaySessionNotifier { + _SnapshotSession(this.ids, {this.connected = true}); + List ids; + final bool connected; + bool fail = false; + bool latestTimeout = false; + Completer? gate; + int membershipRequests = 0; + int latestReads = 0; + final listeners = []; + @override + SessionState build() => SessionState( + status: connected ? SessionStatus.connected : SessionStatus.disconnected, + ); + void connect() => state = const SessionState(status: SessionStatus.connected); + void disconnect() => + state = const SessionState(status: SessionStatus.disconnected); + NostrEvent event(int kind, List> tags) => NostrEvent( + id: '$kind-${tags.first.last}', + pubkey: 'relay', + createdAt: 1, + kind: kind, + tags: tags, + content: '', + sig: 'sig', + ); + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filters.any((filter) => filter.limit == 20)) { + latestReads++; + if (latestTimeout) { + throw RelayException(503, '{"error":"query timed out"}'); + } + } + if (filters.singleOrNull?.kinds.contains(39002) == true) { + if (filters.single.until != null) return []; + membershipRequests++; + final capturedIds = List.of(ids); + if (gate != null) await gate!.future; + if (fail) throw StateError('offline'); + return [ + for (final id in capturedIds) + event(39002, [ + ['d', id], + ['p', 'me'], + ]), + ]; + } + return []; + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filter.kinds.contains(39000)) { + return [ + for (final id in ids) + event(39000, [ + ['d', id], + ['name', id], + ['t', 'stream'], + ['public'], + ]), + ]; + } + if (filter.kinds.contains(30622)) { + return [ + event(30622, [ + ['d', 'hidden'], + ['h', 'hidden-dm'], + ]), + ]; + } + return []; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + }) async { + listeners.add(onEvent); + return () => listeners.remove(onEvent); + } +} + +Future _settle() async { + for (var i = 0; i < 30; i++) { + await Future.delayed(Duration.zero); + } +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 50ec3b2b6f..10acee918d 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -6,6 +6,7 @@ import 'package:buzz/shared/community/paired_community_landing.dart'; import 'dart:async'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:buzz/shared/channels/channel_list_snapshot.dart'; import '../profile/presence_snapshot_test.dart' show PresenceTestRelay, presenceEvent; @@ -158,6 +159,7 @@ void main() { } _communityUnreadLandingTests(buildTestable); + _communityCachedLandingTests(buildTestable); final testChannels = [ Channel( @@ -2375,7 +2377,8 @@ void main() { ); await tester.pump(); final flyingAvatar = find.byKey(const Key('community-flying-avatar')); - expect(notifier.switchedIds, reduceMotion ? ['bravo'] : isEmpty); + // The switch starts with the centering motion, not after it. + expect(notifier.switchedIds, ['bravo']); if (nativeHeader) { expect( tester @@ -2388,7 +2391,6 @@ void main() { final origin = tester.getCenter(flyingAvatar); await tester.pump(const Duration(milliseconds: 105)); if (!reduceMotion) { - expect(notifier.switchedIds, isEmpty); final inFlight = tester.getCenter(flyingAvatar); const center = Offset(195, 422); expect( diff --git a/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart b/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart index 95fc622e4e..50c3f38b61 100644 --- a/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart +++ b/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart @@ -1,7 +1,11 @@ part of '../channels_page_test.dart'; void _communityUnreadLandingTests( - Widget Function({required List overrides}) buildTestable, + Widget Function({ + required List overrides, + _FakeProfileNotifier? profile, + }) + buildTestable, ) { testWidgets('unread deadline lands the destination without a 20-second wait', ( tester, @@ -17,8 +21,11 @@ void _communityUnreadLandingTests( ]; final communitiesNotifier = _FakeCommunityListNotifier(communities); final session = _UnreadDeadlineLandingSession(communitiesNotifier); + // The header avatar's own profile never arrives. It must not gate landing. + final profile = _StalledProfileNotifier(); await tester.pumpWidget( buildTestable( + profile: profile, overrides: [ channelSectionsProvider.overrideWith(_LandingSections.new), channelStarsProvider.overrideWith(_LandingStars.new), @@ -65,6 +72,7 @@ void _communityUnreadLandingTests( await tester.pump(const Duration(milliseconds: 100)); } expect(session.destinationUnreadRequests, greaterThan(0)); + expect(profile.stalled, isTrue); expect(find.byKey(const Key('community-switcher-page')), findsNothing); expect(find.byKey(const Key('community-switch-loading')), findsNothing); expect(find.text('bravo'), findsNothing); @@ -158,3 +166,144 @@ class _LandingSort extends ChannelSortNotifier { @override ChannelSortState build() => const ChannelSortState(isReady: true); } + +class _StalledProfileNotifier extends _FakeProfileNotifier { + bool stalled = false; + int _builds = 0; + + @override + Future build() { + // Rebuild on the community switch, as the real profile does per relay. + ref.watch(activeCommunityProvider); + // Origin Home loads normally; every later rebuild hangs forever. + if (_builds++ == 0) return super.build(); + stalled = true; + return Completer().future; + } +} + +void _communityCachedLandingTests( + Widget Function({required List overrides}) buildTestable, +) { + testWidgets('a cached destination lands while its relay never answers', ( + tester, + ) async { + final communities = [ + for (final id in ['alpha', 'bravo']) + Community( + id: id, + name: id, + relayUrl: 'wss://$id.example.com', + addedAt: DateTime(2025), + ), + ]; + final cached = Channel( + id: 'bravo-cached', + name: 'bravo-cached-channel', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'pk', + createdAt: DateTime(2025), + memberCount: 1, + isMember: true, + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = ChannelListSnapshotStorage(prefs); + expect( + await storage.write( + storage.capture('pk', 'wss://bravo.example.com'), + ChannelListSnapshot(channels: [cached.toJson()], hiddenDmIds: {}), + ), + isTrue, + ); + final communitiesNotifier = _FakeCommunityListNotifier(communities); + final session = _StalledDestinationSession(communitiesNotifier); + await tester.pumpWidget( + buildTestable( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + // Remote preference state never becomes ready for the destination. + channelSectionsProvider.overrideWith(_UnreadyLandingSections.new), + channelStarsProvider.overrideWith(_LandingStars.new), + channelMutesProvider.overrideWith(_LandingMutes.new), + channelSortProvider.overrideWith(_LandingSort.new), + readStateProvider.overrideWith( + () => _FakeReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'pk', + contexts: {}, + version: 0, + ), + ), + ), + myPubkeyProvider.overrideWith((ref) => 'pk'), + relaySessionProvider.overrideWith(() => session), + communityListProvider.overrideWith(() => communitiesNotifier), + activeCommunityProvider.overrideWith((ref) async { + await ref.watch(communityListProvider.future); + return communities.firstWhere( + (c) => c.id == communitiesNotifier.activeId, + ); + }), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Community settings', + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + session.stallDestination = true; + await tester.tap(find.byKey(const Key('community-switcher-row-bravo'))); + + // Only animation frames: the relay never answers, nothing hits a deadline. + for (var frame = 0; frame < 20; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(session.stalledRequests, greaterThan(0)); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(find.byKey(const Key('community-switch-loading')), findsNothing); + expect(find.text('bravo-cached-channel'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} + +/// Answers the origin normally; destination requests never complete. +class _StalledDestinationSession extends _UnreadDeadlineLandingSession { + _StalledDestinationSession(super.communities); + bool stallDestination = false; + int stalledRequests = 0; + + Future? _stall() { + if (!stallDestination || communities.activeId != 'bravo') return null; + stalledRequests++; + return Completer().future; + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) => _stall() ?? super.queryRelay(filters, timeout: timeout); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) => _stall() ?? super.fetchHistory(filter, timeout: timeout); +} + +class _UnreadyLandingSections extends ChannelSectionsNotifier { + @override + ChannelSectionsState build() => const ChannelSectionsState(isReady: false); +} diff --git a/mobile/test/features/channels/channels_provider_request_path_cases.dart b/mobile/test/features/channels/channels_provider_request_path_cases.dart new file mode 100644 index 0000000000..380c221fb3 --- /dev/null +++ b/mobile/test/features/channels/channels_provider_request_path_cases.dart @@ -0,0 +1,289 @@ +part of 'channels_provider_test.dart'; + +void _requestPathCases() { + test('DM hydration overlaps history and shares admitted profiles', () async { + final session = _RequestPathSession(); + final profilesGate = session.profilesGate = Completer(); + final latestGate = session.latestGate = Completer(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + final load = container.read(channelsProvider.future); + + await _settle(); + expect(session.profileRequests, 1); + expect(session.latestRequests, 1); + expect(container.read(channelsProvider).hasValue, isFalse); + final cacheReady = Completer(); + final cacheSubscription = container.listen(userCacheProvider, (_, next) { + if (next.containsKey('alice') && !cacheReady.isCompleted) { + cacheReady.complete(); + } + }); + addTearDown(cacheSubscription.close); + profilesGate.complete(); + await cacheReady.future; + expect(container.read(channelsProvider).hasValue, isFalse); + latestGate.complete(); + + final channel = (await load).single; + expect(channel.displayLabel(currentPubkey: 'me'), 'Alice'); + expect(channel.memberCount, 2); + expect(channel.lastMessageAt?.millisecondsSinceEpoch, 50000); + expect( + container.read(userCacheProvider)['alice']?.avatarUrl, + 'https://example.com/alice.png', + ); + expect( + await container.read(userCacheProvider.notifier).preload(['alice']), + isTrue, + ); + expect(session.profileRequests, 1); + + // Full refreshes discover relay-side profile changes without another + // surface injecting a live cache event. Blank names retain the fallback. + session.profiles = [ + _requestProfile( + 'alice', + name: ' ', + handle: ' alice@example.com ', + createdAt: 2, + ), + ]; + // Ordinary reconnects stay metadata-only and reuse the admitted profile. + session.setStatus(SessionStatus.disconnected); + await _settle(); + session.setStatus(SessionStatus.connected); + await _settle(); + expect(session.profileRequests, 1); + expect( + container + .read(channelsProvider) + .requireValue + .single + .displayLabel(currentPubkey: 'me'), + 'Alice', + ); + await container.read(channelsProvider.notifier).refresh(); + expect( + container + .read(channelsProvider) + .requireValue + .single + .displayLabel(currentPubkey: 'me'), + 'alice@example.com', + ); + expect(session.profileRequests, 2); + expect( + container.read(userCacheProvider)['alice']?.nip05Handle?.trim(), + 'alice@example.com', + ); + session.profiles = [ + _requestProfile('alice', name: ' ', handle: ' ', createdAt: 3), + ]; + await container.read(channelsProvider.notifier).refresh(); + expect( + container + .read(channelsProvider) + .requireValue + .single + .displayLabel(currentPubkey: 'me'), + 'Unknown identity', + ); + expect(session.profileRequests, 3); + }); + + test( + 'late DM profiles cannot enter the destination cache or channel list', + () async { + final session = _RequestPathSession(); + final profilesGate = session.profilesGate = Completer(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + final oldLoad = container.read(channelsProvider.future); + await _settle(); + expect(session.profileRequests, 1); + + session.profiles = [_requestProfile('alice', name: 'Destination Alice')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://destination.example'); + final destination = await container.read(channelsProvider.future); + expect( + destination.single.displayLabel(currentPubkey: 'me'), + 'Destination Alice', + ); + profilesGate.complete(); + await oldLoad; + await _settle(); + expect( + container.read(userCacheProvider)['alice']?.displayName, + 'Destination Alice', + ); + expect( + container + .read(channelsProvider) + .requireValue + .single + .displayLabel(currentPubkey: 'me'), + 'Destination Alice', + ); + }, + ); + + test( + 'DM profile failures propagate while concurrent history is pending', + () async { + final failure = StateError('profile request failed'); + final session = _RequestPathSession()..profileFailure = failure; + final latestGate = session.latestGate = Completer(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await expectLater( + container.read(channelsProvider.future), + throwsA(same(failure)), + ); + expect(session.latestRequests, 1); + latestGate.complete(); + await _settle(); + expect(container.read(channelsProvider).error, same(failure)); + }, + ); + + test( + 'latest discovered roster owns Huddle filtering and member counts', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership( + _channelA, + 'me', + additionalPubkey: 'alice', + createdAt: 2, + eventId: 'new-a', + ), + _membership( + _channelB, + 'me', + ownerPubkey: 'me', + createdAt: 2, + eventId: 'new-b', + ), + _membership(_channelA, 'me', eventId: 'old-a'), + _membership( + _channelB, + 'me', + ownerPubkey: 'imposter', + eventId: 'old-b', + ), + ], + metadata: [ + _meta(id: _channelA, name: 'General'), + _meta(id: _channelB, name: 'Huddle', visibility: 'private'), + ], + huddleStarts: [ + _huddleStart(pubkey: 'me', parent: _channelA, backing: _channelB), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + final channels = await container.read(channelsProvider.future); + expect(channels.single.id, _channelA); + expect(channels.single.memberCount, 2); + expect( + container + .read(channelsProvider.notifier) + .cachedMembersForChannel(_channelA) + .map((member) => member.pubkey), + ['me', 'alice'], + ); + }, + ); +} + +NostrEvent _requestProfile( + String pubkey, { + required String name, + String? handle, + int createdAt = 1, +}) => NostrEvent( + id: 'profile-$pubkey-$createdAt', + pubkey: pubkey, + createdAt: createdAt, + kind: 0, + tags: const [], + content: jsonEncode({ + 'display_name': name, + 'nip05': handle, + 'picture': 'https://example.com/$pubkey.png', + }), + sig: 'sig', +); + +class _RequestPathSession extends _FakeRelaySession { + _RequestPathSession() + : super( + memberships: [_membership(_channelA, 'me', additionalPubkey: 'alice')], + metadata: [ + _meta( + id: _channelA, + name: 'DM', + channelType: 'dm', + participants: ['me', 'alice'], + ), + ], + recentMessages: const [ + NostrEvent( + id: 'dm-latest', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ], + content: 'Hello', + sig: 'sig', + ), + ], + ); + + List profiles = [_requestProfile('alice', name: 'Alice')]; + Completer? profilesGate; + Completer? latestGate; + Object? profileFailure; + int profileRequests = 0; + int latestRequests = 0; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (!filter.kinds.contains(0)) { + return super.fetchHistory(filter, timeout: timeout); + } + profileRequests++; + final snapshot = List.of(profiles); + final gate = profilesGate; + profilesGate = null; + await gate?.future; + if (profileFailure case final failure?) throw failure; + return snapshot; + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filters.isNotEmpty && + filters.every( + (filter) => filter.kinds.contains(9) && filter.since == null, + )) { + latestRequests++; + final gate = latestGate; + latestGate = null; + await gate?.future; + } + return super.queryRelay(filters, timeout: timeout); + } +} diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index e873306d57..9be1a620df 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:fake_async/fake_async.dart'; @@ -9,10 +10,12 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; part 'channels_provider_live_cases.dart'; part 'channels_provider_terminal_cases.dart'; part 'channels_provider_readiness_cases.dart'; +part 'channels_provider_request_path_cases.dart'; /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// @@ -28,6 +31,7 @@ part 'channels_provider_readiness_cases.dart'; /// events on demand. void main() { _unreadReadinessCases(); + _requestPathCases(); const myPk = 'me'; test( @@ -630,15 +634,15 @@ void main() { expect(await container.read(channelsProvider.future), isEmpty); - // The directory refresh parks on the member-count query and fails there + // The directory refresh parks on the member-metadata query and fails there // after the switch. A retired failure must not // overwrite the new scope's list or push it into an error state. session.memberships = [_membership(_channelA, myPk)]; - session.pauseNextMemberCountQuery(); + session.pauseNextMemberMetadataQuery(); final staleRefresh = container .read(channelsProvider.notifier) .retryDirectory(); - await session.nextMemberCountQueryStarted; + await session.nextMemberMetadataQueryStarted; session.memberships = [_membership(_channelB, myPk)]; session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; @@ -647,8 +651,8 @@ void main() { .update(baseUrl: 'https://community-b.example'); await container.read(channelsProvider.future); - session.failClaimedMemberCountQuery = true; - session.resumePausedMemberCountQuery(); + session.failClaimedMemberMetadataQuery = true; + session.resumePausedMemberMetadataQuery(); await staleRefresh; await _settle(); @@ -722,19 +726,19 @@ void main() { expect(await container.read(channelsProvider.future), isEmpty); session.memberships = [_membership(_channelA, myPk)]; - session.pauseNextMemberCountQuery(); + session.pauseNextMemberMetadataQuery(); final staleRefresh = container .read(channelsProvider.notifier) .retryDirectory(); - await session.nextMemberCountQueryStarted; + await session.nextMemberMetadataQueryStarted; session.memberships = [_membership(_channelB, _otherPk)]; session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')]; container.read(_testPubkeyProvider.notifier).set(_otherPk); await container.read(channelsProvider.future); - session.failClaimedMemberCountQuery = true; - session.resumePausedMemberCountQuery(); + session.failClaimedMemberMetadataQuery = true; + session.resumePausedMemberMetadataQuery(); await staleRefresh; await _settle(); @@ -754,11 +758,10 @@ void main() { final container = _buildContainer(session: session); addTearDown(container.dispose); - // Park all four reads. Any of them waiting on another would leave that one + // Park all three reads. Any of them waiting on another would leave that one // unsent. final metadataGate = session.metadataGate = Completer(); session.pauseNextHiddenDmQuery(); - session.pauseNextMemberCountQuery(); session.pauseNextHuddleStartQuery(); final load = container.read(channelsProvider.future); await _settle(); @@ -771,17 +774,11 @@ void main() { }; expect( sentKinds, - containsAll([ - 39000, - 39002, - EventKind.dmVisibility, - EventKind.huddleStarted, - ]), + containsAll([39000, EventKind.dmVisibility, EventKind.huddleStarted]), ); metadataGate.complete(); session.resumePausedHiddenDmQuery(); - session.resumePausedMemberCountQuery(); session.resumePausedHuddleStartQuery(); expect((await load).map((channel) => channel.id), [_channelA]); }); @@ -881,8 +878,8 @@ void main() { await container.read(channelsProvider.future); final notifier = container.read(channelsProvider.notifier); - // Park an older refresh on its Huddle-start query. Both of its membership - // fetches have already landed, so channel A is the list it is carrying. + // Park an older refresh on its Huddle-start query. Its membership snapshot + // has already landed, so channel A is the list it is carrying. session.pauseNextHuddleStartQuery(); final olderRefresh = notifier.refresh(); await session.nextHuddleStartQueryStarted; @@ -2566,10 +2563,12 @@ NostrEvent _membership( String pubkey, { String? additionalPubkey, String? ownerPubkey, + int createdAt = 1, + String? eventId, }) => NostrEvent( - id: 'mem-$channelId', + id: eventId ?? 'mem-$channelId', pubkey: 'creator', - createdAt: 1, + createdAt: createdAt, kind: 39002, tags: [ ['d', channelId], @@ -2605,6 +2604,7 @@ NostrEvent _meta({ int createdAt = 1, int? ttlSeconds, bool archived = false, + List participants = const [], }) => NostrEvent( id: 'meta-$id', pubkey: 'creator', @@ -2617,6 +2617,7 @@ NostrEvent _meta({ [visibility == 'private' ? 'private' : 'public'], if (ttlSeconds != null) ['ttl', '$ttlSeconds'], if (archived) ['archived', 'true'], + for (final participant in participants) ['p', participant], ], content: '', sig: 'sig', @@ -2700,7 +2701,7 @@ class _FakeRelaySession extends RelaySessionNotifier { List recentMessages; int membershipFailures; int directoryFailures = 0; - bool failClaimedMemberCountQuery = false; + bool failClaimedMemberMetadataQuery = false; bool failClaimedUnreadCatchUpQuery = false; int membershipRequestCount = 0; int metadataPageRequestCount = 0; @@ -2731,9 +2732,9 @@ class _FakeRelaySession extends RelaySessionNotifier { Completer? _pausedHiddenDm; Completer? _hiddenDmStarted; Completer? _claimedHiddenDm; - Completer? _pausedMemberCount; - Completer? _memberCountStarted; - Completer? _claimedMemberCount; + Completer? _pausedMemberMetadata; + Completer? _memberMetadataStarted; + Completer? _claimedMemberMetadata; Completer? _pausedHuddleStarts; Completer? _huddleStartsStarted; Completer? _claimedHuddleStarts; @@ -2831,7 +2832,7 @@ class _FakeRelaySession extends RelaySessionNotifier { /// Holds the next Huddle-start query open, one shot only. /// - /// Parks the older refresh AFTER both membership fetches have landed, so the + /// Parks the older refresh AFTER membership discovery has landed, so the /// only guard left between the park and the member-snapshot write is the /// fence on this leg. void pauseNextHuddleStartQuery() { @@ -2860,28 +2861,28 @@ class _FakeRelaySession extends RelaySessionNotifier { paused.complete(); } - /// Holds the next member-count query open, one shot only. - void pauseNextMemberCountQuery() { - if (_pausedMemberCount != null) { - throw StateError('A member-count query is already paused'); + /// Holds the next member-metadata query open, one shot only. + void pauseNextMemberMetadataQuery() { + if (_pausedMemberMetadata != null) { + throw StateError('A member-metadata query is already paused'); } - _pausedMemberCount = Completer(); - _memberCountStarted = Completer(); + _pausedMemberMetadata = Completer(); + _memberMetadataStarted = Completer(); } - /// Completes once the parked member-count query has been requested. - Future get nextMemberCountQueryStarted async { - final started = _memberCountStarted; + /// Completes once the parked member-metadata query has been requested. + Future get nextMemberMetadataQueryStarted async { + final started = _memberMetadataStarted; if (started == null) { - throw StateError('No member-count query is pending'); + throw StateError('No member-metadata query is pending'); } await started.future; } - /// Releases the parked member-count query so its response lands. - void resumePausedMemberCountQuery() { - final paused = _claimedMemberCount ?? _pausedMemberCount; - if (paused == null) throw StateError('No member-count query is paused'); + /// Releases the parked member-metadata query so its response lands. + void resumePausedMemberMetadataQuery() { + final paused = _claimedMemberMetadata ?? _pausedMemberMetadata; + if (paused == null) throw StateError('No member-metadata query is paused'); paused.complete(); } @@ -2929,21 +2930,7 @@ class _FakeRelaySession extends RelaySessionNotifier { throw Exception('restricted: too many explicit channels'); } if (filter.kinds.contains(39002) && filter.tags['#d'] != null) { - final paused = _pausedMemberCount; - if (paused != null) { - _claimedMemberCount = paused; - _pausedMemberCount = null; - _memberCountStarted!.complete(); - _memberCountStarted = null; - await paused.future; - if (failClaimedMemberCountQuery) { - throw Exception('member-count fetch failed'); - } - } - final ids = (filter.tags['#d'] ?? const []).toSet(); - return memberships - .where((event) => ids.contains(event.getTagValue('d'))) - .toList(); + throw StateError('Membership rosters must reuse discovery events'); } if (filter.kinds.contains(39002) && filter.tags['#p'] != null) { membershipRequestCount++; @@ -2974,7 +2961,7 @@ class _FakeRelaySession extends RelaySessionNotifier { } if (filter.kinds.contains(EventKind.huddleStarted)) { // Claim the parked slot so the newer refresh's own Huddle query runs - // unblocked: one shot, exactly like the hidden-DM and member-count hooks. + // unblocked: one shot, exactly like the hidden-DM and member-metadata hooks. final paused = _pausedHuddleStarts; if (paused != null) { _claimedHuddleStarts = paused; @@ -2993,9 +2980,22 @@ class _FakeRelaySession extends RelaySessionNotifier { if (ids == null) { throw StateError('Directory queries must use the HTTP query bridge'); } + final snapshot = metadata + .where((e) => ids.contains(e.getTagValue('d'))) + .toList(); + final paused = _pausedMemberMetadata; + if (paused != null) { + _claimedMemberMetadata = paused; + _pausedMemberMetadata = null; + _memberMetadataStarted!.complete(); + _memberMetadataStarted = null; + await paused.future; + if (failClaimedMemberMetadataQuery) { + throw Exception('member-metadata fetch failed'); + } + } await metadataGate?.future; - // Member metadata query — return only matching `d` tags. - return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList(); + return snapshot; } // Per-filter websocket fallback of a message batch. messageHistoryCount++; diff --git a/mobile/test/features/channels/me/solo_creation_tags_test.dart b/mobile/test/features/channels/me/solo_creation_tags_test.dart index c880e5457a..009f5eba35 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -5,6 +5,7 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/me/me_chat_creation.dart'; import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -19,8 +20,29 @@ import 'package:pointycastle/digests/sha256.dart'; import 'package:shared_preferences/shared_preferences.dart'; class _EmptyChannels extends ChannelsNotifier { + _EmptyChannels(this.existing); + final bool existing; + int freshReads = 0; @override Future> build() async => []; + @override + Future> fetchFresh({bool fetchDirectory = false}) async { + freshReads++; + return [ + if (existing) + Channel( + id: '10000000-0000-4000-8000-000000000001', + name: 'Mobile Solo', + channelType: 'stream', + visibility: 'private', + description: '', + createdBy: 'me', + createdAt: DateTime.utc(2026), + memberCount: 1, + isMember: true, + ), + ]; + } } class _Relay extends RelaySessionNotifier { @@ -32,7 +54,8 @@ class _Relay extends RelaySessionNotifier { Future> queryRelay( List filters, { Duration timeout = const Duration(seconds: 8), - }) async => [profile]; + }) async => + filters.any((filter) => filter.kinds.contains(0)) ? [profile] : []; @override Future> fetchHistory( @@ -53,94 +76,120 @@ class _Relay extends RelaySessionNotifier { } void main() { - testWidgets( - 'New Solo chat submits its discovery label through the production action', - (tester) async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - final owner = nostr.Keys.generate(), agent = nostr.Keys.generate(); - final digest = SHA256Digest() - .process( - Uint8List.fromList( - utf8.encode('nostr:agent-auth:${agent.public}:'), - ), - ) - .map((v) => v.toRadixString(16).padLeft(2, '0')) - .join(); - final profile = NostrEvent.fromJson( - nostr.Event.from( - kind: 0, - content: jsonEncode({'display_name': 'Owned agent'}), - secretKey: agent.secret, - tags: [ - [ - 'auth', - owner.public, - '', - nostr.Schnorr.sign(secretKey: owner.secret, message: digest), - ], - ], - ).toMap(), - ); - final relay = _Relay(profile); - final config = RelayConfig( - baseUrl: 'https://relay.test', - nsec: owner.nsec, - ); - final sync = MeSync(relay, config, prefs: prefs); - const bridge = MethodChannel('buzz/profile_text_editor'); - var forms = 0; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( - call, - ) async { - if (++forms > 1) return null; - return {'name': 'Mobile Solo', 'assistant': agent.public}; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - bridge, - null, - ), - ); - await tester.pumpWidget( - ProviderScope( - overrides: [ - meSyncProvider.overrideWithValue(sync), - meGroupsProvider.overrideWith( - (ref) => Stream.value(const MeGroups()), - ), - meAssistantsProvider.overrideWith((ref) async => []), - channelsProvider.overrideWith(_EmptyChannels.new), - channelActionsProvider.overrideWith( - (ref) => ChannelActions( - ref: ref, - session: relay, - signedEventRelay: SignedEventRelay( - session: relay, - nsec: owner.nsec, - ), - currentPubkey: owner.public, + for (final recoverExisting in [false, true]) { + testWidgets( + recoverExisting + ? 'interrupted Solo recovery uses fresh membership before creating' + : 'New Solo chat submits its discovery label through the production action', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final owner = nostr.Keys.generate(), agent = nostr.Keys.generate(); + final digest = SHA256Digest() + .process( + Uint8List.fromList( + utf8.encode('nostr:agent-auth:${agent.public}:'), ), - ), - ], - child: MaterialApp(theme: AppTheme.light(), home: const MePage()), - ), - ); - await tester.pumpAndSettle(); - tester - .widget(find.byType(FrostedAppBar)) - .nativeActions! - .firstWhere((action) => action.label == 'New chat') - .onPressed!(); - await tester.pumpAndSettle(); - final command = relay.submitted.single; - expect(command.kind, 9007); - expect(command.pubkey, owner.public); - expect(command.tags, contains(equals(['t', 'buzz.solo']))); - expect(command.tags, contains(equals(['channel_type', 'stream']))); - expect(command.tags, contains(equals(['visibility', 'private']))); - expect(command.tags.where((tag) => tag.first == 'P'), isEmpty); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + ) + .map((v) => v.toRadixString(16).padLeft(2, '0')) + .join(); + final profile = NostrEvent.fromJson( + nostr.Event.from( + kind: 0, + content: jsonEncode({'display_name': 'Owned agent'}), + secretKey: agent.secret, + tags: [ + [ + 'auth', + owner.public, + '', + nostr.Schnorr.sign(secretKey: owner.secret, message: digest), + ], + ], + ).toMap(), + ); + final relay = _Relay(profile); + final config = RelayConfig( + baseUrl: 'https://relay.test', + nsec: owner.nsec, + ); + final sync = MeSync(relay, config, prefs: prefs); + final channels = _EmptyChannels(recoverExisting); + if (recoverExisting) { + await MeChatCreation( + '10000000-0000-4000-8000-000000000001', + 'Mobile Solo', + agent.public, + ).save(sync); + } + const bridge = MethodChannel('buzz/profile_text_editor'); + var forms = 0; + final formErrors = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( + call, + ) async { + formErrors.add((call.arguments as Map)['error']); + if (++forms > 1) return null; + return {'name': 'Mobile Solo', 'assistant': agent.public}; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + bridge, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meSyncProvider.overrideWithValue(sync), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meAssistantsProvider.overrideWith((ref) async => []), + channelsProvider.overrideWith(() => channels), + channelMembersProvider( + '10000000-0000-4000-8000-000000000001', + ).overrideWith((ref) async => []), + channelActionsProvider.overrideWith( + (ref) => ChannelActions( + ref: ref, + session: relay, + signedEventRelay: SignedEventRelay( + session: relay, + nsec: owner.nsec, + ), + currentPubkey: owner.public, + ), + ), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + tester + .widget(find.byType(FrostedAppBar)) + .nativeActions! + .firstWhere((action) => action.label == 'New chat') + .onPressed!(); + await tester.pumpAndSettle(); + if (recoverExisting) { + await tester.tap(find.text('Resume')); + await tester.pumpAndSettle(); + // Continue to member setup using the existing channel. A stale lookup + // would instead submit a second kind:9007 creation command. + expect(channels.freshReads, 1, reason: formErrors.toString()); + expect(relay.submitted.where((e) => e.kind == 9007), isEmpty); + return; + } + final command = relay.submitted.single; + expect(command.kind, 9007); + expect(command.pubkey, owner.public); + expect(command.tags, contains(equals(['t', 'buzz.solo']))); + expect(command.tags, contains(equals(['channel_type', 'stream']))); + expect(command.tags, contains(equals(['visibility', 'private']))); + expect(command.tags.where((tag) => tag.first == 'P'), isEmpty); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } } diff --git a/mobile/test/features/invites/invite_fresh_membership_test.dart b/mobile/test/features/invites/invite_fresh_membership_test.dart new file mode 100644 index 0000000000..50752bf45f --- /dev/null +++ b/mobile/test/features/invites/invite_fresh_membership_test.dart @@ -0,0 +1,95 @@ +import 'package:buzz/app.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/invites/invite_join_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + test( + 'invite recovery propagates fresh-read failure despite cached memberships', + () async { + final keys = nostr.Keys.generate(); + final scope = InviteJoinRecoveryScope( + relayHttpOrigin: 'https://relay.example', + nsec: keys.nsec, + ); + final channels = _CachedChannels(); + final recovery = Provider( + (ref) => buildMobileInviteJoinRecovery(ref, scope), + ); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => _Config(keys.nsec)), + relaySessionProvider.overrideWith(_Connected.new), + activeCommunityProvider.overrideWith( + (ref) async => Community.create( + name: 'Test', + relayUrl: scope.relayHttpOrigin, + nsec: keys.nsec, + ), + ), + channelsProvider.overrideWith(() => channels), + ], + ); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await expectLater( + container.read(recovery).ensureStarterChannels(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'membership fetch failed', + ), + ), + ); + expect(channels.freshReads, 1); + expect(container.read(channelsProvider).requireValue, hasLength(2)); + }, + ); +} + +class _CachedChannels extends ChannelsNotifier { + int freshReads = 0; + @override + Future> build() async => [ + for (final name in ['general', 'welcome-everyone']) + Channel( + id: name, + name: name, + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'me', + createdAt: DateTime.utc(2026), + memberCount: 1, + isMember: true, + ), + ]; + // A best-effort refresh can retain stale data without failing its caller. + @override + Future refresh({bool fetchDirectory = false}) async {} + @override + Future> fetchFresh({bool fetchDirectory = false}) async { + freshReads++; + throw StateError('membership fetch failed'); + } +} + +class _Config extends RelayConfigNotifier { + _Config(this.nsec); + final String nsec; + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +class _Connected extends RelaySessionNotifier { + @override + SessionState build() => const SessionState(status: SessionStatus.connected); +} diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index daa6c88cab..2abe1ae055 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -52,7 +52,29 @@ void main() { ); addTearDown(container.dispose); + // The profile survives reconnect transitions without launching reads on + // each intermediate socket state, then revalidates once connected. + container.read(relaySessionProvider); + relaySession.setStatus(SessionStatus.disconnected); + final subscription = container.listen(profileProvider, (_, _) {}); + addTearDown(subscription.close); + await container.pump(); + relaySession.setStatus(SessionStatus.connecting); + await container.pump(); + expect(relaySession.reads, 0); + relaySession.setStatus(SessionStatus.connected); await container.read(profileProvider.future); + final initialReads = relaySession.reads; + relaySession.setStatus(SessionStatus.disconnected); + await container.pump(); + relaySession.setStatus(SessionStatus.connecting); + await container.pump(); + expect(container.read(profileProvider).requireValue?.displayName, 'Alice'); + expect(relaySession.reads, initialReads); + relaySession.setStatus(SessionStatus.connected); + await container.pump(); + await container.read(profileProvider.future); + expect(relaySession.reads, initialReads + 1); await container.read(profileProvider.notifier).updateDisplayName('Alice L'); expect(relaySession.published, hasLength(1)); @@ -681,6 +703,9 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier { class _ProfileRelaySession extends RelaySessionNotifier { _ProfileRelaySession(this.profile); + int reads = 0; + void setStatus(SessionStatus status) => state = SessionState(status: status); + final NostrEvent profile; final List published = []; @@ -691,7 +716,10 @@ class _ProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => [profile, ...published]; + }) async { + reads++; + return [profile, ...published]; + } @override Future publish( diff --git a/mobile/test/shared/channels/channel_list_snapshot_test.dart b/mobile/test/shared/channels/channel_list_snapshot_test.dart new file mode 100644 index 0000000000..9b5b7992c6 --- /dev/null +++ b/mobile/test/shared/channels/channel_list_snapshot_test.dart @@ -0,0 +1,190 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/shared/relay/relay_provider.dart' show RelayConfig; +import 'package:buzz/shared/channels/channel_list_snapshot.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; + +const _pubkey = 'ABCDEF'; +const _relay = 'https://relay.example'; + +final _channel = Channel( + id: 'dm-1', + name: 'DM', + channelType: 'dm', + labels: const ['buzz.solo'], + visibility: 'private', + description: '', + topic: 'topic', + purpose: 'purpose', + createdBy: 'creator', + createdAt: DateTime.utc(2026, 1, 2), + memberCount: 2, + lastMessageAt: DateTime.utc(2026, 1, 3), + archivedAt: DateTime.utc(2026, 1, 4), + participants: const ['Alice'], + participantPubkeys: const ['alice'], + isMember: true, + ttlSeconds: 60, + ttlDeadline: DateTime.utc(2026, 1, 5), +); + +ChannelListSnapshot _snapshot([List? channels]) => ChannelListSnapshot( + channels: [ + for (final c in channels ?? [_channel]) c.toJson(), + ], + hiddenDmIds: const {'dm-2'}, +); + +void main() { + late SharedPreferences prefs; + late ChannelListSnapshotStorage storage; + late _SnapshotStore store; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + store = _SnapshotStore(); + SharedPreferencesStorePlatform.instance = store; + prefs = await SharedPreferences.getInstance(); + storage = ChannelListSnapshotStorage(prefs); + }); + + test('round-trips every channel field and hidden DMs', () async { + final token = storage.capture(_pubkey, _relay); + expect(await storage.write(token, _snapshot()), isTrue); + + final restored = storage.read(_pubkey, _relay)!; + final channel = Channel.fromJson(restored.channels.single); + expect(channel.toJson(), _channel.toJson()); + expect(channel.isArchived, isTrue); + expect(channel.isEphemeral, isTrue); + expect(restored.hiddenDmIds, {'dm-2'}); + }); + + test('scopes by identity and relay, folding websocket origins', () async { + await storage.write(storage.capture(_pubkey, _relay), _snapshot()); + + for (final relay in [ + 'wss://RELAY.example/', + 'wss://relay.example:443', + ' https://RELAY.example/ ', + ]) { + expect(storage.read('abcdef', relay), isNotNull); + } + expect(storage.read(_pubkey, 'wss://relay.example:8443'), isNull); + expect( + ChannelListSnapshotStorage.key(_pubkey, 'ws://relay.example:80'), + ChannelListSnapshotStorage.key(_pubkey, 'http://relay.example'), + ); + expect(storage.read('other', _relay), isNull); + expect(storage.read(_pubkey, 'https://other.example'), isNull); + }); + + test('escaped relay paths have idempotent writer and cleanup keys', () async { + for (final relay in [ + 'wss://relay.example/a%2Fb', + 'wss://relay.example/a%2fb/', + 'wss://RELAY.example:443/a%20b', + ]) { + final canonical = RelayConfig(baseUrl: relay).baseUrl; + expect( + ChannelListSnapshotStorage.key(_pubkey, relay), + ChannelListSnapshotStorage.key(_pubkey, canonical), + ); + final token = storage.capture(_pubkey, canonical); + await storage.write(token, _snapshot()); + await storage.removeOrphans({ + ChannelListSnapshotStorage.key(_pubkey, relay), + }); + expect(storage.read(_pubkey, canonical), isNotNull); + expect(await storage.write(token, _snapshot()), isTrue); + } + }); + + test( + 'unchanged snapshots skip disk writes, failed writes remain retryable', + () async { + final token = storage.capture(_pubkey, _relay); + expect(await storage.write(token, _snapshot()), isTrue); + expect( + await ChannelListSnapshotStorage(prefs).write(token, _snapshot()), + isTrue, + ); + expect(store.writes, 1); + final changed = ChannelListSnapshot(channels: [], hiddenDmIds: {}); + for (final failure in ['false', 'throw']) { + store.failure = failure; + final result = storage.write(token, changed); + if (failure == 'throw') { + await expectLater(result, throwsStateError); + } else { + expect(await result, isFalse); + } + store.failure = null; + final beforeRetry = store.writes; + expect( + await ChannelListSnapshotStorage(prefs).write(token, changed), + isTrue, + ); + expect(store.writes, beforeRetry + 1); + expect(await storage.write(token, _snapshot()), isTrue); + } + }, + ); + + test('removal retires tokens captured before it, on any instance', () async { + final stale = storage.capture(_pubkey, _relay); + await storage.write(stale, _snapshot()); + + await ChannelListSnapshotStorage(prefs).remove(_pubkey, _relay); + + expect(storage.read(_pubkey, _relay), isNull); + expect(await storage.write(stale, _snapshot()), isFalse); + expect(storage.read(_pubkey, _relay), isNull); + final fresh = storage.capture(_pubkey, _relay); + expect(await storage.write(fresh, _snapshot()), isTrue); + }); + + test('corrupt, unsupported and oversized data is discarded', () async { + final key = ChannelListSnapshotStorage.key(_pubkey, _relay); + final tooMany = List.filled(maxChannelListSnapshotChannels + 1, '{}'); + for (final raw in [ + 'not json', + '{"v":2,"channels":[],"hidden_dm_ids":[]}', + '{"v":1,"channels":[${tooMany.join(',')}],"hidden_dm_ids":[]}', + // Fits in UTF-16 code units, exceeds the cap in UTF-8 bytes. + '{"v":1,"channels":[],"hidden_dm_ids":' + '["${'é' * (maxChannelListSnapshotBytes ~/ 2 + 1)}"]}', + ]) { + await prefs.setString(key, raw); + expect(storage.read(_pubkey, _relay), isNull); + expect(prefs.containsKey(key), isFalse); + } + + await storage.write(storage.capture(_pubkey, _relay), _snapshot()); + final oversized = [ + for (var i = 0; i <= maxChannelListSnapshotChannels; i++) _channel, + ]; + expect( + await storage.write( + storage.capture(_pubkey, _relay), + _snapshot(oversized), + ), + isFalse, + ); + expect(storage.read(_pubkey, _relay), isNull); + }); +} + +class _SnapshotStore extends InMemorySharedPreferencesStore { + _SnapshotStore() : super.empty(); + int writes = 0; + String? failure; + @override + Future setValue(String type, String key, Object value) async { + writes++; + if (failure == 'false') return false; + if (failure == 'throw') throw StateError('disk failure'); + return super.setValue(type, key, value); + } +} diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index d79d1620f6..c4f56250c8 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -3,6 +3,12 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart'; +import 'package:buzz/shared/channels/channel_list_snapshot.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; +import 'package:buzz/shared/relay/relay_provider.dart' show RelayConfig; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; @@ -42,7 +48,10 @@ void main() { tearDown(() => container.dispose()); - ProviderContainer createContainer() { + ProviderContainer createContainer({ + List overrides = const [], + CommunityPushLeaseRevocationEnqueuer? enqueueRevocation, + }) { Future writeSnapshot(List communities) async { snapshots.add(List.of(communities)); } @@ -52,15 +61,17 @@ void main() { communityStorageProvider.overrideWithValue(communityStorage), communitySnapshotWriterProvider.overrideWithValue(writeSnapshot), communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator), - communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(( - community, - ) async { - journaledCommunityIds.add(community.id); - return true; - }), + communityPushLeaseRevocationEnqueuerProvider.overrideWithValue( + enqueueRevocation ?? + (community) async { + journaledCommunityIds.add(community.id); + return true; + }, + ), communityPushLeaseRevocationTriggerProvider.overrideWithValue( revocationTrigger, ), + ...overrides, ], ); } @@ -428,6 +439,187 @@ void main() { expect(revocationTriggers, 1); }); + test( + 'removal deletes the channel list snapshot and retires writers', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final community = Community.create( + name: 'Cached', + relayUrl: 'wss://cached.example.com:443', + pubkey: 'incorrect-declared-key', + nsec: ' ${keys.nsec} ', + ); + final otherScope = Community.create( + name: 'Other', + relayUrl: 'https://other.example.com', + nsec: keys.nsec, + ); + final snapshots = ChannelListSnapshotStorage(prefs); + const snapshot = ChannelListSnapshot(channels: [], hiddenDmIds: {}); + await communityStorage.save(community); + await communityStorage.save(otherScope); + // Use the actual provider writer origin, not the raw persisted URL. + final writerRelay = RelayConfig(baseUrl: community.relayUrl).baseUrl; + // A refresh captured its token before removal and writes after it. + final inFlight = snapshots.capture(keys.public, writerRelay); + await snapshots.write(inFlight, snapshot); + await snapshots.write( + snapshots.capture(keys.public, otherScope.relayUrl), + snapshot, + ); + container = createContainer( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + ); + final notifier = container.read(communityListProvider.notifier); + await container.read(communityListProvider.future); + + await notifier.removeCommunity(community.id); + + expect(snapshots.read(keys.public, writerRelay), isNull); + expect(await snapshots.write(inFlight, snapshot), isFalse); + expect(snapshots.read(keys.public, writerRelay), isNull); + expect(snapshots.read(keys.public, otherScope.relayUrl), isNotNull); + }, + ); + + test('startup does not wait for orphan disk deletes', () async { + SharedPreferences.setMockInitialValues({}); + final store = _SnapshotStore(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final cache = ChannelListSnapshotStorage(prefs); + const snapshot = ChannelListSnapshot(channels: [], hiddenDmIds: {}); + for (final id in ['orphan-a', 'orphan-b']) { + await cache.write( + cache.capture(id, 'https://orphan.example'), + snapshot, + ); + } + final gate = store.removeGate = Completer(); + container = createContainer( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + ); + expect(await container.read(communityListProvider.future), isEmpty); + // All old deletions are submitted before new scopes can start writers. + expect(store.removeAttempts, 2); + final token = cache.capture('orphan-b', 'https://orphan.example'); + expect(await cache.write(token, snapshot), isTrue); + gate.complete(); + await Future.delayed(Duration.zero); + expect(await cache.write(token, snapshot), isTrue); + expect((await store.getAll()).values, [ + prefs.getString( + ChannelListSnapshotStorage.key('orphan-b', 'https://orphan.example'), + ), + ]); + }); + + test('startup and credential replacement discard orphan snapshots', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final cache = ChannelListSnapshotStorage(prefs); + const snapshot = ChannelListSnapshot(channels: [], hiddenDmIds: {}); + final oldKeys = nostr.Keys.generate(); + final newKeys = nostr.Keys.generate(); + final community = Community.create( + name: 'Stored', + relayUrl: 'wss://cached.example.com:443', + nsec: oldKeys.nsec, + ); + await communityStorage.save(community); + final oldToken = cache.capture(oldKeys.public, community.relayUrl); + final restoredToken = cache.capture('restored', 'https://orphan.example'); + await cache.write(oldToken, snapshot); + await cache.write(restoredToken, snapshot); + await prefs.setString('unrelated', 'keep'); + container = createContainer( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + ); + await container.read(communityListProvider.future); + expect(cache.read(oldKeys.public, community.relayUrl), isNotNull); + expect(cache.read('restored', 'https://orphan.example'), isNull); + expect(await cache.write(restoredToken, snapshot), isFalse); + + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + expect(cache.read(oldKeys.public, community.relayUrl), isNotNull); + expect(await cache.write(oldToken, snapshot), isTrue); + await notifier.addCommunity(community.copyWith(nsec: newKeys.nsec)); + expect(cache.read(oldKeys.public, community.relayUrl), isNull); + expect(await cache.write(oldToken, snapshot), isFalse); + final newToken = cache.capture(newKeys.public, community.relayUrl); + await cache.write(newToken, snapshot); + await communityStorage.saveActiveId(community.id); + await notifier.removeActiveCommunityForSignOut(); + expect(await communityStorage.loadAll(), isEmpty); + expect(prefs.getKeys(), {'unrelated'}); + expect(await cache.write(newToken, snapshot), isFalse); + + // A backup restored without secure community records is swept at startup. + container.dispose(); + await prefs.setString( + ChannelListSnapshotStorage.key('restored', 'https://orphan.example'), + '{"v":1,"channels":[],"hidden_dm_ids":[]}', + ); + container = createContainer( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + ); + await container.read(communityListProvider.future); + expect(prefs.getKeys(), {'unrelated'}); + }); + + for (final failure in ['false', 'throw']) { + test( + 'cache delete $failure cannot block sign-out; cleanup retries', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _SnapshotStore(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final community = Community.create( + name: 'Cached', + relayUrl: 'https://cache.example', + nsec: keys.nsec, + ); + await communityStorage.save(community); + await communityStorage.saveActiveId(community.id); + final cache = ChannelListSnapshotStorage(prefs); + final token = cache.capture(keys.public, community.relayUrl); + const snapshot = ChannelListSnapshot(channels: [], hiddenDmIds: {}); + await cache.write(token, snapshot); + container = createContainer( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + enqueueRevocation: (community) async { + expect(store.removeAttempts, greaterThan(0)); + journaledCommunityIds.add(community.id); + return true; + }, + ); + await container.read(communityListProvider.future); + store.failure = failure; + await container + .read(communityListProvider.notifier) + .removeActiveCommunityForSignOut(); + expect(await communityStorage.loadAll(), isEmpty); + expect(await communityStorage.loadActiveId(), isNull); + expect(journaledCommunityIds, [community.id]); + expect(revocationTriggers, 1); + expect(await cache.write(token, snapshot), isFalse); + expect( + (await store.getAll()).keys, + isNotEmpty, + ); // Disk deletion failed. + store.failure = null; + container.invalidate(communityListProvider); + await container.read(communityListProvider.future); + expect(await store.getAll(), isEmpty); + }, + ); + } + test('remote tombstone attempt cannot block local removal', () async { final remoteAttempt = Completer(); revocationTrigger = () { @@ -722,3 +914,19 @@ void main() { }); }); } + +class _SnapshotStore extends InMemorySharedPreferencesStore { + _SnapshotStore() : super.empty(); + String? failure; + int removeAttempts = 0; + Completer? removeGate; + @override + Future remove(String key) async { + removeAttempts++; + if (failure == 'false') return false; + if (failure == 'throw') throw StateError('disk failure'); + final removed = super.remove(key); + await removeGate?.future; + return removed; + } +} diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index 2871514ad9..34c20c54a4 100644 --- a/mobile/test/shared/profile/user_cache_provider_test.dart +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -141,89 +141,91 @@ void main() { expect(cache.state['agent']?.displayName, 'Valid'); }); - for (final preload in [false, true]) { - test( - '${preload ? 'preload' : 'refresh'} merges after parsing against newer live state', - () async { - final started = Completer(); - final release = Completer(); - final session = _RecordingProfileSession( - result: Future.value([ - _profileEvent(id: 'old', createdAt: 1, name: 'Old'), - ]), - ); - final container = ProviderContainer( - overrides: [ - relaySessionProvider.overrideWith(() => session), - profileEventBatchParserProvider.overrideWithValue((events) async { - final parsed = await parseProfileEventBatch(events); - started.complete(); - await release.future; - return parsed; - }), - ], - ); - addTearDown(container.dispose); - final cache = container.read(userCacheProvider.notifier); - final result = preload - ? cache.preload(['agent']) - : cache.refresh(['agent']); - await started.future; - cache.cacheProfileEvent( - _profileEvent(id: 'new', createdAt: 2, name: 'Live'), - ); - cache.cacheProfileEvent( - _profileEvent( - id: 'other', - pubkey: 'other', - createdAt: 1, - name: 'Other', - ), - ); - release.complete(); - expect(await result, isTrue); - expect(cache.state['agent']?.displayName, 'Live'); - expect(cache.state['other']?.displayName, 'Other'); - }, - ); + for (final operation in ['refresh', 'preload', 'resolve']) { + test('$operation merges after parsing against newer live state', () async { + final started = Completer(); + final release = Completer(); + final session = _RecordingProfileSession( + result: Future.value([ + _profileEvent(id: 'old', createdAt: 1, name: 'Old'), + ]), + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + profileEventBatchParserProvider.overrideWithValue((events) async { + final parsed = await parseProfileEventBatch(events); + started.complete(); + await release.future; + return parsed; + }), + ], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + final result = switch (operation) { + 'preload' => cache.preload(['agent']), + 'resolve' => cache.resolve(['agent']).then((_) => true), + _ => cache.refresh(['agent']), + }; + await started.future; + cache.cacheProfileEvent( + _profileEvent(id: 'new', createdAt: 2, name: 'Live'), + ); + cache.cacheProfileEvent( + _profileEvent( + id: 'other', + pubkey: 'other', + createdAt: 1, + name: 'Other', + ), + ); + release.complete(); + expect(await result, isTrue); + expect(cache.state['agent']?.displayName, 'Live'); + expect(cache.state['other']?.displayName, 'Other'); + }); - test( - '${preload ? 'preload' : 'refresh'} rejects a retired parsing completion', - () async { - final started = Completer(); - final release = Completer(); - final session = _RecordingProfileSession( - result: Future.value([ - _profileEvent(id: 'old', createdAt: 1, name: 'Previous community'), - ]), - ); - final container = ProviderContainer( - overrides: [ - relaySessionProvider.overrideWith(() => session), - profileEventBatchParserProvider.overrideWithValue((events) async { - final parsed = await parseProfileEventBatch(events); - started.complete(); - await release.future; - return parsed; - }), - ], - ); - addTearDown(container.dispose); - final cache = container.read(userCacheProvider.notifier); - final result = preload - ? cache.preload(['agent']) - : cache.refresh(['agent']); - await started.future; - // This is the same dependency invalidation caused by community config. - container - .read(relayConfigProvider.notifier) - .update(baseUrl: 'https://next-community.invalid'); - container.read(userCacheProvider); - release.complete(); + test('$operation rejects a retired parsing completion', () async { + final started = Completer(); + final release = Completer(); + final session = _RecordingProfileSession( + result: Future.value([ + _profileEvent(id: 'old', createdAt: 1, name: 'Previous community'), + ]), + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + profileEventBatchParserProvider.overrideWithValue((events) async { + final parsed = await parseProfileEventBatch(events); + started.complete(); + await release.future; + return parsed; + }), + ], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + final result = switch (operation) { + 'preload' => cache.preload(['agent']), + 'resolve' => cache.resolve(['agent']).then((_) => true), + _ => cache.refresh(['agent']), + }; + await started.future; + // This is the same dependency invalidation caused by community config. + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://next-community.invalid'); + container.read(userCacheProvider); + release.complete(); + if (operation == 'resolve') { + await expectLater(result, throwsStateError); + } else { expect(await result, isFalse); - expect(container.read(userCacheProvider), isEmpty); - }, - ); + } + expect(container.read(userCacheProvider), isEmpty); + }); } test(