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

@ -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');
});
});
}