diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index d5fecc2..d6c462b 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -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 offers; /// The coarse area currently being browsed. @@ -57,8 +61,21 @@ class OffersState extends Equatable { final Set 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? blockedAuthors, Set? 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 { StreamSubscription? _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 discover(String geohashPrefix) async { final transport = _transport; if (transport == null) { @@ -207,15 +244,38 @@ class OffersCubit extends Cubit { 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 { }); } - /// 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 _merge(List 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 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 _prepend(List 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 _mergePage(List current, List 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 _capped(List 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)); diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index b4a7eeb..23941a5 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -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( + // 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(); + }, + ); + }, + ), ), ), ), diff --git a/apps/app_seeds/test/screenshots/screenshots_test.dart b/apps/app_seeds/test/screenshots/screenshots_test.dart index fac0139..7589506 100644 --- a/apps/app_seeds/test/screenshots/screenshots_test.dart +++ b/apps/app_seeds/test/screenshots/screenshots_test.dart @@ -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 discover(DiscoveryQuery query) => const Stream.empty(); + Stream discover(DiscoveryQuery query, {int? since}) => + const Stream.empty(); + @override + Future discoverPage(DiscoveryQuery query) async => + const OfferPage(offers: []); @override Future publish(Offer offer) => throw UnimplementedError(); @override diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index d38a96e..650088a 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -25,7 +25,7 @@ class FakeOfferTransport implements OfferTransport { } @override - Stream discover(DiscoveryQuery query) { + Stream discover(DiscoveryQuery query, {int? since}) { final controller = StreamController(); 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 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 retract(String offerId) async => _offers.removeWhere((o) => o.id == offerId); @@ -54,7 +63,7 @@ class DuplicatingOfferTransport implements OfferTransport { } @override - Stream discover(DiscoveryQuery query) { + Stream discover(DiscoveryQuery query, {int? since}) { final controller = StreamController(); for (final o in _offers) { if (o.approxGeohash.startsWith(query.geohashPrefix)) { @@ -65,6 +74,17 @@ class DuplicatingOfferTransport implements OfferTransport { return controller.stream; } + @override + Future 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 retract(String offerId) async => _offers.removeWhere((o) => o.id == offerId); @@ -73,6 +93,59 @@ class DuplicatingOfferTransport implements OfferTransport { Future 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 discover(DiscoveryQuery query, {int? since}) => + const Stream.empty(); + + @override + Future 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 publish(Offer offer) async => + PublishResult(accepted: true, transportRef: offer.id); + @override + Future retract(String offerId) async {} + @override + Future 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(); + }); + }); } diff --git a/apps/app_seeds/test/ui/inventory_list_lazy_test.dart b/apps/app_seeds/test/ui/inventory_list_lazy_test.dart new file mode 100644 index 0000000..a9475e1 --- /dev/null +++ b/apps/app_seeds/test/ui/inventory_list_lazy_test.dart @@ -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(); + }); +} diff --git a/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart b/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart index c9408b9..305f842 100644 --- a/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart +++ b/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart @@ -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 discover(DiscoveryQuery query) => _conn - .subscribe(_filter(query)) + Stream 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 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 = []; + 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> discoverUntilEose(DiscoveryQuery query) async { final events = await _conn.reqOnce(_filter(query)); diff --git a/packages/commons_core/lib/src/social/offer.dart b/packages/commons_core/lib/src/social/offer.dart index 5baa1b1..97472d9 100644 --- a/packages/commons_core/lib/src/social/offer.dart +++ b/packages/commons_core/lib/src/social/offer.dart @@ -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 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 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; } diff --git a/packages/commons_core/lib/src/social/offer_transport.dart b/packages/commons_core/lib/src/social/offer_transport.dart index 3dc3740..3d7928e 100644 --- a/packages/commons_core/lib/src/social/offer_transport.dart +++ b/packages/commons_core/lib/src/social/offer_transport.dart @@ -13,8 +13,16 @@ abstract interface class OfferTransport { Future publish(Offer offer); /// Streams offers matching [query]: stored matches first, then live ones, - /// until the caller cancels the subscription. - Stream 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 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 discoverPage(DiscoveryQuery query); /// Retracts a previously published offer (relays that already replicated it /// drop it over time). diff --git a/packages/commons_core/test/social/nostr_offer_transport_page_test.dart b/packages/commons_core/test/social/nostr_offer_transport_page_test.dart new file mode 100644 index 0000000..25f9642 --- /dev/null +++ b/packages/commons_core/test/social/nostr_offer_transport_page_test.dart @@ -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 _events; + + @override + Future> 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 subscribe(Filter filter) => const Stream.empty(); + @override + Future 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'); + }); + }); +}