Merge pull request #1986 from vitorpamplona/claude/simplify-nostrclient-api-ZpOTB

Refactor Nostr client API with clearer naming conventions
This commit is contained in:
Vitor Pamplona
2026-03-28 18:09:12 -04:00
committed by GitHub
84 changed files with 481 additions and 494 deletions
@@ -193,7 +193,7 @@ fun main() {
}
Window(
onCloseRequest = ::exitApplication,
onSubscriptionCloseduest = ::exitApplication,
state = windowState,
title = "Amethyst",
) {
@@ -30,8 +30,7 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
@@ -134,7 +133,7 @@ class AccountManager internal constructor(
private val nip46ClientMutex = Mutex()
private var nip46Client: NostrClient? = null
private suspend fun getOrCreateNip46Client(): NostrClient =
private suspend fun getOrCreateNip46Client(): INostrClient =
nip46ClientMutex.withLock {
nip46Client ?: NostrClient(
BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient),
@@ -156,7 +155,7 @@ class AccountManager internal constructor(
* but we must wait for the websocket to be ready before sending requests.
*/
private suspend fun awaitNip46RelayConnection(
client: NostrClient,
client: INostrClient,
targetRelays: Set<NormalizedRelayUrl>,
) {
withTimeout(NIP46_RELAY_CONNECT_TIMEOUT_MS) {
@@ -180,8 +179,8 @@ class AccountManager internal constructor(
}
}
private fun createLoginRelayListener(): IRelayClientListener =
object : IRelayClientListener {
private fun createLoginRelayListener(): RelayConnectionListener =
object : RelayConnectionListener {
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
@@ -316,7 +315,7 @@ class AccountManager internal constructor(
LoginProgress.ConnectingToRelays(
relaysFromUri.associateWith { RelayLoginStatus.CONNECTING },
)
nip46Client.subscribe(listener)
nip46Client.addConnectionListener(listener)
_loginProgress.value =
LoginProgress.WaitingForSigner(
@@ -356,7 +355,7 @@ class AccountManager internal constructor(
return Result.failure(Exception("Connection failed: ${e.message}"))
} finally {
_loginProgress.value = null
client?.unsubscribe(listener)
client?.removeConnectionListener(listener)
}
}
@@ -376,7 +375,7 @@ class AccountManager internal constructor(
LoginProgress.ConnectingToRelays(
uriData.relays.associateWith { RelayLoginStatus.CONNECTING },
)
nip46Client.subscribe(listener)
nip46Client.addConnectionListener(listener)
onUriGenerated(uriData.uri)
@@ -415,7 +414,7 @@ class AccountManager internal constructor(
return Result.failure(Exception("Connection failed: ${e.message}"))
} finally {
_loginProgress.value = null
client?.unsubscribe(listener)
client?.removeConnectionListener(listener)
}
}
@@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst.desktop.network
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
@@ -43,7 +43,7 @@ import kotlinx.coroutines.flow.asStateFlow
*/
open class RelayConnectionManager(
websocketBuilder: WebsocketBuilder,
) : IRelayClientListener {
) : RelayConnectionListener {
private val _client = NostrClient(websocketBuilder)
/** Exposes the underlying INostrClient for subscription coordinators */
@@ -56,7 +56,7 @@ open class RelayConnectionManager(
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.availableRelaysFlow()
init {
_client.subscribe(this)
_client.addConnectionListener(this)
}
fun connect() {
@@ -85,21 +85,21 @@ open class RelayConnectionManager(
subId: String,
filters: List<Filter>,
relays: Set<NormalizedRelayUrl> = availableRelays.value,
listener: IRequestListener? = null,
listener: SubscriptionListener? = null,
) {
val filterMap = relays.associateWith { filters }
_client.openReqSubscription(subId, filterMap, listener)
_client.subscribe(subId, filterMap, listener)
}
fun unsubscribe(subId: String) {
_client.close(subId)
_client.unsubscribe(subId)
}
fun send(
fun publish(
event: Event,
relays: Set<NormalizedRelayUrl> = connectedRelays.value,
) {
_client.send(event, relays)
_client.publish(event, relays)
}
/**
@@ -107,21 +107,21 @@ open class RelayConnectionManager(
*/
fun broadcastToAll(event: Event) {
val connected = connectedRelays.value
send(event, connected)
publish(event, connected)
}
/**
* Sends an event to a specific relay (for NWC).
* Adds the relay if not already in the list.
*/
fun sendToRelay(
fun publishToRelay(
relay: NormalizedRelayUrl,
event: Event,
) {
if (relay !in availableRelays.value) {
updateRelayStatus(relay) { it.copy(connected = false, error = null) }
}
_client.send(event, setOf(relay))
_client.publish(event, setOf(relay))
}
/**
@@ -138,11 +138,11 @@ open class RelayConnectionManager(
updateRelayStatus(relay) { it.copy(connected = false, error = null) }
}
val filterMap = mapOf(relay to filters)
_client.openReqSubscription(
_client.subscribe(
subId = subId,
filters = filterMap,
listener =
object : IRequestListener {
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
@@ -162,7 +162,7 @@ open class RelayConnectionManager(
relay: NormalizedRelayUrl,
subId: String,
) {
_client.close(subId)
_client.unsubscribe(subId)
}
private fun updateRelayStatus(
@@ -103,7 +103,7 @@ class NwcPaymentHandler(
zappedNote?.addZapPayment(requestNote, null)
// Send request to wallet's relay
relayManager.sendToRelay(nwcConnection.relayUri, requestEvent)
relayManager.publishToRelay(nwcConnection.relayUri, requestEvent)
// Subscribe and wait for response with timeout
return withTimeoutOrNull(timeoutMs) {
@@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.desktop.chess.DesktopChessEventCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
@@ -95,7 +95,7 @@ class DesktopChessSubscriptionController(
filters = allFilters,
relays = state.relays,
listener =
object : IRequestListener {
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
@@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
@@ -153,7 +153,7 @@ class DesktopRelaySubscriptionsCoordinator(
// Cancel any existing subscription with this ID
screenSubscriptions.remove(subId)?.cancel()
client.close(subId)
client.unsubscribe(subId)
if (noteIds.isEmpty() || relays.isEmpty()) return subId
@@ -177,7 +177,7 @@ class DesktopRelaySubscriptionsCoordinator(
)
val listener =
object : IRequestListener {
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
@@ -190,7 +190,7 @@ class DesktopRelaySubscriptionsCoordinator(
val job =
scope.launch {
client.openReqSubscription(
client.subscribe(
subId = subId,
filters = relays.associateWith { filters },
listener = listener,
@@ -206,7 +206,7 @@ class DesktopRelaySubscriptionsCoordinator(
*/
fun releaseInteractions(subId: String) {
screenSubscriptions.remove(subId)?.cancel()
client.close(subId)
client.unsubscribe(subId)
}
/**
@@ -217,7 +217,7 @@ class DesktopRelaySubscriptionsCoordinator(
// Start rate limiter to process queued metadata requests
rateLimiter.start { pubkey ->
// When rate limiter dequeues a pubkey, subscribe to its metadata
client.openReqSubscription(
client.subscribe(
filters =
indexRelays.associateWith {
listOf(
@@ -281,7 +281,7 @@ class DesktopRelaySubscriptionsCoordinator(
if (inboxRelays.isEmpty() && outboxRelays.isEmpty()) return
val listener =
object : IRequestListener {
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
@@ -296,7 +296,7 @@ class DesktopRelaySubscriptionsCoordinator(
if (inboxRelays.isNotEmpty()) {
val inboxSubId = generateSubId("dm-inbox-${userPubKeyHex.take(8)}")
activeDmSubIds.add(inboxSubId)
client.openReqSubscription(
client.subscribe(
subId = inboxSubId,
filters =
inboxRelays.associateWith {
@@ -310,7 +310,7 @@ class DesktopRelaySubscriptionsCoordinator(
if (outboxRelays.isNotEmpty()) {
val outboxSubId = generateSubId("dm-outbox-${userPubKeyHex.take(8)}")
activeDmSubIds.add(outboxSubId)
client.openReqSubscription(
client.subscribe(
subId = outboxSubId,
filters =
outboxRelays.associateWith {
@@ -324,7 +324,7 @@ class DesktopRelaySubscriptionsCoordinator(
if (inboxRelays.isNotEmpty()) {
val giftWrapSubId = generateSubId("giftwrap-${userPubKeyHex.take(8)}")
activeDmSubIds.add(giftWrapSubId)
client.openReqSubscription(
client.subscribe(
subId = giftWrapSubId,
filters =
inboxRelays.associateWith {
@@ -340,7 +340,7 @@ class DesktopRelaySubscriptionsCoordinator(
*/
fun unsubscribeFromDms() {
activeDmSubIds.forEach { subId ->
client.close(subId)
client.unsubscribe(subId)
}
activeDmSubIds.clear()
}
@@ -353,7 +353,7 @@ class DesktopRelaySubscriptionsCoordinator(
// Clean up screen-triggered subscriptions
screenSubscriptions.forEach { (subId, job) ->
job.cancel()
client.close(subId)
client.unsubscribe(subId)
}
screenSubscriptions.clear()
_lastEventAt.value = null
@@ -25,7 +25,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -80,7 +80,7 @@ fun rememberSubscription(
filters = cfg.filters,
relays = cfg.relays,
listener =
object : IRequestListener {
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
@@ -175,7 +175,7 @@ fun ArticleEditorScreen(
)
// TODO: send() is fire-and-forget; markPublished runs before relay ack.
// Consider waiting for relay OK response before marking as published.
relayManager.send(event)
relayManager.publish(event)
draftStore.markPublished(slug)
onPublished()
} catch (e: Exception) {
@@ -170,7 +170,7 @@ fun FeedScreen(
val connectedRelays by relayManager.connectedRelays.collectAsState()
val followedUsers by localCache.followedUsers.collectAsState()
// Available relay URLs — openReqSubscription triggers connection on-demand
// Available relay URLs — subscribe triggers connection on-demand
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
var replyToEvent by remember { mutableStateOf<Event?>(null) }
@@ -72,7 +72,7 @@ import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -436,7 +436,7 @@ private suspend fun fetchMetadataForUsers(
filters = filters,
relays = relays,
listener =
object : IRequestListener {
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
@@ -1058,7 +1058,7 @@ private suspend fun fetchUserLightningAddress(
filters = filters,
relays = relays,
listener =
object : IRequestListener {
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
@@ -171,7 +171,7 @@ fun SearchScreen(
}
}
// NIP-50 people search subscription (use allRelayUrls — openReqSubscription will connect)
// NIP-50 people search subscription (use allRelayUrls — subscribe will connect)
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
return@rememberSubscription null
@@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
@@ -161,7 +161,7 @@ class ChatroomListState(
)
val listener =
object : IRequestListener {
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.chats
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponseDetailed
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -56,7 +56,7 @@ class DmSendTracker(
val allSuccessful = mutableSetOf<NormalizedRelayUrl>()
for ((event, relays) in events) {
val results = client.sendAndWaitForResponseDetailed(event, relays, 10)
val results = client.publishAndConfirmDetailed(event, relays, 10)
allSuccessful.addAll(results.filter { it.value }.keys)
_status.value = DmBroadcastStatus.Sending(allSuccessful.size, totalRelays)
}
@@ -28,8 +28,8 @@ import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -69,7 +69,7 @@ class CoordinatorPipelineTest {
* This lets us test the coordinator's event routing without network dependencies.
*/
private class StubNostrClient : INostrClient {
val openedSubs = mutableMapOf<String, Pair<Map<NormalizedRelayUrl, List<Filter>>, IRequestListener?>>()
val openedSubs = mutableMapOf<String, Pair<Map<NormalizedRelayUrl, List<Filter>>, SubscriptionListener?>>()
override fun connectedRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> = MutableStateFlow(emptySet())
@@ -88,17 +88,17 @@ class CoordinatorPipelineTest {
override fun isActive() = false
override fun renewFilters(relay: IRelayClient) {}
override fun syncFilters(relay: IRelayClient) {}
override fun openReqSubscription(
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: IRequestListener?,
listener: SubscriptionListener?,
) {
openedSubs[subId] = filters to listener
}
override fun queryCount(
override fun count(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {}
@@ -112,9 +112,9 @@ class CoordinatorPipelineTest {
relayList: Set<NormalizedRelayUrl>,
) {}
override fun subscribe(listener: IRelayClientListener) {}
override fun addConnectionListener(listener: RelayConnectionListener) {}
override fun unsubscribe(listener: IRelayClientListener) {}
override fun removeConnectionListener(listener: RelayConnectionListener) {}
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
@@ -376,7 +376,7 @@ class CoordinatorPipelineTest {
val noteIds = listOf("n1".padEnd(64, '0'))
val subId = coordinator.requestInteractions(noteIds, setOf(relayUrl))
// openReqSubscription is launched in scope — wait for it
// subscribe is launched in scope — wait for it
delay(200)
assertTrue(client.openedSubs.containsKey(subId), "Interaction subscription should be opened")