perf(feed): viewport-aware metadata loading with batched author filter

Two optimizations for feed metadata loading speed:

1. Batched author filter (FeedMetadataCoordinator.loadMetadataBatched):
   Single Filter(kind:0, authors:[all visible]) instead of individual
   per-pubkey subscriptions through rate limiter. Closes after EOSE.
   Follows ChessRelayFetchHelper one-shot pattern. Max 100 authors/filter.

2. Viewport-aware scroll observation (FeedScreen):
   snapshotFlow reads LazyListState.visibleItemsInfo (zero recomposition)
   with 500ms debounce + distinctUntilChanged. Only fetches metadata for
   visible notes ± 10 item buffer. Initial load batches first 30 notes.

Before: 100+ authors × 20/sec rate limit × 7 relays = 5+ seconds
After: ~20 visible authors × 1 batched sub × 7 relays = <1 second

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-29 11:10:05 +03:00
parent a826918293
commit 22e50cc852
3 changed files with 107 additions and 17 deletions
@@ -36,7 +36,10 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* Coordinates metadata and reactions loading for feed items.
@@ -245,6 +248,58 @@ class FeedMetadataCoordinator(
)
}
/**
* Fast-path: batched metadata subscription for visible-viewport authors.
* Bypasses rate limiter. Single filter with all authors. Closes after EOSE.
*/
fun loadMetadataBatched(
pubkeys: List<HexKey>,
timeoutMs: Long = 5_000L,
) {
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }.distinct()
if (newPubkeys.isEmpty()) return
queuedPubkeys.addAll(newPubkeys)
scope.launch {
val filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = newPubkeys.take(100),
limit = newPubkeys.size,
)
val filterMap = indexRelays.associateWith { listOf(filter) }
val subId = newSubId()
val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onEvent?.invoke(event, relay)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
if (eoseReceived.size >= indexRelays.size) {
allEose.complete(Unit)
}
}
}
client.subscribe(subId, filterMap, listener)
withTimeoutOrNull(timeoutMs) { allEose.await() }
client.unsubscribe(subId)
}
}
/**
* Clear queued items. Call when switching feeds.
*/