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.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/** /**
* Coordinates metadata and reactions loading for feed items. * 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. * Clear queued items. Call when switching feeds.
*/ */
@@ -247,6 +247,14 @@ class DesktopRelaySubscriptionsCoordinator(
feedMetadata.loadMetadataForPubkeys(pubkeys) feedMetadata.loadMetadataForPubkeys(pubkeys)
} }
/**
* Fast-path: batched metadata for visible-viewport authors.
* Bypasses rate limiter. Single subscription. Closes after EOSE.
*/
fun loadMetadataBatched(pubkeys: List<HexKey>) {
feedMetadata.loadMetadataBatched(pubkeys)
}
// -- DM Subscription Support -- // -- DM Subscription Support --
/** Active DM subscription IDs for cleanup */ /** Active DM subscription IDs for cleanup */
@@ -52,6 +52,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -87,6 +88,9 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
data class LightboxState( data class LightboxState(
val urls: List<String>, val urls: List<String>,
@@ -256,6 +260,7 @@ fun FeedNoteCard(
} }
} }
@OptIn(FlowPreview::class)
@Composable @Composable
fun FeedScreen( fun FeedScreen(
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
@@ -351,25 +356,18 @@ fun FeedScreen(
val feedState by viewModel.feedState.feedContent.collectAsState() val feedState by viewModel.feedState.feedContent.collectAsState()
// Load metadata for visible notes + repost/quoted note authors via Coordinator // Viewport-aware metadata loading: only fetch for visible notes + buffer
// Uses snapshotFlow to avoid per-frame recomposition from scroll observation
LaunchedEffect(feedState, subscriptionsCoordinator) { LaunchedEffect(feedState, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) { if (subscriptionsCoordinator == null || feedState !is FeedState.Loaded) return@LaunchedEffect
val notes = viewModel.feedState.visibleNotes() val loadedFeed = feedState as FeedState.Loaded
if (notes.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForNotes(notes)
// Also load metadata for repost original + quoted note authors // Initial load: batch metadata for first visible notes immediately
val referencedAuthors = val initialNotes = viewModel.feedState.visibleNotes().take(30)
notes if (initialNotes.isNotEmpty()) {
.filter { it.event is RepostEvent || it.event is GenericRepostEvent } val authors = initialNotes.mapNotNull { it.author?.pubkeyHex }.distinct()
.mapNotNull { subscriptionsCoordinator.loadMetadataBatched(authors)
it.replyTo subscriptionsCoordinator.loadMetadataForNotes(initialNotes)
?.lastOrNull()
?.author
?.pubkeyHex
}
subscriptionsCoordinator.loadMetadataForPubkeys(referencedAuthors)
}
} }
} }
@@ -532,7 +530,36 @@ fun FeedScreen(
is FeedState.Loaded -> { is FeedState.Loaded -> {
val loadedState by state.feed.collectAsState() val loadedState by state.feed.collectAsState()
val lazyListState =
androidx.compose.foundation.lazy
.rememberLazyListState()
// Viewport-aware scroll observation: fetch metadata for newly visible notes
LaunchedEffect(lazyListState, loadedState) {
if (subscriptionsCoordinator == null) return@LaunchedEffect
val feedList = loadedState.list
if (feedList.isEmpty()) return@LaunchedEffect
snapshotFlow {
val info = lazyListState.layoutInfo
if (info.visibleItemsInfo.isEmpty()) return@snapshotFlow -1 to -1
info.visibleItemsInfo.first().index to info.visibleItemsInfo.last().index
}.distinctUntilChanged()
.debounce(500)
.collect { (first, last) ->
if (first < 0) return@collect
val from = (first - 10).coerceAtLeast(0)
val to = (last + 10).coerceAtMost(feedList.lastIndex)
val viewportNotes = feedList.subList(from, to + 1)
val authors = viewportNotes.mapNotNull { it.author?.pubkeyHex }.distinct()
if (authors.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataBatched(authors)
}
}
}
LazyColumn( LazyColumn(
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(loadedState.list, key = { it.idHex }) { note -> items(loadedState.list, key = { it.idHex }) { note ->