mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user