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..fcb851526bc --- /dev/null +++ b/mobile/lib/features/channels/channel_directory.dart @@ -0,0 +1,130 @@ +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(); + + /// Marks the directory as loading. + void markLoading(String scope) => state = ChannelDirectoryLoadState( + scope: scope, + status: ChannelDirectoryLoadStatus.loading, + ); + + /// 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', +); + +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; +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 7f990a98c5a..c132ac54dce 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 2f8dddf2d92..1bfabd8d2ac 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -18,6 +18,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'; @@ -25,11 +27,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. @@ -53,6 +55,7 @@ class ChannelsNotifier extends AsyncNotifier> { String? _memberSnapshotRelayBaseUrl; String? _memberSnapshotPubkey; Map> _memberSnapshotsByChannelId = const {}; + List _directoryMetas = const []; /// The member snapshot already returned while loading the channel list. /// @@ -80,6 +83,7 @@ class ChannelsNotifier extends AsyncNotifier> { _memberSnapshotRelayBaseUrl = relayBaseUrl; _memberSnapshotPubkey = pubkey; _memberSnapshotsByChannelId = const {}; + _directoryMetas = const []; } final connected = Completer(); final sessionState = ref.read(relaySessionProvider); @@ -124,10 +128,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; @@ -136,6 +142,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,48 +151,50 @@ class ChannelsNotifier extends AsyncNotifier> { final session = ref.read(relaySessionProvider.notifier); // 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 _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 session.fetchHistory( + NostrFilters.channelMetadata(memberChannelIds.toList()), + ); - // 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. + // 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 directoryScope = channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + myPk, + ); + final directoryStatus = ref.read( + channelDirectoryLoadStatusProvider.notifier, + ); + directoryStatus.markLoading(directoryScope); + try { + _directoryMetas = await _fetchChannelDirectoryMetas(session); + directoryStatus.markLoaded(directoryScope); + } catch (error, stackTrace) { + directoryStatus.markError(directoryScope); + debugPrint( + '[ChannelsNotifier] channel directory refresh failed; retaining ' + 'cached discovery: $error\n$stackTrace', + ); + } + } + + // 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; @@ -232,11 +241,15 @@ class ChannelsNotifier extends AsyncNotifier> { 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; // Ephemeral (TTL) channels are surfaced in the list with an // `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be @@ -246,13 +259,16 @@ class ChannelsNotifier extends AsyncNotifier> { } // Batch-fetch member counts via kind:39002 membership events. - 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 session.fetchHistory( + NostrFilter( + kinds: const [39002], + tags: {'#d': memberCountChannelIds}, + limit: memberCountChannelIds.length, + ), + ); if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); final memberCounts = {}; for (final event in memberEvents) { @@ -527,8 +543,8 @@ 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. + /// 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) { final channelIds = { for (final channel in channels) @@ -870,6 +886,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]; @@ -894,6 +911,52 @@ class ChannelsNotifier extends AsyncNotifier> { state = await AsyncValue.guard(() => _fetch(subscribeLive: true)); } + /// 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), + ); + } catch (error, stackTrace) { + directoryStatus.markError(scope); + state = previousChannels == null + ? AsyncError(error, stackTrace) + : AsyncData(previousChannels); + } + } + void _clearLiveSubscriptions() { _subscriptionVersion++; _desiredLiveChannels = const []; 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 56f3145539b..914a30e237e 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1275,8 +1275,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)); @@ -1289,15 +1289,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; @@ -1311,8 +1319,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), @@ -1331,9 +1343,242 @@ 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 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 { @@ -1725,15 +1970,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 { @@ -1994,6 +2261,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) @@ -2008,6 +2282,65 @@ 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)); + } +} + +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 ea33fb79444..12bcce520d1 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,350 @@ 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('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 { @@ -643,13 +988,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)); @@ -699,6 +1048,7 @@ NostrEvent _meta({ required String id, required String name, String channelType = 'stream', + String visibility = 'open', int createdAt = 1, int? ttlSeconds, bool archived = false, @@ -711,7 +1061,7 @@ NostrEvent _meta({ ['d', id], ['name', name], ['t', channelType], - ['public'], + [visibility == 'private' ? 'private' : 'public'], if (ttlSeconds != null) ['ttl', '$ttlSeconds'], if (archived) ['archived', 'true'], ], @@ -738,25 +1088,44 @@ 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.recentMessages = const [], this.membershipFailures = 0, }); 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 recentMessages; int membershipFailures; + int directoryFailures = 0; + 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; @@ -803,6 +1172,7 @@ class _FakeRelaySession extends RelaySessionNotifier { }) async { historyFilters.add(filter); if (filter.kinds.contains(39002) && filter.tags['#p'] != null) { + membershipRequestCount++; if (membershipFailures > 0) { membershipFailures--; throw Exception('membership fetch failed'); @@ -820,8 +1190,11 @@ class _FakeRelaySession extends RelaySessionNotifier { return hiddenDmEvents; } 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 []; @@ -832,6 +1205,64 @@ 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); + 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 ? List.of(metadata) : const []; + } queryBatches.add(filters); return recentMessages.where((event) { return filters.any((filter) { 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 {