fix(mobile): discover open community channels

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-08-01 16:11:41 -07:00
co-authored by Tom Brow
parent ac4fa13b8e
commit 1aa9844dc7
2 changed files with 128 additions and 44 deletions
@@ -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:<my-pubkey>` 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<List<Channel>> {
until = page.map((e) => e.createdAt).reduce(min) - 1;
}
}
final channelIds = memberships
final memberChannelIds = memberships
.map((e) => e.getTagValue('d'))
.whereType<String>()
.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 <NostrEvent>[]
: 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 = <String, NostrEvent>{};
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<List<Channel>> {
final channels = <Channel>[];
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<List<Channel>> {
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 <NostrEvent>[]
: await session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {'#d': memberCountChannelIds},
limit: memberCountChannelIds.length,
),
);
final memberCounts = <String, int>{};
for (final event in memberEvents) {
final chId = event.getTagValue('d');
@@ -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:<my-pubkey>`
/// 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 <String>[]).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 [];