diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 3104e90335..1dc94351a2 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -79,6 +79,8 @@ class Channel { bool get isPrivate => visibility == 'private'; bool get isArchived => archivedAt != null; + bool get canJoin => visibility == 'open' && !isArchived && !isMember && !isDm; + String displayLabel({String? currentPubkey}) { if (!isDm || participants.isEmpty) { return name; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index ba5d4ebf9d..a079473718 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -54,7 +54,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 9f0b1dd4a7..04e87ee2c8 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -196,7 +196,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/quick_actions.dart b/mobile/lib/features/channels/channels_page/quick_actions.dart index 8754842702..02f5fbc9b9 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.search, + 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 3653932089..c92ec5b3a6 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -107,6 +107,8 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { if (opened != null && context.mounted) { await openChannel(opened); } + case _QuickAction.browseChannels: + await _showBrowseChannelsSheet(context); } } diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index f9fe5453d7..28faf67b00 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -360,29 +360,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_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 2738be0aa9..25bf7e6214 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -2,6 +2,186 @@ part of '../channels_page.dart'; const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60; +Future _showBrowseChannelsSheet(BuildContext context) => + showModalBottomSheet( + context: context, + constraints: _quickActionSheetConstraints(context), + isScrollControlled: true, + showDragHandle: true, + builder: (_) => const _BrowseChannelsSheet(), + ); + +class _BrowseChannelsSheet extends StatelessWidget { + const _BrowseChannelsSheet(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, 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( + 'Browse channels', + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: Grid.half), + 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.compareTo(right.name)); + 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\u2026' : '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, + ), + ), + ), + ], + ); + } +} + class _CreateChannelMenuOption { final Key? key; final String label; diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 614ab054c3..4b8fa8031b 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -19,15 +19,19 @@ import 'unread_badge/should_notify_for_event.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; const _unreadCatchUpLimit = 1000; +const _channelDiscoveryPageSize = 500; +const _maxChannelDiscoveryIterations = 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 unfiltered kind:39000 metadata so open channels that the user +/// has not joined yet are 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 @@ -142,24 +146,70 @@ 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(); - if (channelIds.isEmpty) return const []; + .toSet(); + + // Step 2: pull metadata for joined channels. A zero-membership user must + // continue to step 3 so relay-visible open channels remain discoverable. + final memberMetas = memberChannelIds.isEmpty + ? const [] + : await session.fetchHistory( + NostrFilters.channelMetadata(memberChannelIds.toList()), + ); - // Step 2: pull channel metadata in one batched filter. - final metas = await session.fetchHistory( - NostrFilters.channelMetadata(channelIds), - ); + // Step 3: discover relay-visible open channels without fabricating + // membership. The relay withholds private and DM metadata from this query, + // while the client-side checks below keep that trust boundary explicit. + // The WebSocket relay can ignore the composite cursor and repeat a full + // tied-timestamp page, so termination depends on seeing a new channel id. + final discoverableMetas = []; + { + final seenChannelIds = {}; + int? until; + String? beforeId; + for ( + var iteration = 0; + iteration < _maxChannelDiscoveryIterations; + iteration++ + ) { + final page = await session.fetchHistory( + NostrFilter( + kinds: const [39000], + limit: _channelDiscoveryPageSize, + until: until, + extensions: {'before_id': ?beforeId}, + ), + ); + discoverableMetas.addAll(page); + var madeProgress = false; + for (final event in page) { + final channelId = event.getTagValue('d'); + if (channelId != null && seenChannelIds.add(channelId)) { + madeProgress = true; + } + } + if (!madeProgress || page.length < _channelDiscoveryPageSize) break; + final last = page.last; + until = last.createdAt; + beforeId = last.id; + + if (iteration == _maxChannelDiscoveryIterations - 1) { + throw StateError( + 'Channel discovery exceeded ' + '$_maxChannelDiscoveryIterations iterations', + ); + } + } + } - // Dedupe by `d` tag (channel id) — kind:39000 is parameterized-replaceable, - // so logically there's exactly one current event per id, but stale revisions - // from before the relay's d_tag backfill can linger. Keep the highest - // `created_at` per id so the latest channel_type / name wins. + // Merge and dedupe by `d` tag (channel id). Kind:39000 is + // parameterized-replaceable, 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 / visibility / name wins. final latestMetaPerId = {}; - for (final event in metas) { + for (final event in [...memberMetas, ...discoverableMetas]) { if (event.kind != 39000) continue; final id = event.getTagValue('d'); if (id == null) continue; @@ -206,11 +256,17 @@ 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, ); + // Joined private channels and DMs still come from the membership-scoped + // metadata query. Never admit either type solely through discovery. + 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 @@ -219,14 +275,18 @@ class ChannelsNotifier extends AsyncNotifier> { channels.add(channel); } - // Batch-fetch member counts via kind:39002 membership events. - final memberEvents = await session.fetchHistory( - NostrFilter( - kinds: const [39002], - tags: {'#d': channelIds}, - limit: channelIds.length, - ), - ); + // Preserve the existing member-count query scope. Discovered channels do + // not need a readable roster in order to appear in the browser. + final memberCountChannelIds = memberChannelIds.toList(); + final memberEvents = memberCountChannelIds.isEmpty + ? const [] + : await session.fetchHistory( + NostrFilter( + kinds: const [39002], + tags: {'#d': memberCountChannelIds}, + limit: memberCountChannelIds.length, + ), + ); final memberCounts = {}; for (final event in memberEvents) { final chId = event.getTagValue('d'); diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart index 15ad07b0cd..794fdd74d8 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 991db3b5cd..6940b67207 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -723,8 +723,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)); @@ -737,15 +737,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; @@ -759,8 +767,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), @@ -779,9 +791,98 @@ 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-join-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('create channel sheet lists type and visibility radio options', ( tester, ) async { @@ -1173,6 +1274,127 @@ void main() { expect(find.text('archived-stream'), findsNothing); }); + testWidgets('empty state lets stuck users join an open 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, + ); + expect( + find.byKey(const Key('browse-channel-join-recovery-channel')), + findsOneWidget, + ); + expect(find.text('community-help'), 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('empty state lists every joinable channel without overflow', ( + tester, + ) async { + final discoveredChannels = List.generate( + 6, + (index) => Channel( + id: 'recovery-channel-$index', + name: 'community-channel-$index', + channelType: 'stream', + visibility: 'open', + description: 'Community channel $index', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: index + 1, + ), + ); + + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier(discoveredChannels), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No conversations yet'), findsOneWidget); + for (final channel in discoveredChannels) { + expect(find.byKey(Key('browse-channel-${channel.id}')), findsOneWidget); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('empty state omits browse CTA without joinable channels', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier([ + Channel( + id: 'private-only', + name: 'private-only', + channelType: 'stream', + visibility: 'private', + description: 'Private channel', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 2, + ), + ]), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No conversations yet'), findsOneWidget); + expect(find.byKey(const Key('browse-channel-private-only')), findsNothing); + }); + testWidgets('shows empty state when no channels', (tester) async { await tester.pumpWidget( buildTestable( @@ -1414,7 +1636,7 @@ Widget _buildSettingsPage(BuildContext context) => const Scaffold(body: Text('Injected settings')); class _FakeNotifier extends ChannelsNotifier { - final List _channels; + List _channels; final Map> _observedEventsByChannel; _FakeNotifier( @@ -1428,6 +1650,11 @@ class _FakeNotifier extends ChannelsNotifier { @override Future> build() async => _channels; + void setChannels(List channels) { + _channels = channels; + state = AsyncData(channels); + } + @override Map get latestObservedByChannel => { for (final entry in _observedEventsByChannel.entries) @@ -1442,6 +1669,26 @@ class _FakeNotifier extends ChannelsNotifier { get observedUnreadEventsByChannel => _observedEventsByChannel; } +class _FakeChannelActions extends ChannelActions { + final Future Function(String channelId)? onJoinChannel; + + _FakeChannelActions(Ref ref, {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) async { + await onJoinChannel?.call(channelId); + } +} + class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { _FakeChannelSectionsNotifier(this._store); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 79be7a66f7..8c50a5eaf0 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -7,9 +7,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. unfiltered 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 @@ -52,6 +53,164 @@ void main() { }, ); + 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(channels.map((channel) => channel.id), isNot(contains(_channelB))); + expect(channels.map((channel) => channel.id), isNot(contains(_channelD))); + expect( + session.historyFilters.any( + (filter) => + filter.kinds.length == 1 && + filter.kinds.single == 39000 && + !filter.tags.containsKey('#d'), + ), + isTrue, + ); + }, + ); + + test('paginates open-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 lastPageEvent = _meta( + id: '99999999-9999-4999-8999-999999999999', + name: 'last-page', + createdAt: 9, + ); + final session = _FakeRelaySession( + memberships: const [], + metadataPages: [ + firstPage, + [lastPageEvent], + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + + expect(channels, hasLength(501)); + expect( + channels.map((channel) => channel.id), + contains(lastPageEvent.getTagValue('d')), + ); + final discoveryFilters = session.historyFilters + .where( + (filter) => + filter.kinds.length == 1 && + filter.kinds.single == 39000 && + !filter.tags.containsKey('#d'), + ) + .toList(); + expect(discoveryFilters, hasLength(2)); + expect(discoveryFilters.first.until, isNull); + expect(discoveryFilters.first.extensions, isEmpty); + expect(discoveryFilters.last.until, firstPage.last.createdAt); + expect(discoveryFilters.last.extensions['before_id'], firstPage.last.id); + }); + + test('stops 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)); + final discoveryFilters = session.historyFilters + .where( + (filter) => + filter.kinds.length == 1 && + filter.kinds.single == 39000 && + !filter.tags.containsKey('#d'), + ) + .toList(); + expect(discoveryFilters, hasLength(2)); + }); + + test('fails loudly when discovery exceeds its iteration 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 discovery exceeded'), + ), + ), + ); + }); + + test('deduplicates joined channels from open-channel 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)); + expect(session.subscribeFilters.single.tags['#h'], [_channelA]); + }); + test('live channel events update channel lastMessageAt', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], @@ -331,27 +490,32 @@ void main() { }, ); - test('initial fetch issues membership + metadata queries', () 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); + test( + 'initial fetch issues membership + member + discovery metadata queries', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); - // 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]); + await container.read(channelsProvider.future); - // And one live subscription on the resulting channel. - expect(session.subscribeFilters, hasLength(1)); - }); + // Membership, joined-channel metadata, and unfiltered discovery history + // fetches, plus any message and member-count lookups. + expect(session.historyFilters.length, greaterThanOrEqualTo(3)); + 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.historyFilters[2].kinds, [39000]); + expect(session.historyFilters[2].tags, isEmpty); + + // And one live subscription on the resulting joined channel. + expect(session.subscribeFilters, hasLength(1)); + }, + ); } const _channelA = '11111111-1111-4111-8111-111111111111'; @@ -392,6 +556,7 @@ NostrEvent _meta({ required String id, required String name, String channelType = 'stream', + String visibility = 'open', int createdAt = 1, int? ttlSeconds, bool archived = false, @@ -404,7 +569,7 @@ NostrEvent _meta({ ['d', id], ['name', name], ['t', channelType], - ['public'], + [visibility == 'private' ? 'private' : 'public'], if (ttlSeconds != null) ['ttl', '$ttlSeconds'], if (archived) ['archived', 'true'], ], @@ -428,15 +593,24 @@ ProviderContainer _buildContainer({required _FakeRelaySession session}) { 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.membershipFailures = 0, }); List memberships; List metadata; + final List>? metadataPages; + final List Function(int pageIndex)? metadataPageBuilder; + final bool repeatLastMetadataPage; + final int? maxMetadataPageRequests; final List hiddenDmEvents; int membershipFailures; + int _metadataPageIndex = 0; final List historyFilters = []; final List subscribeFilters = []; @@ -470,8 +644,33 @@ 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(); + // A tagged query models the member-metadata lookup. An unfiltered query + // models the relay's discovery response, including unexpected private/DM + // rows so tests verify the provider rejects them rather than trusting the + // fake to pre-filter them. + final ids = filter.tags['#d']?.toSet(); + if (ids == null) { + final requestIndex = _metadataPageIndex++; + final maxRequests = maxMetadataPageRequests; + if (maxRequests != null && requestIndex >= maxRequests) { + throw StateError( + 'Unexpected discovery page request ${requestIndex + 1}', + ); + } + final builder = metadataPageBuilder; + if (builder != null) return List.of(builder(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); + } return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList(); } return const [];