diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 614ab054c..7e81e69cd 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -24,10 +24,12 @@ 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 +144,32 @@ 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 channel metadata in one batched filter. - final metas = await session.fetchHistory( - NostrFilters.channelMetadata(channelIds), + // 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 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. + final discoverableMetas = await session.fetchHistory( + const NostrFilter(kinds: [39000], limit: 500), ); - // 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 +216,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 +235,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/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 79be7a66f..42eb3dbe7 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,59 @@ 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('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 +385,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); + 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); - await container.read(channelsProvider.future); + await container.read(channelsProvider.future); - // Two history fetches for channel loading, plus one per non-DM channel - // for high-priority event backfill. - expect(session.historyFilters.length, greaterThanOrEqualTo(2)); - expect(session.historyFilters[0].kinds, [39002]); - expect(session.historyFilters[0].tags['#p'], [myPk]); - expect(session.historyFilters[1].kinds, [39000]); - expect(session.historyFilters[1].tags['#d'], [_channelA]); + // 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 channel. - expect(session.subscribeFilters, hasLength(1)); - }); + // And one live subscription on the resulting joined channel. + expect(session.subscribeFilters, hasLength(1)); + }, + ); } const _channelA = '11111111-1111-4111-8111-111111111111'; @@ -392,6 +451,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 +464,7 @@ NostrEvent _meta({ ['d', id], ['name', name], ['t', channelType], - ['public'], + [visibility == 'private' ? 'private' : 'public'], if (ttlSeconds != null) ['ttl', '$ttlSeconds'], if (archived) ['archived', 'true'], ], @@ -470,8 +530,12 @@ 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) return List.of(metadata); return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList(); } return const [];