perf(market): paginate Nostr offer discovery, infinite scroll, memory cap

Bound market memory and let large areas page instead of loading everything:
- DiscoveryQuery gains an until cursor; OfferTransport gains discoverPage()
  (one-shot, EOSE-bounded, newest-first) alongside the existing live discover()
  stream, which now accepts since so it only carries NEW offers going forward.
- NostrOfferTransport.discoverPage sorts by created_at desc and derives the
  next cursor from the oldest event seen (not the oldest type-matched one, so
  filtering never skips a page).
- OffersCubit: discover() fetches the first page + opens a since=now live sub;
  loadNextPage() pages further back; the in-memory list is capped at 400
  offers (each can carry a ~40KB inline photo thumbnail, so unbounded growth in
  a busy area was a real OOM risk).
- market_screen: infinite scroll via a scroll-position trigger + footer
  spinner, instead of a single unbounded ListView.
- Added discoverPage unit tests (commons_core) and cubit pagination tests
  (first page/cursor, accumulation, cap, cross-page dedup).
This commit is contained in:
vjrj 2026-07-20 22:47:12 +02:00
parent 3de01bd948
commit f17ae7751c
9 changed files with 513 additions and 43 deletions

View file

@ -24,12 +24,16 @@ class OffersState extends Equatable {
this.blockedAuthors = const {},
this.hiddenOfferKeys = const {},
this.searching = false,
this.loadingMore = false,
this.nextPageCursor,
this.publishing = false,
this.hasSearched = false,
this.error,
});
/// Offers discovered so far for [areaGeohash], newest appended as they arrive.
/// Offers discovered so far for [areaGeohash], newest first. Bounded in size
/// (see [OffersCubit.maxOffersKept]) so a busy area can't grow the list — and
/// the inline photo thumbnails it carries without limit.
final List<Offer> offers;
/// The coarse area currently being browsed.
@ -57,8 +61,21 @@ class OffersState extends Equatable {
final Set<String> hiddenOfferKeys;
final bool searching;
/// True while a "load more" page is in flight, so the UI shows a footer
/// spinner and doesn't fire overlapping page requests.
final bool loadingMore;
/// Cursor (Unix seconds) for the next older page, or null when the newest
/// page was short (nothing older) or the in-memory cap was reached in both
/// cases there is no more to load.
final int? nextPageCursor;
final bool publishing;
/// Whether more offers can be paged in (drives the infinite-scroll trigger).
bool get canLoadMore => nextPageCursor != null;
/// True once a discovery has been started, so the UI can tell "no search yet"
/// from "searched, found nothing".
final bool hasSearched;
@ -115,6 +132,8 @@ class OffersState extends Equatable {
Set<String>? blockedAuthors,
Set<String>? hiddenOfferKeys,
bool? searching,
bool? loadingMore,
int? Function()? nextPageCursor,
bool? publishing,
bool? hasSearched,
String? Function()? error,
@ -129,6 +148,9 @@ class OffersState extends Equatable {
blockedAuthors: blockedAuthors ?? this.blockedAuthors,
hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys,
searching: searching ?? this.searching,
loadingMore: loadingMore ?? this.loadingMore,
nextPageCursor:
nextPageCursor != null ? nextPageCursor() : this.nextPageCursor,
publishing: publishing ?? this.publishing,
hasSearched: hasSearched ?? this.hasSearched,
error: error != null ? error() : this.error,
@ -146,6 +168,8 @@ class OffersState extends Equatable {
blockedAuthors,
hiddenOfferKeys,
searching,
loadingMore,
nextPageCursor,
publishing,
hasSearched,
error,
@ -183,10 +207,23 @@ class OffersCubit extends Cubit<OffersState> {
StreamSubscription<Offer>? _sub;
Timer? _searchTimeout;
/// Upper bound on offers held in memory. Each offer can carry an inline
/// (~40 KB) photo thumbnail, so an unbounded list in a busy area is a real
/// out-of-memory risk on mobile. Paging stops once the list reaches this, and
/// live offers beyond it evict the oldest the newest stay visible.
static const int maxOffersKept = 400;
/// Whether a live transport is available (relay configured and reachable).
bool get isOnline => _transport != null;
/// Starts (or restarts) discovery for [geohashPrefix]. Results stream in.
/// Current time in Unix seconds the granularity Nostr filters use for
/// `since`/`until`.
int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
/// Starts (or restarts) discovery for [geohashPrefix]. Fetches the newest page
/// up front (bounded), then keeps a live subscription for offers published
/// from now on older results arrive via [loadNextPage], not by draining the
/// whole area into memory.
Future<void> discover(String geohashPrefix) async {
final transport = _transport;
if (transport == null) {
@ -207,15 +244,38 @@ class OffersCubit extends Cubit<OffersState> {
searching: true,
hasSearched: true,
));
_sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen(
(offer) =>
emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)),
onError: (Object e) =>
emit(state.copyWith(searching: false, error: () => '$e')),
final query = DiscoveryQuery(
geohashPrefix: geohashPrefix,
types: state.typeFilter,
);
// The discover stream stays open for live offers and never signals "done",
// so stop the spinner after a beat: no results show the empty state, not
// an endless "searching".
// Live subscription first, bounded to offers published from now on, so a new
// listing shows up immediately without re-dumping the whole area.
final since = _now();
_sub = transport.discover(query, since: since).listen(
(offer) => emit(state.copyWith(
offers: _capped(_prepend(state.offers, offer)),
searching: false,
)),
onError: (Object e) =>
emit(state.copyWith(searching: false, error: () => '$e')),
);
try {
final page = await transport.discoverPage(query);
if (!isClosed) {
emit(state.copyWith(
offers: _capped(_mergePage(state.offers, page.offers)),
nextPageCursor: () => page.nextCursor,
searching: false,
));
}
} catch (e) {
if (!isClosed) emit(state.copyWith(searching: false, error: () => '$e'));
}
// Safety net: if the first page hangs and no live offer arrives, still stop
// the spinner so the screen shows the empty state, not an endless search.
_searchTimeout = Timer(const Duration(seconds: 6), () {
if (!isClosed && state.searching) {
emit(state.copyWith(searching: false));
@ -223,18 +283,64 @@ class OffersCubit extends Cubit<OffersState> {
});
}
/// Appends [incoming] to [current], replacing any existing offer with the same
/// author + id. Relays legitimately resend addressable events (a stored copy
/// plus a live echo after publishing), so a plain append would double the
/// listing; keeping one entry per (author, id) is the NIP-99 semantics.
static List<Offer> _merge(List<Offer> current, Offer incoming) => [
for (final o in current)
if (!(o.id == incoming.id &&
o.authorPubkeyHex == incoming.authorPubkeyHex))
o,
/// Loads the next (older) page of offers for the current area. Called by the
/// list as it nears the end. No-op when offline, already loading, or there is
/// nothing older to fetch.
Future<void> loadNextPage() async {
final transport = _transport;
final cursor = state.nextPageCursor;
if (transport == null || cursor == null || state.loadingMore) return;
emit(state.copyWith(loadingMore: true));
try {
final page = await transport.discoverPage(DiscoveryQuery(
geohashPrefix: state.areaGeohash,
types: state.typeFilter,
until: cursor,
));
final merged = _mergePage(state.offers, page.offers);
// Stop paging once the cap is reached the list is already as large as we
// keep otherwise carry the transport's cursor onward.
final reachedCap = merged.length >= maxOffersKept;
emit(state.copyWith(
offers: _capped(merged),
nextPageCursor: () => reachedCap ? null : page.nextCursor,
loadingMore: false,
));
} catch (e) {
emit(state.copyWith(loadingMore: false, error: () => '$e'));
}
}
/// The offer's identity for de-duplication: NIP-99 addressable events are keyed
/// by (author, `d`-tag id), so the same listing from two relays or a stored
/// copy plus a live echo collapses to one entry.
static String _key(Offer o) => '${o.authorPubkeyHex}:${o.id}';
/// Prepends a freshly-arrived (newer) [incoming] offer, dropping any existing
/// entry with the same identity so a live echo never doubles the listing.
static List<Offer> _prepend(List<Offer> current, Offer incoming) => [
incoming,
for (final o in current)
if (_key(o) != _key(incoming)) o,
];
/// Appends an older [page] after [current] (older offers sort below newer),
/// skipping any already present. Keeps the newest-first ordering.
static List<Offer> _mergePage(List<Offer> current, List<Offer> page) {
final seen = {for (final o in current) _key(o)};
return [
...current,
for (final o in page)
if (seen.add(_key(o))) o,
];
}
/// Caps the list to [maxOffersKept], keeping the newest (front) and dropping
/// the oldest (tail) bounds memory in a busy area.
static List<Offer> _capped(List<Offer> offers) => offers.length <= maxOffersKept
? offers
: offers.sublist(0, maxOffersKept);
/// Narrows the visible offers to those whose summary matches [query]. Purely
/// local over the already-discovered list; does not re-hit the transport.
void search(String query) => emit(state.copyWith(query: query));

View file

@ -414,23 +414,44 @@ class MarketBody extends StatelessWidget {
),
],
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: visible.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final o = visible[i];
final mine = o.authorPubkeyHex == selfPubkey;
return _OfferCard(
offer: o,
mine: mine,
onTap: () async {
await context.push('/market/offer', extra: o);
await onOfferClosed?.call();
},
);
: NotificationListener<ScrollNotification>(
// Page in older offers as the list nears its end, so a
// large area streams in on demand rather than all at
// once. The cubit guards against overlapping loads.
onNotification: (n) {
if (state.canLoadMore &&
!state.loadingMore &&
n.metrics.axis == Axis.vertical &&
n.metrics.extentAfter < 500) {
cubit.loadNextPage();
}
return false;
},
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
// A trailing spinner row while the next page loads.
itemCount: visible.length + (state.loadingMore ? 1 : 0),
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
if (i >= visible.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
);
}
final o = visible[i];
final mine = o.authorPubkeyHex == selfPubkey;
return _OfferCard(
offer: o,
mine: mine,
onTap: () async {
await context.push('/market/offer', extra: o);
await onOfferClosed?.call();
},
);
},
),
),
),
),

View file

@ -156,7 +156,11 @@ class _SeededOffersCubit extends OffersCubit {
/// A transport that is present (so the market reads as online) but never used.
class _NoopOfferTransport implements OfferTransport {
@override
Stream<Offer> discover(DiscoveryQuery query) => const Stream.empty();
Stream<Offer> discover(DiscoveryQuery query, {int? since}) =>
const Stream.empty();
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async =>
const OfferPage(offers: []);
@override
Future<PublishResult> publish(Offer offer) => throw UnimplementedError();
@override

View file

@ -25,7 +25,7 @@ class FakeOfferTransport implements OfferTransport {
}
@override
Stream<Offer> discover(DiscoveryQuery query) {
Stream<Offer> discover(DiscoveryQuery query, {int? since}) {
final controller = StreamController<Offer>();
for (final o in _offers) {
if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o);
@ -33,6 +33,15 @@ class FakeOfferTransport implements OfferTransport {
return controller.stream; // left open (live), like a real subscription
}
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
final matches = [
for (final o in _offers)
if (o.approxGeohash.startsWith(query.geohashPrefix)) o,
];
return OfferPage(offers: matches); // short page nothing older to load
}
@override
Future<void> retract(String offerId) async =>
_offers.removeWhere((o) => o.id == offerId);
@ -54,7 +63,7 @@ class DuplicatingOfferTransport implements OfferTransport {
}
@override
Stream<Offer> discover(DiscoveryQuery query) {
Stream<Offer> discover(DiscoveryQuery query, {int? since}) {
final controller = StreamController<Offer>();
for (final o in _offers) {
if (o.approxGeohash.startsWith(query.geohashPrefix)) {
@ -65,6 +74,17 @@ class DuplicatingOfferTransport implements OfferTransport {
return controller.stream;
}
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
// The stored copy (deduped at relay level); the live echo (the duplicate)
// still arrives via [discover], so the cubit's dedup is what's under test.
final matches = [
for (final o in _offers)
if (o.approxGeohash.startsWith(query.geohashPrefix)) o,
];
return OfferPage(offers: matches);
}
@override
Future<void> retract(String offerId) async =>
_offers.removeWhere((o) => o.id == offerId);
@ -73,6 +93,59 @@ class DuplicatingOfferTransport implements OfferTransport {
Future<void> close() async {}
}
/// A transport that serves offers in `until`-cursored pages (like a relay's
/// stored events), so the cubit's pagination and memory cap can be exercised.
/// Each seeded offer gets a descending createdAt; [discover] (live) is empty.
class PaginatingOfferTransport implements OfferTransport {
PaginatingOfferTransport(int count, {this.geohash = 'sp3e9'}) {
// Newest (highest createdAt) first: offer 0 is newest.
for (var i = 0; i < count; i++) {
_byCreatedAt.add((
createdAt: 1000000 - i,
offer: Offer(
id: 'o$i',
authorPubkeyHex: 'ab' * 32,
summary: 'offer $i',
type: OfferType.gift,
approxGeohash: geohash,
),
));
}
}
final String geohash;
final List<({int createdAt, Offer offer})> _byCreatedAt = [];
@override
Stream<Offer> discover(DiscoveryQuery query, {int? since}) =>
const Stream.empty();
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
final matched = _byCreatedAt
.where((e) => e.offer.approxGeohash.startsWith(query.geohashPrefix))
.where((e) => query.until == null || e.createdAt <= query.until!)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final page = matched.take(query.limit).toList();
final nextCursor = page.length >= query.limit && page.isNotEmpty
? page.last.createdAt - 1
: null;
return OfferPage(
offers: [for (final e in page) e.offer],
nextCursor: nextCursor,
);
}
@override
Future<PublishResult> publish(Offer offer) async =>
PublishResult(accepted: true, transportRef: offer.id);
@override
Future<void> retract(String offerId) async {}
@override
Future<void> close() async {}
}
void main() {
group('OfferMapper', () {
test('maps local sharing intent to the network offer type', () {
@ -701,4 +774,65 @@ void main() {
await cubit.close();
});
});
group('OffersCubit pagination', () {
test('discover loads the first page and exposes a next-page cursor',
() async {
final cubit = OffersCubit(PaginatingOfferTransport(250));
await cubit.discover('sp3');
await pumpEventQueue();
// One page (default limit 100) not the whole 250 is kept in memory.
expect(cubit.state.offers, hasLength(100));
expect(cubit.state.canLoadMore, isTrue);
expect(cubit.state.searching, isFalse);
await cubit.close();
});
test('loadNextPage accumulates older offers until the source is exhausted',
() async {
final cubit = OffersCubit(PaginatingOfferTransport(250));
await cubit.discover('sp3');
await pumpEventQueue();
await cubit.loadNextPage();
expect(cubit.state.offers, hasLength(200));
expect(cubit.state.canLoadMore, isTrue);
await cubit.loadNextPage();
// Only 250 exist: the third page is short, so there is nothing older.
expect(cubit.state.offers, hasLength(250));
expect(cubit.state.canLoadMore, isFalse);
// Paging past the end is a harmless no-op.
await cubit.loadNextPage();
expect(cubit.state.offers, hasLength(250));
await cubit.close();
});
test('the in-memory list is capped and paging stops at the cap', () async {
final cubit = OffersCubit(PaginatingOfferTransport(600));
await cubit.discover('sp3');
await pumpEventQueue();
// Keep paging until the cubit says there is no more.
var guard = 0;
while (cubit.state.canLoadMore && guard++ < 20) {
await cubit.loadNextPage();
}
expect(cubit.state.offers, hasLength(OffersCubit.maxOffersKept));
expect(cubit.state.canLoadMore, isFalse);
await cubit.close();
});
test('offers are de-duplicated across pages by (author, id)', () async {
// Two pages that overlap on the boundary id must not double it.
final cubit = OffersCubit(PaginatingOfferTransport(150));
await cubit.discover('sp3');
await pumpEventQueue();
await cubit.loadNextPage();
final ids = cubit.state.offers.map((o) => o.id).toList();
expect(ids.toSet(), hasLength(ids.length)); // no duplicates
expect(cubit.state.offers, hasLength(150));
await cubit.close();
});
});
}

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/ui/inventory_list_screen.dart';
import '../support/test_support.dart';
/// The inventory list must render lazily: with a large catalogue only the tiles
/// near the viewport are built, not one widget per row. Guards the regression
/// from `ListView(children: )` (every tile built upfront) back in.
void main() {
testWidgets('builds only the on-screen tiles for a large inventory',
(tester) async {
final db = newTestDatabase();
final repo = newTestRepository(db);
// Enough rows that an eager list would build hundreds of tiles at once.
for (var i = 0; i < 300; i++) {
await repo.addQuickVariety(
label: 'Variety ${i.toString().padLeft(3, '0')}',
category: i.isEven ? 'Poaceae' : 'Fabaceae',
);
}
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
// Let the debounced inventory stream emit and the async load resolve
// (bounded pumps the screen holds a live Drift stream, so pumpAndSettle
// would hang; see testing.md).
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.pump(const Duration(milliseconds: 100));
// Every variety tile is a ListTile; a lazy list builds only a viewport-worth.
final built = find.byType(ListTile).evaluate().length;
expect(built, greaterThan(0), reason: 'the list rendered some tiles');
expect(
built,
lessThan(50),
reason: 'lazy list should build ~a screenful, not all 300 ($built built)',
);
await disposeTree(tester);
await db.close();
});
}

View file

@ -13,10 +13,12 @@ class NostrOfferTransport implements OfferTransport {
final NostrChannel _conn;
final Nip99Codec _codec = Nip99Codec();
Filter _filter(DiscoveryQuery q) => Filter(
Filter _filter(DiscoveryQuery q, {int? since}) => Filter(
kinds: const [Nip99Codec.kindActive],
tagFilters: {'g': [q.geohashPrefix]},
limit: q.limit,
since: since,
until: q.until,
);
@override
@ -37,11 +39,34 @@ class NostrOfferTransport implements OfferTransport {
}
@override
Stream<Offer> discover(DiscoveryQuery query) => _conn
.subscribe(_filter(query))
Stream<Offer> discover(DiscoveryQuery query, {int? since}) => _conn
.subscribe(_filter(query, since: since))
.map(_codec.decode)
.where((o) => query.types.isEmpty || query.types.contains(o.type));
@override
Future<OfferPage> discoverPage(DiscoveryQuery query) async {
final events = await _conn.reqOnce(_filter(query))
// Newest first; the relays dedup within themselves and reqOnce dedups
// across them, but order is not guaranteed, so sort here.
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final offers = <Offer>[];
for (final e in events) {
final offer = _codec.decode(e);
if (_matchesType(offer, query)) offers.add(offer);
}
// A full page means more may exist older than the oldest event we saw; a
// short page means we've reached the end. Base the cursor on ALL events
// (not just type-matched ones) so type filtering never makes us re-fetch.
final nextCursor = events.length >= query.limit && events.isNotEmpty
? events.last.createdAt - 1
: null;
return OfferPage(offers: offers, nextCursor: nextCursor);
}
bool _matchesType(Offer o, DiscoveryQuery q) =>
q.types.isEmpty || q.types.contains(o.type);
/// Collects matches up to EOSE (tests/one-shot browse).
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
final events = await _conn.reqOnce(_filter(query));

View file

@ -88,10 +88,28 @@ class DiscoveryQuery {
required this.geohashPrefix,
this.types = const {},
this.limit = 100,
this.until,
});
/// Coarse geohash prefix to search near (e.g. "u09" tens of km).
final String geohashPrefix;
final Set<OfferType> types;
final int limit;
/// Pagination cursor (NIP-01 `until`): return only offers published at or
/// before this Unix time (seconds). Null asks for the newest page. Comes from
/// the previous page's [OfferPage.nextCursor].
final int? until;
}
/// One page of discovered offers plus the cursor to fetch the next (older) page.
class OfferPage {
const OfferPage({required this.offers, this.nextCursor});
/// The page's offers, newest first.
final List<Offer> offers;
/// Pass as the next query's [DiscoveryQuery.until] to page further back. Null
/// when the relays returned less than a full page there is nothing older.
final int? nextCursor;
}

View file

@ -13,8 +13,16 @@ abstract interface class OfferTransport {
Future<PublishResult> publish(Offer offer);
/// Streams offers matching [query]: stored matches first, then live ones,
/// until the caller cancels the subscription.
Stream<Offer> discover(DiscoveryQuery query);
/// until the caller cancels the subscription. Pass [since] (Unix seconds) to
/// stream only offers published after it used to keep a live subscription
/// for NEW offers while older ones are browsed via [discoverPage].
Stream<Offer> discover(DiscoveryQuery query, {int? since});
/// Fetches ONE page of stored offers matching [query] (up to EOSE), newest
/// first, with a cursor to page further back. Bounded unlike [discover] it
/// does not hold a live subscription so the UI can scroll a large result
/// set without accumulating every offer in memory.
Future<OfferPage> discoverPage(DiscoveryQuery query);
/// Retracts a previously published offer (relays that already replicated it
/// drop it over time).

View file

@ -0,0 +1,109 @@
import 'package:commons_core/commons_core.dart';
import 'package:nostr/nostr.dart';
import 'package:test/test.dart';
/// A stand-in relay channel: [reqOnce] honours the filter's `until` and `limit`
/// (newest first), like a relay serving stored events, so [discoverPage]'s
/// ordering and cursor maths can be asserted deterministically.
class _FakeChannel implements NostrChannel {
_FakeChannel(this._events);
final List<Event> _events;
@override
Future<List<Event>> reqOnce(Filter filter) async {
final until = filter.until;
final matched = _events
.where((e) => until == null || e.createdAt <= until)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return matched.take(filter.limit ?? matched.length).toList();
}
@override
String get privateKeyHex => '00' * 32;
@override
String get publicKeyHex => 'ab' * 32;
@override
Future<({bool accepted, String message})> publish(Event event) async =>
(accepted: true, message: '');
@override
Stream<Event> subscribe(Filter filter) => const Stream.empty();
@override
Future<void> close() async {}
}
Event _evt(String id, int createdAt, {String type = 'gift'}) => Event(
'evt-$id',
'ab' * 32,
createdAt,
Nip99Codec.kindActive,
[
['d', id],
['g', 'sp3e9'],
['offer_type', type],
['title', 'offer $id'],
],
'offer $id',
'00' * 64,
verify: false,
);
void main() {
group('NostrOfferTransport.discoverPage', () {
test('returns offers newest-first with a cursor to the older page',
() async {
final transport = NostrOfferTransport(_FakeChannel([
_evt('a', 100), // oldest
_evt('c', 300), // newest
_evt('b', 200),
]));
final page1 = await transport.discoverPage(
const DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 2),
);
// A full page (2 of the 3) sorted newest-first, with a cursor set.
expect(page1.offers.map((o) => o.id), ['c', 'b']);
expect(page1.nextCursor, 199, reason: 'oldest kept (200) minus one');
final page2 = await transport.discoverPage(
DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 2, until: page1.nextCursor),
);
// Only the last one remains a short page, so nothing older.
expect(page2.offers.map((o) => o.id), ['a']);
expect(page2.nextCursor, isNull);
});
test('a short first page reports no next cursor', () async {
final transport = NostrOfferTransport(_FakeChannel([
_evt('a', 100),
_evt('b', 200),
]));
final page = await transport.discoverPage(
const DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 100),
);
expect(page.offers, hasLength(2));
expect(page.nextCursor, isNull);
});
test('type filtering narrows the offers but not the cursor', () async {
// A full page of gifts with one sale mixed in; asking for sales only must
// still advance the cursor by the oldest EVENT seen, not the oldest match,
// so paging never skips unmatched offers.
final transport = NostrOfferTransport(_FakeChannel([
_evt('g1', 300),
_evt('s1', 200, type: 'sale'),
_evt('g2', 100),
]));
final page = await transport.discoverPage(
const DiscoveryQuery(
geohashPrefix: 'sp3e9',
limit: 3,
types: {OfferType.sale},
),
);
expect(page.offers.map((o) => o.id), ['s1']);
expect(page.nextCursor, 99, reason: 'oldest event (100) minus one');
});
});
}