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:
parent
3de01bd948
commit
f17ae7751c
9 changed files with 513 additions and 43 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue