From f76638f077ff4d33b8de58253e7fb3d25024202e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Nov 2025 15:47:08 -0500 Subject: [PATCH] Adds a way to view WoT scores --- .../vitorpamplona/amethyst/model/Account.kt | 25 +++ .../TrustProviderListState.kt | 35 +++-- .../user/UserFinderFilterAssembler.kt | 2 + .../user/watchers/FilterContactCardsToKey.kt | 55 +++++++ .../user/watchers/UserCardsSubAssembler.kt | 147 ++++++++++++++++++ .../amethyst/ui/note/UserProfilePicture.kt | 63 ++++++++ .../relationshipStatus/ContactCardEvent.kt | 14 +- 7 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.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 2b1707920..0aff514b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -120,6 +120,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.mimeType +import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -208,12 +209,17 @@ import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import java.math.BigDecimal import java.util.Locale @@ -1719,6 +1725,25 @@ class Account( ).toSet() } + @OptIn(ExperimentalCoroutinesApi::class) + fun loadUserCardFlow(target: HexKey): Flow = + trustProviderList.liveUserRankProvider + .transformLatest { provider -> + if (provider != null) { + emitAll( + cache + .getOrCreateAddressableNote( + ContactCardEvent.createAddress(provider.pubkey, target), + ).flow() + .metadata.stateFlow, + ) + } else { + emit(null) + } + }.map { + (it?.note?.event as? ContactCardEvent)?.rank() + }.flowOn(Dispatchers.IO) + suspend fun saveDMRelayList(dmRelays: List) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays)) suspend fun savePrivateOutboxRelayList(relays: List) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt index e5db4bcd6..758747bb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -81,15 +80,16 @@ class TrustProviderListState( @OptIn(ExperimentalCoroutinesApi::class) val liveUserRankProvider: StateFlow = - liveTrustProviderList.map { - it.firstOrNull { it.service == ProviderTypes.rank } - }.onStart { - emit( - liveTrustProviderList.value.firstOrNull { - it.service == ProviderTypes.rank - } - ) - }.flowOn(Dispatchers.IO) + liveTrustProviderList + .map { + it.firstOrNull { it.service == ProviderTypes.rank } + }.onStart { + emit( + liveTrustProviderList.value.firstOrNull { + it.service == ProviderTypes.rank + }, + ) + }.flowOn(Dispatchers.IO) .stateIn( scope, SharingStarted.Eagerly, @@ -98,13 +98,14 @@ class TrustProviderListState( @OptIn(ExperimentalCoroutinesApi::class) val liveUserFollowerCount: StateFlow = - liveTrustProviderList.map { tagList -> - tagList.firstOrNull { it.service == ProviderTypes.followerCount } - }.onStart { - emit( - liveTrustProviderList.value.firstOrNull { it.service == ProviderTypes.followerCount } - ) - }.flowOn(Dispatchers.IO) + liveTrustProviderList + .map { tagList -> + tagList.firstOrNull { it.service == ProviderTypes.followerCount } + }.onStart { + emit( + liveTrustProviderList.value.firstOrNull { it.service == ProviderTypes.followerCount }, + ) + }.flowOn(Dispatchers.IO) .stateIn( scope, SharingStarted.Eagerly, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index 63efc0808..02fe3cde5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -46,6 +47,7 @@ class UserFinderFilterAssembler( UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys), UserWatcherSubAssembler(client, cache, ::allKeys), UserReportsSubAssembler(client, ::allKeys), + UserCardsSubAssembler(client, ::allKeys), ) override fun invalidateFilters() = group.forEach { it.invalidateFilters() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt new file mode 100644 index 000000000..5ac88cc6f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt @@ -0,0 +1,55 @@ +/** + * 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.service.relayClient.reqCommand.user.watchers + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +val ContactCardKindList = listOf(ContactCardEvent.KIND) + +fun filterContactCardsToKeysFromTrusted( + targets: Set, + trustedAccounts: Map>, + since: SincePerRelayMap?, +): List { + if (targets.isEmpty() || trustedAccounts.isEmpty()) return emptyList() + val sortedTargets = mapOf("d" to targets.sorted()) + return trustedAccounts.mapNotNull { relayAuthors -> + if (relayAuthors.value.isNotEmpty()) { + RelayBasedFilter( + relay = relayAuthors.key, + filter = + Filter( + kinds = ContactCardKindList, + authors = relayAuthors.value.sorted(), + tags = sortedTargets, + since = since?.get(relayAuthors.key)?.time, + ), + ) + } else { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt new file mode 100644 index 000000000..f82183c63 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -0,0 +1,147 @@ +/** + * 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.service.relayClient.reqCommand.user.watchers + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet + +class UserCardsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + var lastUsersOnFilter: Set = emptySet() + + /** + * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc + * and reports, but only from trusted accounts (follows of all logged in users). + */ + var latestEOSEs: EOSEAccountFast = EOSEAccountFast(2000) + + override fun newEose( + relay: NormalizedRelayUrl, + time: Long, + filters: List?, + ) { + lastUsersOnFilter.forEach { + latestEOSEs.newEose(it, relay, time) + } + super.newEose(relay, time, filters) + } + + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + if (keys.isEmpty()) return null + + lastUsersOnFilter = keys.mapTo(mutableSetOf()) { it.user } + + if (lastUsersOnFilter.isEmpty()) return null + + val accounts = keys.mapTo(mutableSetOf()) { it.account } + + val trustedAccounts: Map> = + mapOfSet { + accounts.forEach { account -> + account.outboxRelays.flow.value.map { + add(it, account.userProfile().pubkeyHex) + } + } + accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { account -> + if (account != null) { + add(account.relayUrl, account.pubkey) + } + } + } + + return groupByRelayPresence(lastUsersOnFilter, latestEOSEs, trustedAccounts.keys) + .map { group -> + val groupIds = group.map { it.pubkeyHex }.toSet() + if (groupIds.isNotEmpty()) { + val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs) + filterContactCardsToKeysFromTrusted(groupIds, trustedAccounts, minEOSEs) + } else { + emptyList() + } + }.flatten() + } + + fun groupByRelayPresence( + users: Iterable, + eoseCache: EOSEAccountFast, + inRelays: Set, + ): Collection> { + if (users.none()) return emptyList() + + val relaySnapshot = inRelays.toSet() + + return users + .groupBy { user -> + val relaysForUser = eoseCache.sinceRelaySet(user) + if (relaysForUser.isNullOrEmpty() || relaySnapshot.isEmpty()) { + null + } else { + val intersection = relaysForUser.filter { it in relaySnapshot }.sorted() + if (intersection.isEmpty()) { + null + } else { + intersection.hashCode() + } + } + }.values + .map { + // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again + it.sortedBy { it.pubkeyHex } + } + } + + fun findMinimumEOSEsForUsers( + users: List, + eoseCache: EOSEAccountFast, + ): SincePerRelayMap { + val minLatestEOSEs = mutableMapOf() + + users.forEach { + eoseCache.since(it)?.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, it.value.copy()) + } else { + minEose.updateIfOlder(it.value.time) + } + } + } + + return minLatestEOSEs + } + + override fun distinct(key: UserFinderQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 4a243c8ad..5b6bc32bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -21,19 +21,26 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -46,6 +53,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font10SP +import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -354,6 +363,12 @@ fun BaseUserPicture( FollowingIcon(Modifier.size(size.div(3.5f))) } } + + WatchUserCards(baseUser.pubkeyHex, accountViewModel) { score -> + if (score != null) { + ScoreTag(score, Modifier.align(Alignment.BottomCenter)) + } + } } } @@ -382,9 +397,41 @@ fun BaseUserPicture( FollowingIcon(Modifier.size(size.div(3.5f))) } } + + WatchUserCards(baseUserHex, accountViewModel) { score -> + if (score != null) { + ScoreTag(score, Modifier.align(Alignment.BottomCenter)) + } + } } } +@Preview +@Composable +fun ScoreTagPreview() { + Box(Modifier.size(55.dp), contentAlignment = Alignment.TopEnd) { + ScoreTag(100, Modifier.align(Alignment.BottomCenter)) + } +} + +@Composable +fun ScoreTag( + score: Int, + modifier: Modifier, +) { + Text( + text = score.toString(), + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = Font10SP, + modifier = + modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp), + ) +} + @Composable fun LoadUserProfilePicture( baseUser: User, @@ -458,3 +505,19 @@ fun WatchUserFollows( onFollowChanges(state.authors.contains(userHex)) } } + +@Composable +fun WatchUserCards( + userHex: String, + accountViewModel: AccountViewModel, + onScoreChanges: @Composable (Int?) -> Unit, +) { + val flow = + remember(userHex) { + accountViewModel.account.loadUserCardFlow(userHex) + } + + val score by flow.collectAsStateWithLifecycle(null) + + onScoreChanges(score) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/ContactCardEvent.kt index cbeb2fe22..b170c5c04 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/ContactCardEvent.kt @@ -24,10 +24,12 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent @@ -49,10 +51,20 @@ class ContactCardEvent( fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - companion object Companion { + companion object { const val KIND = 30382 const val ALT = "Contact Card" + fun createAddress( + owner: HexKey, + target: HexKey, + ): Address = Address(KIND, owner, target) + + fun createAddressTag( + owner: HexKey, + target: HexKey, + ): ATag = ATag(KIND, owner, target, null) + suspend fun create( targetUser: HexKey, petName: String? = null,