diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt new file mode 100644 index 0000000000..a3e5020d41 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt @@ -0,0 +1,42 @@ +/* + * 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.model.nip11RelayInfo + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Whether [relay]'s cached NIP-11 document advertises support for [nip] (as a decimal string, e.g. + * "29"). Reads only the in-memory cache — it never blocks on a network fetch — so it returns false + * for a relay whose NIP-11 hasn't been loaded yet. Callers that need the answer to become true must + * warm the document first (e.g. `loadRelayInfo`), then re-evaluate once it resolves. + */ +fun relayAdvertisesNip( + relay: NormalizedRelayUrl, + nip: String, +): Boolean = + Amethyst.instance.nip11Cache + .getFromCache(relay) + .supported_nips + ?.any { it == nip } == true + +/** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */ +fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29") 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 4c71a6190d..405a65d4db 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,9 @@ 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.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -51,6 +53,7 @@ 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 @@ -75,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.relayGroupDiscoveryChannelFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.toGroupConstraints import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription @@ -215,7 +219,29 @@ private fun WatchAccountForRelayGroupDiscovery( val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList .collectAsStateWithLifecycle() - LaunchedEffect(listName, perRelay, joinedGroups) { + // Discovery only shows groups hosted on relays that actually run NIP-29 (a relay that does + // rejects user-authored 39xxx, so its 39000s are all genuine; general relays instead carry stray + // fake ones). The feed decides this from each relay's cached NIP-11 `supported_nips`, so warm + // the candidate relays here and re-invalidate as support is confirmed — otherwise a relay whose + // NIP-11 lands after its 39000s would stay hidden until a manual refresh. + val candidateRelays = remember(perRelay) { perRelay.toGroupConstraints().keys } + var nip29Relays by remember { mutableStateOf(emptySet()) } + LaunchedEffect(candidateRelays) { + val supported = nip29Relays.toMutableSet() + candidateRelays.forEach { relay -> + Amethyst.instance.nip11Cache.loadRelayInfo( + relay = relay, + onInfo = { info -> + if (info.supported_nips?.any { it == "29" } == true && supported.add(relay)) { + nip29Relays = supported.toSet() + } + }, + onError = { _, _, _ -> }, + ) + } + } + + LaunchedEffect(listName, perRelay, joinedGroups, nip29Relays) { feedContentState.checkKeysInvalidateDataAndSendToTop() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt index 0fa46660e3..8d60d372a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.model.nip11RelayInfo.relayAdvertisesNip29 import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -104,7 +105,19 @@ class RelayGroupDiscoveryFeedFilter( joined: Set, ): Boolean = channel.groupId in joined || channel.membershipOf(account.userProfile().pubkeyHex).isMember() - private fun constraints(): Map = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints() + /** + * The per-relay constraints, restricted to relays that ADVERTISE NIP-29 in their NIP-11 + * `supported_nips`. A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a + * relay every 39000 is relay-signed and genuine; general relays (nostr.wine, etc.) instead store + * stray user-published 39000s that have no roster and can't be joined. Dropping non-advertising + * relays here filters out that noise for both the match test and the REQ-driven feed at one + * point. Relays whose NIP-11 hasn't loaded yet resolve to false and are warmed by the screen + * ([WatchAccountForRelayGroupDiscovery]), which re-invalidates the feed once support is known. + */ + private fun constraints(): Map = + account.liveRelayGroupsDiscoveryFollowListsPerRelay.value + .toGroupConstraints() + .filterKeys { relayAdvertisesNip29(it) } override fun feed(): List { if (isMine()) return sort(myGroupNotes())