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
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue