Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks
This commit is contained in:
@@ -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) {
|
||||
|
||||
+41
@@ -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()
|
||||
}
|
||||
+134
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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(
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+2
@@ -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() }
|
||||
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-2
@@ -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,
|
||||
)
|
||||
|
||||
+147
@@ -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
|
||||
}
|
||||
|
||||
+9
-13
@@ -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
@@ -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" }
|
||||
|
||||
+13
-1
@@ -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,
|
||||
|
||||
+37
@@ -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() })
|
||||
+29
@@ -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)
|
||||
+171
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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)
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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")
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-14
@@ -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)
|
||||
|
||||
+65
@@ -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],
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user