feat(mobile): browse and join open channels

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
This commit is contained in:
Tom Brow
2026-08-17 10:59:06 -07:00
co-authored by Codex
parent 076081bfc6
commit d2b414627c
11 changed files with 668 additions and 57 deletions
@@ -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,
@@ -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;
@@ -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)
@@ -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<Channel> channels;
final bool closeAfterJoin;
const _JoinableChannelList({
required this.channels,
this.closeAfterJoin = false,
});
@override
Widget build(BuildContext context) {
final sortedChannels = List<Channel>.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<String?>(null);
Future<void> 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,
),
),
),
],
);
}
}
@@ -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),
),
],
),
);
@@ -109,6 +109,15 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
if (opened != null && context.mounted) {
await openChannel(opened);
}
case _QuickAction.browseChannels:
await showBuzzModalBottomSheet<void>(
context: context,
title: 'Browse channels',
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _BrowseChannelsSheet(),
);
}
}
@@ -456,29 +456,48 @@ class _ChannelSection extends StatelessWidget {
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
final List<Channel> 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),
],
],
),
),
),
);
@@ -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:<my-pubkey>` 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<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();
.toSet();
_cacheMemberSnapshots(memberships, replaceAll: true);
if (channelIds.isEmpty) {
if (subscribeLive) await _subscribeLive(const []);
return const [];
// Step 2: pull metadata for joined channels. A user with no memberships
// must still continue to directory discovery below.
final memberMetas = memberChannelIds.isEmpty
? const <NostrEvent>[]
: 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 = <NostrEvent>[];
final seenDirectoryChannelIds = <String>{};
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',
);
}
}
// Step 2: pull channel metadata in one batched filter.
final metas = await session.fetchHistory(
NostrFilters.channelMetadata(channelIds),
);
// 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. Kind:39000 is parameterized-replaceable,
// but stale revisions from before the relay's d_tag backfill can linger.
final latestMetaPerId = <String, NostrEvent>{};
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<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,
);
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<List<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,
),
);
final memberCountChannelIds = memberChannelIds.toList();
final memberEvents = memberCountChannelIds.isEmpty
? const <NostrEvent>[]
: await session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {'#d': memberCountChannelIds},
limit: memberCountChannelIds.length,
),
);
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents);
final memberCounts = <String, int>{};
for (final event in memberEvents) {
@@ -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;
@@ -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<Material>(
find.descendant(of: dmCard, matching: find.byType(Material)).first,
);
final browseMaterial = tester.widget<Material>(
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<Text>(find.text('New direct message')).style?.fontSize,
16,
);
expect(
tester.widget<Text>(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 = <String>[];
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<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel => _observedEventsByChannel;
void setChannels(List<Channel> channels) {
state = AsyncData(channels);
}
}
class _FakeChannelActions extends ChannelActions {
final Future<void> 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<void> joinChannel(String channelId) => onJoinChannel(channelId);
}
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
@@ -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:<my-pubkey>`
/// 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<StateError>().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<void> _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<NostrEvent> memberships;
List<NostrEvent> metadata;
final List<List<NostrEvent>>? metadataPages;
final List<NostrEvent> Function(int pageIndex)? metadataPageBuilder;
final bool repeatLastMetadataPage;
final int? maxMetadataPageRequests;
final List<NostrEvent> hiddenDmEvents;
final List<NostrEvent> recentMessages;
int membershipFailures;
int metadataPageRequestCount = 0;
final List<NostrFilter> historyFilters = [];
final List<List<NostrFilter>> 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 <String>[]).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 [];