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