Files
amethyst/docs/plans/2026-04-29-perf-viewport-aware-metadata-loading-plan.md
T
nrobi144andClaude Opus 4.6 7bd5fb3122 feat(desktop): wire NWC wallet operations and AccountManager integration
- Wire AccountManager.setNwcConnection() and clearNwcConnection() into
  wallet column for persistent connect/disconnect
- Implement NwcPaymentHandler.getBalance() via NIP-47 get_balance RPC
- Implement NwcPaymentHandler.makeInvoice() via NIP-47 make_invoice RPC
- Add generic waitForGenericResponse() helper for NWC RPC operations
- Auto-fetch balance on wallet column load via LaunchedEffect
- Wire receive screen to generate real invoices via NWC
- All wallet column features now functional (no blocking TODOs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 09:31:38 +03:00

7.8 KiB

title, type, status, date, origin
title type status date origin
perf: Viewport-aware feed metadata loading perf active 2026-04-29 docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md

perf: Viewport-aware feed metadata loading

Enhancement Summary

Deepened on: 2026-04-29 Research agents: compose-expert, kotlin-coroutines, nostr-expert, relay-client

Key Improvements

  1. Concrete snapshotFlow + debounce pattern for viewport detection (zero recomposition)
  2. loadMetadataBatched() implementation with EOSE close (follows Chess helper pattern)
  3. Nostr filter sizing: 100 authors max per filter, CLOSE after EOSE
  4. collectLatest for cancelling stale fetches on scroll change

Overview

Feed metadata (display names, avatars) takes 5+ seconds because the pipeline loads metadata for ALL notes (100+), rate-limits at 20/sec, and creates individual subscriptions per author. Fix with viewport-aware loading + batched author filter.

Problem Statement

Pre-existing on main. Pipeline:

  1. visibleNotes() returns ALL notes (not viewport-filtered)
  2. MetadataRateLimiter: 20 pubkeys/sec with 1-sec batch delays
  3. Each author gets individual client.subscribe() call
  4. All subscriptions broadcast to 7 relays

(see brainstorm: docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md)

Technical Approach

Phase 1: Viewport-Aware Note Selection

Goal: Only fetch metadata for visible notes + 10-item buffer.

Tasks:

  • Replace the existing LaunchedEffect(feedState, subscriptionsCoordinator) in FeedScreen.kt:354 with a viewport-aware version using snapshotFlow
  • Use lazyListState.layoutInfo.visibleItemsInfo inside snapshotFlow (NOT in composition — avoids per-frame recomposition)
  • Buffer ±10 items with coerceIn(0, list.lastIndex) to prevent IndexOutOfBounds
  • Debounce at 500ms via .debounce(500)
  • Use collectLatest to cancel stale fetches when scroll position changes

Implementation pattern (compose-expert + kotlin-coroutines):

// In FeedScreen, sibling to LazyColumn (not inside it)
LaunchedEffect(lazyListState, feedNotes) {
    snapshotFlow {
        val info = lazyListState.layoutInfo
        if (info.visibleItemsInfo.isEmpty() || feedNotes.isEmpty()) {
            return@snapshotFlow emptyList<Note>()
        }
        val first = (info.visibleItemsInfo.first().index - 10).coerceAtLeast(0)
        val last = (info.visibleItemsInfo.last().index + 10).coerceAtMost(feedNotes.lastIndex)
        feedNotes.subList(first, last + 1)
    }
        .distinctUntilChanged()
        .debounce(500)
        .collectLatest { viewportNotes ->
            if (viewportNotes.isNotEmpty()) {
                // Fast path: batched metadata for visible authors
                val authors = viewportNotes.mapNotNull { it.author?.pubkeyHex }.distinct()
                subscriptionsCoordinator.loadMetadataBatched(authors)
                // Also load reactions for visible notes
                subscriptionsCoordinator.loadMetadataForNotes(viewportNotes)
            }
        }
}

Key insights:

  • snapshotFlow reads layout info outside composition → zero recomposition cost
  • collectLatest cancels in-flight loadMetadataBatched when scroll changes
  • distinctUntilChanged skips if same indices visible after debounce
  • feedNotes captured in LaunchedEffect key ensures re-launch when feed data changes

Files:

  • desktopApp/.../ui/FeedScreen.kt — replace metadata LaunchedEffect

Phase 2: Batched Author Subscription

Goal: One relay subscription per batch instead of N individual ones.

Tasks:

  • Add loadMetadataBatched(pubkeys) to FeedMetadataCoordinator
  • Bypass rate limiter — subscribe directly via client.subscribe()
  • Single Filter(kind:0, authors:pubkeys, limit:pubkeys.size) sent to index relays
  • Close subscription after EOSE from all relays (one-shot fetch)
  • 5-second timeout to prevent hanging on slow relays
  • Deduplicate against queuedPubkeys to avoid double-fetch with background path

Implementation pattern (relay-client + nostr-expert):

// In FeedMetadataCoordinator — follows ChessRelayFetchHelper pattern
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), // max 100 per filter
            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)
    }
}

Key insights (nostr-expert):

  • Kind 0 is replaceable — relay returns one event per pubkey, bulk lookup is efficient
  • 100 authors per filter is safe for index relays (purplepag.es, profiles.nostr1.com)
  • CLOSE after EOSE — transient viewport set doesn't need live updates
  • No since on cold load — relay returns latest replaceable event regardless
  • Pattern matches existing ChessRelayFetchHelper.fetchEvents() idiom in codebase

Files:

  • commons/.../relayClient/assemblers/FeedMetadataCoordinator.kt — add loadMetadataBatched()

Phase 3: Progressive Background Loading

Goal: Pre-warm cache for off-screen notes.

Tasks:

  • After viewport metadata loads via batched path, queue remaining feed authors through existing loadMetadataForNotes() (rate-limited, low priority)
  • This runs in background — no UI impact

Files:

  • desktopApp/.../ui/FeedScreen.kt — secondary background pass after viewport pass

Acceptance Criteria

  • Visible note metadata loads within 1-2 seconds of feed render
  • Scrolling to new notes triggers metadata fetch within 500ms
  • No per-frame recomposition from scroll observation (verify with Layout Inspector)
  • No regression for off-screen notes (still loads via background path)
  • ./gradlew :desktopApp:compileKotlin succeeds
  • ./gradlew :commons:compileKotlinJvm succeeds
  • ./gradlew spotlessApply passes
  • Existing tests pass

Key Files

File Purpose
desktopApp/.../ui/FeedScreen.kt:354 LaunchedEffect trigger (replace)
commons/.../relayClient/assemblers/FeedMetadataCoordinator.kt Add loadMetadataBatched()
commons/.../relayClient/preload/MetadataRateLimiter.kt Bypassed for viewport path
commons/.../chess/ChessRelayFetchHelper.kt:82 Reference pattern for one-shot fetch

Sources & References

Origin

Internal References

  • ChessRelayFetchHelper (one-shot pattern): commons/.../chess/ChessRelayFetchHelper.kt:82-131
  • MetadataFilterAssembler (persistent sub): commons/.../relayClient/assemblers/MetadataFilterAssembler.kt
  • FeedContentState.visibleNotes: commons/.../ui/feeds/FeedContentState.kt:77
  • DefaultIndexerRelayList: amethyst/.../model/Constants.kt