Adds a list of users of a relay to the relay information dialog.
This commit is contained in:
@@ -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
|
||||
|
||||
+67
@@ -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(),
|
||||
)
|
||||
}
|
||||
+115
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -67,7 +67,7 @@ class UserReportsSubAssembler(
|
||||
|
||||
val trustedAccountsPerRelay =
|
||||
mapOfSet {
|
||||
accounts.map { it.declaredFollowsPerRelay.value }.forEach {
|
||||
accounts.map { it.declaredFollowsPerOutboxRelay.value }.forEach {
|
||||
add(it)
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -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()
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user