fix(mobile): paginate channel discovery

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:36:13 -07:00
co-authored by Tom Brow
parent 1aa9844dc7
commit 4e7cefc4e6
2 changed files with 81 additions and 5 deletions
@@ -160,9 +160,29 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// 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),
);
// Page with the relay's composite cursor so tied timestamps cannot skip
// channels and communities larger than one relay page remain complete.
final discoverableMetas = <NostrEvent>[];
{
int? until;
String? beforeId;
const pageSize = 500;
while (true) {
final page = await session.fetchHistory(
NostrFilter(
kinds: const [39000],
limit: pageSize,
until: until,
extensions: {'before_id': ?beforeId},
),
);
discoverableMetas.addAll(page);
if (page.length < pageSize) break;
final last = page.last;
until = last.createdAt;
beforeId = last.id;
}
}
// Merge and dedupe by `d` tag (channel id). Kind:39000 is
// parameterized-replaceable, but stale revisions from before the relay's
@@ -86,6 +86,52 @@ void main() {
},
);
test('paginates open-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 lastPageEvent = _meta(
id: '99999999-9999-4999-8999-999999999999',
name: 'last-page',
createdAt: 9,
);
final session = _FakeRelaySession(
memberships: const [],
metadataPages: [
firstPage,
[lastPageEvent],
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final channels = await container.read(channelsProvider.future);
expect(channels, hasLength(501));
expect(
channels.map((channel) => channel.id),
contains(lastPageEvent.getTagValue('d')),
);
final discoveryFilters = session.historyFilters
.where(
(filter) =>
filter.kinds.length == 1 &&
filter.kinds.single == 39000 &&
!filter.tags.containsKey('#d'),
)
.toList();
expect(discoveryFilters, hasLength(2));
expect(discoveryFilters.first.until, isNull);
expect(discoveryFilters.first.extensions, isEmpty);
expect(discoveryFilters.last.until, firstPage.last.createdAt);
expect(discoveryFilters.last.extensions['before_id'], firstPage.last.id);
});
test('deduplicates joined channels from open-channel discovery', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
@@ -488,15 +534,18 @@ ProviderContainer _buildContainer({required _FakeRelaySession session}) {
class _FakeRelaySession extends RelaySessionNotifier {
_FakeRelaySession({
required this.memberships,
required this.metadata,
this.metadata = const [],
this.metadataPages,
this.hiddenDmEvents = const [],
this.membershipFailures = 0,
});
List<NostrEvent> memberships;
List<NostrEvent> metadata;
final List<List<NostrEvent>>? metadataPages;
final List<NostrEvent> hiddenDmEvents;
int membershipFailures;
int _metadataPageIndex = 0;
final List<NostrFilter> historyFilters = [];
final List<NostrFilter> subscribeFilters = [];
@@ -535,7 +584,14 @@ class _FakeRelaySession extends RelaySessionNotifier {
// 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);
if (ids == null) {
final pages = metadataPages;
if (pages != null) {
if (_metadataPageIndex >= pages.length) return const [];
return List.of(pages[_metadataPageIndex++]);
}
return List.of(metadata);
}
return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList();
}
return const [];