diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart new file mode 100644 index 000000000..ae66b1a20 --- /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 7a6f34b73..f6ffe71a7 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 4ac18a448..040a1acdb 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 accc28a6c..39fbbdb7b 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 a940e6b9b..d877e7eb7 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 97c1b65ce..a9892c504 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) {