perf: warm NIP-29 relays' NIP-11 in parallel + pre-warm joined groups

The relay-signed group check reads each host relay's NIP-11 `self` from cache,
but the discovery screen was warming those docs SERIALLY — Nip11Retriever awaits
each HTTP fetch, so N relays meant N sequential round-trips and one slow or
unreachable relay stalled every group behind it until its socket timeout. That's
why groups trickled in.

- Add WarmNip11(relays): fans the fetches out, one coroutine each, so the wait is
  the slowest single fetch instead of the sum. Discovery now uses it and
  re-invalidates the feed as each doc lands (via a version counter).
- Pre-warm NIP-11 for joined groups' host relays from the Messages tab
  (WarmJoinedRelayGroupNip11), so by the time one surfaces in discovery the
  answer is a cache hit. NIP-11 stays cached for an hour.

Note: the Messages tab itself never needed this — joined ("My Groups") rows are
authoritative from the kind-10009 list and were never gated on NIP-11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-10 15:43:20 +00:00
parent 0f01dc0c12
commit fa0883f4a0
5 changed files with 95 additions and 18 deletions
@@ -21,16 +21,48 @@
package com.vitorpamplona.amethyst.model.nip11RelayInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable
fun loadRelayInfo(relay: NormalizedRelayUrl): State<Nip11RelayInformation> = loadRelayInfo(relay, Amethyst.instance.nip11Cache)
/**
* Eagerly warms the NIP-11 cache for a whole set of [relays] **in parallel** (each fetch on its own
* coroutine), so callers that later read the cache — e.g. the NIP-29 relay-signed group check — get
* hits instead of cold fetches. Warming a set serially would sum every relay's latency and let one
* slow/unreachable relay stall the rest until its socket timeout; fanning out bounds the wait to the
* slowest single fetch. [onEachLoaded] fires (on an IO thread) as each relay resolves, so a screen
* can re-evaluate incrementally as docs arrive. The cache dedups, so re-warming is cheap.
*/
@Composable
fun WarmNip11(
relays: Collection<NormalizedRelayUrl>,
onEachLoaded: () -> Unit = {},
) {
val cache = Amethyst.instance.nip11Cache
LaunchedEffect(relays) {
coroutineScope {
relays.forEach { relay ->
launch {
cache.loadRelayInfo(
relay = relay,
onInfo = { onEachLoaded() },
onError = { _, _, _ -> onEachLoaded() },
)
}
}
}
}
}
@Composable
fun loadRelayInfo(
relay: NormalizedRelayUrl,
@@ -42,7 +42,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -53,7 +53,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -61,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
@@ -221,29 +221,20 @@ private fun WatchAccountForRelayGroupDiscovery(
// Discovery only shows groups whose 39000 is signed by the host relay's own key (NIP-29's
// authority — the NIP-11 `self` pubkey; general relays instead carry stray user-published 39000s
// that can't be joined). The feed reads that from each relay's cached NIP-11, so warm the
// candidate relays here and re-invalidate as each doc resolves — otherwise a relay whose NIP-11
// lands after its 39000s would stay hidden until a manual refresh.
// that can't be joined). The feed reads that from each relay's cached NIP-11, so warm every
// candidate relay (in parallel — WarmNip11) and re-invalidate as each doc lands, otherwise a
// relay whose NIP-11 resolves after its 39000s would stay hidden until a manual refresh.
val candidateRelays = remember(perRelay) { perRelay.toGroupConstraints().keys }
var nip11Loaded by remember { mutableStateOf(emptySet<NormalizedRelayUrl>()) }
LaunchedEffect(candidateRelays) {
val loaded = nip11Loaded.toMutableSet()
candidateRelays.forEach { relay ->
Amethyst.instance.nip11Cache.loadRelayInfo(
relay = relay,
onInfo = { if (loaded.add(relay)) nip11Loaded = loaded.toSet() },
onError = { _, _, _ -> if (loaded.add(relay)) nip11Loaded = loaded.toSet() },
)
}
}
var nip11Version by remember { mutableIntStateOf(0) }
WarmNip11(candidateRelays) { nip11Version++ }
LaunchedEffect(listName, perRelay, joinedGroups) {
feedContentState.checkKeysInvalidateDataAndSendToTop()
}
// A NIP-11 doc resolving doesn't change the feed key (the self-key test lives in the filter's
// match, not the key), so force a re-filter directly when one lands.
LaunchedEffect(nip11Loaded) {
// match, not the key), so force a re-filter directly as each one lands.
LaunchedEffect(nip11Version) {
feedContentState.invalidateData()
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* Eagerly warms the NIP-11 documents of the host relays of every group the user has joined, in
* parallel. The NIP-29 relay-signed check ([com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup])
* reads those docs from cache, so pre-warming them from a primary tab (Messages) means the moment
* one of these groups also surfaces in discovery — or any relay-signed gate runs — the answer is a
* cache hit rather than a cold fetch. Cheap: the cache dedups and holds each doc for an hour.
*/
@Composable
fun WarmJoinedRelayGroupNip11(accountViewModel: AccountViewModel) {
val servers by accountViewModel.account.relayGroupList.liveRelayGroupServers
.collectAsStateWithLifecycle()
val relays = remember(servers) { servers.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } }
WarmNip11(relays)
}
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
@@ -105,6 +106,10 @@ fun MessagesSinglePane(
// membership/pending state is accurate inline without opening each chat.
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
// Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a cache hit
// when those groups surface in discovery or any gated surface.
WarmJoinedRelayGroupNip11(accountViewModel)
// The inline-vs-grouped NIP-29 display preference lives in Settings Messages; joined groups
// (or per-relay rows in grouped mode) are woven directly into the feed below.
MessagesPager(
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.theme.Size20dp
@@ -95,6 +96,10 @@ fun MessagesTwoPane(
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) {
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
// Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a
// cache hit when those groups surface in discovery or any gated surface.
WarmJoinedRelayGroupNip11(accountViewModel)
// The inline-vs-grouped NIP-29 preference lives in Settings Messages; joined
// groups (or per-relay rows in grouped mode) are woven into the list itself.
ChatroomList(