diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 7f0eb97d36e..120acca3c95 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -82,6 +82,7 @@ class Channel { bool get isForum => channelType == 'forum'; bool get isDm => channelType == 'dm'; bool get isPrivate => visibility == 'private'; + bool get canJoin => visibility == 'open' && !isArchived && !isMember && !isDm; /// Whether [selfRole] may add *another* identity here, mirroring the relay's /// kind:9000 authority (`validate_admin_event` + `add_member`): DMs never, diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart new file mode 100644 index 00000000000..a99822ede5e --- /dev/null +++ b/mobile/lib/features/channels/channel_directory.dart @@ -0,0 +1,487 @@ +part of 'channels_provider.dart'; + +const _channelDirectoryPageSize = 500; +const _maxChannelDirectoryPages = 100; + +/// Describes whether the open-channel directory is ready to browse. +enum ChannelDirectoryLoadStatus { + /// No directory request has completed for the active identity and relay. + idle, + + /// A directory request is currently in flight. + loading, + + /// The directory request completed, including when it returned no channels. + loaded, + + /// The most recent directory request could not complete. + error, +} + +/// Directory loading state scoped to one relay and signing identity. +class ChannelDirectoryLoadState { + /// Relay-and-identity scope that produced [status]. + final String? scope; + + /// Current loading status for [scope]. + final ChannelDirectoryLoadStatus status; + + /// Creates directory loading state. + const ChannelDirectoryLoadState({required this.scope, required this.status}); + + /// Initial state before any directory request has started. + const ChannelDirectoryLoadState.idle() + : scope = null, + status = ChannelDirectoryLoadStatus.idle; +} + +/// Returns the stable directory scope for a relay and signing identity. +String channelDirectoryScope(String relayBaseUrl, String? pubkey) => + '$relayBaseUrl:${pubkey?.toLowerCase() ?? ''}'; + +/// Owns the independently observable channel-directory loading state. +class ChannelDirectoryLoadNotifier extends Notifier { + @override + ChannelDirectoryLoadState build() => const ChannelDirectoryLoadState.idle(); + + /// Whether [scope] currently owns an in-flight directory request. + bool isLoading(String scope) => + state.scope == scope && + state.status == ChannelDirectoryLoadStatus.loading; + + /// Marks the directory as loading. + void markLoading(String scope) => state = ChannelDirectoryLoadState( + scope: scope, + status: ChannelDirectoryLoadStatus.loading, + ); + + /// Marks the directory as eligible for a fresh request. + void markIdle(String scope) => state = ChannelDirectoryLoadState( + scope: scope, + status: ChannelDirectoryLoadStatus.idle, + ); + + /// Marks the directory as successfully loaded. + void markLoaded(String scope) => state = ChannelDirectoryLoadState( + scope: scope, + status: ChannelDirectoryLoadStatus.loaded, + ); + + /// Marks the directory request as unsuccessful. + void markError(String scope) => state = ChannelDirectoryLoadState( + scope: scope, + status: ChannelDirectoryLoadStatus.error, + ); +} + +/// Loading state for open-channel discovery, separate from membership loading. +final channelDirectoryLoadStatusProvider = + NotifierProvider( + ChannelDirectoryLoadNotifier.new, + ); + +Future> _fetchChannelMemberships( + RelaySessionNotifier session, + String pubkey, +) => _fetchPaginatedChannelEvents( + session, + kind: 39002, + tags: { + '#p': [pubkey], + }, + operation: 'Channel memberships', +); + +Future> _fetchChannelDirectoryMetas( + RelaySessionNotifier session, +) => _fetchPaginatedChannelEvents( + session, + kind: 39000, + operation: 'Channel directory', +); + +/// Thrown when a channel-list request is retired before it settles. +/// +/// Callers must treat this as "write nothing": a newer request or scope now +/// owns the installed list and its related cache, subscription, and load state. +class _StaleChannelRefresh implements Exception { + const _StaleChannelRefresh(); + + @override + String toString() => + 'Channel refresh retired by a newer request or scope change'; +} + +/// Carries one channel-list refresh's request ownership across every await. +/// +/// This token is captured once at the start of every ordinary, directory, and +/// reconnect refresh. It is re-checked after each relay await, so an older +/// request cannot regain ownership by reaching subscription setup last. +class _ChannelRefreshFence { + /// Relay-and-identity scope that started the refresh. + final String scope; + + final _ChannelRefreshCoordinator _coordinator; + final int _generation; + + _ChannelRefreshFence(this._coordinator, this.scope, this._generation); + + /// Whether the refresh still owns the active scope and generation. + bool get isCurrent => + _generation == _coordinator.generation && + scope == _coordinator.currentScope(); + + /// Throws [_StaleChannelRefresh] once this refresh has been retired. + /// + /// Call after every await and immediately before every write to metadata, + /// cache, load status, subscriptions or provider state. + void ensureCurrent() { + if (!isCurrent) throw const _StaleChannelRefresh(); + } +} + +/// Whether a detached unread catch-up has been superseded, so it writes nothing. +/// +/// The refresh fence is acquired before the first relay await, which makes this +/// request-ordered rather than subscription-completion-ordered. The subscription +/// generation remains a second lifecycle check for disconnect and disposal. +/// +/// A retired catch-up returns rather than throwing [_StaleChannelRefresh]: +/// nothing awaits it, so a throw would only surface as an unhandled error. +/// +/// An extension in this part file rather than a method on the notifier because +/// `channels_provider.dart` sits against the repository-wide 1000-line file +/// ceiling enforced by `just file-size-check`. +extension _CatchUpFencing on ChannelsNotifier { + bool _isCatchUpRetired( + _ChannelRefreshFence fence, + int subscriptionGeneration, + ) => !fence.isCurrent || subscriptionGeneration != _subscriptionVersion; +} + +/// Awaits [future], then rejects the result if the refresh was retired. +/// +/// One helper keeps every await site on the fenced path identical, so a new +/// await cannot be added without deciding whether it needs the fence. +/// +/// The error path is fenced too. Without it a retired refresh that fails would +/// surface an ordinary exception, and `retryDirectory` would treat it as a +/// failure of the current scope: it would mark the wrong scope's status and +/// reinstall the channel list it captured before the switch. +Future _fenced(_ChannelRefreshFence fence, Future future) async { + final T value; + try { + value = await future; + } catch (_) { + if (!fence.isCurrent) throw const _StaleChannelRefresh(); + rethrow; + } + fence.ensureCurrent(); + return value; +} + +/// Resolves display labels for the other participants in every DM meta. +/// +/// The relay stores DM channels with the literal name "DM", and the pure-Nostr +/// architecture puts name resolution in the client. So collect the non-self +/// participant pubkeys across all DM metas and batch-fetch their kind:0 +/// profiles in one round-trip. Returns lowercase pubkey to label. +/// +/// Lives in this part file because `channels_provider.dart` sits against the +/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +Future> _resolveDmDisplayNames( + RelaySessionNotifier session, + _ChannelRefreshFence fence, + Iterable dedupedMetas, + String myPk, +) async { + final dmParticipants = {}; + final myPkLower = myPk.toLowerCase(); + for (final event in dedupedMetas) { + final data = ChannelData.fromEvent(event); + if (data.channelType != 'dm') continue; + for (final pk in data.participantPubkeys) { + final lower = pk.toLowerCase(); + if (lower != myPkLower) dmParticipants.add(lower); + } + } + if (dmParticipants.isEmpty) return const {}; + + final profileEvents = await _fenced( + fence, + session.fetchHistory(NostrFilters.profilesBatch(dmParticipants.toList())), + ); + final displayNames = {}; + for (final event in profileEvents) { + if (event.kind != 0) continue; + final profile = ProfileData.fromEvent(event); + final label = profile.displayName?.trim().isNotEmpty == true + ? profile.displayName!.trim() + : profile.nip05?.trim().isNotEmpty == true + ? profile.nip05!.trim() + : shortPubkey(profile.pubkey); + displayNames[profile.pubkey.toLowerCase()] = label; + } + return displayNames; +} + +Future> _fetchHiddenDmIds( + RelaySessionNotifier session, + String myPk, +) async { + try { + final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk)); + if (events.isEmpty) return const {}; + NostrEvent latest = events.first; + for (final event in events.skip(1)) { + if (event.createdAt > latest.createdAt) latest = event; + } + return { + for (final tag in latest.tags) + if (tag.length >= 2 && tag[0] == 'h') tag[1], + }; + } catch (_) { + return const {}; + } +} + +Future> _fetchHuddleStarts( + RelaySessionNotifier session, + List parentChannelIds, +) async { + if (parentChannelIds.isEmpty) return const []; + try { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return await session.fetchHistory( + NostrFilter( + kinds: const [EventKind.huddleStarted], + tags: {'#h': parentChannelIds}, + since: now - const Duration(hours: 2).inSeconds, + limit: 500, + ), + ); + } catch (error) { + debugPrint( + '[ChannelsNotifier] Huddle backing-channel query failed: $error', + ); + return const []; + } +} + +/// Counts distinct `p`-tagged members per channel from kind:39002 events. +/// +/// Lives in this part file to keep `channels_provider.dart` under the +/// repository-wide 1000-line ceiling enforced by `just file-size-check`. +Map _memberCountsByChannelId(Iterable memberEvents) { + final memberCounts = {}; + for (final event in memberEvents) { + final channelId = event.getTagValue('d'); + if (channelId == null) continue; + final pTags = {}; + for (final tag in event.tags) { + if (tag.isNotEmpty && tag[0] == 'p' && tag.length > 1) { + pTags.add(tag[1].toLowerCase()); + } + } + memberCounts[channelId] = pTags.length; + } + return memberCounts; +} + +/// Loads the open-channel directory fenced to the scope that requested it. +/// +/// A community or identity switch changes the scope, and a newer request bumps +/// the generation. Either one retires an in-flight request, so a delayed +/// response can never populate the current community's state. This is the +/// tenant boundary described in VISION.md: isolation is the boundary, not a +/// filter, so a retired response is discarded rather than merged. +/// +/// Lives in this part file because `channels_provider.dart` sits against the +/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +class _ChannelRefreshCoordinator { + /// Resolves the relay-and-identity scope that is active right now. + final String Function() currentScope; + + /// Owns the externally observable directory load status. + final ChannelDirectoryLoadNotifier Function() loadStatus; + + int _generation = 0; + + /// Generation of the most recently issued or retired request. + int get generation => _generation; + + _ChannelRefreshCoordinator({ + required this.currentScope, + required this.loadStatus, + }); + + /// Binds the fence to a notifier's [Ref] so the provider needs one line. + /// + /// Both closures read rather than watch: the fence asks what the scope is + /// right now, and must not make the notifier depend on it. + factory _ChannelRefreshCoordinator.forRef(Ref ref) => + _ChannelRefreshCoordinator( + currentScope: () => channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), + ), + loadStatus: () => ref.read(channelDirectoryLoadStatusProvider.notifier), + ); + + /// Retires any in-flight request without starting a new one. + /// + /// Called when the relay or identity changes so a response already on the + /// wire cannot be written into the new scope. + void retireInFlight() => _generation++; + + /// Starts a fenced refresh without issuing the directory query. + /// + /// Used by callers that must carry the scope across later awaits even when + /// they do not refresh discovery, so a membership-only refresh cannot install + /// an old scope's list either. + _ChannelRefreshFence beginRefresh({required bool fetchesDirectory}) { + final scope = currentScope(); + final generation = ++_generation; + if (!fetchesDirectory) { + final status = loadStatus(); + if (status.isLoading(scope)) { + // This refresh takes ownership of the shared generation, so the + // in-flight directory response will be discarded. The mounted Browse + // sheet cannot restart an idle request on its own: it only starts a + // load when it mounts and deliberately renders idle as its initial + // spinner. Settle the displaced load to a retryable terminal state so + // the sheet never waits forever for a response that may no longer + // write results. + status.markError(scope); + } + } + return _ChannelRefreshFence(this, scope, generation); + } + + /// Fetches the directory under [fence], or throws if the fence is retired. + /// + /// Returns null when the request failed inside the current scope, which means + /// "retain the cached discovery". The fence is re-checked after the await and + /// before every write, on both the success and the failure path. + Future?> loadDirectory( + RelaySessionNotifier session, + _ChannelRefreshFence fence, + ) async { + loadStatus().markLoading(fence.scope); + final List metas; + try { + metas = await _fetchChannelDirectoryMetas(session); + } catch (error, stackTrace) { + fence.ensureCurrent(); + loadStatus().markError(fence.scope); + debugPrint( + '[ChannelsNotifier] channel directory refresh failed; retaining ' + 'cached discovery: $error\n$stackTrace', + ); + return null; + } + fence.ensureCurrent(); + loadStatus().markLoaded(fence.scope); + return metas; + } +} + +Future> _fetchPaginatedChannelEvents( + RelaySessionNotifier session, { + required int kind, + required String operation, + Map> tags = const {}, +}) async { + final events = []; + final seenEventIds = {}; + int? until; + String? beforeId; + for (var pageIndex = 0; pageIndex < _maxChannelDirectoryPages; pageIndex++) { + final page = await session.queryRelay([ + NostrFilter( + kinds: [kind], + tags: tags, + limit: _channelDirectoryPageSize, + until: until, + extensions: {'before_id': ?beforeId}, + ), + ]); + if (page.isEmpty) break; + var madeProgress = false; + for (final event in page) { + if (seenEventIds.add(event.id)) { + events.add(event); + madeProgress = true; + } + } + if (!madeProgress) break; + + final last = page.last; + until = last.createdAt; + beforeId = last.id; + if (pageIndex == _maxChannelDirectoryPages - 1) { + throw StateError('$operation exceeded $_maxChannelDirectoryPages pages'); + } + } + return events; +} + +/// Thread-interest and unread helpers shared by [ChannelsNotifier]. +/// +/// Lives in this part file because `channels_provider.dart` sits against the +/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +String? _observedUnreadRootId(NostrEvent event) => + _isBroadcastReply(event) ? null : event.threadReference.rootId; + +bool _isBroadcastReply(NostrEvent event) => event.tags.any( + (tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1', +); + +Set _readRootIdSet(String? raw) { + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return {}; + return { + for (final value in decoded) + if (value is String) value, + }; + } catch (_) { + return {}; + } +} + +String _encodeRootIdSet(Set values) => jsonEncode(values.toList()); + +/// Records one observed unread event for a channel's badge state. +/// +/// An extension in this part file rather than a method on the notifier because +/// `channels_provider.dart` sits against the repository-wide 1000-line file +/// ceiling enforced by `just file-size-check`. Private members stay reachable: +/// a part shares its parent's library. +extension _ObservedUnreadRecording on ChannelsNotifier { + void _recordUnreadEvent(Channel channel, NostrEvent event, String myPk) { + final isThreadedReply = + event.threadReference.parentId != null && !_isBroadcastReply(event); + final isHighPriority = + channel.isDm || isHighPriorityEvent(event.tags, myPk); + recordObservedUnreadEvent( + _observedUnreadEventsByChannel, + channel.id, + makeObservedUnreadEvent( + id: event.id, + createdAt: event.createdAt, + rootId: _observedUnreadRootId(event), + highPriority: isHighPriority, + channelType: channel.channelType, + isThreadedReply: isThreadedReply, + ), + _unreadCatchUpLimit, + ); + + final current = _latestObservedByChannel[channel.id] ?? 0; + if (event.createdAt > current) { + _latestObservedByChannel[channel.id] = event.createdAt; + } + } +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index d50ed8c46e8..5f1881facd4 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -53,6 +53,7 @@ import '../../shared/read_state/read_state_time.dart'; import 'unread_badge/observed_unread_event.dart'; part 'channels_page/body.dart'; +part 'channels_page/browse_channels_sheet.dart'; part 'channels_page/sections.dart'; part 'channels_page/channel_tile.dart'; part 'channels_page/sheets.dart'; @@ -62,7 +63,7 @@ part 'channels_page/community.dart'; part 'channels_page/quick_actions.dart'; part 'channels_page/quick_actions_launcher.dart'; -enum _QuickAction { createChannel, newDm } +enum _QuickAction { createChannel, newDm, browseChannels } const double _kChannelSectionInset = Grid.gutter; const double _kChannelLeadingWidth = 22.0; diff --git a/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart new file mode 100644 index 00000000000..9d1bb633716 --- /dev/null +++ b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart @@ -0,0 +1,191 @@ +part of '../channels_page.dart'; + +class _BrowseChannelsSheet extends HookConsumerWidget { + const _BrowseChannelsSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final channelsAsync = ref.watch(channelsProvider); + final directoryState = ref.watch(channelDirectoryLoadStatusProvider); + final activeDirectoryScope = channelDirectoryScope( + ref.watch(relayConfigProvider).baseUrl, + ref.watch(myPubkeyProvider), + ); + final directoryStatus = directoryState.scope == activeDirectoryScope + ? directoryState.status + : ChannelDirectoryLoadStatus.idle; + final channels = channelsAsync.asData?.value + .where((channel) => channel.canJoin) + .toList(); + channels?.sort( + (left, right) => + left.name.toLowerCase().compareTo(right.name.toLowerCase()), + ); + + useEffect(() { + unawaited( + Future.microtask( + ref.read(channelsProvider.notifier).ensureDirectoryLoaded, + ), + ); + return null; + }, const []); + + final directoryIsLoading = + directoryStatus == ChannelDirectoryLoadStatus.idle || + directoryStatus == ChannelDirectoryLoadStatus.loading; + final directoryHasError = + directoryStatus == ChannelDirectoryLoadStatus.error || + channelsAsync.hasError; + + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Join an open channel to add it to your conversations.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xs), + ], + ), + ), + if (directoryIsLoading && (channels == null || channels.isEmpty)) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center(child: BuzzLoadingIndicator()), + ), + ) + else if (directoryHasError && + (channels == null || channels.isEmpty)) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.sm), + child: Column( + children: [ + Text( + 'Couldn’t load open channels.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xxs), + TextButton( + key: const Key('browse-channels-retry'), + onPressed: () => unawaited( + ref.read(channelsProvider.notifier).retryDirectory(), + ), + child: const Text('Try again'), + ), + ], + ), + ), + ) + else if (channels == null || channels.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.sm), + child: Text( + 'No open channels available to join.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ) + else + SliverList.builder( + itemCount: channels.length, + itemBuilder: (context, index) => _JoinableChannelTile( + channel: channels[index], + closeAfterJoin: true, + ), + ), + ], + ), + ), + ); + } +} + +class _JoinableChannelTile extends HookConsumerWidget { + final Channel channel; + final bool closeAfterJoin; + + const _JoinableChannelTile({ + required this.channel, + required this.closeAfterJoin, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isJoining = useState(false); + final actionError = useState(null); + + Future join() async { + if (isJoining.value) return; + isJoining.value = true; + actionError.value = null; + try { + await ref.read(channelActionsProvider).joinChannel(channel.id); + if (closeAfterJoin && context.mounted) Navigator.of(context).pop(); + } catch (error) { + actionError.value = error.toString(); + } finally { + isJoining.value = false; + } + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: Key('browse-channel-${channel.id}'), + contentPadding: EdgeInsets.zero, + leading: Icon(channelIcon(channel)), + title: Text(channel.name), + subtitle: channel.description.trim().isEmpty + ? null + : Text( + channel.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: FilledButton.tonal( + key: Key('browse-channel-join-${channel.id}'), + onPressed: isJoining.value ? null : () => unawaited(join()), + child: Text(isJoining.value ? 'Joining…' : 'Join'), + ), + ), + if (actionError.value case final error?) + Align( + alignment: Alignment.centerLeft, + child: Text( + error, + key: Key('browse-channel-error-${channel.id}'), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/channels/channels_page/quick_actions.dart b/mobile/lib/features/channels/channels_page/quick_actions.dart index 87548427024..3e0f6982b40 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions.dart @@ -7,7 +7,7 @@ const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1); const double _kMorphOpenBounce = 0.14; const double _kMorphCloseBounce = 0.06; const double _kMorphClosedSize = 56; -const double _kMorphOpenHeight = 160; +const double _kMorphOpenHeight = 216; const double _kMorphOpenRadius = 20; const double _kMorphSlide = 40; const double _kMorphScale = 0.97; @@ -274,6 +274,13 @@ class _QuickActionsMenu extends StatelessWidget { key: const Key('quick-action-new-dm-card'), onTap: () => onSelected(_QuickAction.newDm), ), + const SizedBox(height: Grid.xxs), + _QuickActionItem( + icon: LucideIcons.compass, + title: 'Browse channels', + key: const Key('quick-action-browse-channels-card'), + onTap: () => onSelected(_QuickAction.browseChannels), + ), ], ), ); diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart index 8517fa70b9f..b298c192d0a 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -109,6 +109,15 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { if (opened != null && context.mounted) { await openChannel(opened); } + case _QuickAction.browseChannels: + await showBuzzModalBottomSheet( + context: context, + title: 'Browse channels', + constraints: _quickActionSheetConstraints(context), + isScrollControlled: true, + showDragHandle: true, + builder: (_) => const _BrowseChannelsSheet(), + ); } } diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index deb6869cb2e..2258a493c99 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -19,6 +19,8 @@ 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'; + const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; const _unreadCatchUpLimit = 1000; const _participatedRootIdsPrefix = 'buzz-thread-participation.v1'; @@ -26,11 +28,11 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// Loads the user's channel list from the relay over WebSocket. /// -/// Two-step query: -/// 1. Fetch kind:39002 membership events tagged `#p:` to find -/// the channel ids I'm a member of. -/// 2. Fetch the corresponding kind:39000 channel metadata events. +/// Membership loading resolves kind:39002 events tagged `#p:`, +/// then fetches kind:39000 metadata for those channel ids. /// +/// The paginated kind:39000 directory is fetched separately when Browse +/// channels opens, so discovery never delays the main Conversations screen. /// Live updates are layered on top via per-channel subscriptions on the /// `#h` tag for any of the visible channel event kinds — incoming events /// bump `lastMessageAt` for that channel. @@ -39,7 +41,6 @@ class ChannelsNotifier extends AsyncNotifier> { final Map _unsubscribersByChannel = {}; Future _liveSubscriptionQueue = Future.value(); - List _desiredLiveChannels = const []; Set _desiredLiveChannelIds = const {}; int _subscriptionVersion = 0; String? _subscriptionRelayBaseUrl; @@ -54,6 +55,11 @@ class ChannelsNotifier extends AsyncNotifier> { String? _memberSnapshotRelayBaseUrl; String? _memberSnapshotPubkey; Map> _memberSnapshotsByChannelId = const {}; + List _directoryMetas = const []; + + /// Fences directory responses to the relay and identity that requested them. + late final _ChannelRefreshCoordinator _refreshCoordinator = + _ChannelRefreshCoordinator.forRef(ref); /// The member snapshot already returned while loading the channel list. /// @@ -81,6 +87,10 @@ class ChannelsNotifier extends AsyncNotifier> { _memberSnapshotRelayBaseUrl = relayBaseUrl; _memberSnapshotPubkey = pubkey; _memberSnapshotsByChannelId = const {}; + _directoryMetas = const []; + // Retire any in-flight directory request: its response describes the + // previous relay or identity and must not reach this scope's state. + _refreshCoordinator.retireInFlight(); } final connected = Completer(); final sessionState = ref.read(relaySessionProvider); @@ -125,10 +135,12 @@ class ChannelsNotifier extends AsyncNotifier> { Future> _fetch({ bool subscribeLive = false, bool fetchLastMessage = true, + bool fetchDirectory = false, }) async { final channels = await _fetchChannels( subscribeLive: subscribeLive, fetchLastMessage: fetchLastMessage, + fetchDirectory: fetchDirectory, ); _hasLoaded = true; return channels; @@ -137,6 +149,7 @@ class ChannelsNotifier extends AsyncNotifier> { Future> _fetchChannels({ bool subscribeLive = false, bool fetchLastMessage = true, + bool fetchDirectory = false, }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); @@ -144,49 +157,48 @@ class ChannelsNotifier extends AsyncNotifier> { final session = ref.read(relaySessionProvider.notifier); + // Acquire request ownership before the first relay await. Every channel-list + // path uses this fence so completion order cannot let an older ordinary, + // directory, or reconnect refresh replace a newer membership list. + final fence = _refreshCoordinator.beginRefresh( + fetchesDirectory: fetchDirectory, + ); + // Step 1: find the channels I'm a member of via kind:39002. - final memberships = []; - { - int? until; - const pageSize = 500; - while (true) { - final page = await session.fetchHistory( - NostrFilter( - kinds: const [39002], - tags: { - '#p': [myPk], - }, - limit: pageSize, - until: until, - ), - ); - memberships.addAll(page); - if (page.length < pageSize) break; - until = page.map((e) => e.createdAt).reduce(min) - 1; - } - } - final channelIds = memberships + final memberships = await _fenced( + fence, + _fetchChannelMemberships(session, myPk), + ); + final memberChannelIds = memberships .map((e) => e.getTagValue('d')) .whereType() - .toSet() - .toList(); + .toSet(); _cacheMemberSnapshots(memberships, replaceAll: true); - if (channelIds.isEmpty) { - if (subscribeLive) await _subscribeLive(const []); - return const []; - } - // Step 2: pull channel metadata in one batched filter. - final metas = await session.fetchHistory( - NostrFilters.channelMetadata(channelIds), - ); + // Step 2: pull metadata for joined channels. A user with no memberships + // must still continue to directory discovery below. + final memberMetas = memberChannelIds.isEmpty + ? const [] + : await _fenced( + fence, + session.fetchHistory( + NostrFilters.channelMetadata(memberChannelIds.toList()), + ), + ); + + // 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 + // private channels and DMs below so discovery fails closed if that contract + // ever regresses. The composite cursor preserves tied-timestamp rows. + if (fetchDirectory) { + final metas = await _refreshCoordinator.loadDirectory(session, fence); + if (metas != null) _directoryMetas = metas; + } - // Dedupe by `d` tag (channel id) — kind:39000 is parameterized-replaceable, - // so logically there's exactly one current event per id, but stale revisions - // from before the relay's d_tag backfill can linger. Keep the highest - // `created_at` per id so the latest channel_type / name wins. + // 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 metas) { + for (final event in [...memberMetas, ..._directoryMetas]) { if (event.kind != 39000) continue; final id = event.getTagValue('d'); if (id == null) continue; @@ -197,62 +209,56 @@ class ChannelsNotifier extends AsyncNotifier> { } final dedupedMetas = latestMetaPerId.values; - // Resolve DM participant display names. Relay stores DM channels with - // literal name="DM"; pure-Nostr architecture pushes name resolution to - // the client, so collect non-self participant pubkeys across all DM - // metas and batch-fetch their kind:0 profiles in one round-trip. - final dmParticipants = {}; - final myPkLower = myPk.toLowerCase(); - for (final event in dedupedMetas) { - final data = ChannelData.fromEvent(event); - if (data.channelType != 'dm') continue; - for (final pk in data.participantPubkeys) { - final lower = pk.toLowerCase(); - if (lower != myPkLower) dmParticipants.add(lower); - } - } - - final displayNames = {}; - if (dmParticipants.isNotEmpty) { - final profileEvents = await session.fetchHistory( - NostrFilters.profilesBatch(dmParticipants.toList()), - ); - for (final event in profileEvents) { - if (event.kind != 0) continue; - final profile = ProfileData.fromEvent(event); - final label = profile.displayName?.trim().isNotEmpty == true - ? profile.displayName!.trim() - : profile.nip05?.trim().isNotEmpty == true - ? profile.nip05!.trim() - : shortPubkey(profile.pubkey); - displayNames[profile.pubkey.toLowerCase()] = label; - } - } + // Resolve DM participant display names. Extracted into the part file so + // `channels_provider.dart` stays under the 1000-line ceiling enforced by + // `just file-size-check`. + final displayNames = await _resolveDmDisplayNames( + session, + fence, + dedupedMetas, + myPk, + ); - final hiddenDmIds = await _fetchHiddenDmIds(session, myPk); + final hiddenDmIds = await _fenced(fence, _fetchHiddenDmIds(session, myPk)); // Fetch the authoritative membership snapshots before filtering 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 memberEvents = await session.fetchHistory( - NostrFilter( - kinds: const [39002], - tags: {'#d': channelIds}, - limit: channelIds.length, - ), - ); + final memberCountChannelIds = memberChannelIds.toList(); + final memberEvents = memberCountChannelIds.isEmpty + ? const [] + : await _fenced( + fence, + session.fetchHistory( + NostrFilter( + kinds: const [39002], + tags: {'#d': memberCountChannelIds}, + limit: memberCountChannelIds.length, + ), + ), + ); + final huddleStarts = memberCountChannelIds.isEmpty + ? const [] + : await _fenced( + fence, + _fetchHuddleStarts(session, memberCountChannelIds), + ); final huddleBackingIds = huddleBackingChannelIds( - await _fetchHuddleStarts(session, channelIds), + huddleStarts, memberEvents, ); final channels = []; for (final event in dedupedMetas) { + final id = event.getTagValue('d'); + if (id == null) continue; + final isMember = memberChannelIds.contains(id); final channel = _channelFromMeta( event, - isMember: true, + 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 && @@ -269,18 +275,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 = {}; - for (final event in memberEvents) { - final chId = event.getTagValue('d'); - if (chId == null) continue; - final pTags = {}; - for (final tag in event.tags) { - if (tag.isNotEmpty && tag[0] == 'p' && tag.length > 1) { - pTags.add(tag[1].toLowerCase()); - } - } - memberCounts[chId] = pTags.length; - } + final memberCounts = _memberCountsByChannelId(memberEvents); for (var i = 0; i < channels.length; i++) { final count = memberCounts[channels[i].id]; if (count != null) { @@ -301,7 +296,10 @@ class ChannelsNotifier extends AsyncNotifier> { final channelById = { for (final channel in activeChannels) channel.id: channel, }; - final events = await _fetchLastMessageEvents(session, activeChannels); + final events = await _fenced( + fence, + _fetchLastMessageEvents(session, activeChannels), + ); final lastMessageMap = {}; final mutedChannelIds = _mutedChannelIds(); for (final event in events) { @@ -356,6 +354,12 @@ class ChannelsNotifier extends AsyncNotifier> { // Scoped narrowly to the archived flip — broader metadata staleness // (renames, topic changes, etc.) is a separate, pre-existing concern that // already affects this provider for other reasons. + // Re-check before the first write that other providers can observe. Every + // await above is fenced, but the switch can also land in the synchronous + // gap, so the guard sits immediately before the write rather than only + // after the await. + fence.ensureCurrent(); + final prevById = { for (final c in state.value ?? const []) c.id: c, }; @@ -367,8 +371,14 @@ class ChannelsNotifier extends AsyncNotifier> { } if (subscribeLive) { - await _subscribeLive(channels); - } + // Subscriptions are shared relay state, so a retired refresh must not + // install them even though its channel list is already built. + fence.ensureCurrent(); + await _fenced(fence, _subscribeLive(channels, fence)); + } + // Guard the provider-state write in `retryDirectory` and `build`: the + // caller assigns whatever this returns, so the last check belongs here. + fence.ensureCurrent(); return channels; } @@ -470,51 +480,6 @@ class ChannelsNotifier extends AsyncNotifier> { return events; } - Future> _fetchHiddenDmIds( - RelaySessionNotifier session, - String myPk, - ) async { - try { - final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk)); - if (events.isEmpty) return const {}; - NostrEvent latest = events.first; - for (final event in events.skip(1)) { - if (event.createdAt > latest.createdAt) { - latest = event; - } - } - return { - for (final tag in latest.tags) - if (tag.length >= 2 && tag[0] == 'h') tag[1], - }; - } catch (_) { - return const {}; - } - } - - Future> _fetchHuddleStarts( - RelaySessionNotifier session, - List parentChannelIds, - ) async { - if (parentChannelIds.isEmpty) return const []; - try { - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - return await session.fetchHistory( - NostrFilter( - kinds: const [EventKind.huddleStarted], - tags: {'#h': parentChannelIds}, - since: now - const Duration(hours: 2).inSeconds, - limit: 500, - ), - ); - } catch (error) { - debugPrint( - '[ChannelsNotifier] Huddle backing-channel query failed: $error', - ); - return const []; - } - } - /// Build a [Channel] from a kind:39000 metadata event. /// /// [displayNames] maps lowercase participant pubkey → resolved label and is @@ -565,26 +530,34 @@ class ChannelsNotifier extends AsyncNotifier> { } /// Subscribe per-channel to live events (requires `#h` tag for relay - /// channel-scoped fan-out). Also starts a 60s WS backstop poll to detect - /// newly created channels we don't yet have subscriptions for. - Future _subscribeLive(List channels) { + /// channel-scoped fan-out). Also starts a 60s WS backstop poll to reconcile + /// membership changes without repeatedly downloading the global directory. + Future _subscribeLive( + List channels, + _ChannelRefreshFence fence, + ) { final channelIds = { for (final channel in channels) if (channel.isMember && !channel.isArchived) channel.id, }; final relayBaseUrl = ref.read(relayConfigProvider).baseUrl; - _desiredLiveChannels = channels; _desiredLiveChannelIds = channelIds; final subscriptionVersion = ++_subscriptionVersion; final sync = _liveSubscriptionQueue.then( - (_) => - _syncLiveSubscriptions(relayBaseUrl, subscriptionVersion, channels), + (_) => _syncLiveSubscriptions( + relayBaseUrl, + subscriptionVersion, + channels, + fence, + ), ); _liveSubscriptionQueue = sync.catchError((Object error, StackTrace stack) { - debugPrint( - '[ChannelsNotifier] live subscription sync failed: $error\n$stack', - ); + if (error is! _StaleChannelRefresh) { + debugPrint( + '[ChannelsNotifier] live subscription sync failed: $error\n$stack', + ); + } }); return sync; } @@ -593,17 +566,14 @@ class ChannelsNotifier extends AsyncNotifier> { String relayBaseUrl, int subscriptionVersion, List channels, + _ChannelRefreshFence fence, ) async { + fence.ensureCurrent(); if (ref.read(relaySessionProvider).status != SessionStatus.connected) { return; } if (subscriptionVersion != _subscriptionVersion) { - await _syncLiveSubscriptions( - ref.read(relayConfigProvider).baseUrl, - _subscriptionVersion, - _desiredLiveChannels, - ); return; } @@ -642,7 +612,12 @@ class ChannelsNotifier extends AsyncNotifier> { ), _handleLiveEvent, ); - if (ref.read(relaySessionProvider).status != SessionStatus.connected || + if (!fence.isCurrent) { + unsubscribe(); + throw const _StaleChannelRefresh(); + } + if (subscriptionVersion != _subscriptionVersion || + ref.read(relaySessionProvider).status != SessionStatus.connected || !_desiredLiveChannelIds.contains(channelId) || ref.read(relayConfigProvider).baseUrl != relayBaseUrl || _subscriptionRelayBaseUrl != relayBaseUrl) { @@ -655,6 +630,8 @@ class ChannelsNotifier extends AsyncNotifier> { continue; } _unsubscribersByChannel[channelId] = unsubscribe; + } on _StaleChannelRefresh { + rethrow; } catch (error) { debugPrint( '[ChannelsNotifier] live subscription failed for $channelId: $error', @@ -676,7 +653,8 @@ class ChannelsNotifier extends AsyncNotifier> { return; } - unawaited(_catchUpUnreadEvents(channels)); + fence.ensureCurrent(); + unawaited(_catchUpUnreadEvents(channels, fence, subscriptionVersion)); _backstopTimer?.cancel(); _backstopTimer = Timer.periodic( @@ -685,7 +663,24 @@ class ChannelsNotifier extends AsyncNotifier> { ); } - Future _catchUpUnreadEvents(List channels) async { + /// Backfills unread badges for the channels this refresh just installed. + /// + /// Runs detached from the refresh that starts it, so the lifecycle token + /// captured below is what keeps a response that outlived its refresh from + /// writing unread state into whatever the user is looking at now. Every + /// refresh path starts a catch-up, including the initial load, the ordinary + /// membership refresh a join performs and the reconnect backstop, so the + /// token is unconditional rather than tied to discovery. A retired refresh + /// returns instead of throwing: nothing awaits this future, so a thrown + /// [_StaleChannelRefresh] would only surface as an unhandled error. + /// + /// The request fence and subscription generation are passed from the refresh + /// that installed [channels], preserving request ownership after detachment. + Future _catchUpUnreadEvents( + List channels, + _ChannelRefreshFence fence, + int subscriptionGeneration, + ) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) return; @@ -727,6 +722,10 @@ class ChannelsNotifier extends AsyncNotifier> { filters, operation: 'unread catch-up', ); + // The relay round-trip above is the window Jed's probes park in: a newer + // refresh, a community switch or an identity switch here means every + // write below belongs to a channel list the user has left. + if (_isCatchUpRetired(fence, subscriptionGeneration)) return; for (final event in events) { if (event.pubkey.toLowerCase() == myPk.toLowerCase()) { @@ -734,6 +733,7 @@ class ChannelsNotifier extends AsyncNotifier> { } } + var recorded = false; for (final event in events) { final channelId = event.channelId; if (channelId == null) continue; @@ -754,12 +754,19 @@ class ChannelsNotifier extends AsyncNotifier> { continue; } _recordUnreadEvent(channel, event, myPk); + recorded = true; + } + // Republish only when this catch-up actually changed unread state. A + // batch that recorded nothing has nothing to show, and a failed or + // superseded batch must not repaint another refresh's list: the retired + // check above already returned in that case, and no await separates it + // from here, so a second check would be dead code. + if (recorded) { + state = state.whenData((channels) => List.of(channels)); } } catch (error) { debugPrint('[ChannelsNotifier] unread catch-up failed: $error'); } - - state = state.whenData((channels) => List.of(channels)); } void _handleLiveEvent(NostrEvent event) { @@ -858,31 +865,6 @@ class ChannelsNotifier extends AsyncNotifier> { } } - void _recordUnreadEvent(Channel channel, NostrEvent event, String myPk) { - final isThreadedReply = - event.threadReference.parentId != null && !_isBroadcastReply(event); - final isHighPriority = - channel.isDm || isHighPriorityEvent(event.tags, myPk); - recordObservedUnreadEvent( - _observedUnreadEventsByChannel, - channel.id, - makeObservedUnreadEvent( - id: event.id, - createdAt: event.createdAt, - rootId: _observedUnreadRootId(event), - highPriority: isHighPriority, - channelType: channel.channelType, - isThreadedReply: isThreadedReply, - ), - _unreadCatchUpLimit, - ); - - final current = _latestObservedByChannel[channel.id] ?? 0; - if (event.createdAt > current) { - _latestObservedByChannel[channel.id] = event.createdAt; - } - } - void clearObservedUnreadForChannel(String channelId) { _latestObservedByChannel.remove(channelId); _observedUnreadEventsByChannel.remove(channelId); @@ -908,6 +890,7 @@ class ChannelsNotifier extends AsyncNotifier> { 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]; @@ -916,6 +899,8 @@ class ChannelsNotifier extends AsyncNotifier> { } } state = AsyncData(channels); + } on _StaleChannelRefresh { + return; } catch (error) { debugPrint('[ChannelsNotifier] backstop refresh failed: $error'); } @@ -929,12 +914,69 @@ class ChannelsNotifier extends AsyncNotifier> { // cached channel list with [] or an error. Wait for `build()` to re-run // when the session transitions to connected. if (sessionState.status != SessionStatus.connected) return; - state = await AsyncValue.guard(() => _fetch(subscribeLive: true)); + try { + final channels = await _fetch(subscribeLive: true); + state = AsyncData(channels); + } on _StaleChannelRefresh { + return; + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + } + } + + /// Loads the directory when Browse channels opens after startup or an error. + Future ensureDirectoryLoaded() async { + final directoryState = ref.read(channelDirectoryLoadStatusProvider); + final scope = channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), + ); + if (directoryState.scope == scope && + (directoryState.status == ChannelDirectoryLoadStatus.loading || + directoryState.status == ChannelDirectoryLoadStatus.loaded)) { + return; + } + await retryDirectory(); + } + + /// Retries channel discovery while retaining the current channel list. + Future retryDirectory() async { + final previousChannels = state.value; + final directoryStatus = ref.read( + channelDirectoryLoadStatusProvider.notifier, + ); + final scope = channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), + ); + final directoryState = ref.read(channelDirectoryLoadStatusProvider); + if (directoryState.scope == scope && + directoryState.status == ChannelDirectoryLoadStatus.loading) { + return; + } + if (ref.read(relaySessionProvider).status != SessionStatus.connected) { + directoryStatus.markError(scope); + return; + } + try { + state = AsyncData( + await _fetch(subscribeLive: true, fetchDirectory: true), + ); + } on _StaleChannelRefresh { + // A community or identity switch retired this request. Its response + // describes a scope the user has left, so write neither the channel list + // nor the load status; the new scope owns both now. + return; + } catch (error, stackTrace) { + directoryStatus.markError(scope); + state = previousChannels == null + ? AsyncError(error, stackTrace) + : AsyncData(previousChannels); + } } void _clearLiveSubscriptions() { _subscriptionVersion++; - _desiredLiveChannels = const []; _desiredLiveChannelIds = const {}; for (final unsubscribe in _unsubscribersByChannel.values) { unsubscribe(); @@ -949,26 +991,3 @@ class ChannelsNotifier extends AsyncNotifier> { final channelsProvider = AsyncNotifierProvider>( ChannelsNotifier.new, ); - -String? _observedUnreadRootId(NostrEvent event) => - _isBroadcastReply(event) ? null : event.threadReference.rootId; - -bool _isBroadcastReply(NostrEvent event) => event.tags.any( - (tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1', -); - -Set _readRootIdSet(String? raw) { - if (raw == null || raw.isEmpty) return {}; - try { - final decoded = jsonDecode(raw); - if (decoded is! List) return {}; - return { - for (final value in decoded) - if (value is String) value, - }; - } catch (_) { - return {}; - } -} - -String _encodeRootIdSet(Set values) => jsonEncode(values.toList()); diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 563a46c9e8b..514629b1f72 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -644,12 +644,10 @@ class _RecentSearches extends StatelessWidget { class _ChannelsSection extends StatelessWidget { final List channels; final VoidCallback onResultSelected; - const _ChannelsSection({ required this.channels, required this.onResultSelected, }); - @override Widget build(BuildContext context) { return Column( @@ -670,12 +668,14 @@ class _ChannelsSection extends StatelessWidget { key: ValueKey('search-channel-title-${channel.id}'), style: contentListTitleTextStyle, ), - subtitle: Text( - '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', - style: contentListBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), + subtitle: channel.isMember + ? Text( + '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', + style: contentListBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ) + : null, trailing: !channel.isMember && !channel.isDm ? Container( padding: const EdgeInsets.symmetric( diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 9cd955ce52d..68fa3152365 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -24,6 +24,7 @@ import 'package:buzz/shared/community/community_icon_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; @@ -1278,8 +1279,8 @@ void main() { } await tester.pumpAndSettle(); - expect(largestHeight, greaterThan(160)); - expect(tester.getSize(surface).height, closeTo(160, 0.01)); + expect(largestHeight, greaterThan(216)); + expect(tester.getSize(surface).height, closeTo(216, 0.01)); final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width; final surfaceRect = tester.getRect(surface); expect(surfaceRect.left, closeTo(20, 0.01)); @@ -1292,15 +1293,23 @@ void main() { const Key('quick-action-create-channel-card'), ); final dmCard = find.byKey(const Key('quick-action-new-dm-card')); + final browseCard = find.byKey( + const Key('quick-action-browse-channels-card'), + ); final createRect = tester.getRect(createCard); final dmRect = tester.getRect(dmCard); + final browseRect = tester.getRect(browseCard); expect(createRect.left - menuRect.left, closeTo(8, 0.01)); expect(menuRect.right - createRect.right, closeTo(8, 0.01)); expect(dmRect.left - menuRect.left, closeTo(8, 0.01)); expect(menuRect.right - dmRect.right, closeTo(8, 0.01)); + expect(browseRect.left - menuRect.left, closeTo(8, 0.01)); + expect(menuRect.right - browseRect.right, closeTo(8, 0.01)); expect(dmRect.top - createRect.bottom, closeTo(8, 0.01)); + expect(browseRect.top - dmRect.bottom, closeTo(8, 0.01)); expect(dmRect.width, createRect.width); + expect(browseRect.width, createRect.width); expect(dmRect.width, closeTo(menuRect.width - 16, 0.01)); final cardScheme = Theme.of(tester.element(createCard)).colorScheme; @@ -1314,8 +1323,12 @@ void main() { final dmMaterial = tester.widget( find.descendant(of: dmCard, matching: find.byType(Material)).first, ); + final browseMaterial = tester.widget( + find.descendant(of: browseCard, matching: find.byType(Material)).first, + ); expect(createMaterial.color, expectedCardColor); expect(dmMaterial.color, expectedCardColor); + expect(browseMaterial.color, expectedCardColor); expect( (createMaterial.borderRadius as BorderRadius).topLeft.x, closeTo(12, 0.01), @@ -1334,9 +1347,297 @@ void main() { tester.widget(find.text('New direct message')).style?.fontSize, 16, ); + expect( + tester.widget(find.text('Browse channels')).style?.fontSize, + 16, + ); expect(find.text('Message one or more people'), findsNothing); }); + testWidgets('browse action lists only channels eligible to join', ( + tester, + ) async { + final channels = [ + ...testChannels, + Channel( + id: 'open-to-join', + name: 'announcements', + channelType: 'stream', + visibility: 'open', + description: 'Community announcements', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 8, + ), + Channel( + id: 'private-channel', + name: 'private-planning', + channelType: 'stream', + visibility: 'private', + description: 'Private planning', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 4, + ), + Channel( + id: 'archived-channel', + name: 'old-announcements', + channelType: 'stream', + visibility: 'open', + description: 'Archived announcements', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 3, + archivedAt: DateTime(2025, 1, 2), + ), + Channel( + id: 'unjoined-dm', + name: 'Hidden DM', + channelType: 'dm', + visibility: 'open', + description: 'Direct message', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 2, + ), + ]; + + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(channels)), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('browse-channel-open-to-join')), + findsOneWidget, + ); + expect(find.byKey(const Key('browse-channel-1')), findsNothing); + expect( + find.byKey(const Key('browse-channel-private-channel')), + findsNothing, + ); + expect( + find.byKey(const Key('browse-channel-archived-channel')), + findsNothing, + ); + expect(find.byKey(const Key('browse-channel-unjoined-dm')), findsNothing); + }); + + testWidgets('browse action explains when no channels are discoverable', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect(find.text('No open channels available to join.'), findsOneWidget); + }); + + testWidgets('browse action retries an initial directory request problem', ( + tester, + ) async { + final joinable = Channel( + id: 'retry-discovery', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Help from the community', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 0, + ); + late _RetryingDirectoryNotifier notifier; + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith( + () => notifier = _RetryingDirectoryNotifier( + initialChannels: testChannels, + retriedChannels: [...testChannels, joinable], + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Couldn’t load open channels.'), findsOneWidget); + expect(find.text('No open channels available to join.'), findsNothing); + expect(find.byKey(const Key('browse-channels-retry')), findsOneWidget); + + await tester.tap(find.byKey(const Key('browse-channels-retry'))); + await tester.pumpAndSettle(); + + expect(notifier.retryCount, 1); + expect(find.text('Couldn’t load open channels.'), findsNothing); + expect( + find.byKey(const Key('browse-channel-retry-discovery')), + findsOneWidget, + ); + }); + + testWidgets('browse action exposes retry when refresh supersedes loading', ( + tester, + ) async { + final joinable = Channel( + id: 'superseded-directory', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Help from the community', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 0, + ); + late _SupersededDirectoryNotifier notifier; + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith( + () => notifier = _SupersededDirectoryNotifier( + initialChannels: testChannels, + retriedChannels: [...testChannels, joinable], + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect(find.byType(BuzzLoadingIndicator), findsOneWidget); + + notifier.supersedeLoadingDirectory(); + await tester.pumpAndSettle(); + + expect(find.byType(BuzzLoadingIndicator), findsNothing); + expect(find.text('Couldn’t load open channels.'), findsOneWidget); + expect(find.byKey(const Key('browse-channels-retry')), findsOneWidget); + + await tester.tap(find.byKey(const Key('browse-channels-retry'))); + await tester.pumpAndSettle(); + + expect(notifier.retryCount, 1); + expect( + find.byKey(const Key('browse-channel-superseded-directory')), + findsOneWidget, + ); + }); + + testWidgets('browse action scrolls and joins an offscreen channel', ( + tester, + ) async { + final channels = List.generate( + 500, + (index) => Channel( + id: 'directory-$index', + name: 'channel-${index.toString().padLeft(3, '0')}', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 0, + ), + ); + late _RecordingChannelActions actions; + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(channels)), + channelActionsProvider.overrideWith( + (ref) => actions = _RecordingChannelActions(ref), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('browse-channel-directory-0')), + findsAtLeast(1), + ); + expect(find.byKey(const Key('browse-channel-directory-499')), findsNothing); + + final sheet = find.byType(BottomSheet).last; + final scrollable = find + .descendant(of: sheet, matching: find.byType(Scrollable)) + .last; + expect( + tester.state(scrollable).position.maxScrollExtent, + greaterThan(0), + ); + await tester.scrollUntilVisible( + find.byKey(const Key('browse-channel-directory-499')), + 500, + scrollable: scrollable, + maxScrolls: 100, + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('browse-channel-directory-499')), + findsOneWidget, + ); + expect(find.byKey(const Key('browse-channel-directory-0')), findsNothing); + + await tester.tap( + find.byKey(const Key('browse-channel-join-directory-499')), + ); + await tester.pumpAndSettle(); + + expect(actions.joinedChannelIds, ['directory-499']); + expect(find.byType(BottomSheet), findsNothing); + }); + testWidgets('create channel sheet lists type and visibility radio options', ( tester, ) async { @@ -1728,15 +2029,37 @@ void main() { expect(find.text('archived-stream'), findsNothing); }); - testWidgets('shows empty state when no channels', (tester) async { + testWidgets('empty state does not preview unjoined channels', (tester) async { + final discoveredChannel = Channel( + id: 'discovered-channel', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Get help from the community', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 7, + ); await tester.pumpWidget( buildTestable( - overrides: [channelsProvider.overrideWith(() => _FakeNotifier([]))], + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier([discoveredChannel]), + ), + ], ), ); await tester.pumpAndSettle(); expect(find.text('No conversations yet'), findsOneWidget); + expect( + find.text('Join an open channel to start a conversation.'), + findsNothing, + ); + expect( + find.byKey(const Key('browse-channel-discovered-channel')), + findsNothing, + ); }); testWidgets('shows error view with retry button', (tester) async { @@ -1997,6 +2320,13 @@ class _FakeNotifier extends ChannelsNotifier { @override Future> build() async => _channels; + @override + Future ensureDirectoryLoaded() async { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoaded(_activeDirectoryScope(ref)); + } + @override Map get latestObservedByChannel => { for (final entry in _observedEventsByChannel.entries) @@ -2011,6 +2341,105 @@ class _FakeNotifier extends ChannelsNotifier { get observedUnreadEventsByChannel => _observedEventsByChannel; } +class _RetryingDirectoryNotifier extends ChannelsNotifier { + _RetryingDirectoryNotifier({ + required this.initialChannels, + required this.retriedChannels, + }); + + final List initialChannels; + final List retriedChannels; + int retryCount = 0; + + @override + Future> build() async => initialChannels; + + @override + Future ensureDirectoryLoaded() async { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markError(_activeDirectoryScope(ref)); + } + + @override + Future retryDirectory() async { + retryCount++; + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoading(_activeDirectoryScope(ref)); + await Future.delayed(Duration.zero); + state = AsyncData(retriedChannels); + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoaded(_activeDirectoryScope(ref)); + } +} + +class _SupersededDirectoryNotifier extends ChannelsNotifier { + _SupersededDirectoryNotifier({ + required this.initialChannels, + required this.retriedChannels, + }); + + final List initialChannels; + final List retriedChannels; + final _directoryCompletion = Completer(); + int retryCount = 0; + + @override + Future> build() async => initialChannels; + + @override + Future ensureDirectoryLoaded() async { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoading(_activeDirectoryScope(ref)); + await _directoryCompletion.future; + } + + /// Mirrors an ordinary refresh invalidating the active directory request. + void supersedeLoadingDirectory() { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markError(_activeDirectoryScope(ref)); + _directoryCompletion.complete(); + } + + @override + Future retryDirectory() async { + retryCount++; + state = AsyncData(retriedChannels); + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoaded(_activeDirectoryScope(ref)); + } +} + +String _activeDirectoryScope(Ref ref) => channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), +); + +class _RecordingChannelActions extends ChannelActions { + _RecordingChannelActions(Ref ref) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: 'self', + ); + + final List joinedChannelIds = []; + + @override + Future joinChannel(String channelId) async { + joinedChannelIds.add(channelId); + } +} + class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { _FakeChannelSectionsNotifier(this._store); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index fa5d3a0e118..ea6c38cdfd1 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -9,10 +9,11 @@ import 'package:buzz/shared/relay/relay.dart'; /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// -/// The provider performs a two-step WS query: -/// 1. kind:39002 memberships tagged `#p:` +/// The provider loads membership-backed channels first: +/// 1. paginated kind:39002 memberships tagged `#p:` /// 2. kind:39000 metadata for those channel ids -/// then layers per-channel live subscriptions on the `#h` tag. +/// then layers per-channel live subscriptions on the `#h` tag. Browse channels +/// separately triggers paginated kind:39000 open-channel discovery. /// /// Tests stub out the relay session by overriding [relaySessionProvider] with /// a [_FakeRelaySession] that returns canned events from [fetchHistory] and @@ -21,6 +22,1496 @@ import 'package:buzz/shared/relay/relay.dart'; void main() { const myPk = 'me'; + test( + 'discovers open channels for a user with zero channel memberships', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'staff', visibility: 'private'), + _meta(id: _channelD, name: 'DM', channelType: 'dm'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + expect(session.directoryQueryFilters, isEmpty); + + await container.read(channelsProvider.notifier).retryDirectory(); + final channels = container.read(channelsProvider).requireValue; + + expect(channels, hasLength(1)); + expect(channels.single.id, _channelA); + expect(channels.single.isMember, isFalse); + expect(session.subscribeFilters, isEmpty); + expect(session.directoryQueryFilters, isNotEmpty); + }, + ); + + test('paginates channel discovery with a composite cursor', () async { + final firstPage = List.generate( + 500, + (index) => _meta( + id: '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000', + name: 'channel-$index', + createdAt: 10, + ), + ); + final finalChannel = _meta( + id: '99999999-9999-4999-8999-999999999999', + name: 'last-page', + createdAt: 9, + ); + final session = _FakeRelaySession( + memberships: const [], + metadataPages: [ + firstPage, + [finalChannel], + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + await container.read(channelsProvider.notifier).retryDirectory(); + final channels = container.read(channelsProvider).requireValue; + + expect(channels, hasLength(501)); + final directoryFilters = session.directoryQueryFilters; + expect(directoryFilters, hasLength(3)); + expect(directoryFilters.first.until, isNull); + expect(directoryFilters.first.extensions, isEmpty); + expect(directoryFilters[1].until, firstPage.last.createdAt); + expect(directoryFilters[1].extensions['before_id'], firstPage.last.id); + expect(directoryFilters.last.until, finalChannel.createdAt); + expect(directoryFilters.last.extensions['before_id'], finalChannel.id); + }); + + test( + 'paginates memberships when the relay caps responses below limit', + () async { + final firstPage = List.generate( + 100, + (index) => _membership( + '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000', + myPk, + ), + ); + final finalChannelId = '99999999-9999-4999-8999-999999999999'; + final finalMembership = _membership(finalChannelId, myPk); + final session = _FakeRelaySession( + memberships: const [], + membershipPages: [ + firstPage, + [finalMembership], + ], + metadata: [_meta(id: finalChannelId, name: 'last-membership')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + + expect(channels.single.id, finalChannelId); + expect(channels.single.isMember, isTrue); + expect(session.membershipQueryFilters, hasLength(3)); + expect(session.membershipQueryFilters.first.until, isNull); + expect(session.membershipQueryFilters.first.extensions, isEmpty); + expect(session.membershipQueryFilters[1].until, firstPage.last.createdAt); + expect( + session.membershipQueryFilters[1].extensions['before_id'], + firstPage.last.id, + ); + expect( + session.membershipQueryFilters.last.extensions['before_id'], + finalMembership.id, + ); + }, + ); + + test('stops membership pagination when the relay repeats a page', () async { + final repeatedPage = List.generate( + 500, + (index) => _membership( + '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000', + myPk, + ), + ); + final session = _FakeRelaySession( + memberships: const [], + membershipPages: [repeatedPage], + repeatLastMembershipPage: true, + maxMembershipPageRequests: 2, + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + expect(session.membershipRequestCount, 2); + }); + + test('stops channel discovery when the relay repeats a full page', () async { + final repeatedPage = List.generate( + 500, + (index) => _meta( + id: 'repeated-channel-$index', + name: 'repeated-$index', + createdAt: 10, + ), + ); + final session = _FakeRelaySession( + memberships: const [], + metadataPages: [repeatedPage], + repeatLastMetadataPage: true, + maxMetadataPageRequests: 2, + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + await container.read(channelsProvider.notifier).retryDirectory(); + final channels = container.read(channelsProvider).requireValue; + + expect(channels, hasLength(500)); + expect(session.metadataPageRequestCount, 2); + }); + + test( + 'directory page-cap failure is distinct from an empty directory', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadataPageBuilder: (pageIndex) => List.generate( + 500, + (eventIndex) => _meta( + id: 'channel-$pageIndex-$eventIndex', + name: 'channel-$pageIndex-$eventIndex', + createdAt: 1000 - pageIndex, + ), + ), + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + await container.read(channelsProvider.notifier).retryDirectory(); + expect(session.metadataPageRequestCount, 100); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + }, + ); + + test( + 'directory failure retains discovery while membership refreshes', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect( + (await container.read( + channelsProvider.future, + )).map((channel) => channel.id), + [_channelA], + ); + await container.read(channelsProvider.notifier).retryDirectory(); + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + unorderedEquals([_channelA, _channelB]), + ); + + session.memberships = [ + _membership(_channelA, myPk), + _membership(_channelD, myPk), + ]; + session.metadata = [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + _meta(id: _channelD, name: 'newly joined'), + ]; + session.directoryFailures = 1; + + await container.read(channelsProvider.notifier).retryDirectory(); + + final refreshed = container.read(channelsProvider).requireValue; + expect( + refreshed.map((channel) => channel.id), + unorderedEquals([_channelA, _channelB, _channelD]), + ); + expect( + refreshed.firstWhere((channel) => channel.id == _channelD).isMember, + isTrue, + ); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + + await container.read(channelsProvider.notifier).retryDirectory(); + + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.loaded, + ); + }, + ); + + test('directory retry failure retains the current channel list', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final initial = await container.read(channelsProvider.future); + session.membershipFailures = 1; + + await container.read(channelsProvider.notifier).retryDirectory(); + + expect(container.read(channelsProvider).requireValue, initial); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + }); + + test( + 'ordinary refresh settles a superseded directory load for retry', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect( + (await container.read( + channelsProvider.future, + )).map((channel) => channel.id), + [_channelA], + ); + + session.pauseNextDirectoryQuery(); + final directory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + // A foreground, reconnect, pull-to-refresh, or membership update can + // start an ordinary refresh while Browse is still loading discovery. + await container.read(channelsProvider.notifier).refresh(); + + session.resumePausedDirectoryQuery(); + await directory; + await _settle(); + + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + [_channelA], + ); + + await container.read(channelsProvider.notifier).retryDirectory(); + + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.loaded, + ); + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + unorderedEquals([_channelA, _channelB]), + ); + }, + ); + + test( + 'community switch discards a stale directory success from the old relay', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.pauseNextDirectoryQuery(); + final staleDirectory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + // Switch communities while community A's directory response is paused. + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + + session.resumePausedDirectoryQuery(); + await staleDirectory; + await _settle(); + + final ids = container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + expect( + container.read(channelDirectoryLoadStatusProvider).scope, + isNot( + channelDirectoryScope( + 'https://community-b.example', + container.read(myPubkeyProvider), + ), + ), + ); + }, + ); + + test( + 'community switch discards a stale directory failure from the old relay', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.pauseNextDirectoryQuery(); + final staleDirectory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + + // Community A's directory request fails after the switch. + session.directoryFailures = 1; + session.resumePausedDirectoryQuery(); + await staleDirectory; + await _settle(); + + final ids = container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id); + expect(ids, contains(_channelB)); + expect( + container.read(channelDirectoryLoadStatusProvider).scope, + isNot( + channelDirectoryScope( + 'https://community-b.example', + container.read(myPubkeyProvider), + ), + ), + ); + }, + ); + + test( + 'identity switch discards a stale directory success from the old identity', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.pauseNextDirectoryQuery(); + final staleDirectory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + // Switch signing identity while the first identity's response is paused. + 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.resumePausedDirectoryQuery(); + await staleDirectory; + await _settle(); + + final ids = container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + }, + ); + + test( + 'identity switch discards a stale directory failure from the old identity', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.pauseNextDirectoryQuery(); + final staleDirectory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + 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.directoryFailures = 1; + session.resumePausedDirectoryQuery(); + await staleDirectory; + await _settle(); + + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + contains(_channelB), + ); + }, + ); + + test( + 'community switch after directory success discards the late refresh', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + // Community A now has a joined channel, so the directory-triggered + // refresh reaches the live-subscribe step and parks there. That await is + // AFTER the loader's own directory fence, which is the window this arm + // covers: directory success, then retirement, then settlement. + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextSubscribe(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextSubscribeStarted; + + // Record every list emitted from the switch onward. The live-subscription + // queue is serialized, so community B's own subscribe waits behind the + // parked one. That makes the observable defect an emission of community + // A's channel into the current scope, not just a wrong final state. + final emitted = >[]; + container.listen(channelsProvider, (previous, next) { + final value = next.value; + if (value != null) { + emitted.add(value.map((channel) => channel.id).toList()); + } + }); + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + + session.resumePausedSubscribe(); + await staleRefresh; + await _settle(); + + expect( + emitted.where((ids) => ids.contains(_channelA)), + isEmpty, + reason: 'community A channel emitted into community B scope: $emitted', + ); + // The retired refresh must not leave a subscription on the old channel. + expect(session.activeChannels, isNot(contains(_channelA))); + // Nor may it claim the new scope's directory status as its own. + expect( + container.read(channelDirectoryLoadStatusProvider).scope, + isNot( + channelDirectoryScope( + 'https://community-b.example', + container.read(myPubkeyProvider), + ), + ), + ); + }, + ); + + test( + 'community switch after directory success discards a late failure', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + // The directory query succeeds, then the refresh parks on the member-count + // 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(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextMemberCountQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + + session.failClaimedMemberCountQuery = true; + session.resumePausedMemberCountQuery(); + await staleRefresh; + await _settle(); + + final current = container.read(channelsProvider); + expect(current.hasError, isFalse); + final ids = current.requireValue.map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + // A retired failure must not claim the new scope's directory status. + expect( + container.read(channelDirectoryLoadStatusProvider).scope, + isNot( + channelDirectoryScope( + 'https://community-b.example', + container.read(myPubkeyProvider), + ), + ), + ); + }, + ); + + test( + 'identity switch after directory success discards the late refresh', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + // Directory success, then the refresh parks on the hidden-DM query while + // the signing identity changes and the new identity finishes its rebuild. + session.pauseNextHiddenDmQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextHiddenDmQueryStarted; + + 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.resumePausedHiddenDmQuery(); + await staleRefresh; + await _settle(); + + final ids = container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + expect(session.activeChannels, isNot(contains(_channelA))); + }, + ); + + test( + 'identity switch after directory success discards a late failure', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextMemberCountQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextMemberCountQueryStarted; + + 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(); + await staleRefresh; + await _settle(); + + final current = container.read(channelsProvider); + expect(current.hasError, isFalse); + final ids = current.requireValue.map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + }, + ); + + test('a newer ordinary refresh owns the installed membership list', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.pauseNextHiddenDmQuery(); + final olderRefresh = container.read(channelsProvider.notifier).refresh(); + await session.nextHiddenDmQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await container.read(channelsProvider.notifier).refresh(); + + session.resumePausedHiddenDmQuery(); + await olderRefresh; + await _settle(); + + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + [_channelB], + reason: 'an older ordinary refresh overwrote the newer membership list', + ); + expect(session.activeChannels, {_channelB}); + }); + + test( + 'a newer refresh owns the list over an older reconnect backstop', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.pauseNextHiddenDmQuery(); + session.setStatus(SessionStatus.reconnecting); + session.setStatus(SessionStatus.connected); + await session.nextHiddenDmQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await container.read(channelsProvider.notifier).refresh(); + + session.resumePausedHiddenDmQuery(); + await _settle(); + + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + [_channelB], + reason: + 'an older reconnect backstop overwrote the newer membership list', + ); + expect(session.activeChannels, {_channelB}); + }, + ); + + test('a stale request\'s Huddle leg writes no member snapshot', () async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + huddleStarts: [ + NostrEvent( + id: 'huddle-start', + pubkey: myPk, + createdAt: now, + kind: EventKind.huddleStarted, + tags: const [ + ['h', _channelA], + ], + content: '{"ephemeral_channel_id":"$_channelB"}', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + 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. + session.pauseNextHuddleStartQuery(); + final olderRefresh = notifier.refresh(); + await session.nextHuddleStartQueryStarted; + + // A newer refresh completes on a disjoint membership set. + session.memberships = [ + _membership(_channelB, myPk, additionalPubkey: _otherPk), + ]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await notifier.refresh(); + + // Release the older request. Its member-snapshot write sits AFTER the + // Huddle leg, so if that leg is unfenced the stale snapshot lands. + session.resumePausedHuddleStartQuery(); + await olderRefresh; + await _settle(); + + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + [_channelB], + reason: 'a stale request installed its own channel list', + ); + expect( + notifier.cachedMembersForChannel(_channelA), + isEmpty, + reason: + 'a stale request wrote a member snapshot past the Huddle leg fence', + ); + expect( + notifier.cachedMembersForChannel(_channelB).map((m) => m.pubkey), + containsAll([myPk, _otherPk]), + reason: 'the newer request\'s member snapshot was clobbered', + ); + }); + + test('community switch discards a parked unread catch-up', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + recentMessages: const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in community A', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + // The directory query succeeds and the refresh completes, but the unread + // catch-up it kicks off stays parked across the community switch. + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread state landed in community B: ' + '${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread events landed in community B: ' + '${notifier.observedUnreadEventsByChannel}', + ); + }); + + test('identity switch discards a parked unread catch-up', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + recentMessages: const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention for the first identity', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextUnreadCatchUpQueryStarted; + + 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.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'first identity unread state landed on the second identity: ' + '${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'first identity unread events landed on the second identity: ' + '${notifier.observedUnreadEventsByChannel}', + ); + }); + + test('community switch discards a parked catch-up failure', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + // Let community B's own refresh and its own catch-up settle first, so the + // emissions counted below can only come from the parked community A work. + await _settle(); + + // A retired catch-up that fails must publish nothing: the trailing `state` + // write belongs to whichever community is active now. + final staleEmissions = []; + final subscription = container.listen( + channelsProvider, + (_, next) => staleEmissions.add(next.requireValue.length), + fireImmediately: false, + ); + addTearDown(subscription.close); + + session.failClaimedUnreadCatchUpQuery = true; + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final current = container.read(channelsProvider); + expect(current.hasError, isFalse); + expect(current.requireValue.map((channel) => channel.id), [_channelB]); + expect( + staleEmissions, + isEmpty, + reason: + 'a retired catch-up failure republished community B provider state: ' + '$staleEmissions', + ); + }); + + test('identity switch discards a parked catch-up failure', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextUnreadCatchUpQueryStarted; + + 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); + await _settle(); + + final staleEmissions = []; + final subscription = container.listen( + channelsProvider, + (_, next) => staleEmissions.add(next.requireValue.length), + fireImmediately: false, + ); + addTearDown(subscription.close); + + session.failClaimedUnreadCatchUpQuery = true; + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final current = container.read(channelsProvider); + expect(current.hasError, isFalse); + expect(current.requireValue.map((channel) => channel.id), [_channelB]); + expect( + staleEmissions, + isEmpty, + reason: + 'a retired catch-up failure republished the second identity provider ' + 'state: $staleEmissions', + ); + }); + + test('an ordinary membership refresh retires an older catch-up', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + // Same relay, same identity. Only the refresh generation separates the + // parked catch-up from the membership list the user is looking at, which + // is the window the post-join membership refresh opens. + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in channel A', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container.read(channelsProvider.notifier).refresh(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'a superseded ordinary refresh wrote unread state for a channel the ' + 'user has left: ${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'a superseded ordinary refresh wrote unread events for a channel the ' + 'user has left: ${notifier.observedUnreadEventsByChannel}', + ); + }); + + test('a newer refresh retires a parked backstop catch-up', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in channel A', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + // Reconnecting runs the backstop refresh, which never fetches the + // directory, so this is the path that carried no lifecycle token at all. + session.setStatus(SessionStatus.reconnecting); + session.setStatus(SessionStatus.connected); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'a superseded backstop refresh wrote unread state for a channel the ' + 'user has left: ${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'a superseded backstop refresh wrote unread events for a channel the ' + 'user has left: ${notifier.observedUnreadEventsByChannel}', + ); + }); + + test( + 'community switch discards a parked catch-up from an ordinary refresh', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'community-a-general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in community A', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container.read(channelsProvider.notifier).refresh(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread state landed in community B: ' + '${notifier.latestObservedByChannel}', + ); + }, + ); + + test( + 'identity switch discards a parked catch-up from an ordinary refresh', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'first-identity-general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention for the first identity', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container.read(channelsProvider.notifier).refresh(); + await session.nextUnreadCatchUpQueryStarted; + + 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); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'first identity unread state landed on the second identity: ' + '${notifier.latestObservedByChannel}', + ); + }, + ); + + test('a disconnected community switch retires a parked catch-up', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'community-a-general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in community A', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + final started = session.nextUnreadCatchUpQueryStarted; + final staleRefresh = container.read(channelsProvider.notifier).refresh(); + await started; + + // Switch community while the session is down, so the new scope runs no + // refresh of its own. This is the arm that shows the single generation + // counter is sufficient: the rebuild's disposal bumps it even though no + // new refresh does. + session.setStatus(SessionStatus.disconnected); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread state survived a disconnected switch: ' + '${notifier.latestObservedByChannel}', + ); + }); + + test('a catch-up that records nothing does not repaint the list', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + // No unread events to record, so the catch-up has nothing to publish. + // The catch-up is detached, so its query starts inside the refresh: hold + // the started future before awaiting the refresh or the one-shot slot is + // already claimed and reset by the time we ask for it. + session.pauseNextUnreadCatchUpQuery(); + final started = session.nextUnreadCatchUpQueryStarted; + await container.read(channelsProvider.notifier).refresh(); + await started; + await _settle(); + + final emissions = []; + final subscription = container.listen( + channelsProvider, + (_, next) => emissions.add(next.requireValue.length), + fireImmediately: false, + ); + addTearDown(subscription.close); + + session.resumePausedUnreadCatchUpQuery(); + await _settle(); + + expect( + emissions, + isEmpty, + reason: 'an empty unread catch-up republished provider state: $emissions', + ); + }); + + test('community switch drops unread state recorded before it', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'community-a-general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + // A live mention in community A records unread state the badges read. + session.emit( + const NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in community A', + sig: 'sig', + ), + ); + await _settle(); + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + contains(_channelA), + reason: 'precondition: community A unread state was never recorded', + ); + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + await _settle(); + + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread state survived into community B: ' + '${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread events survived into community B: ' + '${notifier.observedUnreadEventsByChannel}', + ); + }); + + test('reconnect backstop does not refetch the channel directory', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final initialDirectoryRequests = session.metadataPageRequestCount; + final initialMembershipRequests = session.membershipRequestCount; + + session.setStatus(SessionStatus.reconnecting); + session.setStatus(SessionStatus.connected); + await _waitUntil( + () => session.membershipRequestCount > initialMembershipRequests, + ); + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } + + expect(session.metadataPageRequestCount, initialDirectoryRequests); + }); + + test('membership refresh does not refetch a loaded directory', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await container.read(channelsProvider.notifier).retryDirectory(); + final directoryRequestCount = session.metadataPageRequestCount; + + await container.read(channelsProvider.notifier).refresh(); + + expect(session.metadataPageRequestCount, directoryRequestCount); + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + unorderedEquals([_channelA, _channelB]), + ); + }); + + test('deduplicates joined channels from directory discovery', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect( + (await container.read( + channelsProvider.future, + )).map((channel) => channel.id), + [_channelA], + ); + await container.read(channelsProvider.notifier).retryDirectory(); + final channels = container.read(channelsProvider).requireValue; + + expect(channels.map((channel) => channel.id), [_channelA, _channelB]); + expect(channels.first.isMember, isTrue); + expect(channels.last.isMember, isFalse); + expect(session.subscribeFilters, hasLength(1)); + }); + test( 'seeds members from the channel-list snapshot during reconnect', () async { @@ -766,13 +2257,17 @@ void main() { await container.read(channelsProvider.future); - // Two history fetches for channel loading, plus one per non-DM channel - // for high-priority event backfill. - expect(session.historyFilters.length, greaterThanOrEqualTo(2)); - expect(session.historyFilters[0].kinds, [39002]); - expect(session.historyFilters[0].tags['#p'], [myPk]); - expect(session.historyFilters[1].kinds, [39000]); - expect(session.historyFilters[1].tags['#d'], [_channelA]); + expect(session.membershipQueryFilters, isNotEmpty); + expect(session.membershipQueryFilters.first.kinds, [39002]); + expect(session.membershipQueryFilters.first.tags['#p'], [myPk]); + expect( + session.historyFilters.any( + (filter) => + filter.kinds.contains(39000) && + filter.tags['#d']?.contains(_channelA) == true, + ), + isTrue, + ); // And one live subscription on the resulting channel. expect(session.subscribeFilters, hasLength(1)); @@ -782,6 +2277,7 @@ void main() { const _channelA = '11111111-1111-4111-8111-111111111111'; const _channelB = '22222222-2222-4222-8222-222222222222'; const _channelD = '44444444-4444-4444-8444-444444444444'; +const _otherPk = 'someone-else'; /// Build a kind:39002 membership event tagged with the channel id and member. NostrEvent _membership( @@ -824,9 +2320,9 @@ NostrEvent _meta({ required String id, required String name, String channelType = 'stream', + String visibility = 'open', int createdAt = 1, int? ttlSeconds, - String visibility = 'open', bool archived = false, }) => NostrEvent( id: 'meta-$id', @@ -851,11 +2347,32 @@ ProviderContainer _buildContainer({required _FakeRelaySession session}) { overrides: [ appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()), relaySessionProvider.overrideWith(() => session), - myPubkeyProvider.overrideWithValue('me'), + // Route the pubkey through a mutable notifier so tests can switch the + // signing identity mid-flight the way an account change does at runtime. + myPubkeyProvider.overrideWith((ref) => ref.watch(_testPubkeyProvider)), ], ); } +/// Mutable stand-in for the signing identity derived from the active community. +class _TestPubkeyNotifier extends Notifier { + @override + String? build() => 'me'; + + void set(String? pubkey) => state = pubkey; +} + +final _testPubkeyProvider = NotifierProvider<_TestPubkeyNotifier, String?>( + _TestPubkeyNotifier.new, +); + +/// Drains pending microtasks so provider rebuilds and awaited writes land. +Future _settle() async { + for (var i = 0; i < 20; i++) { + await Future.delayed(Duration.zero); + } +} + Future _waitUntil(bool Function() predicate) async { for (var i = 0; i < 100; i++) { if (predicate()) return; @@ -864,12 +2381,19 @@ Future _waitUntil(bool Function() predicate) async { fail('Timed out waiting for asynchronous provider work'); } -/// Fake [RelaySessionNotifier] that returns canned events from [fetchHistory] -/// and records subscribe calls. +/// Fake [RelaySessionNotifier] that returns canned query results and records +/// subscriptions. class _FakeRelaySession extends RelaySessionNotifier { _FakeRelaySession({ required this.memberships, - required this.metadata, + this.membershipPages, + this.repeatLastMembershipPage = false, + this.maxMembershipPageRequests, + this.metadata = const [], + this.metadataPages, + this.metadataPageBuilder, + this.repeatLastMetadataPage = false, + this.maxMetadataPageRequests, this.hiddenDmEvents = const [], this.huddleStarts = const [], this.recentMessages = const [], @@ -877,19 +2401,47 @@ class _FakeRelaySession extends RelaySessionNotifier { }); List memberships; + final List>? membershipPages; + final bool repeatLastMembershipPage; + final int? maxMembershipPageRequests; List metadata; + final List>? metadataPages; + final List Function(int pageIndex)? metadataPageBuilder; + final bool repeatLastMetadataPage; + final int? maxMetadataPageRequests; final List hiddenDmEvents; final List huddleStarts; - final List recentMessages; + List recentMessages; int membershipFailures; + int directoryFailures = 0; + bool failClaimedMemberCountQuery = false; + bool failClaimedUnreadCatchUpQuery = false; + int membershipRequestCount = 0; + int metadataPageRequestCount = 0; final List historyFilters = []; final List> queryBatches = []; + final List directoryQueryFilters = []; + final List membershipQueryFilters = []; final List subscribeFilters = []; final Map _subscriptions = {}; int _nextSubscriptionKey = 0; Completer? _pausedSubscribe; Completer? _subscribeStarted; + Completer? _pausedDirectory; + Completer? _directoryStarted; + Completer? _pausedHiddenDm; + Completer? _hiddenDmStarted; + Completer? _claimedHiddenDm; + Completer? _pausedMemberCount; + Completer? _memberCountStarted; + Completer? _claimedMemberCount; + Completer? _pausedHuddleStarts; + Completer? _huddleStartsStarted; + Completer? _claimedHuddleStarts; + Completer? _pausedUnreadCatchUp; + Completer? _unreadCatchUpStarted; + Completer? _claimedUnreadCatchUp; int unsubscribeCount = 0; int totalSubscribeCount = 0; @@ -921,6 +2473,148 @@ class _FakeRelaySession extends RelaySessionNotifier { paused.complete(); } + /// Holds the next directory query open so a community or identity switch can + /// be interleaved between the request and its response. + void pauseNextDirectoryQuery() { + if (_pausedDirectory != null) { + throw StateError('A directory query is already paused'); + } + _pausedDirectory = Completer(); + _directoryStarted = Completer(); + } + + /// Completes once the paused directory query has been requested. + Future get nextDirectoryQueryStarted async { + final started = _directoryStarted; + if (started == null) { + throw StateError('No directory query is pending'); + } + await started.future; + } + + /// Releases the paused directory query so its response lands. + void resumePausedDirectoryQuery() { + final paused = _pausedDirectory; + if (paused == null) throw StateError('No directory query is paused'); + paused.complete(); + } + + /// Holds the next hidden-DM query open, one shot only. + /// + /// One shot matters: the refresh that follows the switch issues its own + /// hidden-DM query, and it must be able to finish while the earlier scope's + /// query is still parked. That is the window Jed's second probe describes. + void pauseNextHiddenDmQuery() { + if (_pausedHiddenDm != null) { + throw StateError('A hidden-DM query is already paused'); + } + _pausedHiddenDm = Completer(); + _hiddenDmStarted = Completer(); + } + + /// Completes once the parked hidden-DM query has been requested. + Future get nextHiddenDmQueryStarted async { + final started = _hiddenDmStarted; + if (started == null) { + throw StateError('No hidden-DM query is pending'); + } + await started.future; + } + + /// Releases the parked hidden-DM query so its response lands. + void resumePausedHiddenDmQuery() { + final paused = _claimedHiddenDm ?? _pausedHiddenDm; + if (paused == null) throw StateError('No hidden-DM query is paused'); + paused.complete(); + } + + /// Holds the next Huddle-start query open, one shot only. + /// + /// Parks the older refresh AFTER both membership fetches have landed, so the + /// only guard left between the park and the member-snapshot write is the + /// fence on this leg. + void pauseNextHuddleStartQuery() { + if (_pausedHuddleStarts != null) { + throw StateError('A Huddle-start query is already paused'); + } + _pausedHuddleStarts = Completer(); + _huddleStartsStarted = Completer(); + } + + /// Completes once the parked Huddle-start query has been requested. + Future get nextHuddleStartQueryStarted async { + final started = _huddleStartsStarted; + if (started == null) { + throw StateError('No Huddle-start query is pending'); + } + await started.future; + } + + /// Releases the parked Huddle-start query so its response lands. + void resumePausedHuddleStartQuery() { + final paused = _claimedHuddleStarts ?? _pausedHuddleStarts; + if (paused == null) throw StateError('No Huddle-start query is paused'); + _claimedHuddleStarts = null; + _pausedHuddleStarts = null; + 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'); + } + _pausedMemberCount = Completer(); + _memberCountStarted = Completer(); + } + + /// Completes once the parked member-count query has been requested. + Future get nextMemberCountQueryStarted async { + final started = _memberCountStarted; + if (started == null) { + throw StateError('No member-count 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'); + paused.complete(); + } + + /// Holds the next unread catch-up batch open, one shot only. + /// + /// The catch-up runs detached from the refresh that starts it, so this is the + /// window where a community or identity switch can land between the request + /// and the writes its response drives. + void pauseNextUnreadCatchUpQuery() { + if (_pausedUnreadCatchUp != null) { + throw StateError('An unread catch-up query is already paused'); + } + _pausedUnreadCatchUp = Completer(); + _unreadCatchUpStarted = Completer(); + } + + /// Completes once the parked unread catch-up batch has been requested. + Future get nextUnreadCatchUpQueryStarted async { + final started = _unreadCatchUpStarted; + if (started == null) { + throw StateError('No unread catch-up query is pending'); + } + await started.future; + } + + /// Releases the parked unread catch-up batch so its response lands. + void resumePausedUnreadCatchUpQuery() { + final paused = _claimedUnreadCatchUp ?? _pausedUnreadCatchUp; + if (paused == null) { + throw StateError('No unread catch-up query is paused'); + } + paused.complete(); + } + @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -931,12 +2625,24 @@ class _FakeRelaySession extends RelaySessionNotifier { }) async { historyFilters.add(filter); 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(); } if (filter.kinds.contains(39002) && filter.tags['#p'] != null) { + membershipRequestCount++; if (membershipFailures > 0) { membershipFailures--; throw Exception('membership fetch failed'); @@ -951,14 +2657,36 @@ class _FakeRelaySession extends RelaySessionNotifier { .toList(); } if (filter.kinds.contains(EventKind.dmVisibility)) { + // Claim the parked slot so the switch's own refresh runs unblocked. + final paused = _pausedHiddenDm; + if (paused != null) { + _claimedHiddenDm = paused; + _pausedHiddenDm = null; + _hiddenDmStarted!.complete(); + _hiddenDmStarted = null; + await paused.future; + } return hiddenDmEvents; } 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. + final paused = _pausedHuddleStarts; + if (paused != null) { + _claimedHuddleStarts = paused; + _pausedHuddleStarts = null; + _huddleStartsStarted!.complete(); + _huddleStartsStarted = null; + await paused.future; + } return huddleStarts; } if (filter.kinds.contains(39000)) { - // Metadata query — return all metadata events whose `d` tag matches. - final ids = (filter.tags['#d'] ?? const []).toSet(); + final ids = filter.tags['#d']?.toSet(); + if (ids == null) { + throw StateError('Directory queries must use the HTTP query bridge'); + } + // Member metadata query — return only matching `d` tags. return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList(); } return const []; @@ -969,8 +2697,98 @@ class _FakeRelaySession extends RelaySessionNotifier { List filters, { Duration timeout = const Duration(seconds: 8), }) async { + if (filters case [final filter] + when filter.kinds.length == 1 && + filter.kinds.single == 39002 && + filter.tags['#p'] != null) { + membershipQueryFilters.add(filter); + if (membershipFailures > 0) { + membershipFailures--; + throw Exception('membership fetch failed'); + } + final requestIndex = membershipRequestCount++; + final maxRequests = maxMembershipPageRequests; + if (maxRequests != null && requestIndex >= maxRequests) { + throw StateError('Unexpected membership page request'); + } + final pages = membershipPages; + if (pages != null) { + if (requestIndex < pages.length) return List.of(pages[requestIndex]); + if (repeatLastMembershipPage && pages.isNotEmpty) { + return List.of(pages.last); + } + return const []; + } + if (filter.until != null) return const []; + final myPk = filter.tags['#p']?.single; + return memberships + .where( + (event) => event.tags.any( + (tag) => tag.length >= 2 && tag[0] == 'p' && tag[1] == myPk, + ), + ) + .toList(); + } + if (filters case [final filter] + when filter.kinds.length == 1 && + filter.kinds.single == 39000 && + !filter.tags.containsKey('#d')) { + directoryQueryFilters.add(filter); + // Snapshot the directory contents at request time so a paused response + // reflects the community that issued it, not whichever community is + // active when the response is released. + final directorySnapshot = List.of(metadata); + final paused = _pausedDirectory; + if (paused != null) { + _directoryStarted!.complete(); + await paused.future; + _pausedDirectory = null; + _directoryStarted = null; + } + if (directoryFailures > 0) { + directoryFailures--; + throw Exception('directory fetch failed'); + } + final requestIndex = metadataPageRequestCount++; + final maxRequests = maxMetadataPageRequests; + if (maxRequests != null && requestIndex >= maxRequests) { + throw StateError('Unexpected directory page request'); + } + final pageBuilder = metadataPageBuilder; + if (pageBuilder != null) return List.of(pageBuilder(requestIndex)); + final pages = metadataPages; + if (pages != null) { + if (requestIndex < pages.length) return List.of(pages[requestIndex]); + if (repeatLastMetadataPage && pages.isNotEmpty) { + return List.of(pages.last); + } + return const []; + } + return filter.until == null ? directorySnapshot : const []; + } queryBatches.add(filters); - return recentMessages.where((event) { + // The unread catch-up is the only batch that carries `since` on every + // filter; the latest-message batch leaves it null. Snapshot the messages at + // request time so a parked response reflects the scope that asked for it. + final isUnreadCatchUp = + filters.isNotEmpty && filters.every((filter) => filter.since != null); + final messageSnapshot = List.of(recentMessages); + if (isUnreadCatchUp) { + // Claim the parked slot so the refresh that follows the switch can run + // its own catch-up unblocked while this one stays parked. + final paused = _pausedUnreadCatchUp; + if (paused != null) { + _claimedUnreadCatchUp = paused; + _pausedUnreadCatchUp = null; + _unreadCatchUpStarted!.complete(); + _unreadCatchUpStarted = null; + await paused.future; + if (failClaimedUnreadCatchUpQuery) { + throw Exception('unread catch-up fetch failed'); + } + } + } + return messageSnapshot.where((event) { return filters.any((filter) { if (!filter.kinds.contains(event.kind)) return false; for (final entry in filter.tags.entries) { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index e2951323600..8b4c903b564 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -930,6 +930,43 @@ void main() { expect(content.agentMentionPubkeys, contains(agentPubkey)); expect(find.byIcon(LucideIcons.bot), findsOneWidget); }); + + testWidgets('does not label an unjoined channel as having zero members', ( + tester, + ) async { + final state = SearchState( + query: 'community', + channelResults: [ + Channel( + id: 'community-help', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Help from the community', + createdBy: 'test', + createdAt: DateTime(2025), + memberCount: 0, + ), + ], + ); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Open'), findsOneWidget); + expect(find.text('0 members'), findsNothing); + }); } class _FakeSearchNotifier extends SearchNotifier {