From b8511b5ac3bc8deaa4257f547051475019607062 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 29 Oct 2025 16:51:49 -0400 Subject: [PATCH] Adds simplified methods to create Requests from NostrClient --- README.md | 53 ++++++---- .../relay/client/reqs/IOpenNostrRequest.kt | 27 ++++++ .../NostrClientDynamicFilterRequest.kt} | 24 ++--- .../reqs/NostrClientStaticFilterRequest.kt | 97 +++++++++++++++++++ .../relay/NostrClientSubscriptionTest.kt | 25 ++--- 5 files changed, 180 insertions(+), 46 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IOpenNostrRequest.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/{NostrClientSubscription.kt => reqs/NostrClientDynamicFilterRequest.kt} (75%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticFilterRequest.kt diff --git a/README.md b/README.md index 7d847181e..517e185bd 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ val readOnly = KeyPair(pubKey = "npub1...".bechToBytes()) ``` Create signers that can be Internal, when you have the private key or a read-only public key, -or External, when it is controlled by Amber in NIP-55. +or External, when it is controlled by Amber in NIP-55. Use either the `NostrSignerInternal` or `NostrSignerExternal` class: @@ -277,7 +277,7 @@ val signer = NostrSignerInternal(keyPair) val amberSigner = NostrSignerExternal( pubKey = keyPair.pubKey.toHexKey(), packageName = signerPackageName, // Amber package name - contentResolver = appContext.contentResolver, + contentResolver = appContext.contentResolver, ) ``` @@ -305,39 +305,54 @@ val authCoordinator = RelayAuthenticator(client, appScope) { challenge, relay -> } ``` -To manage subscriptions, the simplest approach is to build mutable subscriptions in -the Application class. To use the best of the outbox model, this class allows you to -build filters for as many relays as needed. The `NostrClient` will connect to the -complete set of relays for all subscriptions. +To make a request subscription simply do: ```kt -val metadataSub = NostrClientSubscription( - client = client, - filter = { +val metadataSub = client.req( + relays = listOf("wss://nos.lol", "wss://nostr.mom"), + filters = Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(signer.pubkey) + ) +) { event -> + /* consume event */ +} +``` + +The client will add the relay to the pool, connect to it and start receiving events. The +`metadataSub` will be active until you call `metadataSub.close()`. If the client disconnects +and reconnects, the sub will be active again. + +To manage subscriptions that change over time, the simplest approach is to build mutable +subscriptions in the Application class. + +```kt +val metadataSub = client.req( + filters = { + // Let's say you have a list of users that need to be rendered + val users = pubkeysSeeingInTheScreen() + // And a cache repository with their outbox relays + val outboxRelays = outboxRelays(users) + val filters = listOf( Filter( kinds = listOf(MetadataEvent.KIND), - authors = listOf(signer.pubkey) + authors = users ) ) - val signerOutboxRelays = listOfNotNull( - RelayUrlNormalizer.normalizeOrNull("wss://relay1.com"), - RelayUrlNormalizer.normalizeOrNull("wss://relay2.com") - ) - - signerOutboxRelays.associateWith { filters } + outboxRelays.associateWith { filters } } ) { event -> /* consume event */ } ``` -In that way, you can simply call `metadataSub.updateFilter()` when you need to update -subscriptions to all relays. Or call `metadataSub.closeSubscription()` to stop the sub +In that way, you can simply call `metadataSub.updateFilter()` when you need to update +subscriptions to all relays. Or call `metadataSub.close()` to stop the sub without deleting it. -When your app goes to the background, you can use NostrClient's `connect` and `disconnect` +When your app goes to the background, you can use NostrClient's `connect` and `disconnect` methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect` to `onPause` methods. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IOpenNostrRequest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IOpenNostrRequest.kt new file mode 100644 index 000000000..5724db664 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IOpenNostrRequest.kt @@ -0,0 +1,27 @@ +/** + * 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.nip01Core.relay.client.reqs + +interface IOpenNostrRequest { + fun updateFilter() + + fun close() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientDynamicFilterRequest.kt similarity index 75% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientDynamicFilterRequest.kt index ea3519b99..c6b386afb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientDynamicFilterRequest.kt @@ -18,19 +18,20 @@ * 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.nip01Core.relay.client +package com.vitorpamplona.quartz.nip01Core.relay.client.reqs import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.RandomInstance -class NostrClientSubscription( +class NostrClientDynamicFilterRequest( val client: INostrClient, - val filter: () -> Map> = { emptyMap() }, + val filter: () -> Map>, val onEvent: (event: Event) -> Unit = {}, -) : IRequestListener { +) : IRequestListener, + IOpenNostrRequest { private val subId = RandomInstance.randomChars(10) override fun onEvent( @@ -46,15 +47,16 @@ class NostrClientSubscription( * Creates or Updates the filter with relays. This method should be called * everytime the filter changes. */ - fun updateFilter() = client.openReqSubscription(subId, filter(), this) + override fun updateFilter() = client.openReqSubscription(subId, filter(), this) - fun closeSubscription() = client.close(subId) - - fun destroy() { - closeSubscription() - } + override fun close() = client.close(subId) init { updateFilter() } } + +fun INostrClient.req( + filters: () -> Map>, + onEvent: (event: Event) -> Unit = {}, +): IOpenNostrRequest = NostrClientDynamicFilterRequest(this, filters, onEvent) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticFilterRequest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticFilterRequest.kt new file mode 100644 index 000000000..7c42d4d9e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticFilterRequest.kt @@ -0,0 +1,97 @@ +/** + * 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.nip01Core.relay.client.reqs + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.RandomInstance + +class NostrClientStaticFilterRequest( + val client: INostrClient, + val filter: Map>, + val onEvent: (event: Event) -> Unit = {}, +) : IRequestListener, + IOpenNostrRequest { + private val subId = RandomInstance.randomChars(10) + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onEvent(event) + } + + /** + * Creates or Updates the filter with relays. This method should be called + * everytime the filter changes. + */ + override fun updateFilter() = client.openReqSubscription(subId, filter, this) + + override fun close() = client.close(subId) + + init { + updateFilter() + } +} + +fun INostrClient.req( + relay: NormalizedRelayUrl, + filters: List, + onEvent: (event: Event) -> Unit = {}, +): IOpenNostrRequest = NostrClientStaticFilterRequest(this, mapOf(relay to filters), onEvent) + +fun INostrClient.req( + relay: NormalizedRelayUrl, + filter: Filter, + onEvent: (event: Event) -> Unit = {}, +): IOpenNostrRequest = NostrClientStaticFilterRequest(this, mapOf(relay to listOf(filter)), onEvent) + +fun INostrClient.req( + relays: List, + filters: List, + onEvent: (event: Event) -> Unit = {}, +): IOpenNostrRequest = NostrClientStaticFilterRequest(this, relays.associateWith { filters }, onEvent) + +fun INostrClient.req( + relays: List, + filter: Filter, + onEvent: (event: Event) -> Unit = {}, +): IOpenNostrRequest = NostrClientStaticFilterRequest(this, relays.associateWith { listOf(filter) }, onEvent) + +// ----------------------------------- +// Helper methods with relay as string +// ----------------------------------- +fun INostrClient.req( + relay: String, + filters: List, + onEvent: (event: Event) -> Unit = {}, +): IOpenNostrRequest = NostrClientStaticFilterRequest(this, mapOf(RelayUrlNormalizer.normalize(relay) to filters), onEvent) + +fun INostrClient.req( + relay: String, + filter: Filter, + onEvent: (event: Event) -> Unit = {}, +): IOpenNostrRequest = NostrClientStaticFilterRequest(this, mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), onEvent) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index f8eeb1acd..c738e4215 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -23,9 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClientSubscription +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -48,19 +47,13 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { val events = mutableSetOf() val sub = - NostrClientSubscription( - client = client, - filter = { - mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ) - }, + client.req( + relay = "wss://relay.damus.io", + filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 100, + ), ) { event -> assertEquals(MetadataEvent.KIND, event.kind) resultChannel.trySend(event) @@ -75,7 +68,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { resultChannel.close() - sub.closeSubscription() + sub.close() client.disconnect() appScope.cancel()