diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt index 308688d6c3..854e7147ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt @@ -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 = 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, + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt index 730239c0f6..3b7ff378eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt @@ -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()) } - 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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/WarmJoinedRelayGroupNip11.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/WarmJoinedRelayGroupNip11.kt new file mode 100644 index 0000000000..364aeba052 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/WarmJoinedRelayGroupNip11.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index 1f0bc4aef4..3b53a22c97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 7f0a3c60a1..2cf453a10c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -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(