From bdf4add64f736f96541c60dead76e12c47a0d359 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 9 Mar 2026 12:24:10 -0400 Subject: [PATCH] Adds a list of users of a relay to the relay information dialog. --- .../vitorpamplona/amethyst/model/Account.kt | 4 +- .../DeclaredFollowsPerUsingRelay.kt | 67 ++++++++++ .../model/topNavFeeds/UsingRelayUnwrapper.kt | 115 ++++++++++++++++++ .../user/watchers/UserReportsSubAssembler.kt | 2 +- .../loggedIn/relays/RelayInformationScreen.kt | 43 +++++++ .../connected/ConnectedRelayListViewModel.kt | 2 +- amethyst/src/main/res/values/strings.xml | 1 + 7 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/DeclaredFollowsPerUsingRelay.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/UsingRelayUnwrapper.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f394fec57..e56ce3929 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayStat import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState import com.vitorpamplona.amethyst.model.nip02FollowLists.DeclaredFollowsPerOutboxRelay +import com.vitorpamplona.amethyst.model.nip02FollowLists.DeclaredFollowsPerUsingRelay import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListReusedOutboxOrProxyRelays import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowsPerOutboxRelay @@ -340,7 +341,8 @@ class Account( val defaultGlobalRelays = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, scope) // keeps a cache of the declared outbox relays for each author - val declaredFollowsPerRelay = DeclaredFollowsPerOutboxRelay(kind3FollowList, cache, scope).flow + val declaredFollowsPerOutboxRelay = DeclaredFollowsPerOutboxRelay(kind3FollowList, cache, scope).flow + val declaredFollowsPerUsingRelay = DeclaredFollowsPerUsingRelay(kind3FollowList, cache, scope).flow // keeps a cache of the outbox relays for each author val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/DeclaredFollowsPerUsingRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/DeclaredFollowsPerUsingRelay.kt new file mode 100644 index 000000000..cc99d7801 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/DeclaredFollowsPerUsingRelay.kt @@ -0,0 +1,67 @@ +/* + * 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.nip02FollowLists + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.UsingRelayUnwrapper +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +/** + * Computes a list of all declared relays for each user in the follow list. + */ +class DeclaredFollowsPerUsingRelay( + kind3Follows: Kind3FollowListState, + val cache: LocalCache, + scope: CoroutineScope, +) { + val calculator = UsingRelayUnwrapper() + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow>> = + kind3Follows.flow + .transformLatest { + emitAll( + calculator.toUsersPerRelayFlow(it.authors, cache) { it }, + ) + }.onStart { + emit( + calculator.usersPerRelaySnapshot(kind3Follows.flow.value.authors, cache) { it }, + ) + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyMap(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/UsingRelayUnwrapper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/UsingRelayUnwrapper.kt new file mode 100644 index 000000000..e58fac34b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/UsingRelayUnwrapper.kt @@ -0,0 +1,115 @@ +/* + * 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.topNavFeeds + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine + +class UsingRelayUnwrapper { + fun usersPerRelay(relayListNotes: Array): Map> = + mapOfSet { + relayListNotes.forEach { outboxNote -> + val note = outboxNote.note + + val authorHex = + if (note is AddressableNote) { + note.address.pubKeyHex + } else { + note.author?.pubkeyHex + } + + if (authorHex != null) { + val relays = + when (val noteEvent = outboxNote.note.event) { + is AdvertisedRelayListEvent -> noteEvent.relaysNorm() + is ChatMessageRelayListEvent -> noteEvent.relays() + else -> emptySet() + } + + relays.forEach { + add(it, authorHex) + } + relays.forEach { + add(it, authorHex) + } + } + } + } + + fun usersPerRelaySnapshot( + users: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): T { + val nip65RelayNotes = + users + .map { pubkeyHex -> + cache + .getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + .flow() + .metadata.stateFlow.value + }.toTypedArray() + + val nip17RelayNotes = + users + .map { pubkeyHex -> + cache + .getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(pubkeyHex)) + .flow() + .metadata.stateFlow.value + }.toTypedArray() + + return transformation(usersPerRelay(nip65RelayNotes + nip17RelayNotes)) + } + + fun toUsersPerRelayFlow( + users: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): Flow { + val relayNoteFlows = + users.map { pubkeyHex -> + val note = cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + note.flow().metadata.stateFlow + } + + users.map { pubkeyHex -> + val note = cache.getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(pubkeyHex)) + note.flow().metadata.stateFlow + } + + return if (relayNoteFlows.isEmpty()) { + MutableStateFlow(transformation(emptyMap())) + } else { + combine(relayNoteFlows) { relayListNotes -> + transformation(usersPerRelay(relayListNotes)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index ce49b1346..fd2e68532 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -67,7 +67,7 @@ class UserReportsSubAssembler( val trustedAccountsPerRelay = mapOfSet { - accounts.map { it.declaredFollowsPerRelay.value }.forEach { + accounts.map { it.declaredFollowsPerOutboxRelay.value }.forEach { add(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 07862a7dd..9bb4a6934 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues @@ -97,6 +98,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.appendLink @@ -105,6 +107,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.graspLink import com.vitorpamplona.amethyst.ui.note.nipLink import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot @@ -113,7 +116,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Height25Modifier import com.vitorpamplona.amethyst.ui.theme.Size100dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow @@ -345,6 +350,13 @@ fun RelayInformationBody( val activeCounts = remember(relay) { nostrClient?.activeCounts(relay) ?: emptyMap() } val activeOutbox = remember(relay) { nostrClient?.activeOutboxCache(relay) ?: emptySet() } + val usedBy = + remember(relay) { + accountViewModel.account.declaredFollowsPerUsingRelay.value[relay]?.mapNotNull { hex -> + LocalCache.checkGetOrCreateUser(hex) + } ?: emptyList() + } + LazyColumn( modifier = Modifier @@ -407,6 +419,37 @@ fun RelayInformationBody( item { SoftwareCard(relayInfo) } } + if (usedBy.isNotEmpty()) { + item { + SectionHeader(stringRes(R.string.used_by)) + } + item { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + FlowRow(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.Center) { + usedBy.take(30).forEach { + UserPicture( + user = it, + size = Size25dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + if (usedBy.size > 30) { + Box(contentAlignment = Alignment.Center, modifier = Height25Modifier) { + Text( + text = stringRes(R.string.and_more, usedBy.size - 30), + maxLines = 1, + ) + } + } + } + } + } + } + // Active subscriptions and outbox section val hasReqs = activeReqs.isNotEmpty() val hasCounts = activeCounts.isNotEmpty() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index 58bd31dc1..5edd2f34f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -41,7 +41,7 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { Amethyst.instance.torEvaluatorFlow.flow.value .useTor(it), users = - account.declaredFollowsPerRelay.value[it]?.mapNotNull { hex -> + account.declaredFollowsPerUsingRelay.value[it]?.mapNotNull { hex -> LocalCache.checkGetOrCreateUser(hex) } ?: emptyList(), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5aee22a37..84d399338 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -772,6 +772,7 @@ The amount in bytes that was received from this relay, including filters and events An error occurred trying to get relay information from %1$s Owner + Used By Service Key Running %1$s Running %1$s (%2$s)