From 0d7f2ce838592dd23a94058907c711def87c2fec Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 11:29:46 -0500 Subject: [PATCH 01/21] Adds support for Trust Provider lists --- .../amethyst/LocalPreferences.kt | 5 + .../vitorpamplona/amethyst/model/Account.kt | 5 + .../amethyst/model/AccountSettings.kt | 12 ++ .../amethyst/model/LocalCache.kt | 8 + .../TrustProviderListDecryptionCache.kt | 36 ++++ .../TrustProviderListState.kt | 98 ++++++++++ .../list/TagArrayBuilderExt.kt | 37 ++++ .../trustedAssertions/list/TagArrayExt.kt | 26 +++ .../list/TrustProviderListEvent.kt | 171 ++++++++++++++++++ .../list/tags/ServiceProviderTag.kt | 88 +++++++++ .../quartz/utils/EventFactory.kt | 2 + .../trustedAssertions/ServiceParser.kt | 65 +++++++ 12 files changed, 553 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TrustProviderListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 38c81a7ee7..910991d39f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper @@ -110,6 +111,7 @@ private object PrefKeys { const val LATEST_HASHTAG_LIST = "latestHashtagList" const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" + const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later @@ -354,6 +356,7 @@ object LocalPreferences { putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList) putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList) putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList) + putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) @@ -482,6 +485,7 @@ object LocalPreferences { val latestHashtagList = parseEventOrNull(PrefKeys.LATEST_HASHTAG_LIST) val latestGeohashList = parseEventOrNull(PrefKeys.LATEST_GEOHASH_LIST) val latestEphemeralList = parseEventOrNull(PrefKeys.LATEST_EPHEMERAL_LIST) + val latestTrustProviderList = parseEventOrNull(PrefKeys.LATEST_TRUST_PROVIDER_LIST) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) @@ -525,6 +529,7 @@ object LocalPreferences { backupHashtagList = latestHashtagList, backupGeohashList = latestGeohashList, backupEphemeralChatList = latestEphemeralList, + backupTrustProviderList = latestTrustProviderList, lastReadPerRoute = MutableStateFlow(lastReadPerRoute), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), pendingAttestations = MutableStateFlow(pendingAttestations), 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 e25bc0e086..2b1707920f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -92,6 +92,8 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState +import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListDecryptionCache +import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader @@ -283,6 +285,9 @@ class Account( val muteListDecryptionCache = MuteListDecryptionCache(signer) val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings) + val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer) + val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings) + val peopleListDecryptionCache = PeopleListDecryptionCache(signer) val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index c1bcc246e8..a654dc4369 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -138,6 +139,7 @@ class AccountSettings( var backupHashtagList: HashtagListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, + var backupTrustProviderList: TrustProviderListEvent? = null, val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), var hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow>(mapOf()), @@ -437,6 +439,16 @@ class AccountSettings( } } + fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) { + if (trustProviderList == null || trustProviderList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupTrustProviderList?.id != trustProviderList.id) { + backupTrustProviderList = trustProviderList + saveAccountSettings() + } + } + fun updateMuteList(newMuteList: MuteListEvent?) { if (newMuteList == null || newMuteList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3a20854602..a8c901eff8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -964,6 +965,12 @@ object LocalCache : ILocalCache { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( + event: TrustProviderListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( event: ProxyRelayListEvent, relay: NormalizedRelayUrl?, @@ -2848,6 +2855,7 @@ object LocalCache : ILocalCache { is TorrentEvent -> consume(event, relay, wasVerified) is TorrentCommentEvent -> consume(event, relay, wasVerified) is TrustedRelayListEvent -> consume(event, relay, wasVerified) + is TrustProviderListEvent -> consume(event, relay, wasVerified) is VideoHorizontalEvent -> consume(event, relay, wasVerified) is VideoNormalEvent -> consume(event, relay, wasVerified) is VideoVerticalEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt new file mode 100644 index 0000000000..6a4321978a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt @@ -0,0 +1,36 @@ +/** + * 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.trustedAssertions + +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviders +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache + +class TrustProviderListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedServiceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagListPrecached(event).serviceProviders() + + suspend fun serviceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagList(event).serviceProviders() +} 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 new file mode 100644 index 0000000000..5e97b53fe1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt @@ -0,0 +1,98 @@ +/** + * 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.trustedAssertions + +import com.vitorpamplona.amethyst.model.AccountSettings +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.ServiceProviderTag +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class TrustProviderListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: TrustProviderListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note it self + val trustProviderListNote = cache.getOrCreateAddressableNote(getTrustProviderListAddress()) + + fun getTrustProviderListAddress() = TrustProviderListEvent.createAddress(signer.pubKey) + + fun getTrustProviderListFlow(): StateFlow = trustProviderListNote.flow().metadata.stateFlow + + fun getTrustProviderList(): TrustProviderListEvent? = trustProviderListNote.event as? TrustProviderListEvent + + suspend fun trustProviderListWithBackup(note: Note): Set { + val event = note.event as? TrustProviderListEvent ?: settings.backupTrustProviderList + return event?.let { decryptionCache.roomSet(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val liveTrustProviderList: StateFlow> = + getTrustProviderListFlow() + .transformLatest { noteState -> + emit(trustProviderListWithBackup(noteState.note)) + }.onStart { + emit(trustProviderListWithBackup(trustProviderListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + init { + settings.backupTrustProviderList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "TrustProviderList Collector Start") + getTrustProviderListFlow().collect { noteState -> + Log.d("AccountRegisterObservers", "TrustProviderList List for ${signer.pubKey}") + (noteState.note.event as? TrustProviderListEvent)?.let { + settings.updateTrustProviderListTo(it) + } + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..1442fb5fa1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayBuilderExt.kt @@ -0,0 +1,37 @@ +/** + * 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 + +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceType +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun TagArrayBuilder.serviceProvider( + service: ServiceType, + pubkey: HexKey, + relayUrl: NormalizedRelayUrl, +) = addUnique(ServiceProviderTag.assemble(service, pubkey, relayUrl)) + +fun TagArrayBuilder.serviceProvider(provider: ServiceProviderTag) = addUnique(provider.toTagArray()) + +fun TagArrayBuilder.serviceProviders(providers: List) = addAll(providers.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt new file mode 100644 index 0000000000..5f44f5ea42 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt @@ -0,0 +1,26 @@ +/** + * 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 + +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.serviceProviders() = mapNotNull(ServiceProviderTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TrustProviderListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TrustProviderListEvent.kt new file mode 100644 index 0000000000..50a6278b05 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TrustProviderListEvent.kt @@ -0,0 +1,171 @@ +/** + * 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 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.removeParsing +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class TrustProviderListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun serviceProviders() = tags.serviceProviders() + + companion object { + const val KIND = 10040 + const val ALT = "Trusted Service Providers" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + provider: ServiceProviderTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustProviderListEvent = + if (isPrivate) { + create( + publicProviders = emptyList(), + privateProviders = listOf(provider), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicProviders = listOf(provider), + privateProviders = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: TrustProviderListEvent, + provider: ServiceProviderTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustProviderListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(provider.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(provider.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: TrustProviderListEvent, + provider: ServiceProviderTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustProviderListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.removeParsing(ServiceProviderTag::parse, provider), + tags = earlierVersion.tags.removeParsing(ServiceProviderTag::parse, provider), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustProviderListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicProviders: List = emptyList(), + privateProviders: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = signer.sign(build(publicProviders, privateProviders, signer, createdAt)) + + suspend fun build( + publicProviders: List = emptyList(), + privateProviders: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateProviders.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + serviceProviders(publicProviders) + + initializer() + } + } +} 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 new file mode 100644 index 0000000000..f83bdec73a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt @@ -0,0 +1,88 @@ +/** + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +data class ServiceProviderTag( + val service: ServiceType, + val pubkey: HexKey, + val relayUrl: NormalizedRelayUrl, +) { + fun toTagArray() = assemble(service, pubkey, relayUrl) + + companion object { + fun parse(tag: Array): ServiceProviderTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0].isNotEmpty()) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val service = ServiceType.parse(tag[0]) ?: return null + val relay = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + + return ServiceProviderTag(service, tag[1], relay) + } + + fun assemble( + serviceType: String, + pubkey: HexKey, + relayUrl: String, + ) = arrayOf(serviceType, pubkey, relayUrl) + + fun assemble( + service: ServiceType, + pubkey: HexKey, + relayUrl: NormalizedRelayUrl, + ) = assemble(service.toValue(), pubkey, relayUrl.url) + + 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/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 5b6fee5a89..08ba4bd555 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -292,6 +293,7 @@ class EventFactory { TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig) TorrentCommentEvent.KIND -> TorrentCommentEvent(id, pubKey, createdAt, tags, content, sig) TrustedRelayListEvent.KIND -> TrustedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + TrustProviderListEvent.KIND -> TrustProviderListEvent(id, pubKey, createdAt, tags, content, sig) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) VideoNormalEvent.KIND -> VideoNormalEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt new file mode 100644 index 0000000000..f7f27c0150 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt @@ -0,0 +1,65 @@ +/** + * 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 + +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals + +class ServiceParser { + val brainstorm = + TrustProviderListEvent( + id = "d74ce2e62a152e787001f81b51f7f18b69816cbafc064e38af99f1ee55895424", + pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + createdAt = 1762635473, + tags = + arrayOf( + arrayOf("30382:rank", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:followers", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedGrapeRank_influence", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedGrapeRank_average", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedGrapeRank_confidence", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedGrapeRank_input", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedPageRank", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:verifiedFollowersCount", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:verifiedMutersCount", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:verifiedReportersCount", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:hops", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + ), + content = "", + sig = "8947d7b83b9483d241aba1b3af09c5b95b4b053d3c9e4832e1b39eb805ba33a156ea62150e99416a38ff07026f8f99f6a0bbdedb928413ba404c8b9631415c21", + ) + + @Test() + fun parseService() { + assertEquals( + ServiceProviderTag( + ServiceType(30382, "rank"), + "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", + RelayUrlNormalizer.normalize("wss://nip85.brainstorm.world"), + ), + brainstorm.services()[0], + ) + } +} From 2bff99834002e966ff32ac95426de69952bab9ae Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 15:18:31 -0500 Subject: [PATCH 02/21] Moves from List to Set of Trust Provider --- .../trustedAssertions/TrustProviderListDecryptionCache.kt | 5 +++++ .../model/trustedAssertions/TrustProviderListState.kt | 2 +- .../experimental/trustedAssertions/list/TagArrayExt.kt | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt index 6a4321978a..c35dfbaac0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.trustedAssertions import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviderSet import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviders import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache @@ -32,5 +33,9 @@ class TrustProviderListDecryptionCache( fun cachedServiceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagListPrecached(event).serviceProviders() + fun cachedServiceProviderSet(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagListPrecached(event).serviceProviderSet() + suspend fun serviceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagList(event).serviceProviders() + + suspend fun serviceProviderSet(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagList(event).serviceProviderSet() } 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 5e97b53fe1..ec79f5a4ee 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 @@ -59,7 +59,7 @@ class TrustProviderListState( suspend fun trustProviderListWithBackup(note: Note): Set { val event = note.event as? TrustProviderListEvent ?: settings.backupTrustProviderList - return event?.let { decryptionCache.roomSet(it) } ?: emptySet() + return event?.let { decryptionCache.serviceProviderSet(it) } ?: emptySet() } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt index 5f44f5ea42..de6eec972a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.quartz.experimental.trustedAssertions.list +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag.Companion.parse import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip01Core.core.TagArray fun TagArray.serviceProviders() = mapNotNull(ServiceProviderTag::parse) + +fun TagArray.serviceProviderSet() = mapNotNullTo(mutableSetOf(), ServiceProviderTag::parse) From c5fae65f2dd2146adc8b914c51017fa753f1624c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 15:31:02 -0500 Subject: [PATCH 03/21] Moves temporaryAccount handler to lazy loading --- .../service/relayClient/authCommand/model/AuthCoordinator.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 53edb8d971..8f3d02e32c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -37,7 +37,9 @@ class AuthCoordinator( scope: CoroutineScope, ) { private val authWithAccounts = ListWithUniqueSetCache { it.account } - private val tempAccount = NostrSignerSync() + private val tempAccount by lazy { + NostrSignerSync() + } val receiver = RelayAuthenticator( From 1f3bd7614eb01e4bcd7618035bcd7ecfc5baca0e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:37:48 -0500 Subject: [PATCH 04/21] Moves account loading to the IO thread --- .../ui/screen/AccountStateViewModel.kt | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index abc12cdbc6..c96851b95b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -65,7 +65,6 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -85,17 +84,14 @@ class AccountStateViewModel : ViewModel() { fun loginWithDefaultAccountIfLoggedOff() { // pulls account from storage. if (_accountContent.value !is AccountState.LoggedIn) { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { loginWithDefaultAccount() } } } private suspend fun loginWithDefaultAccount(route: Route? = null) { - val accountSettings = - withContext(Dispatchers.IO) { - LocalPreferences.loadAccountConfigFromEncryptedStorage() - } + val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage() if (accountSettings != null) { startUI(accountSettings, route) @@ -135,7 +131,7 @@ class AccountStateViewModel : ViewModel() { throw Exception("Invalid key while trying to login with external signer") } - val account = + val accountSettings = if (loginWithExternalSigner) { AccountSettings( keyPair = KeyPair(pubKey = pubKeyParsed), @@ -164,18 +160,18 @@ class AccountStateViewModel : ViewModel() { ) } - LocalPreferences.setDefaultAccount(account) + LocalPreferences.setDefaultAccount(accountSettings) - startUI(account) + startUI(accountSettings) } - @OptIn(FlowPreview::class) - suspend fun startUI( + fun startUI( accountSettings: AccountSettings, route: Route? = null, - ) = withContext(Dispatchers.Main) { + ) { + val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) _accountContent.update { - AccountState.LoggedIn(Amethyst.instance.accountsCache.loadAccount(accountSettings), route) + AccountState.LoggedIn(account, route) } } From 50f91e8b911962ce72888febdb4aba8c47ae4eb1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:37:56 -0500 Subject: [PATCH 05/21] Updates dependencies --- gradle/libs.versions.toml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5313db7e2..ff80d0e702 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] accompanistAdaptive = "0.37.3" -activityCompose = "1.11.0" +activityCompose = "1.12.0" agp = "8.13.1" android-compileSdk = "36" android-minSdk = "26" @@ -13,11 +13,11 @@ benchmark = "1.4.1" benchmarkJunit4 = "1.4.1" biometricKtx = "1.2.0-alpha05" coil = "3.3.0" -composeBom = "2025.11.00" +composeBom = "2025.11.01" coreKtx = "1.17.0" -datastore = "1.1.7" +datastore = "1.2.0" espressoCore = "3.7.0" -firebaseBom = "34.5.0" +firebaseBom = "34.6.0" fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.20.1" @@ -32,35 +32,35 @@ kotlinxSerializationPlugin = "2.2.21" languageId = "17.0.6" lazysodiumAndroid = "5.2.0" lazysodiumJava = "5.2.0" -lifecycleRuntimeKtx = "2.9.4" +lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" media3 = "1.8.0" mockk = "1.14.6" kotlinx-coroutines-test = "1.10.2" navigationCompose = "2.9.6" -okhttp = "5.3.0" +okhttp = "5.3.2" runner = "1.7.0" rfc3986 = "0.1.2" secp256k1KmpJniAndroid = "0.21.0" securityCryptoKtx = "1.1.0" -spotless = "8.0.0" +spotless = "8.1.0" torAndroid = "0.4.8.19" translate = "17.0.3" unifiedpush = "3.0.10" urlDetector = "0.1.23" -vico-charts = "2.3.4" +vico-charts = "2.3.6" zelory = "3.0.1" zoomable = "2.9.0" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" -windowCoreAndroid = "1.5.0" +windowCoreAndroid = "1.5.1" androidxCamera = "1.5.1" androidxCollection = "1.5.0" -kotlinStdlib = "2.2.20" +kotlinStdlib = "2.2.21" kotlinTest = "2.2.20" core = "1.7.0" -mavenPublish = "0.34.0" +mavenPublish = "0.35.0" [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } From ad879fcf6337c3e3e25bcd2525fba2a658868471 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:42:07 -0500 Subject: [PATCH 06/21] Fix test --- .../quartz/experimental/trustedAssertions/ServiceParser.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt index f7f27c0150..506ce14ffc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt @@ -59,7 +59,7 @@ class ServiceParser { "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", RelayUrlNormalizer.normalize("wss://nip85.brainstorm.world"), ), - brainstorm.services()[0], + brainstorm.serviceProviders()[0], ) } } From 25094be07ced1cacfcb43307d4b2fae7a8b6d75e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:43:32 -0500 Subject: [PATCH 07/21] Puts flow creation inside a remember --- .../amethyst/ui/note/RelayListBox.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index 881675ffe1..6fe9311f25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -123,12 +123,17 @@ fun WatchAndRenderRelay( accountViewModel: AccountViewModel, nav: INav, ) { - val relay by baseNote - .flow() - .relays.stateFlow - .mapNotNull { - it.note.relays.getOrNull(relayIndex) - }.collectAsStateWithLifecycle(baseNote.relays.getOrNull(relayIndex)) + val flow = + remember(baseNote, relayIndex) { + baseNote + .flow() + .relays.stateFlow + .mapNotNull { + it.note.relays.getOrNull(relayIndex) + } + } + + val relay by flow.collectAsStateWithLifecycle(baseNote.relays.getOrNull(relayIndex)) CrossfadeIfEnabled(targetState = relay, label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) { if (it != null) { From 382de9ba03f11743cd53a7d74d3a5024388c0ebc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:51:21 -0500 Subject: [PATCH 08/21] Fixing new remember rules --- .../amethyst/ui/note/types/Highlight.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index d6b55afac0..f20b58ed84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview -import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote @@ -113,7 +112,7 @@ fun DisplayHighlightPreview() { makeItShort = false, canPreview = true, quotesLeft = 3, - backgroundColor = mutableStateOf(Color.White), + backgroundColor = remember { mutableStateOf(Color.White) }, accountViewModel = mockAccountViewModel(), nav = EmptyNav(), ) @@ -137,7 +136,7 @@ fun DisplayHighlightPreviewNewLine() { makeItShort = false, canPreview = true, quotesLeft = 3, - backgroundColor = mutableStateOf(Color.White), + backgroundColor = remember { mutableStateOf(Color.White) }, accountViewModel = mockAccountViewModel(), nav = EmptyNav(), ) @@ -239,7 +238,7 @@ private fun DisplayQuoteAuthor( accountViewModel: AccountViewModel, nav: INav, ) { - var userBase by remember { mutableStateOf(authorHex?.let { accountViewModel.getUserIfExists(it) }) } + var userBase by remember { mutableStateOf(authorHex?.let { accountViewModel.getUserIfExists(it) }) } if (userBase == null && authorHex != null) { LaunchedEffect(authorHex) { @@ -248,7 +247,7 @@ private fun DisplayQuoteAuthor( } var addressable by remember { - mutableStateOf(postAddress?.let { accountViewModel.getAddressableNoteIfExists(it) }) + mutableStateOf(postAddress?.let { accountViewModel.getAddressableNoteIfExists(it) }) } if (addressable == null && postAddress != null) { @@ -264,7 +263,7 @@ private fun DisplayQuoteAuthor( } var version by remember { - mutableStateOf(postVersion?.let { accountViewModel.getNoteIfExists(it.eventId) }) + mutableStateOf(postVersion?.let { accountViewModel.getNoteIfExists(it.eventId) }) } if (version == null && postVersion != null) { @@ -324,7 +323,7 @@ fun DisplayEntryForNote( ) { val noteState by observeNote(note, accountViewModel) - val author = userBase ?: noteState?.note?.author + val author = userBase ?: noteState.note.author if (author != null) { RenderUserAsClickableText(author, null, accountViewModel, nav) @@ -361,7 +360,7 @@ fun DisplayEntryForAUrl( remember { try { URL(url) - } catch (e: Exception) { + } catch (_: Exception) { Log.w("Note Compose", "Invalid URI: $url") null } From d12a8ba1c1e964ce79e2d9a5f96a96b1d776689b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 17:16:08 -0500 Subject: [PATCH 09/21] Revert runTest because the wait to get results back fails in the function --- .../relay/client/accessories/NostrClientSendAndWaitExt.kt | 2 +- .../quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt index a138070319..c830628907 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt @@ -103,7 +103,7 @@ suspend fun INostrClient.sendAndWaitForResponse( val currentResult = receivedResults[result.relay] // do not override a successful result. if (currentResult == null || !currentResult) { - receivedResults.put(result.relay, result.success) + receivedResults[result.relay] = result.success } } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index 479e8658cf..b9966c105b 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -30,14 +30,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals class NostrClientSendAndWaitTest : BaseNostrClientTest() { @Test fun testSendAndWaitForResponse() = - runTest { + runBlocking { val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) @@ -61,6 +61,6 @@ class NostrClientSendAndWaitTest : BaseNostrClientTest() { appScope.cancel() assertEquals(true, resultDamus) - assertEquals(false, resultNos) + assertEquals(true, resultNos) } } From f3938fb33665515919715cbdbb830af3382c1111 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Fri, 21 Nov 2025 22:18:06 +0000 Subject: [PATCH 10/21] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-lv-rLV/strings.xml | 1 + amethyst/src/main/res/values-pl-rPL/strings.xml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index 6044600cf7..a3cb39fd4b 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -16,6 +16,7 @@ Ļaunprogrammatūra Skatījumi rediģēts + Citāts Pievienot " un " " Seko" diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 48d6c77b66..c30d1a8a1d 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -231,7 +231,7 @@ opis dla i zdjęcie do Wyjdź - Porzuć obserwację + Nie obserwuj Kanał utworzony "Informacje o kanale zmienione na" Czat efemeryczny From 5fb309d00c3fd811e4eb2f5f118a084066542efc Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 22 Nov 2025 17:16:30 +0000 Subject: [PATCH 11/21] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index b56c6d01c7..0fc84e146f 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -347,6 +347,7 @@ 发布举报 阻止与举报 仅阻止 + 手动 Zap 拆分 书签 草稿 私人书签 From 4f019c6e491390e179ef0e9de878d41e622997a3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Nov 2025 10:01:05 -0500 Subject: [PATCH 12/21] Removes ConcurrentModificationException on outbox events --- .../relay/client/pool/PoolEventOutboxState.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt index 195c3f9091..6f4088f60f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -28,13 +28,13 @@ class PoolEventOutboxState( val event: Event, var relays: Set, ) { - private val tries = mutableMapOf() + private var tries = mapOf() fun updateRelays(newRelays: Set) { relays = newRelays } - fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false + fun isDone(url: NormalizedRelayUrl) = tries[url]?.isDone() ?: false fun isDone() = relays.all { isDone(it) } @@ -54,7 +54,7 @@ class PoolEventOutboxState( if (currentTries != null) { currentTries.tries.add(TimeUtils.now()) } else { - tries.put(url, Tries(mutableListOf(TimeUtils.now()))) + tries = tries + (url to Tries(mutableListOf(TimeUtils.now()))) } } @@ -67,12 +67,12 @@ class PoolEventOutboxState( if (currentTries != null) { currentTries.responses.add(Response(success, message)) } else { - tries.put( - url, - Tries( - mutableListOf(TimeUtils.now() - 1), - mutableListOf(Response(success, message)), - ), + tries = tries + ( + url to + Tries( + mutableListOf(TimeUtils.now() - 1), + mutableListOf(Response(success, message)), + ) ) } } @@ -82,7 +82,7 @@ class PoolEventOutboxState( val tries: MutableList = mutableListOf(), val responses: MutableList = mutableListOf(), ) { - fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3 + fun isDone() = responses.any { it.success } || responses.size > 2 || tries.size > 3 } class Response( From 1e00c08f7d1ed7bbecbe74f4a40ea0b01b84bf7f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Nov 2025 10:50:58 -0500 Subject: [PATCH 13/21] Fixes ConcurrentExceptions on Event outbox tentative management --- .../relay/client/pool/PoolEventOutboxState.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt index 6f4088f60f..b01a238909 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -52,9 +52,9 @@ class PoolEventOutboxState( fun newTry(url: NormalizedRelayUrl) { val currentTries = tries[url] if (currentTries != null) { - currentTries.tries.add(TimeUtils.now()) + currentTries.addTriedTime(TimeUtils.now()) } else { - tries = tries + (url to Tries(mutableListOf(TimeUtils.now()))) + tries = tries + (url to Tries(listOf(TimeUtils.now()))) } } @@ -65,13 +65,13 @@ class PoolEventOutboxState( ) { val currentTries = tries[url] if (currentTries != null) { - currentTries.responses.add(Response(success, message)) + currentTries.addResponse(Response(success, message)) } else { tries = tries + ( url to Tries( - mutableListOf(TimeUtils.now() - 1), - mutableListOf(Response(success, message)), + listOf(TimeUtils.now() - 1), + listOf(Response(success, message)), ) ) } @@ -79,10 +79,18 @@ class PoolEventOutboxState( // Tries 3 times class Tries( - val tries: MutableList = mutableListOf(), - val responses: MutableList = mutableListOf(), + var tries: List = listOf(), + var responses: List = listOf(), ) { fun isDone() = responses.any { it.success } || responses.size > 2 || tries.size > 3 + + fun addResponse(r: Response) { + responses += r + } + + fun addTriedTime(tried: Long) { + tries += tried + } } class Response( From 1bfe1e484443f1b1aa8750c08efdaf75b3b469a7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Nov 2025 11:03:32 -0500 Subject: [PATCH 14/21] Adds TrustProvider loader to the account filter --- .../account/metadata/FilterAccountInfoAndListsFromKey.kt | 1 + .../account/metadata/FilterBasicAccountInfoFromKeys.kt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 40aed99a84..00eec6f17a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -65,6 +65,7 @@ val AccountInfoAndListsFromKeyKinds2 = ProxyRelayListEvent.KIND, HashtagListEvent.KIND, GeohashListEvent.KIND, + TrustedRelayListEvent.KIND, ) val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 6348658b42..951db16596 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -59,6 +60,7 @@ val BasicAccountInfoKinds2 = ProxyRelayListEvent.KIND, HashtagListEvent.KIND, GeohashListEvent.KIND, + TrustProviderListEvent.KIND, ) fun filterBasicAccountInfoFromKeys( From 0c864b7c7657665d04c81c4b24a2a76143c95dd8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Nov 2025 11:18:27 -0500 Subject: [PATCH 15/21] fixes wong kind --- .../account/metadata/FilterAccountInfoAndListsFromKey.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 00eec6f17a..523c90ae7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -65,7 +66,7 @@ val AccountInfoAndListsFromKeyKinds2 = ProxyRelayListEvent.KIND, HashtagListEvent.KIND, GeohashListEvent.KIND, - TrustedRelayListEvent.KIND, + TrustProviderListEvent.KIND, ) val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) From 7981ecc82ccedcf9a510a1aed57e8d8a18fcf682 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Nov 2025 11:03:00 -0500 Subject: [PATCH 16/21] 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 ec79f5a4ee..e5db4bcd67 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 f83bdec73a..b177660bef 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 0000000000..2fe121b536 --- /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 17/21] 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 17234cfa96..d12144f89e 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 18/21] 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 a997ab1739..2f634dbcfc 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 19/21] 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 2b1707920f..0aff514b29 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 e5db4bcd67..758747bb9f 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 63efc08083..02fe3cde55 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 0000000000..5ac88cc6f9 --- /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 0000000000..f82183c637 --- /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 4a243c8add..5b6bc32bbc 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 cbeb2fe222..b170c5c049 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 20/21] 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 5b6bc32bbc..c8858dec61 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 c566aa94c2..2f62d22157 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 21/21] 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 80061efe03..270cb5aee4 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) {