fix(mobile): harden channel discovery refresh

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 11:13:17 -07:00
co-authored by Codex
parent d2b414627c
commit ac3464a0fa
6 changed files with 230 additions and 84 deletions
@@ -0,0 +1,43 @@
part of 'channels_provider.dart';
const _channelDirectoryPageSize = 500;
const _maxChannelDirectoryPages = 100;
Future<List<NostrEvent>> _fetchChannelDirectoryMetas(
RelaySessionNotifier session,
) async {
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',
);
}
}
return directoryMetas;
}
@@ -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<Channel> 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),
],
);
}
@@ -494,7 +494,7 @@ class _EmptyState extends StatelessWidget {
),
),
const SizedBox(height: Grid.xs),
_JoinableChannelList(channels: channels),
_JoinableChannelList(channels: channels.take(3).toList()),
],
],
),
@@ -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<List<Channel>> {
String? _memberSnapshotRelayBaseUrl;
String? _memberSnapshotPubkey;
Map<String, List<ChannelMember>> _memberSnapshotsByChannelId = const {};
List<NostrEvent> _directoryMetas = const [];
/// The member snapshot already returned while loading the channel list.
///
@@ -84,6 +85,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_memberSnapshotRelayBaseUrl = relayBaseUrl;
_memberSnapshotPubkey = pubkey;
_memberSnapshotsByChannelId = const {};
_directoryMetas = const [];
}
final connected = Completer<void>();
final sessionState = ref.read(relaySessionProvider);
@@ -128,10 +130,12 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Future<List<Channel>> _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<List<Channel>> {
Future<List<Channel>> _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<List<Channel>> {
// 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',
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<List<Channel>> {
// 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 [...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<List<Channel>> {
}
/// 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<void> _subscribeLive(List<Channel> channels) {
final channelIds = {
for (final channel in channels)
@@ -919,6 +897,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
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];
@@ -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 {
@@ -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<StateError>().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<void>.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<NostrEvent> hiddenDmEvents;
final List<NostrEvent> recentMessages;
int membershipFailures;
int directoryFailures = 0;
int membershipRequestCount = 0;
int metadataPageRequestCount = 0;
final List<NostrFilter> 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) {