From d2b414627cfe5a869dd8883ffbf4f46d4e897fa2 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 17 Aug 2026 10:59:06 -0700 Subject: [PATCH 1/4] 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 30b3f2b48f..ef1f3f4762 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 c77ef278ec..4b40213909 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 34faa068eb..68ba6c8e30 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 0000000000..7a6f34b732 --- /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 8754842702..3e0f6982b4 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 8517fa70b9..b298c192d0 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 6e17845d74..4ac18a448c 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 2f8dddf2d9..accc28a6c6 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 3f0d612745..dba5ecdef6 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 b0387b54f8..a940e6b9b2 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 ea33fb7944..97c1b65ce8 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/4] 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 0000000000..ae66b1a200 --- /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 7a6f34b732..f6ffe71a72 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 4ac18a448c..040a1acdb4 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 accc28a6c6..39fbbdb7ba 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 a940e6b9b2..d877e7eb7e 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 97c1b65ce8..a9892c5049 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/4] 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 ae66b1a200..328cfbb268 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 a9892c5049..a5eb1d751e 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/4] 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 68ba6c8e30..34faa068eb 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 f6ffe71a72..24cd1f654e 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 040a1acdb4..6e17845d74 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 d877e7eb7e..cefbb574f7 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 {