From d2b414627cfe5a869dd8883ffbf4f46d4e897fa2 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 17 Aug 2026 10:59:06 -0700 Subject: [PATCH 1/7] feat(mobile): browse and join open channels Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/features/channels/channel.dart | 1 + .../lib/features/channels/channels_page.dart | 3 +- .../features/channels/channels_page/body.dart | 4 +- .../channels_page/browse_channels_sheet.dart | 160 ++++++++++++++ .../channels/channels_page/quick_actions.dart | 9 +- .../channels_page/quick_actions_launcher.dart | 9 + .../channels/channels_page/sections.dart | 53 +++-- .../features/channels/channels_provider.dart | 99 ++++++--- .../channels/manage_channel_sheet.dart | 6 +- .../features/channels/channels_page_test.dart | 198 +++++++++++++++++- .../channels/channels_provider_test.dart | 183 +++++++++++++++- 11 files changed, 668 insertions(+), 57 deletions(-) create mode 100644 mobile/lib/features/channels/channels_page/browse_channels_sheet.dart diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 30b3f2b48ff..ef1f3f47628 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/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c77ef278ec6..4b402139096 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/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 34faa068eba..68ba6c8e306 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -235,7 +235,9 @@ class _SliverChannelsList extends HookConsumerWidget { sliver: SliverList.list( children: [ if (visibleChannels.isEmpty) - const _EmptyState() + _EmptyState( + channels: channels.where((channel) => channel.canJoin).toList(), + ) else ...[ // Starred channels (exclusive — pinned above all sections). if (starredStreamChannels.isNotEmpty) 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..7a6f34b7322 --- /dev/null +++ b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart @@ -0,0 +1,160 @@ +part of '../channels_page.dart'; + +class _BrowseChannelsSheet extends ConsumerWidget { + const _BrowseChannelsSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final channelsAsync = ref.watch(channelsProvider); + final channels = channelsAsync.asData?.value + .where((channel) => channel.canJoin) + .toList(); + + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: ListView( + shrinkWrap: true, + 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 (channelsAsync.isLoading && channels == null) + const Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center(child: BuzzLoadingIndicator()), + ) + else if (channelsAsync.hasError && channels == null) + Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.sm), + child: Text( + 'Could not load open channels.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ) + else if (channels == null || channels.isEmpty) + 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 + _JoinableChannelList(channels: channels, closeAfterJoin: true), + ], + ), + ), + ); + } +} + +class _JoinableChannelList extends StatelessWidget { + final List channels; + final bool closeAfterJoin; + + const _JoinableChannelList({ + required this.channels, + this.closeAfterJoin = false, + }); + + @override + Widget build(BuildContext context) { + final sortedChannels = List.of(channels) + ..sort( + (left, right) => + left.name.toLowerCase().compareTo(right.name.toLowerCase()), + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final channel in sortedChannels) + _JoinableChannelTile( + channel: channel, + closeAfterJoin: closeAfterJoin, + ), + ], + ); + } +} + +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_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 6e17845d744..4ac18a448c8 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -456,29 +456,48 @@ class _ChannelSection extends StatelessWidget { } class _EmptyState extends StatelessWidget { - const _EmptyState(); + final List channels; + + const _EmptyState({required this.channels}); @override Widget build(BuildContext context) { - return SizedBox( - height: MediaQuery.sizeOf(context).height * 0.55, + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.sizeOf(context).height * 0.55, + ), child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.messagesSquare, - size: Grid.xl, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(height: Grid.xs), - Text( - 'No conversations yet', - style: context.textTheme.bodyLarge?.copyWith( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.messagesSquare, + size: Grid.xl, color: context.colors.onSurfaceVariant, ), - ), - ], + const SizedBox(height: Grid.xs), + Text( + 'No conversations yet', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (channels.isNotEmpty) ...[ + const SizedBox(height: Grid.xs), + Text( + 'Join an open channel to start a conversation.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xs), + _JoinableChannelList(channels: channels), + ], + ], + ), ), ), ); diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 2f8dddf2d92..accc28a6c60 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -20,15 +20,19 @@ import 'unread_badge/should_notify_for_event.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; const _unreadCatchUpLimit = 1000; +const _channelDirectoryPageSize = 500; +const _maxChannelDirectoryPages = 100; const _participatedRootIdsPrefix = 'buzz-thread-participation.v1'; const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// Loads the user's channel list from the relay over WebSocket. /// -/// Two-step query: +/// Three-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. +/// 3. Fetch the paginated kind:39000 directory so open channels that the +/// user has not joined remain discoverable. /// /// Live updates are layered on top via per-channel subscriptions on the /// `#h` tag for any of the visible channel event kinds — incoming events @@ -164,28 +168,66 @@ class ChannelsNotifier extends AsyncNotifier> { until = page.map((e) => e.createdAt).reduce(min) - 1; } } - final channelIds = memberships + 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()), + ); + + // 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. + final directoryMetas = []; + final seenDirectoryChannelIds = {}; + int? directoryUntil; + String? directoryBeforeId; + for ( + var pageIndex = 0; + pageIndex < _maxChannelDirectoryPages; + pageIndex++ + ) { + final page = await session.fetchHistory( + NostrFilter( + kinds: const [39000], + limit: _channelDirectoryPageSize, + until: directoryUntil, + extensions: {'before_id': ?directoryBeforeId}, + ), + ); + directoryMetas.addAll(page); - // 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. + var madeProgress = false; + for (final event in page) { + final channelId = event.getTagValue('d'); + if (channelId != null && seenDirectoryChannelIds.add(channelId)) { + madeProgress = true; + } + } + if (!madeProgress || page.length < _channelDirectoryPageSize) break; + + final last = page.last; + directoryUntil = last.createdAt; + directoryBeforeId = last.id; + if (pageIndex == _maxChannelDirectoryPages - 1) { + throw StateError( + 'Channel directory exceeded $_maxChannelDirectoryPages pages', + ); + } + } + + // 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 +274,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 +292,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) { diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart index 3f0d6127456..dba5ecdef60 100644 --- a/mobile/lib/features/channels/manage_channel_sheet.dart +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -34,11 +34,7 @@ class ManageChannelSheet extends HookConsumerWidget { final mutesState = ref.watch(channelMutesProvider); final isMuted = mutesState.store.channels[channel.id]?.muted == true; - final canJoin = - channel.visibility == 'open' && - !channel.isArchived && - !channel.isMember && - !channel.isDm; + final canJoin = channel.canJoin; final canLeave = channel.isMember && !channel.isArchived && !channel.isDm; final canEditCanvas = channel.isMember && !channel.isArchived; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index b0387b54f84..a940e6b9b2b 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1241,8 +1241,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)); @@ -1255,15 +1255,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; @@ -1277,8 +1285,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), @@ -1297,9 +1309,117 @@ 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('create channel sheet lists type and visibility radio options', ( tester, ) async { @@ -1702,6 +1822,58 @@ void main() { expect(find.text('No conversations yet'), findsOneWidget); }); + testWidgets('empty state lets users join a discovered channel', ( + tester, + ) async { + final discoveredChannel = Channel( + id: 'recovery-channel', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Get help from the community', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 7, + ); + final channelsNotifier = _FakeNotifier([discoveredChannel]); + final joinedChannelIds = []; + + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => channelsNotifier), + channelActionsProvider.overrideWith( + (ref) => _FakeChannelActions( + ref, + onJoinChannel: (channelId) async { + joinedChannelIds.add(channelId); + channelsNotifier.setChannels([ + discoveredChannel.copyWith(isMember: true), + ]); + }, + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No conversations yet'), findsOneWidget); + expect( + find.byKey(const Key('browse-channel-recovery-channel')), + findsOneWidget, + ); + + await tester.tap( + find.byKey(const Key('browse-channel-join-recovery-channel')), + ); + await tester.pumpAndSettle(); + + expect(joinedChannelIds, ['recovery-channel']); + expect(find.text('No conversations yet'), findsNothing); + expect(find.text('community-help'), findsOneWidget); + }); + testWidgets('shows error view with retry button', (tester) async { await tester.pumpWidget( buildTestable( @@ -1972,6 +2144,28 @@ class _FakeNotifier extends ChannelsNotifier { @override Map> get observedUnreadEventsByChannel => _observedEventsByChannel; + + void setChannels(List channels) { + state = AsyncData(channels); + } +} + +class _FakeChannelActions extends ChannelActions { + final Future Function(String channelId) onJoinChannel; + + _FakeChannelActions(Ref ref, {required this.onJoinChannel}) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: 'aabb', + ); + + @override + Future joinChannel(String channelId) => onJoinChannel(channelId); } class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index ea33fb79444..97c1b65ce86 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -9,9 +9,10 @@ import 'package:buzz/shared/relay/relay.dart'; /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// -/// The provider performs a two-step WS query: +/// The provider performs a three-step WS query: /// 1. kind:39002 memberships tagged `#p:` /// 2. kind:39000 metadata for those channel ids +/// 3. paginated kind:39000 metadata for discoverable open channels /// then layers per-channel live subscriptions on the `#h` tag. /// /// Tests stub out the relay session by overriding [relaySessionProvider] with @@ -21,6 +22,150 @@ 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); + + final channels = await container.read(channelsProvider.future); + + expect(channels, hasLength(1)); + expect(channels.single.id, _channelA); + expect(channels.single.isMember, isFalse); + expect(session.subscribeFilters, isEmpty); + expect( + session.historyFilters.any( + (filter) => + filter.kinds.length == 1 && + filter.kinds.single == 39000 && + !filter.tags.containsKey('#d'), + ), + isTrue, + ); + }, + ); + + 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); + + final channels = await container.read(channelsProvider.future); + + expect(channels, hasLength(501)); + final directoryFilters = session.historyFilters + .where( + (filter) => + filter.kinds.length == 1 && + filter.kinds.single == 39000 && + !filter.tags.containsKey('#d'), + ) + .toList(); + expect(directoryFilters, hasLength(2)); + expect(directoryFilters.first.until, isNull); + expect(directoryFilters.first.extensions, isEmpty); + expect(directoryFilters.last.until, firstPage.last.createdAt); + expect(directoryFilters.last.extensions['before_id'], firstPage.last.id); + }); + + 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); + + final channels = await container.read(channelsProvider.future); + + expect(channels, hasLength(500)); + expect(session.metadataPageRequestCount, 2); + }); + + test('fails loudly when channel discovery exceeds its page cap', () 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); + + await expectLater( + container.read(channelsProvider.future), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Channel directory exceeded'), + ), + ), + ); + }); + + 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); + + final channels = await container.read(channelsProvider.future); + + 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 { @@ -699,6 +844,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 +857,7 @@ NostrEvent _meta({ ['d', id], ['name', name], ['t', channelType], - ['public'], + [visibility == 'private' ? 'private' : 'public'], if (ttlSeconds != null) ['ttl', '$ttlSeconds'], if (archived) ['archived', 'true'], ], @@ -743,7 +889,11 @@ Future _waitUntil(bool Function() predicate) async { class _FakeRelaySession extends RelaySessionNotifier { _FakeRelaySession({ required this.memberships, - required this.metadata, + this.metadata = const [], + this.metadataPages, + this.metadataPageBuilder, + this.repeatLastMetadataPage = false, + this.maxMetadataPageRequests, this.hiddenDmEvents = const [], this.recentMessages = const [], this.membershipFailures = 0, @@ -751,9 +901,14 @@ class _FakeRelaySession extends RelaySessionNotifier { List memberships; 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 metadataPageRequestCount = 0; final List historyFilters = []; final List> queryBatches = []; @@ -820,8 +975,26 @@ 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) { + 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 List.of(metadata); + } + // Member metadata query — return only matching `d` tags. return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList(); } return const []; From ac3464a0faa4ca0df099aaa78f5412fc2bf5196e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 17 Aug 2026 11:13:17 -0700 Subject: [PATCH 2/7] fix(mobile): harden channel discovery refresh Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../features/channels/channel_directory.dart | 43 +++++++++ .../channels_page/browse_channels_sheet.dart | 84 +++++++++++------- .../channels/channels_page/sections.dart | 2 +- .../features/channels/channels_provider.dart | 57 ++++-------- .../features/channels/channels_page_test.dart | 40 +++++++++ .../channels/channels_provider_test.dart | 88 ++++++++++++++++--- 6 files changed, 230 insertions(+), 84 deletions(-) create mode 100644 mobile/lib/features/channels/channel_directory.dart diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart new file mode 100644 index 00000000000..ae66b1a2009 --- /dev/null +++ b/mobile/lib/features/channels/channel_directory.dart @@ -0,0 +1,43 @@ +part of 'channels_provider.dart'; + +const _channelDirectoryPageSize = 500; +const _maxChannelDirectoryPages = 100; + +Future> _fetchChannelDirectoryMetas( + RelaySessionNotifier session, +) async { + final directoryMetas = []; + final seenDirectoryChannelIds = {}; + int? directoryUntil; + String? directoryBeforeId; + for (var pageIndex = 0; pageIndex < _maxChannelDirectoryPages; pageIndex++) { + final page = await session.fetchHistory( + NostrFilter( + kinds: const [39000], + limit: _channelDirectoryPageSize, + until: directoryUntil, + extensions: {'before_id': ?directoryBeforeId}, + ), + ); + directoryMetas.addAll(page); + + var madeProgress = false; + for (final event in page) { + final channelId = event.getTagValue('d'); + if (channelId != null && seenDirectoryChannelIds.add(channelId)) { + madeProgress = true; + } + } + if (!madeProgress || page.length < _channelDirectoryPageSize) break; + + final last = page.last; + directoryUntil = last.createdAt; + directoryBeforeId = last.id; + if (pageIndex == _maxChannelDirectoryPages - 1) { + throw StateError( + 'Channel directory exceeded $_maxChannelDirectoryPages pages', + ); + } + } + return directoryMetas; +} diff --git a/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart index 7a6f34b7322..f6ffe71a727 100644 --- a/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart +++ b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart @@ -9,6 +9,10 @@ class _BrowseChannelsSheet extends ConsumerWidget { final channels = channelsAsync.asData?.value .where((channel) => channel.canJoin) .toList(); + channels?.sort( + (left, right) => + left.name.toLowerCase().compareTo(right.name.toLowerCase()), + ); return SafeArea( top: false, @@ -19,45 +23,64 @@ class _BrowseChannelsSheet extends ConsumerWidget { Grid.gutter, Grid.xs, ), - child: ListView( + child: CustomScrollView( shrinkWrap: true, - children: [ - Text( - 'Join an open channel to add it to your conversations.', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + 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), + ], ), ), - const SizedBox(height: Grid.xs), if (channelsAsync.isLoading && channels == null) - const Padding( - padding: EdgeInsets.all(Grid.sm), - child: Center(child: BuzzLoadingIndicator()), + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center(child: BuzzLoadingIndicator()), + ), ) else if (channelsAsync.hasError && channels == null) - Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.sm), - child: Text( - 'Could not load open channels.', - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.sm), + child: Text( + 'Could not load open channels.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), ) else if (channels == null || channels.isEmpty) - 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, + 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 - _JoinableChannelList(channels: channels, closeAfterJoin: true), + SliverList.builder( + itemCount: channels.length, + itemBuilder: (context, index) => _JoinableChannelTile( + channel: channels[index], + closeAfterJoin: true, + ), + ), ], ), ), @@ -67,12 +90,8 @@ class _BrowseChannelsSheet extends ConsumerWidget { class _JoinableChannelList extends StatelessWidget { final List channels; - final bool closeAfterJoin; - const _JoinableChannelList({ - required this.channels, - this.closeAfterJoin = false, - }); + const _JoinableChannelList({required this.channels}); @override Widget build(BuildContext context) { @@ -85,10 +104,7 @@ class _JoinableChannelList extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ for (final channel in sortedChannels) - _JoinableChannelTile( - channel: channel, - closeAfterJoin: closeAfterJoin, - ), + _JoinableChannelTile(channel: channel, closeAfterJoin: false), ], ); } diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 4ac18a448c8..040a1acdb4a 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -494,7 +494,7 @@ class _EmptyState extends StatelessWidget { ), ), const SizedBox(height: Grid.xs), - _JoinableChannelList(channels: channels), + _JoinableChannelList(channels: channels.take(3).toList()), ], ], ), diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index accc28a6c60..39fbbdb7ba9 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -18,10 +18,10 @@ 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 _channelDirectoryPageSize = 500; -const _maxChannelDirectoryPages = 100; const _participatedRootIdsPrefix = 'buzz-thread-participation.v1'; const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; @@ -57,6 +57,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. /// @@ -84,6 +85,7 @@ class ChannelsNotifier extends AsyncNotifier> { _memberSnapshotRelayBaseUrl = relayBaseUrl; _memberSnapshotPubkey = pubkey; _memberSnapshotsByChannelId = const {}; + _directoryMetas = const []; } final connected = Completer(); final sessionState = ref.read(relaySessionProvider); @@ -128,10 +130,12 @@ class ChannelsNotifier extends AsyncNotifier> { Future> _fetch({ bool subscribeLive = false, bool fetchLastMessage = true, + bool fetchDirectory = true, }) async { final channels = await _fetchChannels( subscribeLive: subscribeLive, fetchLastMessage: fetchLastMessage, + fetchDirectory: fetchDirectory, ); _hasLoaded = true; return channels; @@ -140,6 +144,7 @@ class ChannelsNotifier extends AsyncNotifier> { Future> _fetchChannels({ bool subscribeLive = false, bool fetchLastMessage = true, + bool fetchDirectory = true, }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); @@ -186,40 +191,13 @@ class ChannelsNotifier extends AsyncNotifier> { // 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. - final directoryMetas = []; - final seenDirectoryChannelIds = {}; - int? directoryUntil; - String? directoryBeforeId; - for ( - var pageIndex = 0; - pageIndex < _maxChannelDirectoryPages; - pageIndex++ - ) { - final page = await session.fetchHistory( - NostrFilter( - kinds: const [39000], - limit: _channelDirectoryPageSize, - until: directoryUntil, - extensions: {'before_id': ?directoryBeforeId}, - ), - ); - directoryMetas.addAll(page); - - var madeProgress = false; - for (final event in page) { - final channelId = event.getTagValue('d'); - if (channelId != null && seenDirectoryChannelIds.add(channelId)) { - madeProgress = true; - } - } - if (!madeProgress || page.length < _channelDirectoryPageSize) break; - - final last = page.last; - directoryUntil = last.createdAt; - directoryBeforeId = last.id; - if (pageIndex == _maxChannelDirectoryPages - 1) { - throw StateError( - 'Channel directory exceeded $_maxChannelDirectoryPages pages', + if (fetchDirectory) { + try { + _directoryMetas = await _fetchChannelDirectoryMetas(session); + } catch (error) { + debugPrint( + '[ChannelsNotifier] channel directory refresh failed; retaining ' + 'cached discovery: $error', ); } } @@ -227,7 +205,7 @@ class ChannelsNotifier extends AsyncNotifier> { // Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable, // but stale revisions from before the relay's d_tag backfill can linger. final latestMetaPerId = {}; - for (final event in [...memberMetas, ...directoryMetas]) { + for (final event in [...memberMetas, ..._directoryMetas]) { if (event.kind != 39000) continue; final id = event.getTagValue('d'); if (id == null) continue; @@ -576,8 +554,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) @@ -919,6 +897,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]; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index a940e6b9b2b..d877e7eb7eb 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1420,6 +1420,46 @@ void main() { expect(find.text('No open channels available to join.'), findsOneWidget); }); + testWidgets('browse action lazily builds a large channel directory', ( + 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, + ), + ); + 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-directory-0')), + findsAtLeast(1), + ); + expect(find.byKey(const Key('browse-channel-directory-499')), findsNothing); + }); + testWidgets('create channel sheet lists type and visibility radio options', ( tester, ) async { diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 97c1b65ce86..a9892c5049c 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -120,7 +120,7 @@ void main() { expect(session.metadataPageRequestCount, 2); }); - test('fails loudly when channel discovery exceeds its page cap', () async { + test('directory page-cap failure does not fail channel loading', () async { final session = _FakeRelaySession( memberships: const [], metadataPageBuilder: (pageIndex) => List.generate( @@ -135,16 +135,77 @@ void main() { final container = _buildContainer(session: session); addTearDown(container.dispose); - await expectLater( - container.read(channelsProvider.future), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Channel directory exceeded'), - ), - ), + expect(await container.read(channelsProvider.future), isEmpty); + expect(session.metadataPageRequestCount, 100); + }); + + 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), + 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).refresh(); + + 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, + ); + }, + ); + + 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('deduplicates joined channels from directory discovery', () async { @@ -908,6 +969,8 @@ class _FakeRelaySession extends RelaySessionNotifier { final List hiddenDmEvents; final List recentMessages; int membershipFailures; + int directoryFailures = 0; + int membershipRequestCount = 0; int metadataPageRequestCount = 0; final List historyFilters = []; @@ -958,6 +1021,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'); @@ -977,6 +1041,10 @@ class _FakeRelaySession extends RelaySessionNotifier { if (filter.kinds.contains(39000)) { final ids = filter.tags['#d']?.toSet(); if (ids == null) { + if (directoryFailures > 0) { + directoryFailures--; + throw Exception('directory fetch failed'); + } final requestIndex = metadataPageRequestCount++; final maxRequests = maxMetadataPageRequests; if (maxRequests != null && requestIndex >= maxRequests) { From 502739d29b08f92e6f5121341d7955a98e33577c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 18 Aug 2026 09:34:27 -0700 Subject: [PATCH 3/7] fix(mobile): preserve channel directory cursor Signed-off-by: Tom Brow --- .../features/channels/channel_directory.dart | 4 +- .../channels/channels_provider_test.dart | 59 ++++++++++--------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index ae66b1a2009..328cfbb2683 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -11,14 +11,14 @@ Future> _fetchChannelDirectoryMetas( int? directoryUntil; String? directoryBeforeId; for (var pageIndex = 0; pageIndex < _maxChannelDirectoryPages; pageIndex++) { - final page = await session.fetchHistory( + final page = await session.queryRelay([ NostrFilter( kinds: const [39000], limit: _channelDirectoryPageSize, until: directoryUntil, extensions: {'before_id': ?directoryBeforeId}, ), - ); + ]); directoryMetas.addAll(page); var madeProgress = false; diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index a9892c5049c..a5eb1d751e4 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -43,7 +43,7 @@ void main() { expect(channels.single.isMember, isFalse); expect(session.subscribeFilters, isEmpty); expect( - session.historyFilters.any( + session.directoryQueryFilters.any( (filter) => filter.kinds.length == 1 && filter.kinds.single == 39000 && @@ -81,14 +81,7 @@ void main() { final channels = await container.read(channelsProvider.future); expect(channels, hasLength(501)); - final directoryFilters = session.historyFilters - .where( - (filter) => - filter.kinds.length == 1 && - filter.kinds.single == 39000 && - !filter.tags.containsKey('#d'), - ) - .toList(); + final directoryFilters = session.directoryQueryFilters; expect(directoryFilters, hasLength(2)); expect(directoryFilters.first.until, isNull); expect(directoryFilters.first.extensions, isEmpty); @@ -975,6 +968,7 @@ class _FakeRelaySession extends RelaySessionNotifier { final List historyFilters = []; final List> queryBatches = []; + final List directoryQueryFilters = []; final List subscribeFilters = []; final Map _subscriptions = {}; int _nextSubscriptionKey = 0; @@ -1041,26 +1035,7 @@ class _FakeRelaySession extends RelaySessionNotifier { if (filter.kinds.contains(39000)) { final ids = filter.tags['#d']?.toSet(); if (ids == 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 List.of(metadata); + 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(); @@ -1073,6 +1048,32 @@ 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 == 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 List.of(metadata); + } queryBatches.add(filters); return recentMessages.where((event) { return filters.any((filter) { From 7feb172ff4263df93c6d018636f7be2e696d7d67 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 18 Aug 2026 09:50:02 -0700 Subject: [PATCH 4/7] refactor(mobile): remove empty channel preview Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../features/channels/channels_page/body.dart | 4 +- .../channels_page/browse_channels_sheet.dart | 22 ------ .../channels/channels_page/sections.dart | 53 +++++--------- .../features/channels/channels_page_test.dart | 70 +++---------------- 4 files changed, 27 insertions(+), 122 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 68ba6c8e306..34faa068eba 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -235,9 +235,7 @@ class _SliverChannelsList extends HookConsumerWidget { sliver: SliverList.list( children: [ if (visibleChannels.isEmpty) - _EmptyState( - channels: channels.where((channel) => channel.canJoin).toList(), - ) + const _EmptyState() else ...[ // Starred channels (exclusive — pinned above all sections). if (starredStreamChannels.isNotEmpty) diff --git a/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart index f6ffe71a727..24cd1f654ea 100644 --- a/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart +++ b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart @@ -88,28 +88,6 @@ class _BrowseChannelsSheet extends ConsumerWidget { } } -class _JoinableChannelList extends StatelessWidget { - final List channels; - - const _JoinableChannelList({required this.channels}); - - @override - Widget build(BuildContext context) { - final sortedChannels = List.of(channels) - ..sort( - (left, right) => - left.name.toLowerCase().compareTo(right.name.toLowerCase()), - ); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final channel in sortedChannels) - _JoinableChannelTile(channel: channel, closeAfterJoin: false), - ], - ); - } -} - class _JoinableChannelTile extends HookConsumerWidget { final Channel channel; final bool closeAfterJoin; diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 040a1acdb4a..6e17845d744 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -456,48 +456,29 @@ class _ChannelSection extends StatelessWidget { } class _EmptyState extends StatelessWidget { - final List channels; - - const _EmptyState({required this.channels}); + const _EmptyState(); @override Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints( - minHeight: MediaQuery.sizeOf(context).height * 0.55, - ), + return SizedBox( + height: MediaQuery.sizeOf(context).height * 0.55, child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.messagesSquare, - size: Grid.xl, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.messagesSquare, + size: Grid.xl, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(height: Grid.xs), + Text( + 'No conversations yet', + style: context.textTheme.bodyLarge?.copyWith( color: context.colors.onSurfaceVariant, ), - const SizedBox(height: Grid.xs), - Text( - 'No conversations yet', - style: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - if (channels.isNotEmpty) ...[ - const SizedBox(height: Grid.xs), - Text( - 'Join an open channel to start a conversation.', - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - const SizedBox(height: Grid.xs), - _JoinableChannelList(channels: channels.take(3).toList()), - ], - ], - ), + ), + ], ), ), ); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index d877e7eb7eb..cefbb574f7a 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1851,22 +1851,9 @@ void main() { expect(find.text('archived-stream'), findsNothing); }); - testWidgets('shows empty state when no channels', (tester) async { - await tester.pumpWidget( - buildTestable( - overrides: [channelsProvider.overrideWith(() => _FakeNotifier([]))], - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('No conversations yet'), findsOneWidget); - }); - - testWidgets('empty state lets users join a discovered channel', ( - tester, - ) async { + testWidgets('empty state does not preview unjoined channels', (tester) async { final discoveredChannel = Channel( - id: 'recovery-channel', + id: 'discovered-channel', name: 'community-help', channelType: 'stream', visibility: 'open', @@ -1875,23 +1862,11 @@ void main() { createdAt: DateTime(2025), memberCount: 7, ); - final channelsNotifier = _FakeNotifier([discoveredChannel]); - final joinedChannelIds = []; - await tester.pumpWidget( buildTestable( overrides: [ - channelsProvider.overrideWith(() => channelsNotifier), - channelActionsProvider.overrideWith( - (ref) => _FakeChannelActions( - ref, - onJoinChannel: (channelId) async { - joinedChannelIds.add(channelId); - channelsNotifier.setChannels([ - discoveredChannel.copyWith(isMember: true), - ]); - }, - ), + channelsProvider.overrideWith( + () => _FakeNotifier([discoveredChannel]), ), ], ), @@ -1900,18 +1875,13 @@ void main() { expect(find.text('No conversations yet'), findsOneWidget); expect( - find.byKey(const Key('browse-channel-recovery-channel')), - findsOneWidget, + find.text('Join an open channel to start a conversation.'), + findsNothing, ); - - await tester.tap( - find.byKey(const Key('browse-channel-join-recovery-channel')), + expect( + find.byKey(const Key('browse-channel-discovered-channel')), + findsNothing, ); - await tester.pumpAndSettle(); - - expect(joinedChannelIds, ['recovery-channel']); - expect(find.text('No conversations yet'), findsNothing); - expect(find.text('community-help'), findsOneWidget); }); testWidgets('shows error view with retry button', (tester) async { @@ -2184,28 +2154,6 @@ class _FakeNotifier extends ChannelsNotifier { @override Map> get observedUnreadEventsByChannel => _observedEventsByChannel; - - void setChannels(List channels) { - state = AsyncData(channels); - } -} - -class _FakeChannelActions extends ChannelActions { - final Future Function(String channelId) onJoinChannel; - - _FakeChannelActions(Ref ref, {required this.onJoinChannel}) - : super( - ref: ref, - session: ref.read(relaySessionProvider.notifier), - signedEventRelay: SignedEventRelay( - session: ref.read(relaySessionProvider.notifier), - nsec: null, - ), - currentPubkey: 'aabb', - ); - - @override - Future joinChannel(String channelId) => onJoinChannel(channelId); } class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { From d4a84e96b496baab3a7c6d56206aaf0c79c71068 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 18 Aug 2026 16:25:09 -0700 Subject: [PATCH 5/7] test(mobile): cover scrolling channel directory Signed-off-by: Tom Brow --- .../features/channels/channels_page_test.dart | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index cefbb574f7a..f4753eeeac9 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1420,7 +1420,7 @@ void main() { expect(find.text('No open channels available to join.'), findsOneWidget); }); - testWidgets('browse action lazily builds a large channel directory', ( + testWidgets('browse action scrolls and joins an offscreen channel', ( tester, ) async { final channels = List.generate( @@ -1436,11 +1436,15 @@ void main() { memberCount: 0, ), ); + late _RecordingChannelActions actions; await tester.pumpWidget( buildTestable( disableAnimations: true, overrides: [ channelsProvider.overrideWith(() => _FakeNotifier(channels)), + channelActionsProvider.overrideWith( + (ref) => actions = _RecordingChannelActions(ref), + ), ], ), ); @@ -1458,6 +1462,36 @@ void main() { 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', ( @@ -2156,6 +2190,26 @@ class _FakeNotifier extends ChannelsNotifier { get observedUnreadEventsByChannel => _observedEventsByChannel; } +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); From 14b8a9e7c5c2daf42f08c89d4f4afe8c3be678f7 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 19 Aug 2026 11:59:56 -0700 Subject: [PATCH 6/7] fix(mobile): harden channel directory loading Signed-off-by: Tom Brow --- .../features/channels/channel_directory.dart | 125 +++++++++-- .../channels_page/browse_channels_sheet.dart | 55 ++++- .../features/channels/channels_provider.dart | 79 +++++-- .../features/channels/channels_page_test.dart | 97 ++++++++ .../channels/channels_provider_test.dart | 208 +++++++++++++++--- 5 files changed, 483 insertions(+), 81 deletions(-) diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index 328cfbb2683..fcb851526bc 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -3,41 +3,128 @@ 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, -) async { - final directoryMetas = []; - final seenDirectoryChannelIds = {}; - int? directoryUntil; - String? directoryBeforeId; +) => _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: const [39000], + kinds: [kind], + tags: tags, limit: _channelDirectoryPageSize, - until: directoryUntil, - extensions: {'before_id': ?directoryBeforeId}, + until: until, + extensions: {'before_id': ?beforeId}, ), ]); - directoryMetas.addAll(page); - + if (page.isEmpty) break; var madeProgress = false; for (final event in page) { - final channelId = event.getTagValue('d'); - if (channelId != null && seenDirectoryChannelIds.add(channelId)) { + if (seenEventIds.add(event.id)) { + events.add(event); madeProgress = true; } } - if (!madeProgress || page.length < _channelDirectoryPageSize) break; + if (!madeProgress) break; final last = page.last; - directoryUntil = last.createdAt; - directoryBeforeId = last.id; + until = last.createdAt; + beforeId = last.id; if (pageIndex == _maxChannelDirectoryPages - 1) { - throw StateError( - 'Channel directory exceeded $_maxChannelDirectoryPages pages', - ); + throw StateError('$operation exceeded $_maxChannelDirectoryPages pages'); } } - return directoryMetas; + return events; } diff --git a/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart index 24cd1f654ea..9d1bb633716 100644 --- a/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart +++ b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart @@ -1,11 +1,19 @@ part of '../channels_page.dart'; -class _BrowseChannelsSheet extends ConsumerWidget { +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(); @@ -14,6 +22,22 @@ class _BrowseChannelsSheet extends ConsumerWidget { 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( @@ -40,23 +64,36 @@ class _BrowseChannelsSheet extends ConsumerWidget { ], ), ), - if (channelsAsync.isLoading && channels == null) + if (directoryIsLoading && (channels == null || channels.isEmpty)) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.all(Grid.sm), child: Center(child: BuzzLoadingIndicator()), ), ) - else if (channelsAsync.hasError && channels == null) + else if (directoryHasError && + (channels == null || channels.isEmpty)) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: Grid.sm), - child: Text( - 'Could not load open channels.', - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), + 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'), + ), + ], ), ), ) diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 39fbbdb7ba9..e225974f745 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -153,26 +153,7 @@ 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 memberships = await _fetchChannelMemberships(session, myPk); final memberChannelIds = memberships .map((e) => e.getTagValue('d')) .whereType() @@ -192,12 +173,22 @@ class ChannelsNotifier extends AsyncNotifier> { // 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); - } catch (error) { + directoryStatus.markLoaded(directoryScope); + } catch (error, stackTrace) { + directoryStatus.markError(directoryScope); debugPrint( '[ChannelsNotifier] channel directory refresh failed; retaining ' - 'cached discovery: $error', + 'cached discovery: $error\n$stackTrace', ); } } @@ -922,6 +913,50 @@ 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)); + } catch (error, stackTrace) { + directoryStatus.markError(scope); + state = previousChannels == null + ? AsyncError(error, stackTrace) + : AsyncData(previousChannels); + } + } + void _clearLiveSubscriptions() { _subscriptionVersion++; _desiredLiveChannels = const []; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 3224fd3d942..914a30e237e 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1454,6 +1454,57 @@ void main() { 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 { @@ -2210,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) @@ -2224,6 +2282,45 @@ 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( diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index a5eb1d751e4..ad604b0db02 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -9,8 +9,8 @@ import 'package:buzz/shared/relay/relay.dart'; /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// -/// The provider performs a three-step WS query: -/// 1. kind:39002 memberships tagged `#p:` +/// The provider performs a three-step relay query: +/// 1. paginated kind:39002 memberships tagged `#p:` /// 2. kind:39000 metadata for those channel ids /// 3. paginated kind:39000 metadata for discoverable open channels /// then layers per-channel live subscriptions on the `#h` tag. @@ -82,11 +82,76 @@ void main() { expect(channels, hasLength(501)); final directoryFilters = session.directoryQueryFilters; - expect(directoryFilters, hasLength(2)); + expect(directoryFilters, hasLength(3)); expect(directoryFilters.first.until, isNull); expect(directoryFilters.first.extensions, isEmpty); - expect(directoryFilters.last.until, firstPage.last.createdAt); - expect(directoryFilters.last.extensions['before_id'], firstPage.last.id); + 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 { @@ -113,24 +178,31 @@ void main() { expect(session.metadataPageRequestCount, 2); }); - test('directory page-cap failure does not fail channel loading', () async { - final session = _FakeRelaySession( - memberships: const [], - metadataPageBuilder: (pageIndex) => List.generate( - 500, - (eventIndex) => _meta( - id: 'channel-$pageIndex-$eventIndex', - name: 'channel-$pageIndex-$eventIndex', - createdAt: 1000 - pageIndex, + 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); + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); - expect(await container.read(channelsProvider.future), isEmpty); - expect(session.metadataPageRequestCount, 100); - }); + expect(await container.read(channelsProvider.future), isEmpty); + expect(session.metadataPageRequestCount, 100); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + }, + ); test( 'directory failure retains discovery while membership refreshes', @@ -174,9 +246,40 @@ void main() { 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)], @@ -842,13 +945,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)); @@ -938,11 +1045,14 @@ 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, + this.membershipPages, + this.repeatLastMembershipPage = false, + this.maxMembershipPageRequests, this.metadata = const [], this.metadataPages, this.metadataPageBuilder, @@ -954,6 +1064,9 @@ 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; @@ -969,6 +1082,7 @@ class _FakeRelaySession extends RelaySessionNotifier { final List historyFilters = []; final List> queryBatches = []; final List directoryQueryFilters = []; + final List membershipQueryFilters = []; final List subscribeFilters = []; final Map _subscriptions = {}; int _nextSubscriptionKey = 0; @@ -1048,6 +1162,38 @@ 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 && @@ -1072,7 +1218,7 @@ class _FakeRelaySession extends RelaySessionNotifier { } return const []; } - return List.of(metadata); + return filter.until == null ? List.of(metadata) : const []; } queryBatches.add(filters); return recentMessages.where((event) { From fafe4cb74452dd4ad1553c399426b9b6741a5cca Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 19 Aug 2026 12:24:19 -0700 Subject: [PATCH 7/7] fix(mobile): load channel directory on demand Signed-off-by: Tom Brow --- .../features/channels/channels_provider.dart | 18 ++--- mobile/lib/features/search/search_page.dart | 16 ++-- .../channels/channels_provider_test.dart | 77 +++++++++++++++---- .../features/search/search_page_test.dart | 37 +++++++++ 4 files changed, 114 insertions(+), 34 deletions(-) diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index e225974f745..1bfabd8d2ac 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -27,13 +27,11 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// Loads the user's channel list from the relay over WebSocket. /// -/// Three-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. -/// 3. Fetch the paginated kind:39000 directory so open channels that the -/// user has not joined remain discoverable. +/// 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. @@ -130,7 +128,7 @@ class ChannelsNotifier extends AsyncNotifier> { Future> _fetch({ bool subscribeLive = false, bool fetchLastMessage = true, - bool fetchDirectory = true, + bool fetchDirectory = false, }) async { final channels = await _fetchChannels( subscribeLive: subscribeLive, @@ -144,7 +142,7 @@ class ChannelsNotifier extends AsyncNotifier> { Future> _fetchChannels({ bool subscribeLive = false, bool fetchLastMessage = true, - bool fetchDirectory = true, + bool fetchDirectory = false, }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); @@ -948,7 +946,9 @@ class ChannelsNotifier extends AsyncNotifier> { return; } try { - state = AsyncData(await _fetch(subscribeLive: true)); + state = AsyncData( + await _fetch(subscribeLive: true, fetchDirectory: true), + ); } catch (error, stackTrace) { directoryStatus.markError(scope); state = previousChannels == null 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_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index ad604b0db02..12bcce520d1 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -9,11 +9,11 @@ import 'package:buzz/shared/relay/relay.dart'; /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// -/// The provider performs a three-step relay query: +/// The provider loads membership-backed channels first: /// 1. paginated kind:39002 memberships tagged `#p:` /// 2. kind:39000 metadata for those channel ids -/// 3. paginated kind:39000 metadata for discoverable open channels -/// 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 @@ -36,21 +36,17 @@ void main() { final container = _buildContainer(session: session); addTearDown(container.dispose); - final channels = await container.read(channelsProvider.future); + 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.any( - (filter) => - filter.kinds.length == 1 && - filter.kinds.single == 39000 && - !filter.tags.containsKey('#d'), - ), - isTrue, - ); + expect(session.directoryQueryFilters, isNotEmpty); }, ); @@ -78,7 +74,9 @@ void main() { final container = _buildContainer(session: session); addTearDown(container.dispose); - final channels = await container.read(channelsProvider.future); + 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; @@ -172,7 +170,9 @@ void main() { final container = _buildContainer(session: session); addTearDown(container.dispose); - final channels = await container.read(channelsProvider.future); + 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); @@ -196,6 +196,7 @@ void main() { 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, @@ -221,6 +222,14 @@ void main() { (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]), ); @@ -235,7 +244,7 @@ void main() { ]; session.directoryFailures = 1; - await container.read(channelsProvider.notifier).refresh(); + await container.read(channelsProvider.notifier).retryDirectory(); final refreshed = container.read(channelsProvider).requireValue; expect( @@ -304,6 +313,33 @@ void main() { 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)], @@ -315,7 +351,14 @@ void main() { final container = _buildContainer(session: session); addTearDown(container.dispose); - final channels = await container.read(channelsProvider.future); + 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); 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 {