Adds a list of users of a relay to the relay information dialog.

This commit is contained in:
Vitor Pamplona
2026-03-09 12:24:10 -04:00
parent a34ba7cea8
commit bdf4add64f
7 changed files with 231 additions and 3 deletions
@@ -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
@@ -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<Map<NormalizedRelayUrl, Set<HexKey>>> =
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(),
)
}
@@ -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<NoteState>): Map<NormalizedRelayUrl, Set<HexKey>> =
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 <T> usersPerRelaySnapshot(
users: Set<HexKey>,
cache: LocalCache,
transformation: (Map<NormalizedRelayUrl, Set<HexKey>>) -> 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 <T> toUsersPerRelayFlow(
users: Set<HexKey>,
cache: LocalCache,
transformation: (Map<NormalizedRelayUrl, Set<HexKey>>) -> T,
): Flow<T> {
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))
}
}
}
}
@@ -67,7 +67,7 @@ class UserReportsSubAssembler(
val trustedAccountsPerRelay =
mapOfSet {
accounts.map { it.declaredFollowsPerRelay.value }.forEach {
accounts.map { it.declaredFollowsPerOutboxRelay.value }.forEach {
add(it)
}
}
@@ -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()
@@ -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(),
)
+1
View File
@@ -772,6 +772,7 @@
<string name="read_from_relay_description">The amount in bytes that was received from this relay, including filters and events</string>
<string name="an_error_occurred_trying_to_get_relay_information">An error occurred trying to get relay information from %1$s</string>
<string name="owner">Owner</string>
<string name="used_by">Used By</string>
<string name="self">Service Key</string>
<string name="running_software">Running %1$s</string>
<string name="running_software_version">Running %1$s (%2$s)</string>