mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tom Brow
parent
ac4fa13b8e
commit
1aa9844dc7
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user