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
@@ -247,6 +247,14 @@ class DesktopRelaySubscriptionsCoordinator(
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 --
/** Active DM subscription IDs for cleanup */
@@ -52,6 +52,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
data class LightboxState(
val urls: List<String>,
@@ -256,6 +260,7 @@ fun FeedNoteCard(
}
}
@OptIn(FlowPreview::class)
@Composable
fun FeedScreen(
relayManager: DesktopRelayConnectionManager,
@@ -351,25 +356,18 @@ fun FeedScreen(
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) {
if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) {
val notes = viewModel.feedState.visibleNotes()
if (notes.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForNotes(notes)
if (subscriptionsCoordinator == null || feedState !is FeedState.Loaded) return@LaunchedEffect
val loadedFeed = feedState as FeedState.Loaded
// Also load metadata for repost original + quoted note authors
val referencedAuthors =
notes
.filter { it.event is RepostEvent || it.event is GenericRepostEvent }
.mapNotNull {
it.replyTo
?.lastOrNull()
?.author
?.pubkeyHex
}
subscriptionsCoordinator.loadMetadataForPubkeys(referencedAuthors)
}
// Initial load: batch metadata for first visible notes immediately
val initialNotes = viewModel.feedState.visibleNotes().take(30)
if (initialNotes.isNotEmpty()) {
val authors = initialNotes.mapNotNull { it.author?.pubkeyHex }.distinct()
subscriptionsCoordinator.loadMetadataBatched(authors)
subscriptionsCoordinator.loadMetadataForNotes(initialNotes)
}
}
@@ -532,7 +530,36 @@ fun FeedScreen(
is FeedState.Loaded -> {
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(
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(loadedState.list, key = { it.idHex }) { note ->