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:
+8
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user