Merge branch 'vitorpamplona:main' into labeled-bookmarks

This commit is contained in:
KotlinGeekDev
2025-11-22 02:45:27 +00:00
committed by GitHub
19 changed files with 606 additions and 43 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
@@ -284,6 +286,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)
@@ -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)
@@ -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,98 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.trustedAssertions
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
class TrustProviderListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: TrustProviderListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val trustProviderListNote = cache.getOrCreateAddressableNote(getTrustProviderListAddress())
fun getTrustProviderListAddress() = TrustProviderListEvent.createAddress(signer.pubKey)
fun getTrustProviderListFlow(): StateFlow<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(),
)
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(
@@ -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) {
@@ -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)
}
}
+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" }
@@ -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,88 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.ensure
data class ServiceProviderTag(
val service: ServiceType,
val pubkey: HexKey,
val relayUrl: NormalizedRelayUrl,
) {
fun toTagArray() = assemble(service, pubkey, relayUrl)
companion object {
fun parse(tag: Array<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)
}
}
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] == ':'
}
}
@@ -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
}
}
}
@@ -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],
)
}
}
@@ -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)
}
}