From 7981ecc82ccedcf9a510a1aed57e8d8a18fcf682 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Nov 2025 11:03:00 -0500 Subject: [PATCH 1/6] Adds pre-caching for trust providers --- .../TrustProviderListState.kt | 35 ++++++++++ .../list/tags/ServiceProviderTag.kt | 25 ------- .../list/tags/ServiceType.kt | 67 +++++++++++++++++++ 3 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceType.kt 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 ec79f5a4e..e5db4bcd6 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 @@ -25,7 +25,9 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note 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 @@ -36,6 +38,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest @@ -76,6 +79,38 @@ class TrustProviderListState( emptySet(), ) + @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) + .stateIn( + scope, + SharingStarted.Eagerly, + null, + ) + + @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) + .stateIn( + scope, + SharingStarted.Eagerly, + null, + ) + init { settings.backupTrustProviderList?.let { event -> Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt index f83bdec73..b177660be 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt @@ -61,28 +61,3 @@ data class ServiceProviderTag( fun assemble(id: ServiceProviderTag) = arrayOf(id.service, id.pubkey, id.relayUrl) } } - -data class ServiceType( - val kind: Int, - val type: String, -) { - fun toValue() = assemble(kind, type) - - companion object { - fun assemble( - kind: Int, - type: String, - ) = "$kind:$type" - - fun parse(serviceType: String): ServiceType? { - val (kindStr, type) = serviceType.split(":", limit = 2) - val kind = kindStr.toIntOrNull() ?: return null - return ServiceType(kind, type) - } - - fun isOfKind( - serviceType: String, - kind: String, - ) = serviceType.startsWith(kind) && serviceType[kind.length] == ':' - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceType.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceType.kt new file mode 100644 index 000000000..2fe121b53 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceType.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.quartz.experimental.trustedAssertions.list.tags + +data class ServiceType( + val kind: Int, + val type: String, +) { + fun toValue() = assemble(kind, type) + + companion object { + fun assemble( + kind: Int, + type: String, + ) = "$kind:$type" + + fun parse(serviceType: String): ServiceType? { + val (kindStr, type) = serviceType.split(":", limit = 2) + val kind = kindStr.toIntOrNull() ?: return null + return ServiceType(kind, type) + } + + fun isOfKind( + serviceType: String, + kind: String, + ) = serviceType.startsWith(kind) && serviceType[kind.length] == ':' + } +} + +object ProviderTypes { + val rank = ServiceType(30382, "rank") + val followerCount = ServiceType(30382, "followers") + + val firstPost = ServiceType(30382, "first_created_at") + val postCount = ServiceType(30382, "post_cnt") + val replyCount = ServiceType(30382, "reply_cnt") + val reactionsCount = ServiceType(30382, "reactions_cnt") + val zapReceivedAmount = ServiceType(30382, "zap_amt_recd") + val zapSentAmount = ServiceType(30382, "zap_amt_sent") + val zapReceivedCount = ServiceType(30382, "zap_cnt_recd") + val zapSentCount = ServiceType(30382, "zap_cnt_sent") + val averageZapAmountReceivedPerDay = ServiceType(30382, "zap_avg_amt_day_recd") + val averageZapAmountSentPerDay = ServiceType(30382, "zap_avg_amt_day_sent") + val reportsReceivedCount = ServiceType(30382, "reports_cnt_recd") + val reportsSentCount = ServiceType(30382, "reports_cnt_sent") + val topics = ServiceType(30382, "t") + val activeFrom = ServiceType(30382, "active_hours_start") + val activeTo = ServiceType(30382, "active_hours_end") +} From ff8b441bf62dea9be13ef9d4ca7fd5881e8c1606 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Nov 2025 15:02:50 -0500 Subject: [PATCH 2/6] Increases max pool of SHA 256 processors --- .../com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt index 17234cfa9..d12144f89 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.utils.sha256 import java.io.InputStream -val pool = Sha256Pool(5) // max parallel operations +val pool = Sha256Pool(10) // max parallel operations actual fun sha256(data: ByteArray) = pool.hash(data) From fd628fffd2f79fed0dec5b52671d6de44f655a79 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Nov 2025 15:46:51 -0500 Subject: [PATCH 3/6] Removes Contact cards from filter since it wasn't returning anything (user is in the d tag, not p or author) --- .../reqCommand/user/watchers/FilterUserMetadataForKey.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index a997ab173..2f634dbcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -38,7 +37,6 @@ val UserMetadataForKeyKinds = listOf( MetadataEvent.KIND, StatusEvent.KIND, - ContactCardEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, ) From f76638f077ff4d33b8de58253e7fb3d25024202e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Nov 2025 15:47:08 -0500 Subject: [PATCH 4/6] 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, From 0ad1d75fe73b1461d1477940bccee52170c17cbb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Nov 2025 16:47:53 -0500 Subject: [PATCH 5/6] Improving font size of the WoT Score --- .../amethyst/ui/note/UserProfilePicture.kt | 86 +++++++++++++++++-- .../vitorpamplona/amethyst/ui/theme/Type.kt | 3 + 2 files changed, 82 insertions(+), 7 deletions(-) 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 5b6bc32bb..c8858dec6 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 @@ -25,6 +25,7 @@ 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.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -52,9 +53,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font10SP +import com.vitorpamplona.amethyst.ui.theme.Font6SP import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -366,7 +370,7 @@ fun BaseUserPicture( WatchUserCards(baseUser.pubkeyHex, accountViewModel) { score -> if (score != null) { - ScoreTag(score, Modifier.align(Alignment.BottomCenter)) + ScoreTag(score, size, Modifier.align(Alignment.BottomCenter)) } } } @@ -400,7 +404,7 @@ fun BaseUserPicture( WatchUserCards(baseUserHex, accountViewModel) { score -> if (score != null) { - ScoreTag(score, Modifier.align(Alignment.BottomCenter)) + ScoreTag(score, size, Modifier.align(Alignment.BottomCenter)) } } } @@ -408,27 +412,95 @@ fun BaseUserPicture( @Preview @Composable -fun ScoreTagPreview() { - Box(Modifier.size(55.dp), contentAlignment = Alignment.TopEnd) { - ScoreTag(100, Modifier.align(Alignment.BottomCenter)) +fun ScoreTag55Preview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + Row { + var size = 75.dp + Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { + InnerUserPicture( + userHex = "AABBCC", + userPicture = "http://null", + userName = "vitor", + size = size, + modifier = Modifier, + accountViewModel = accountViewModel, + ) + FollowingIcon(Modifier.size(size.div(3.5f))) + ScoreTag(100, size, Modifier.align(Alignment.BottomCenter)) + } + size = 55.dp + Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { + InnerUserPicture( + userHex = "AABBCC", + userPicture = "http://null", + userName = "vitor", + size = size, + modifier = Modifier, + accountViewModel = accountViewModel, + ) + FollowingIcon(Modifier.size(size.div(3.5f))) + ScoreTag(100, size, Modifier.align(Alignment.BottomCenter)) + } + size = 35.dp + Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { + InnerUserPicture( + userHex = "AABBCC", + userPicture = "http://null", + userName = "vitor", + size = size, + modifier = Modifier, + accountViewModel = accountViewModel, + ) + FollowingIcon(Modifier.size(size.div(3.5f))) + ScoreTag(100, size, Modifier.align(Alignment.BottomCenter)) + } + size = 25.dp + Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { + InnerUserPicture( + userHex = "AABBCC", + userPicture = "http://null", + userName = "vitor", + size = size, + modifier = Modifier, + accountViewModel = accountViewModel, + ) + FollowingIcon(Modifier.size(size.div(3.5f))) + ScoreTag(100, size, Modifier.align(Alignment.BottomCenter)) + } + size = 18.dp + Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { + InnerUserPicture( + userHex = "AABBCC", + userPicture = "http://null", + userName = "vitor", + size = size, + modifier = Modifier, + accountViewModel = accountViewModel, + ) + FollowingIcon(Modifier.size(size.div(3.5f))) + ScoreTag(100, size, Modifier.align(Alignment.BottomCenter)) + } + } } } @Composable fun ScoreTag( score: Int, + size: Dp, modifier: Modifier, ) { Text( text = score.toString(), color = Color.White, fontWeight = FontWeight.Bold, - fontSize = Font10SP, + fontSize = if (size > 34.dp) Font10SP else Font6SP, modifier = modifier .clip(SmallBorder) .background(Color.Black) - .padding(horizontal = 5.dp), + .padding(horizontal = if (size > 34.dp) 4.dp else 3.dp, vertical = 0.dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index c566aa94c..2f62d2215 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -52,6 +52,9 @@ val Typography = */ ) +val Font4SP = 4.sp +val Font6SP = 6.sp +val Font8SP = 8.sp val Font10SP = 10.sp val Font12SP = 12.sp val Font14SP = 14.sp From f29a6337a01647599d83466046a066ca2f2293c4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Nov 2025 16:56:04 -0500 Subject: [PATCH 6/6] Removing unused methods in User --- .../main/java/com/vitorpamplona/amethyst/model/User.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 80061efe0..270cb5aee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -29,8 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -68,6 +66,8 @@ class User( var relaysBeingUsed = mapOf() private set + var flowSet: UserFlowSet? = null + fun pubkey() = Hex.decode(pubkeyHex) fun pubkeyNpub() = pubkey().toNpub() @@ -251,10 +251,6 @@ class User( fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false - fun isFollowingHashtag(tag: String) = latestContactList?.isTaggedHash(tag) ?: false - - fun isFollowingGeohash(geoTag: String) = latestContactList?.isTaggedGeoHash(geoTag) ?: false - fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } @@ -303,8 +299,6 @@ class User( fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false - var flowSet: UserFlowSet? = null - @Synchronized fun createOrDestroyFlowSync(create: Boolean) { if (create) {