Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks

This commit is contained in:
KotlinGeekDev
2025-11-26 22:45:50 +01:00
35 changed files with 1096 additions and 70 deletions
@@ -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<HashtagListEvent>(PrefKeys.LATEST_HASHTAG_LIST)
val latestGeohashList = parseEventOrNull<GeohashListEvent>(PrefKeys.LATEST_GEOHASH_LIST)
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
val latestTrustProviderList = parseEventOrNull<TrustProviderListEvent>(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),
@@ -93,6 +93,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
@@ -119,6 +121,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
@@ -207,12 +210,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
@@ -284,6 +292,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)
@@ -1716,6 +1727,25 @@ class Account(
).toSet()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun loadUserCardFlow(target: HexKey): Flow<Int?> =
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<NormalizedRelayUrl>) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays))
suspend fun savePrivateOutboxRelayList(relays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays))
@@ -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<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(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
@@ -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
@@ -971,6 +972,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?,
@@ -2856,6 +2863,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)
@@ -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<NormalizedRelayUrl, RelayInfo>()
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) {
@@ -0,0 +1,41 @@
/**
* 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.serviceProviderSet
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<TrustProviderListEvent>(signer)
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()
}
@@ -0,0 +1,134 @@
/**
* 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.ProviderTypes
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.map
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<NoteState> = trustProviderListNote.flow().metadata.stateFlow
fun getTrustProviderList(): TrustProviderListEvent? = trustProviderListNote.event as? TrustProviderListEvent
suspend fun trustProviderListWithBackup(note: Note): Set<ServiceProviderTag> {
val event = note.event as? TrustProviderListEvent ?: settings.backupTrustProviderList
return event?.let { decryptionCache.serviceProviderSet(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveTrustProviderList: StateFlow<Set<ServiceProviderTag>> =
getTrustProviderListFlow()
.transformLatest { noteState ->
emit(trustProviderListWithBackup(noteState.note))
}.onStart {
emit(trustProviderListWithBackup(trustProviderListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val liveUserRankProvider: StateFlow<ServiceProviderTag?> =
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<ServiceProviderTag?> =
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")
@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)
}
}
}
}
}
@@ -37,7 +37,9 @@ class AuthCoordinator(
scope: CoroutineScope,
) {
private val authWithAccounts = ListWithUniqueSetCache<ScreenAuthAccount, Account> { it.account }
private val tempAccount = NostrSignerSync()
private val tempAccount by lazy {
NostrSignerSync()
}
val receiver =
RelayAuthenticator(
@@ -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,6 +66,7 @@ val AccountInfoAndListsFromKeyKinds2 =
ProxyRelayListEvent.KIND,
HashtagListEvent.KIND,
GeohashListEvent.KIND,
TrustProviderListEvent.KIND,
)
val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND)
@@ -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(
@@ -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() }
@@ -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<HexKey>,
trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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
}
}
}
@@ -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,
)
@@ -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<UserFinderQueryState>,
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) {
var lastUsersOnFilter: Set<User> = 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<User> = EOSEAccountFast<User>(2000)
override fun newEose(
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>?,
) {
lastUsersOnFilter.forEach {
latestEOSEs.newEose(it, relay, time)
}
super.newEose(relay, time, filters)
}
override fun updateFilter(
keys: List<UserFinderQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
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<NormalizedRelayUrl, Set<HexKey>> =
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<User>,
eoseCache: EOSEAccountFast<User>,
inRelays: Set<NormalizedRelayUrl>,
): Collection<List<User>> {
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<User>,
eoseCache: EOSEAccountFast<User>,
): SincePerRelayMap {
val minLatestEOSEs = mutableMapOf<NormalizedRelayUrl, MutableTime>()
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
}
@@ -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) {
@@ -21,19 +21,27 @@
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.Row
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
@@ -45,7 +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
@@ -354,6 +367,12 @@ fun BaseUserPicture(
FollowingIcon(Modifier.size(size.div(3.5f)))
}
}
WatchUserCards(baseUser.pubkeyHex, accountViewModel) { score ->
if (score != null) {
ScoreTag(score, size, Modifier.align(Alignment.BottomCenter))
}
}
}
}
@@ -382,9 +401,109 @@ fun BaseUserPicture(
FollowingIcon(Modifier.size(size.div(3.5f)))
}
}
WatchUserCards(baseUserHex, accountViewModel) { score ->
if (score != null) {
ScoreTag(score, size, Modifier.align(Alignment.BottomCenter))
}
}
}
}
@Preview
@Composable
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 = if (size > 34.dp) Font10SP else Font6SP,
modifier =
modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = if (size > 34.dp) 4.dp else 3.dp, vertical = 0.dp),
)
}
@Composable
fun LoadUserProfilePicture(
baseUser: User,
@@ -458,3 +577,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)
}
@@ -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<User?>(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<AddressableNote?>(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<Note?>(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
}
@@ -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)
}
}
@@ -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
@@ -16,6 +16,7 @@
<string name="malware">Ļaunprogrammatūra</string>
<string name="view_count">Skatījumi</string>
<string name="edited">rediģēts</string>
<string name="quote">Citāts</string>
<string name="add">Pievienot</string>
<string name="and">" un "</string>
<string name="following">" Seko"</string>
@@ -231,7 +231,7 @@
<string name="description_to">opis dla</string>
<string name="and_picture_to">i zdjęcie do</string>
<string name="leave">Wyjdź</string>
<string name="unfollow">Porzuć obserwację</string>
<string name="unfollow">Nie obserwuj</string>
<string name="channel_created">Kanał utworzony</string>
<string name="channel_information_changed_to">"Informacje o kanale zmienione na"</string>
<string name="ephemeral_relay_chat">Czat efemeryczny</string>
@@ -347,6 +347,7 @@
<string name="report_dialog_post_report_btn">发布举报</string>
<string name="report_dialog_title">阻止与举报</string>
<string name="block_only">仅阻止</string>
<string name="manual_zaps">手动 Zap 拆分</string>
<string name="bookmarks">书签</string>
<string name="drafts">草稿</string>
<string name="private_bookmarks">私人书签</string>
+11 -11
View File
@@ -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" }
@@ -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,
@@ -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<TrustProviderListEvent>.serviceProvider(
service: ServiceType,
pubkey: HexKey,
relayUrl: NormalizedRelayUrl,
) = addUnique(ServiceProviderTag.assemble(service, pubkey, relayUrl))
fun TagArrayBuilder<TrustProviderListEvent>.serviceProvider(provider: ServiceProviderTag) = addUnique(provider.toTagArray())
fun TagArrayBuilder<TrustProviderListEvent>.serviceProviders(providers: List<ServiceProviderTag>) = addAll(providers.map { it.toTagArray() })
@@ -0,0 +1,29 @@
/**
* 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.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)
@@ -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<Array<String>>,
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<ServiceProviderTag> = emptyList(),
privateProviders: List<ServiceProviderTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = signer.sign(build(publicProviders, privateProviders, signer, createdAt))
suspend fun build(
publicProviders: List<ServiceProviderTag> = emptyList(),
privateProviders: List<ServiceProviderTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<TrustProviderListEvent>.() -> Unit = {},
) = eventTemplate(
kind = KIND,
description = PrivateTagsInContent.encryptNip44(privateProviders.map { it.toTagArray() }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
serviceProviders(publicProviders)
initializer()
}
}
}
@@ -0,0 +1,63 @@
/**
* 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<String>): 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)
}
}
@@ -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")
}
@@ -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
}
}
}
@@ -28,13 +28,13 @@ class PoolEventOutboxState(
val event: Event,
var relays: Set<NormalizedRelayUrl>,
) {
private val tries = mutableMapOf<NormalizedRelayUrl, Tries>()
private var tries = mapOf<NormalizedRelayUrl, Tries>()
fun updateRelays(newRelays: Set<NormalizedRelayUrl>) {
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) }
@@ -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.put(url, Tries(mutableListOf(TimeUtils.now())))
tries = tries + (url to Tries(listOf(TimeUtils.now())))
}
}
@@ -65,24 +65,32 @@ class PoolEventOutboxState(
) {
val currentTries = tries[url]
if (currentTries != null) {
currentTries.responses.add(Response(success, message))
currentTries.addResponse(Response(success, message))
} else {
tries.put(
url,
Tries(
mutableListOf(TimeUtils.now() - 1),
mutableListOf(Response(success, message)),
),
tries = tries + (
url to
Tries(
listOf(TimeUtils.now() - 1),
listOf(Response(success, message)),
)
)
}
}
// Tries 3 times
class Tries(
val tries: MutableList<Long> = mutableListOf(),
val responses: MutableList<Response> = mutableListOf(),
var tries: List<Long> = listOf(),
var responses: List<Response> = listOf(),
) {
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
fun addResponse(r: Response) {
responses += r
}
fun addTriedTime(tried: Long) {
tries += tried
}
}
class Response(
@@ -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
@@ -294,6 +295,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)
@@ -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.serviceProviders()[0],
)
}
}
@@ -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)
@@ -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)
}
}