Restructuring Relay systems to:

- Maintain order of incoming messages for relay listeners
- Defers all processing of incoming messages to coroutines via channels, freeing OkHttp's thread as soon as possible.
- Simplifies the main relay class by using attached listener modules for each function of the relay client.
- Migrate defaultOnConnect calls to become listener based and moved to NostrClients
- Treat counts as query only, not subscriptions.
- Coordinates REQs so that if an update is required to be sent but the server has not finished processing events, waits for it to finish and sends it later as soon as EOSE or Close arrives
- Correctly sustain the local state of each Req.
- Creates an Account follow list per Relay state that only includes shared relays as a better source of functioning relays
- Changes UserLoading features in a tentative to make them faster since they are used by all functions in the app.
- Correctly marks EOSE for filters that are aligned with the Req State from NostrClient
- Avoid subsequent REQ updates before EOSE or CLOSE calls.
- Refactoring RelayClient listener to be not dependent of which module is active for a relay client.
- Refactors authenticators to do complete operation as a module
- Breaks down Relay Client modules (Auth, Reqs, Counts, Event submissions) in the Relay Pool class.
- Creates listeners just for special REQ situations
- Move statistics to outside the base relay class as a listener
- Move logs to outside the base relay class as a listener
- Better structures a Standalone Relay client
- More appropriately communicate errors to the listeners
- Remove relay state on listeners
This commit is contained in:
Vitor Pamplona
2025-10-18 18:10:45 -04:00
parent e20afd39a3
commit e45b4b11dc
72 changed files with 3211 additions and 1753 deletions
@@ -25,7 +25,6 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
@@ -60,6 +59,8 @@ import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
@@ -199,10 +200,12 @@ class AppModules(
val relayStats = RelayStats(client)
val detailedLogger = if (isDebug) RelayLogger(client, true, false) else null
val relayReqStats = if (isDebug) RelayReqStats(client) else null
val logger = if (isDebug) RelaySpeedLogger(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationDefaultScope)
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, authCoordinator.receiver, applicationDefaultScope)
// keeps all accounts live
val accountsCache =
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayStat
import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListReusedOutboxOrProxyRelays
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowsPerOutboxRelay
import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState
@@ -126,7 +127,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.downloadFirstEvent
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
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
@@ -309,6 +309,10 @@ class Account(
// Follows Relays
val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope)
// only follow relays that are declared in more than one user.
val followSharedOutboxesOrProxy = FollowListReusedOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope)
val followPlusAllMineWithIndex = MergedFollowPlusMineWithIndexRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, indexerRelayList, scope)
val followPlusAllMineWithSearch = MergedFollowPlusMineWithSearchRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, searchRelayList, scope)
val defaultGlobalRelays = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, scope)
@@ -1547,13 +1551,10 @@ class Account(
}
}
suspend fun sendAuthEvent(
relay: IRelayClient,
suspend fun createAuthEvent(
relay: NormalizedRelayUrl,
challenge: String,
) {
val auth = RelayAuthEvent.create(relay.url, challenge, signer)
client.sendIfExists(auth, relay.url)
}
): RelayAuthEvent = RelayAuthEvent.create(relay, challenge, signer)
suspend fun hideWord(word: String) {
sendMyPublicAndPrivateOutbox(muteList.hideWord(word))
@@ -65,6 +65,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@@ -2638,7 +2639,7 @@ object LocalCache : ILocalCache {
if (isDebug) {
Log.d("LocalCache", "Updating ${relay.url.url} with a Deletion Event ${event.id} ${deletionEvent.id} because of ${event.toJson()} with ${deletionEvent.toJson()}")
}
relay.send(deletionEvent)
relay.sendIfConnected(EventCmd(deletionEvent))
note.addRelay(relay.url)
}
}
@@ -2656,7 +2657,7 @@ object LocalCache : ILocalCache {
Log.d("LocalCache", "Updating ${relay.url.url} with a new version of ${event.kind} ${event.id} to ${existingEvent.id}")
}
relay.send(existingEvent)
relay.sendIfConnected(EventCmd(existingEvent))
// only send once.
note.addRelay(relay.url)
}
@@ -0,0 +1,135 @@
/**
* 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.nip02FollowLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
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
class FollowListReusedOutboxOrProxyRelays(
kind3Follows: FollowListState,
blockedRelayList: BlockedRelayListState,
proxyRelayList: ProxyRelayListState,
val cache: LocalCache,
scope: CoroutineScope,
) {
@OptIn(ExperimentalCoroutinesApi::class)
val outboxRelayFlow: StateFlow<Set<NormalizedRelayUrl>> =
kind3Follows.flow
.transformLatest { follows ->
emitAll(
OutboxRelayLoader(true).toAuthorsPerRelayFlow(follows.authors, cache) { authorsPerRelay ->
authorsPerRelay
.mapNotNull {
if (it.value.size > 1) {
it.key
} else {
null
}
}.toSet()
},
)
}.onStart {
emit(
OutboxRelayLoader(true).authorsPerRelaySnapshot(kind3Follows.flow.value.authors, cache) { authorsPerRelay ->
authorsPerRelay
.mapNotNull {
if (it.value.size > 1) {
it.key
} else {
null
}
}.toSet()
},
)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
val outboxRelayMinusBlockedFlow: StateFlow<Set<NormalizedRelayUrl>> =
combine(outboxRelayFlow, blockedRelayList.flow) { followList, blockedRelays ->
followList.minus(blockedRelays)
}.onStart {
emit(outboxRelayFlow.value.minus(blockedRelayList.flow.value.toSet()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<NormalizedRelayUrl>> =
proxyRelayList.flow
.flatMapLatest { proxyRelays ->
if (proxyRelays.isEmpty()) {
outboxRelayMinusBlockedFlow
} else {
MutableStateFlow(proxyRelays)
}
}.onStart {
emit(
proxyRelayList.flow.value.ifEmpty {
outboxRelayMinusBlockedFlow.value
},
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flowSet: StateFlow<Set<String>> =
flow
.map { relayList ->
relayList.mapTo(mutableSetOf()) { it.url }
}.onStart {
emit(flow.value.mapTo(mutableSetOf()) { it.url })
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
@@ -40,8 +40,7 @@ class CustomMediaSourceFactory(
) : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(
Amethyst.Companion.instance.videoCache
.get(okHttpClient),
Amethyst.instance.videoCache.get(okHttpClient),
)
private var nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
@@ -23,7 +23,8 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
@@ -36,15 +37,35 @@ class AuthCoordinator(
scope: CoroutineScope,
) {
private val authWithAccounts = ListWithUniqueSetCache<ScreenAuthAccount, Account> { it.account }
private val tempAccount = NostrSignerSync()
val receiver =
RelayAuthenticator(client, scope) { challenge, relay ->
authWithAccounts.distinct().forEach {
if (it.isWriteable()) {
it.sendAuthEvent(relay, challenge)
RelayAuthenticator(
client,
scope,
signWithAllLoggedInUsers = { authTemplate ->
val results =
authWithAccounts.distinct().mapNotNull {
if (it.signer.isWriteable()) {
try {
it.signer.sign(authTemplate)
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
null
}
} else {
null
}
}
// Always auth, even with random keys
if (!results.isEmpty()) {
results
} else {
listOf(tempAccount.sign(authTemplate))
}
}
}
},
)
fun destroy() {
receiver.destroy()
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -50,7 +50,7 @@ abstract class BaseEoseManager<T>(
fun getSubscription(subId: String) = orchestrator.getSub(subId)
fun requestNewSubscription(onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null) = orchestrator.requestNewSubscription(newSubscriptionId(), onEOSE)
fun requestNewSubscription(listener: IRequestListener) = orchestrator.requestNewSubscription(newSubscriptionId(), listener)
fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId)
@@ -68,7 +68,6 @@ abstract class BaseEoseManager<T>(
override fun destroy() {
bundler.cancel()
orchestrator.destroy()
if (isDebug) {
Log.d(logTag, "Destroy, Unsubscribe")
}
@@ -22,11 +22,15 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.service.relays.EOSEByKey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
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.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* This query type creates a new relay subscription for every SubID.id()
@@ -53,6 +57,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
key: T,
relayUrl: NormalizedRelayUrl,
time: Long,
filters: List<Filter>? = null,
) {
latestEOSEs.newEose(id(key), relayUrl, time)
if (invalidateAfterEose) {
@@ -61,9 +66,27 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
}
open fun newSub(key: T): Subscription =
requestNewSubscription { time, relayUrl ->
newEose(key, relayUrl, time)
}
requestNewSubscription(
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
}
},
)
open fun endSub(
key: U,
@@ -23,11 +23,15 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
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.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* This query type creates a new relay subscription for every logged-in
@@ -54,15 +58,36 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
key: T,
relay: NormalizedRelayUrl,
time: Long,
) = latestEOSEs.newEose(user(key), list(key), relay, time)
filters: List<Filter>? = null,
) {
latestEOSEs.newEose(user(key), list(key), relay, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
open fun newSub(key: T): Subscription =
requestNewSubscription { time, relayUrl ->
newEose(key, relayUrl, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
requestNewSubscription(
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
}
},
)
open fun endSub(
key: User,
@@ -23,11 +23,15 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
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.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* This query type creates a new relay subscription for every distinct
@@ -48,19 +52,40 @@ abstract class PerUserEoseManager<T>(
fun since(key: T) = latestEOSEs.since(user(key))
fun newEose(
open fun newEose(
key: T,
relay: NormalizedRelayUrl,
time: Long,
) = latestEOSEs.newEose(user(key), relay, time)
filters: List<Filter>? = null,
) {
latestEOSEs.newEose(user(key), relay, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
open fun newSub(key: T): Subscription =
requestNewSubscription { time, relayUrl ->
newEose(key, relayUrl, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
requestNewSubscription(
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
}
},
)
open fun endSub(
key: User,
@@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.service.relays.EOSERelayList
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* This query type creates only ONE relay subscription. It filters duplicates
@@ -52,15 +56,36 @@ abstract class SingleSubEoseManager<T>(
open fun newEose(
relay: NormalizedRelayUrl,
time: Long,
) = latestEOSEs.newEose(relay, time)
filters: List<Filter>? = null,
) {
latestEOSEs.newEose(relay, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
val sub =
requestNewSubscription { time, relayUrl ->
newEose(relayUrl, time)
if (invalidateAfterEose) {
invalidateFilters()
}
}
requestNewSubscription(
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(relay, TimeUtils.now(), forFilters)
}
}
},
)
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) }
@@ -20,9 +20,13 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* This query type creates only ONE relay subscription and does not
@@ -35,11 +39,31 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
val invalidateAfterEose: Boolean = false,
) : BaseEoseManager<T>(client, allKeys) {
val sub =
requestNewSubscription { time, relayUrl ->
if (invalidateAfterEose) {
invalidateFilters()
}
}
requestNewSubscription(
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (invalidateAfterEose) {
invalidateFilters()
}
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
if (invalidateAfterEose) {
invalidateFilters()
}
}
}
},
)
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) }
@@ -40,15 +40,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProf
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
import kotlinx.coroutines.CoroutineScope
class RelaySubscriptionsCoordinator(
cache: LocalCache,
client: INostrClient,
authenticator: IAuthStatus,
scope: CoroutineScope,
) {
// main one: notifications, dms and account settings
val account = AccountFilterAssembler(client)
val account = AccountFilterAssembler(client, cache, authenticator, scope)
// always running, feed assemblers.
val home = HomeFilterAssembler(client)
@@ -60,7 +62,7 @@ class RelaySubscriptionsCoordinator(
// they are active when looking at events, users, channels.
val channelFinder = ChannelFinderFilterAssemblyGroup(client)
val eventFinder = EventFinderFilterAssembler(client)
val userFinder = UserFinderFilterAssembler(client)
val userFinder = UserFinderFilterAssembler(client, cache)
// active when searching or tagging users.
val search = SearchFilterAssembler(client, scope, cache)
@@ -21,7 +21,10 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.AccountFollowsLoaderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager
@@ -29,6 +32,9 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59Gi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import kotlinx.coroutines.CoroutineScope
// This allows multiple screen to be listening to logged-in accounts.
class AccountQueryState(
@@ -42,11 +48,16 @@ class AccountQueryState(
*/
class AccountFilterAssembler(
client: INostrClient,
cache: LocalCache,
authenticator: IAuthStatus,
scope: CoroutineScope,
) : ComposeSubscriptionManager<AccountQueryState>() {
val group =
listOf(
AccountMetadataEoseManager(client, ::allKeys),
AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, ::allKeys),
AccountGiftWrapsEoseManager(client, ::allKeys),
AccountDraftsEoseManager(client, ::allKeys),
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys),
)
@@ -0,0 +1,248 @@
/**
* 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.account.follows
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.IEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
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.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlin.collections.forEach
import kotlin.collections.ifEmpty
/**
* This downloads the outbox events for all Follows of all users.
* It needs to be super fast on startup.
*/
class AccountFollowsLoaderSubAssembler(
val client: INostrClient,
val cache: LocalCache,
val scope: CoroutineScope,
val authStatus: IAuthStatus,
val allKeys: () -> Set<AccountQueryState>,
) : IEoseManager {
private val logTag = "AccountFollowsLoaderSubAssembler"
private val orchestrator = SubscriptionController(client)
private val cannotConnectRelays = mutableSetOf<NormalizedRelayUrl>()
// Refreshes observers in batches of 500ms
private val bundler = BundledUpdate(500, Dispatchers.Default)
/**
* 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).
*/
val hasTried: EOSEAccountFast<User> = EOSEAccountFast<User>(2000)
// updates all filters
override fun invalidateFilters(ignoreIfDoing: Boolean) {
bundler.invalidate(ignoreIfDoing) {
updateSubscriptions(allKeys())
orchestrator.updateRelays()
}
}
override fun destroy() {
orchestrator.dismissSubscription(sub.id)
bundler.cancel()
}
fun newEose(
time: Long,
relayUrl: NormalizedRelayUrl,
filters: List<Filter>?,
) {
filters?.forEach { filter ->
filter.authors?.forEach { pubkey ->
cache.getUserIfExists(pubkey)?.let { user ->
hasTried.newEose(user, relayUrl, time)
}
}
}
invalidateFilters(true)
}
val sub =
orchestrator.requestNewSubscription(
if (isDebug) logTag + newSubId() else newSubId(),
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(TimeUtils.now(), relay, forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(TimeUtils.now(), relay, forFilters)
}
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// If the relay doesn't want to provide data (and it is not because of auth), cancel REQ
if (
!message.startsWith("auth") || authStatus.hasFinishedAuthentication(relay)
) {
newEose(TimeUtils.now(), relay, forFilters)
}
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
cannotConnectRelays.add(relay)
}
},
)
fun updateFilterForAllAccounts(accounts: Collection<Account>): List<RelayBasedFilter>? {
val noOutboxList = mutableSetOf<User>()
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
val homeRelays = mutableSetOf<NormalizedRelayUrl>()
val searchRelays = mutableSetOf<NormalizedRelayUrl>()
val commonRelays = mutableSetOf<NormalizedRelayUrl>()
accounts.forEach { key ->
key.kind3FollowList.userList.value.forEach { user ->
if (user.authorRelayList() == null) {
noOutboxList.add(user)
}
}
indexRelays.addAll(
key.indexerRelayList.flow.value
.ifEmpty { DefaultIndexerRelayList },
)
homeRelays.addAll(key.nip65RelayList.allFlowNoDefaults.value)
homeRelays.addAll(key.privateStorageRelayList.flow.value)
homeRelays.addAll(key.localRelayList.flow.value)
searchRelays.addAll(key.trustedRelayList.flow.value)
searchRelays.addAll(
key.searchRelayList.flow.value
.ifEmpty { DefaultSearchRelayList },
)
// uses followShared to ignore personal relays when finding users.
commonRelays.addAll(key.followSharedOutboxesOrProxy.flow.value - cannotConnectRelays)
}
if (noOutboxList.isEmpty()) return null
val perRelay = pickRelaysToLoadUsers(noOutboxList, indexRelays, homeRelays, searchRelays, commonRelays, hasTried)
hasTried.removeEveryoneBut(noOutboxList)
return perRelay.mapNotNull { (relay, users) ->
if (users.isNotEmpty()) {
RelayBasedFilter(
relay = relay,
filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted()),
)
} else {
null
}
}
}
fun updateSubscriptions(keys: Set<AccountQueryState>) {
val uniqueSubscribedAccounts = keys.associate { it.account.userProfile() to it.account }
val allFilters = updateFilterForAllAccounts(uniqueSubscribedAccounts.values)
sub.updateFilters(allFilters?.groupByRelay())
// adds new subscriptions
uniqueSubscribedAccounts.forEach {
if (it.value.userProfile() !in accountUpdatesJobMap.keys) {
newWatcher(it.value.userProfile(), it.value.kind3FollowList.userList)
}
}
// removes accounts that are not being subscribed anymore.
accountUpdatesJobMap.forEach {
if (it.key !in uniqueSubscribedAccounts.keys) {
endWatcher(it.key)
}
}
}
private val accountUpdatesJobMap = mutableMapOf<User, Job>()
@OptIn(FlowPreview::class)
fun newWatcher(
user: User,
followList: Flow<List<User>>,
) {
accountUpdatesJobMap[user]?.cancel()
accountUpdatesJobMap[user] =
scope.launch(Dispatchers.Default) {
followList.sample(1000).collectLatest {
invalidateFilters(true)
}
}
}
fun endWatcher(key: User) {
accountUpdatesJobMap[key]?.cancel()
accountUpdatesJobMap.remove(key)
}
}
@@ -0,0 +1,187 @@
/**
* 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.account.follows
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
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.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
val MetadataOnlyKinds = listOf(MetadataEvent.KIND)
val OutboxOnlyKinds = listOf(AdvertisedRelayListEvent.KIND)
val BothKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND)
fun filterFindFollowMetadataForKey(
loadMetadata: Set<User>,
loadOutbox: Set<User>,
connectedRelays: Set<NormalizedRelayUrl>,
indexRelays: Set<NormalizedRelayUrl>,
searchRelays: Set<NormalizedRelayUrl>,
allRelays: Set<NormalizedRelayUrl>,
hasTried: EOSEAccountFast<User>,
): List<RelayBasedFilter> {
val both = loadMetadata.intersect(loadOutbox)
val onlyMetadata = loadMetadata - both
val onlyOutbox = loadOutbox - both
val perRelayKeysBoth = pickRelaysToLoadUsers(both, connectedRelays, indexRelays, searchRelays, allRelays, hasTried)
val perRelayKeysOnlyMetadata = pickRelaysToLoadUsers(onlyMetadata, connectedRelays, indexRelays, searchRelays, allRelays, hasTried)
val perRelayKeysOnlyOutbox = pickRelaysToLoadUsers(onlyOutbox, connectedRelays, indexRelays, searchRelays, allRelays, hasTried)
return perRelayKeysBoth.mapNotNull {
val sortedUsers = it.value.sorted()
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = Filter(kinds = BothKinds, authors = sortedUsers),
)
} else {
null
}
} +
perRelayKeysOnlyMetadata.mapNotNull {
val sortedUsers = it.value.sorted()
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = Filter(kinds = MetadataOnlyKinds, authors = sortedUsers),
)
} else {
null
}
} +
perRelayKeysOnlyOutbox.mapNotNull {
val sortedUsers = it.value.sorted()
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = Filter(kinds = OutboxOnlyKinds, authors = sortedUsers),
)
} else {
null
}
}
}
fun pickRelaysToLoadUsers(
users: Set<User>,
indexRelays: Set<NormalizedRelayUrl>,
homeRelays: Set<NormalizedRelayUrl>,
searchRelays: Set<NormalizedRelayUrl>,
commonRelays: Set<NormalizedRelayUrl>,
hasTried: EOSEAccountFast<User>,
): Map<NormalizedRelayUrl, Set<HexKey>> =
mapOfSet {
users.forEachIndexed { idx, key ->
val tried = hasTried.since(key)?.keys ?: emptySet()
val outbox = key.authorRelayList()?.writeRelaysNorm()
if (outbox != null && outbox.isNotEmpty()) {
// If there is a home, get from it.
// if it tried all outbox relays, stop.
// the UserWatch will take over from here.
val leftToTry = (outbox - tried)
leftToTry.forEach {
add(it, key.pubkeyHex)
}
} else {
// if not, tries hints first.
val hints = key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex)
val leftToTryOnHints = hints - tried
leftToTryOnHints.forEach {
add(it, key.pubkeyHex)
}
// if there are only a few hints, broadens the search
if (leftToTryOnHints.size < 3) {
// This creates a pre-deterministic order of the array such that
// if this function is called twice, it returns the same arrays
// which gets ignored by the relay client if we send it twice
val indexRelaysLeftToTry =
(indexRelays - tried).sortedBy { relay ->
key.pubkeyHex.hashCode() xor relay.url.hashCode()
}
// This creates a pre-deterministic order of the array such that
// if this function is called twice, it returns the same arrays
// which gets ignored by the relay client if we send it twice
val homeRelaysLeftToTry =
(homeRelays - tried).sortedBy { relay ->
key.pubkeyHex.hashCode() xor relay.url.hashCode()
}
// picks one at random to avoid overloading these relays
if (users.size > 300) {
if (indexRelaysLeftToTry.size >= 2) {
add(indexRelaysLeftToTry[0], key.pubkeyHex)
add(indexRelaysLeftToTry[1], key.pubkeyHex)
} else if (indexRelaysLeftToTry.size == 1) {
add(indexRelaysLeftToTry.first(), key.pubkeyHex)
}
homeRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
} else {
indexRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
homeRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
}
if (indexRelaysLeftToTry.size < 2) {
val searchRelaysLeftToTry = searchRelays - tried
searchRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
if (searchRelaysLeftToTry.size < 2) {
// This creates a pre-deterministic order of the array such that
// if this function is called twice, it returns the same arrays
// which gets ignored by the relay client if we send it twice
val allRelaysLeftToTry =
(commonRelays - tried)
.sortedBy { relay ->
key.pubkeyHex.hashCode() xor relay.url.hashCode()
}.take(100)
allRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
}
}
}
}
}
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.ammolite.relays.filters.MutableTime
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
class EventWatcherSubAssembler(
@@ -41,11 +42,12 @@ class EventWatcherSubAssembler(
override fun newEose(
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>?,
) {
lastNotesOnFilter.forEach {
latestEOSEs.newEose(it, relay, time)
}
super.newEose(relay, time)
super.newEose(relay, time, filters)
}
override fun updateFilter(
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user
import com.vitorpamplona.amethyst.model.Account
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.UserLoaderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.IEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler
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
@@ -36,11 +38,12 @@ class UserFinderQueryState(
class UserFinderFilterAssembler(
client: INostrClient,
cache: LocalCache,
) : ComposeSubscriptionManager<UserFinderQueryState>() {
val group =
listOf(
UserLoaderSubAssembler(client, ::allKeys),
UserWatcherSubAssembler(client, ::allKeys),
UserOutboxFinderSubAssembler(client, cache, ::allKeys),
UserWatcherSubAssembler(client, cache, ::allKeys),
UserReportsSubAssembler(client, ::allKeys),
)
@@ -20,59 +20,33 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
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.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
val MetadataAndRelayListKinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
)
val RelayListKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND)
fun filterFindUserMetadataForKey(
author: HexKey,
indexRelays: Set<NormalizedRelayUrl>,
defaultRelays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> =
LocalCache.checkGetOrCreateUser(author)?.let {
filterFindUserMetadataForKey(setOf(it), indexRelays, defaultRelays)
} ?: emptyList()
fun filterFindUserMetadataForKey(
fun findFindUserOutBox(
authors: Set<User>,
connectedRelays: Set<NormalizedRelayUrl>,
indexRelays: Set<NormalizedRelayUrl>,
defaultRelays: Set<NormalizedRelayUrl>,
searchRelays: Set<NormalizedRelayUrl>,
allRelays: Set<NormalizedRelayUrl>,
hasTried: EOSEAccountFast<User>,
): List<RelayBasedFilter> {
val perRelayKeys =
mapOfSet {
authors.forEach { key ->
val relays =
key.authorRelayList()?.writeRelaysNorm()
?: (key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays).ifEmpty { null }
?: defaultRelays.toList()
val perRelayKeysBoth = pickRelaysToLoadUsers(authors, connectedRelays, indexRelays, searchRelays, allRelays, hasTried)
relays.forEach {
add(it, key.pubkeyHex)
}
}
}
return perRelayKeys.mapNotNull {
if (it.value.isNotEmpty()) {
return perRelayKeysBoth.mapNotNull {
val sortedUsers = it.value.sorted()
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = MetadataAndRelayListKinds,
authors = it.value.sorted(),
),
filter = Filter(kinds = RelayListKinds, authors = sortedUsers),
)
} else {
null
@@ -1,73 +0,0 @@
/**
* 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.loaders
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class UserLoaderSubAssembler(
client: INostrClient,
allKeys: () -> Set<UserFinderQueryState>,
) : SingleSubNoEoseCacheEoseManager<UserFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(keys: List<UserFinderQueryState>): List<RelayBasedFilter>? {
val firstTimers = mutableSetOf<User>()
keys.forEach {
if (it.user.latestMetadata == null) {
firstTimers.add(it.user)
} else {
null
}
}
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
val defaultRelays = mutableSetOf<NormalizedRelayUrl>()
keys.mapTo(mutableSetOf()) { it.account }.forEach {
indexRelays.addAll(
it.indexerRelayList.flow.value
.ifEmpty { DefaultIndexerRelayList },
)
defaultRelays.addAll(it.followPlusAllMineWithSearch.flow.value)
it.kind3FollowList.flow.value.authors.forEach {
val user = LocalCache.getOrCreateUser(it)
if (user.latestMetadata == null) {
firstTimers.add(user)
} else {
null
}
}
}
if (firstTimers.isEmpty()) return null
return filterFindUserMetadataForKey(firstTimers, indexRelays, defaultRelays)
}
override fun distinct(key: UserFinderQueryState) = key.user
}
@@ -0,0 +1,125 @@
/**
* 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.loaders
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.collections.ifEmpty
class UserOutboxFinderSubAssembler(
client: INostrClient,
val cache: LocalCache,
allKeys: () -> Set<UserFinderQueryState>,
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
/**
* 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 hasTried: EOSEAccountFast<User> = EOSEAccountFast<User>(200)
fun newEose(
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>? = null,
) = {
filters?.forEach {
it.authors?.forEach {
cache.getUserIfExists(it)?.let { user ->
hasTried.newEose(user, relay, time)
}
}
}
invalidateFilters()
}
val sub =
requestNewSubscription(
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(relay, TimeUtils.now(), forFilters)
}
},
)
override fun updateSubscriptions(keys: Set<UserFinderQueryState>) {
val uniqueSubscribedAccounts = keys.distinctBy { it.user }
val newFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null }
sub.updateFilters(newFilters?.groupByRelay())
}
fun updateFilter(keys: List<UserFinderQueryState>): List<RelayBasedFilter>? {
val noOutboxList = mutableSetOf<User>()
keys.forEach {
if (it.user.authorRelayList() == null) {
noOutboxList.add(it.user)
}
}
if (noOutboxList.isEmpty()) return null
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
val searchRelays = mutableSetOf<NormalizedRelayUrl>()
val allRelays = mutableSetOf<NormalizedRelayUrl>()
keys.mapTo(mutableSetOf()) { it.account }.forEach { acc ->
// broadens the search as it goes.
indexRelays.addAll(
acc.indexerRelayList.flow.value
.ifEmpty { DefaultIndexerRelayList },
)
indexRelays.addAll(acc.proxyRelayList.flow.value)
searchRelays.addAll(acc.nip65RelayList.allFlowNoDefaults.value)
searchRelays.addAll(acc.privateStorageRelayList.flow.value)
searchRelays.addAll(acc.localRelayList.flow.value)
searchRelays.addAll(acc.searchRelayList.flow.value)
searchRelays.addAll(acc.trustedRelayList.flow.value)
allRelays.addAll(acc.followOutboxesOrProxy.flow.value)
}
allRelays.addAll(Constants.eventFinderRelays)
return findFindUserOutBox(
noOutboxList,
client.relayStatusFlow().value.connected,
indexRelays,
searchRelays,
allRelays,
hasTried,
)
}
}
@@ -21,15 +21,20 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
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
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
import kotlin.collections.forEach
import kotlin.collections.plus
val UserMetadataForKeyKinds =
listOf(
@@ -41,30 +46,60 @@ val UserMetadataForKeyKinds =
)
fun filterUserMetadataForKey(
authors: Set<HexKey>,
since: SincePerRelayMap?,
authors: Set<User>,
indexRelays: Set<NormalizedRelayUrl>,
since: EOSEAccountFast<User>,
): List<RelayBasedFilter> {
val relays =
authors
.map {
val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it)
val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent)
val perRelayUsers =
mapOfSet {
authors.forEach { key ->
val relays =
key.outboxRelays()
?: (key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays)
authorHomeRelayEvent?.writeRelaysNorm()
?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null }
?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay)
}.flatten()
.toSet()
relays.forEach {
add(it, key)
}
}
}
return relays.map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = UserMetadataForKeyKinds,
authors = authors.toList(),
since = since?.get(it)?.time,
return perRelayUsers
.map { (relay, users) ->
val firstTimers = mutableSetOf<HexKey>()
val updates = mutableSetOf<HexKey>()
var minimumTime: Long = Long.MAX_VALUE
users.forEach { user ->
val time = since.since(user)?.get(relay)?.time
if (time == null) {
firstTimers.add(user.pubkeyHex)
} else {
updates.add(user.pubkeyHex)
if (time < minimumTime) {
minimumTime = time
}
}
}
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = UserMetadataForKeyKinds,
authors = firstTimers.sorted(),
),
),
)
}
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = UserMetadataForKeyKinds,
authors = updates.sorted(),
since = minimumTime,
),
),
)
}.flatten()
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.ammolite.relays.filters.MutableTime
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
@@ -46,11 +47,12 @@ class UserReportsSubAssembler(
override fun newEose(
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>?,
) {
lastUsersOnFilter.forEach {
latestEOSEs.newEose(it, relay, time)
}
super.newEose(relay, time)
super.newEose(relay, time, filters)
}
override fun updateFilter(
@@ -20,94 +20,87 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager
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.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
class UserWatcherSubAssembler(
client: INostrClient,
val cache: LocalCache,
allKeys: () -> Set<UserFinderQueryState>,
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) {
var lastUsersOnFilter: Set<User> = emptySet()
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
/**
* 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)
var latestEOSEs: EOSEAccountFast<User> = EOSEAccountFast(200)
override fun newEose(
fun newEose(
relay: NormalizedRelayUrl,
time: Long,
) {
lastUsersOnFilter.forEach {
latestEOSEs.newEose(it, relay, time)
}
super.newEose(relay, time)
}
override fun updateFilter(
keys: List<UserFinderQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (keys.isEmpty()) return null
lastUsersOnFilter =
keys.mapNotNullTo(mutableSetOf()) {
if (it.user.latestMetadata != null) it.user else null
}
if (lastUsersOnFilter.isEmpty()) return null
return groupByRelayPresence(lastUsersOnFilter, latestEOSEs)
.map { group ->
val groupIds = group.map { it.pubkeyHex }.toSet()
if (groupIds.isNotEmpty()) {
val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs)
filterUserMetadataForKey(groupIds, minEOSEs)
} else {
emptyList()
}
}.flatten()
}
fun groupByRelayPresence(
users: Iterable<User>,
eoseCache: EOSEAccountFast<User>,
): Collection<List<User>> =
users
.groupBy { eoseCache.since(it)?.keys?.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)
filters: List<Filter>? = null,
) = {
filters?.forEach {
it.authors?.forEach {
cache.getUserIfExists(it)?.let { user ->
latestEOSEs.newEose(user, relay, time)
}
}
}
return minLatestEOSEs
}
override fun distinct(key: UserFinderQueryState) = key.user
val sub =
requestNewSubscription(
object : IRequestListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (isLive) {
newEose(relay, TimeUtils.now(), forFilters)
}
}
},
)
override fun updateSubscriptions(keys: Set<UserFinderQueryState>) {
val users = keys.mapTo(mutableSetOf()) { it.user }
if (users.isEmpty()) {
sub.updateFilters(null)
return
}
// assembles all index relays from all accounts
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
keys.mapTo(mutableSetOf()) { it.account }.forEach {
indexRelays.addAll(
it.indexerRelayList.flow.value
.ifEmpty { DefaultIndexerRelayList },
)
}
val newFilters = filterUserMetadataForKey(users, indexRelays, latestEOSEs).ifEmpty { null }
sub.updateFilters(newFilters?.groupByRelay())
}
}
@@ -20,12 +20,45 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.filterFindUserMetadataForKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
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
import kotlin.collections.ifEmpty
import kotlin.collections.plus
val MetadataKindList = listOf(MetadataEvent.KIND)
fun filterByAuthor(
pubKey: HexKey,
indexRelays: Set<NormalizedRelayUrl>,
defaultRelays: Set<NormalizedRelayUrl>,
) = filterFindUserMetadataForKey(pubKey, indexRelays, defaultRelays)
) = LocalCache.checkGetOrCreateUser(pubKey)?.let { key ->
val perRelay =
mapOfSet {
val relays =
key.authorRelayList()?.writeRelaysNorm()
?: (key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays).ifEmpty { null }
?: defaultRelays.toList()
relays.forEach {
add(it, key.pubkeyHex)
}
}
val myUser = listOf(pubKey)
perRelay.map {
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = MetadataKindList,
authors = myUser,
),
)
}
} ?: emptyList()
@@ -20,10 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.speedLogger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.utils.Log
/**
@@ -40,15 +41,14 @@ class RelaySpeedLogger(
private val clientListener =
object : IRelayClientListener {
/** A new message was received */
override fun onEvent(
override fun onIncomingMessage(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
msgStr: String,
msg: Message,
) {
current.increment(event.kind, subId, relay.url, event.countMemory())
if (msg is EventMessage) {
current.increment(msg.event.kind, msg.subId, relay.url, msg.event.countMemory())
}
}
}
@@ -99,6 +99,7 @@ class MainActivity : AppCompatActivity() {
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
debugState(this@MainActivity)
Amethyst.instance.relayReqStats?.printStats()
}
super.onPause()
@@ -44,7 +44,6 @@ import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UiSettingsFlow
@@ -99,6 +98,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -130,7 +130,6 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
@@ -204,20 +203,20 @@ class AccountViewModel(
} else {
MutableStateFlow(null)
}
}.flatMapLatest {
}.flatMapLatest { loadedFeedState ->
val flows =
it?.list?.mapNotNull { chat ->
loadedFeedState?.list?.mapNotNull { chat ->
(chat.event as? ChatroomKeyable)?.let { event ->
val room = event.chatroomKey(account.signer.pubKey)
account.settings.getLastReadFlow("Room/${room.hashCode()}").map {
(chat.event?.createdAt ?: 0) > it
account.settings.getLastReadFlow("Room/${room.hashCode()}").map { lastReadAt ->
(chat.event?.createdAt ?: 0) > lastReadAt
}
}
}
if (!flows.isNullOrEmpty()) {
combine(flows) {
it.any { it }
combine(flows) { newItems ->
newItems.any { it }
}
} else {
MutableStateFlow(false)
@@ -240,8 +239,8 @@ class AccountViewModel(
} else {
MutableStateFlow(null)
}
}.map {
it?.list?.firstOrNull { it.event != null && it.event !is GenericRepostEvent && it.event !is RepostEvent }?.createdAt()
}.map { loadedFeedState ->
loadedFeedState?.list?.firstOrNull { it.event != null && it.event !is GenericRepostEvent && it.event !is RepostEvent }?.createdAt()
},
) { lastRead, newestItemCreatedAt ->
emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead)
@@ -712,8 +711,6 @@ class AccountViewModel(
fun cachedDecrypt(note: Note): String? = account.cachedDecryptContent(note)
fun cachedDecrypt(event: Event?): String? = account.cachedDecryptContent(event)
fun decrypt(
note: Note,
onReady: (String) -> Unit,
@@ -725,17 +722,17 @@ class AccountViewModel(
viewModelScope.launch(Dispatchers.IO) {
try {
action()
} catch (e: SignerExceptions.ReadOnlyException) {
} catch (_: SignerExceptions.ReadOnlyException) {
toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
} catch (e: SignerExceptions.UnauthorizedDecryptionException) {
} catch (_: SignerExceptions.UnauthorizedDecryptionException) {
toastManager.toast(
R.string.unauthorized_exception,
R.string.unauthorized_exception_description,
)
} catch (e: SignerExceptions.SignerNotFoundException) {
} catch (_: SignerExceptions.SignerNotFoundException) {
toastManager.toast(
R.string.signer_not_found_exception,
R.string.signer_not_found_exception_description,
@@ -938,7 +935,7 @@ class AccountViewModel(
fun checkGetOrCreateUser(key: HexKey): User? = LocalCache.checkGetOrCreateUser(key)
override suspend fun getOrCreateUser(key: HexKey): User = LocalCache.getOrCreateUser(key)
override suspend fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex)
fun checkGetOrCreateUser(
key: HexKey,
@@ -951,7 +948,7 @@ class AccountViewModel(
fun checkGetOrCreateNote(key: HexKey): Note? = LocalCache.checkGetOrCreateNote(key)
override suspend fun getOrCreateNote(key: HexKey): Note = LocalCache.getOrCreateNote(key)
override suspend fun getOrCreateNote(hex: HexKey): Note = LocalCache.getOrCreateNote(hex)
fun checkGetOrCreateNote(
key: HexKey,
@@ -1004,13 +1001,6 @@ class AccountViewModel(
fun checkGetOrCreateEphemeralChatChannel(key: RoomId): EphemeralChatChannel? = LocalCache.getOrCreateEphemeralChannel(key)
fun checkGetOrCreateChannel(
key: HexKey,
onResult: (Channel?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreatePublicChatChannel(key)) }
}
fun getPublicChatChannelIfExists(hex: HexKey) = LocalCache.getPublicChatChannelIfExists(hex)
fun getEphemeralChatChannelIfExists(key: RoomId) = LocalCache.getEphemeralChatChannelIfExists(key)
@@ -1444,13 +1434,6 @@ class AccountViewModel(
onSent()
}
fun getRelayListFor(user: User): AdvertisedRelayListEvent? = (getRelayListNoteFor(user)?.event as? AdvertisedRelayListEvent?)
fun getRelayListNoteFor(user: User): AddressableNote? =
LocalCache.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(user.pubkeyHex),
)
fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag))
fun updateInteractiveStoryReadingState(
@@ -1657,6 +1640,7 @@ fun mockAccountViewModel(): AccountViewModel {
)
val client = EmptyNostrClient
val authenticator = EmptyIAuthStatus
val nwcFilters = NWCPaymentFilterAssembler(client)
@@ -1677,7 +1661,7 @@ fun mockAccountViewModel(): AccountViewModel {
settings = uiState,
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, scope),
).also {
mockedCache = it
}
@@ -1705,6 +1689,7 @@ fun mockVitorAccountViewModel(): AccountViewModel {
)
val client = EmptyNostrClient
val authenticator = EmptyIAuthStatus
val nwcFilters = NWCPaymentFilterAssembler(client)
@@ -1725,7 +1710,7 @@ fun mockVitorAccountViewModel(): AccountViewModel {
settings = uiState,
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, scope),
).also {
vitorCache = it
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -43,22 +45,25 @@ interface INostrClient {
fun isActive(): Boolean
/**
* Sends all current filters, events, etc to the relay.
* This is called every time the relay connects
* and when auth is successful
*/
fun renewFilters(relay: IRelayClient)
fun openReqSubscription(
subId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: IRequestListener? = null,
)
fun openCountSubscription(
fun queryCount(
subId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
)
fun close(subscriptionId: String)
fun sendIfExists(
event: Event,
connectedRelay: NormalizedRelayUrl,
)
fun close(subId: String)
fun send(
event: Event,
@@ -88,22 +93,20 @@ object EmptyNostrClient : INostrClient {
override fun isActive() = false
override fun renewFilters(relay: IRelayClient) { }
override fun openReqSubscription(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: IRequestListener?,
) { }
override fun openCountSubscription(
override fun queryCount(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) { }
override fun close(subscriptionId: String) { }
override fun sendIfExists(
event: Event,
connectedRelay: NormalizedRelayUrl,
) { }
override fun close(subId: String) { }
override fun send(
event: Event,
@@ -23,18 +23,20 @@ package com.vitorpamplona.quartz.nip01Core.relay.client
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutboxRepository
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolSubscriptionRepository
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolCounts
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutbox
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolRequests
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
@@ -77,17 +79,18 @@ class NostrClient(
private val scope: CoroutineScope,
) : INostrClient,
IRelayClientListener {
private val relayPool: RelayPool = RelayPool(this, ::buildRelay)
private val activeRequests: PoolSubscriptionRepository = PoolSubscriptionRepository()
private val activeCounts: PoolSubscriptionRepository = PoolSubscriptionRepository()
private val eventOutbox: PoolEventOutboxRepository = PoolEventOutboxRepository()
private val relayPool: RelayPool = RelayPool(websocketBuilder, this)
private val activeRequests: PoolRequests = PoolRequests()
private val activeCounts: PoolCounts = PoolCounts()
private val eventOutbox: PoolEventOutbox = PoolEventOutbox()
private var listeners = setOf<IRelayClientListener>()
// controls the state of the client in such a way that if it is active
// new filters will be sent to the relays and a potential reconnect can
// be triggered.
// STARTS active
// Default: STARTS active
private var isActive = true
/**
@@ -97,7 +100,7 @@ class NostrClient(
@OptIn(FlowPreview::class)
private val allRelays =
combine(
activeRequests.relays,
activeRequests.desiredRelays,
activeCounts.relays,
eventOutbox.relays,
) { reqs, counts, outbox ->
@@ -109,25 +112,9 @@ class NostrClient(
.stateIn(
scope,
SharingStarted.Eagerly,
activeRequests.relays.value + activeCounts.relays.value + eventOutbox.relays.value,
activeRequests.desiredRelays.value + activeCounts.relays.value + eventOutbox.relays.value,
)
fun buildRelay(relay: NormalizedRelayUrl): IRelayClient =
BasicRelayClient(
url = relay,
socketBuilder = websocketBuilder,
listener = relayPool,
stats = RelayStats.get(relay),
) { liveRelay ->
if (isActive) {
scope.launch(Dispatchers.Default) {
activeRequests.forEachSub(relay, liveRelay::sendRequest)
activeCounts.forEachSub(relay, liveRelay::sendCount)
eventOutbox.forEachUnsentEvent(relay, liveRelay::send)
}
}
}
// Reconnects all relays that may have disconnected
override fun connect() {
isActive = true
@@ -172,134 +159,42 @@ class NostrClient(
refreshConnection.tryEmit(Reconnect(onlyIfChanged, ignoreRetryDelays))
}
fun needsToResendRequest(
oldFilters: List<Filter>,
newFilters: List<Filter>,
): Boolean {
if (oldFilters.size != newFilters.size) return true
oldFilters.forEachIndexed { index, oldFilter ->
val newFilter = newFilters.getOrNull(index) ?: return true
return needsToResendRequest(oldFilter, newFilter)
}
return false
}
/**
* Checks if the filter has changed, with a special case for when the since changes due to new
* EOSE times.
*/
fun needsToResendRequest(
oldFilter: Filter,
newFilter: Filter,
): Boolean {
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
// fast check
if (oldFilter.authors?.size != newFilter.authors?.size ||
oldFilter.ids?.size != newFilter.ids?.size ||
oldFilter.tags?.size != newFilter.tags?.size ||
oldFilter.kinds?.size != newFilter.kinds?.size ||
oldFilter.limit != newFilter.limit ||
oldFilter.search?.length != newFilter.search?.length ||
oldFilter.until != newFilter.until
) {
return true
}
// deep check
if (oldFilter.ids != newFilter.ids ||
oldFilter.authors != newFilter.authors ||
oldFilter.tags != newFilter.tags ||
oldFilter.kinds != newFilter.kinds ||
oldFilter.search != newFilter.search
) {
return true
}
if (oldFilter.since != null) {
if (newFilter.since == null) {
// went was checking the future only and now wants everything
return true
} else if (oldFilter.since > newFilter.since) {
// went backwards in time, forces update
return true
}
}
return false
}
override fun openReqSubscription(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: IRequestListener?,
) {
val oldFilters = activeRequests.getSubscriptionFiltersOrNull(subId) ?: emptyMap()
activeRequests.addOrUpdate(subId, filters)
val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener)
if (isActive) {
val allRelays = filters.keys + oldFilters.keys
allRelays.forEach { relay ->
val oldFilters = oldFilters[relay]
val newFilters = filters[relay]
if (newFilters.isNullOrEmpty()) {
// some relays are not in this sub anymore. Stop their subscriptions
if (!oldFilters.isNullOrEmpty()) {
// only update if the old filters are not already closed.
relayPool.close(relay, subId)
}
} else if (oldFilters.isNullOrEmpty()) {
// new relays were added. Start a new sub in them
relayPool.sendRequest(relay, subId, newFilters)
} else if (needsToResendRequest(oldFilters, newFilters)) {
// filters were changed enough (not only an update in since) to warn a new update
relayPool.sendRequest(relay, subId, newFilters)
activeRequests.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
if (cmd is CloseCmd || cmd is AuthCmd) {
relayPool.sendIfConnected(relay, cmd)
} else {
// makes sure the relay wakes up if it was disconnected by the server
// upon connection, the relay will run the default Sync and update all
// filters, including this one.
relayPool.connectIfDisconnected(relay)
relayPool.sendOrConnectAndSync(relay, cmd)
}
}
// wakes up all the other relays
// makes sure the relay wakes up if it was disconnected by the server
// upon connection, the relay will run the default Sync and update all
// filters, including this one.
reconnect(true)
}
}
override fun openCountSubscription(
override fun queryCount(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {
val oldFilters = activeCounts.getSubscriptionFiltersOrNull(subId) ?: emptyMap()
activeCounts.addOrUpdate(subId, filters)
val relaysToUpdate = activeCounts.addOrUpdate(subId, filters)
if (isActive) {
val allRelays = filters.keys + oldFilters.keys
allRelays.forEach { relay ->
val oldFilters = oldFilters[relay]
val newFilters = filters[relay]
if (newFilters.isNullOrEmpty()) {
// some relays are not in this sub anymore. Stop their subscriptions
if (!oldFilters.isNullOrEmpty()) {
// only update if the old filters are not already closed.
relayPool.close(relay, subId)
}
} else if (oldFilters.isNullOrEmpty()) {
// new relays were added. Start a new sub in them
relayPool.sendCount(relay, subId, newFilters)
} else if (needsToResendRequest(oldFilters, newFilters)) {
// filters were changed enough (not only an update in since) to warn a new update
relayPool.sendCount(relay, subId, newFilters)
activeCounts.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
if (cmd is CloseCmd || cmd is AuthCmd) {
relayPool.sendIfConnected(relay, cmd)
} else {
// makes sure the relay wakes up if it was disconnected by the server
// upon connection, the relay will run the default Sync and update all
// filters, including this one.
relayPool.connectIfDisconnected(relay)
relayPool.sendOrConnectAndSync(relay, cmd)
}
}
@@ -308,123 +203,104 @@ class NostrClient(
}
}
override fun sendIfExists(
event: Event,
connectedRelay: NormalizedRelayUrl,
) {
if (isActive) {
relayPool.getRelay(connectedRelay)?.send(event)
// wakes up all the other relays
reconnect(true)
}
}
override fun send(
event: Event,
relayList: Set<NormalizedRelayUrl>,
) {
eventOutbox.markAsSending(event, relayList)
val relaysToUpdate = eventOutbox.markAsSending(event, relayList)
if (isActive) {
relayPool.send(event, relayList)
eventOutbox.sendToRelayIfChanged(event, relaysToUpdate, relayPool::sendOrConnectAndSync)
// wakes up all the other relays
reconnect(true)
}
}
override fun close(subscriptionId: String) {
activeRequests.remove(subscriptionId)
activeCounts.remove(subscriptionId)
relayPool.close(subscriptionId)
override fun close(subId: String) {
val relaysToUpdateReqs = activeRequests.remove(subId)
val relaysToUpdateCounts = activeCounts.remove(subId)
if (isActive) {
activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected)
activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected)
}
}
override fun onEvent(
override fun renewFilters(relay: IRelayClient) {
if (isActive) {
scope.launch(Dispatchers.Default) {
activeRequests.syncState(relay.url, relay::sendOrConnectAndSync)
activeCounts.syncState(relay.url, relay::sendOrConnectAndSync)
eventOutbox.syncState(relay.url, relay::sendOrConnectAndSync)
}
}
}
/**
* when a new connection starts, resets the state
*/
override fun onConnecting(relay: IRelayClient) {
activeRequests.onConnecting(relay.url)
listeners.forEach { it.onConnecting(relay) }
}
/**
* Relay just connected. Use this to send all
* filters and events you need.
*/
override fun onConnected(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
pingMillis: Int,
compressed: Boolean,
) {
listeners.forEach { it.onEvent(relay, subId, event, arrivalTime, afterEOSE) }
renewFilters(relay)
listeners.forEach { it.onConnected(relay, pingMillis, compressed) }
}
override fun onEOSE(
override fun onSent(
relay: IRelayClient,
subId: String,
arrivalTime: Long,
) {
listeners.forEach { it.onEOSE(relay, subId, arrivalTime) }
}
override fun onClosed(
relay: IRelayClient,
subId: String,
message: String,
) {
listeners.forEach { it.onClosed(relay, subId, message) }
}
override fun onRelayStateChange(
relay: IRelayClient,
type: RelayState,
) {
listeners.forEach { it.onRelayStateChange(relay, type) }
}
@OptIn(DelicateCoroutinesApi::class)
override fun onBeforeSend(
relay: IRelayClient,
event: Event,
) {
eventOutbox.newTry(event.id, relay.url)
listeners.forEach { it.onBeforeSend(relay, event) }
}
@OptIn(DelicateCoroutinesApi::class)
override fun onSendResponse(
relay: IRelayClient,
eventId: String,
success: Boolean,
message: String,
) {
eventOutbox.newResponse(eventId, relay.url, success, message)
listeners.forEach { it.onSendResponse(relay, eventId, success, message) }
}
@OptIn(DelicateCoroutinesApi::class)
override fun onAuth(
relay: IRelayClient,
challenge: String,
) {
listeners.forEach { it.onAuth(relay, challenge) }
}
@OptIn(DelicateCoroutinesApi::class)
override fun onNotify(
relay: IRelayClient,
description: String,
) {
listeners.forEach { it.onNotify(relay, description) }
}
@OptIn(DelicateCoroutinesApi::class)
override fun onSend(
relay: IRelayClient,
msg: String,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
listeners.forEach { it.onSend(relay, msg, success) }
if (success) {
activeRequests.onSent(relay.url, cmd)
activeCounts.onSent(relay.url, cmd)
eventOutbox.onSent(relay.url, cmd)
}
listeners.forEach { it.onSent(relay, cmdStr, cmd, success) }
}
@OptIn(DelicateCoroutinesApi::class)
override fun onError(
override fun onIncomingMessage(
relay: IRelayClient,
subId: String,
error: Error,
msgStr: String,
msg: Message,
) {
listeners.forEach { it.onError(relay, subId, error) }
activeRequests.onIncomingMessage(relay, msg)
activeCounts.onIncomingMessage(relay, msg)
eventOutbox.onIncomingMessage(relay.url, msg)
listeners.forEach { it.onIncomingMessage(relay, msgStr, msg) }
}
/**
* Relay just diconnected.
*/
override fun onDisconnected(relay: IRelayClient) {
activeRequests.onDisconnected(relay.url)
listeners.forEach { it.onDisconnected(relay) }
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
activeRequests.onCannotConnect(relay.url, errorMessage)
activeCounts.onCannotConnect(relay.url, errorMessage)
eventOutbox.onCannotConnect(relay.url, errorMessage)
listeners.forEach { it.onCannotConnect(relay, errorMessage) }
}
override fun subscribe(listener: IRelayClientListener) {
@@ -21,8 +21,7 @@
package com.vitorpamplona.quartz.nip01Core.relay.client
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -31,35 +30,31 @@ class NostrClientSubscription(
val client: INostrClient,
val filter: () -> Map<NormalizedRelayUrl, List<Filter>> = { emptyMap() },
val onEvent: (event: Event) -> Unit = {},
) : IRelayClientListener {
) : IRequestListener {
private val subId = RandomInstance.randomChars(10)
override fun onEvent(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (this.subId == subId) {
onEvent(event)
}
onEvent(event)
}
/**
* Creates or Updates the filter with relays. This method should be called
* everytime the filter changes.
*/
fun updateFilter() = client.openReqSubscription(subId, filter())
fun updateFilter() = client.openReqSubscription(subId, filter(), this)
fun closeSubscription() = client.close(subId)
fun destroy() {
client.unsubscribe(this)
closeSubscription()
}
init {
client.subscribe(this)
updateFilter()
}
}
@@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.utils.Log
/**
@@ -35,14 +37,14 @@ class EventCollector(
) {
private val clientListener =
object : IRelayClientListener {
override fun onEvent(
override fun onIncomingMessage(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
msgStr: String,
msg: Message,
) {
onEvent(event, relay)
if (msg is EventMessage) {
onEvent(msg.event, relay)
}
}
}
@@ -23,8 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
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.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -53,36 +54,37 @@ suspend fun INostrClient.sendAndWaitForResponse(
val subscription =
object : IRelayClientListener {
override fun onError(
override fun onCannotConnect(
relay: IRelayClient,
subId: String,
error: Error,
errorMessage: String,
) {
if (relay.url in relayList) {
resultChannel.trySend(Result(relay.url, false))
Log.d("sendAndWaitForResponse", "onError Error from relay ${relay.url} error: $error")
Log.d("sendAndWaitForResponse", "Error from relay ${relay.url}: $errorMessage")
}
}
override fun onRelayStateChange(
relay: IRelayClient,
type: RelayState,
) {
if (type == RelayState.DISCONNECTED && relay.url in relayList) {
override fun onDisconnected(relay: IRelayClient) {
if (relay.url in relayList) {
resultChannel.trySend(Result(relay.url, false))
Log.d("sendAndWaitForResponse", "onRelayStateChange ${type.name} from relay ${relay.url}")
Log.d("sendAndWaitForResponse", "Disconnected from relay ${relay.url}")
}
}
override fun onSendResponse(
override fun onIncomingMessage(
relay: IRelayClient,
eventId: String,
success: Boolean,
message: String,
msgStr: String,
msg: Message,
) {
if (eventId == event.id) {
resultChannel.trySend(Result(relay.url, success))
Log.d("sendAndWaitForResponse", "onSendResponse Received response for $eventId from relay ${relay.url} message $message success $success")
super.onIncomingMessage(relay, msgStr, msg)
when (msg) {
is OkMessage -> {
if (msg.eventId == event.id) {
resultChannel.trySend(Result(relay.url, msg.success))
Log.d("sendAndWaitForResponse", "onSendResponse Received response for ${msg.eventId} from relay ${relay.url} message ${msg.message} success ${msg.success}")
}
}
}
}
}
@@ -22,8 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -71,23 +70,18 @@ suspend fun INostrClient.downloadFirstEvent(
val resultChannel = Channel<Event>(UNLIMITED)
val listener =
object : IRelayClientListener {
object : IRequestListener {
override fun onEvent(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (subId == subscriptionId) {
resultChannel.trySend(event)
}
resultChannel.trySend(event)
}
}
subscribe(listener)
openReqSubscription(subscriptionId, filters)
openReqSubscription(subscriptionId, filters, listener)
val result =
withTimeoutOrNull(30000) {
@@ -95,7 +89,7 @@ suspend fun INostrClient.downloadFirstEvent(
}
close(subscriptionId)
unsubscribe(listener)
resultChannel.close()
return result
@@ -1,57 +0,0 @@
/**
* 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.accessories
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class RelayAuthenticator(
val client: INostrClient,
val scope: CoroutineScope,
val authenticate: suspend (challenge: String, relay: IRelayClient) -> Unit,
) {
private val clientListener =
object : IRelayClientListener {
override fun onAuth(
relay: IRelayClient,
challenge: String,
) {
scope.launch {
authenticate(challenge, relay)
}
}
}
init {
Log.d("RelayAuthenticator", "Init, Subscribe")
client.subscribe(clientListener)
}
fun destroy() {
// makes sure to run
Log.d("RelayAuthenticator", "Destroy, Unsubscribe")
client.unsubscribe(clientListener)
}
}
@@ -24,6 +24,8 @@ 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.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.utils.Log
/**
@@ -35,14 +37,13 @@ class RelayInsertConfirmationCollector(
) {
private val clientListener =
object : IRelayClientListener {
override fun onSendResponse(
override fun onIncomingMessage(
relay: IRelayClient,
eventId: String,
success: Boolean,
message: String,
msgStr: String,
msg: Message,
) {
if (success) {
onRelayReceived(eventId, relay)
if (msg is OkMessage && msg.success) {
onRelayReceived(msg.eventId, relay)
}
}
}
@@ -20,10 +20,20 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.utils.Log
/**
@@ -31,27 +41,68 @@ import com.vitorpamplona.quartz.utils.Log
*/
class RelayLogger(
val client: INostrClient,
val notify: (message: String, relay: IRelayClient) -> Unit,
val debugSending: Boolean = false,
val debugReceiving: Boolean = false,
) {
fun logTag(url: NormalizedRelayUrl) = "Relay ${url.displayUrl()}"
private val clientListener =
object : IRelayClientListener {
/** A new message was received */
override fun onEvent(
override fun onIncomingMessage(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
msgStr: String,
msg: Message,
) {
Log.d("Relay", "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}")
val logTag = logTag(relay.url)
when (msg) {
is EventMessage -> if (debugReceiving) Log.d(logTag, "Received: $msgStr")
is EoseMessage -> if (debugReceiving) Log.d(logTag, "EOSE: ${msg.subId}")
is NoticeMessage -> Log.w(logTag, "Notice: ${msg.message}")
is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}")
is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}")
is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}")
is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}")
}
}
override fun onSend(
override fun onSent(
relay: IRelayClient,
msg: String,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
Log.d("Relay", "Relay send ${relay.url} (${msg.length} chars) $msg")
if (success) {
if (debugSending) {
Log.d(logTag(relay.url), "Sent (${cmdStr.length} chars): $cmdStr")
}
} else {
Log.e(logTag(relay.url), "Failure sending (${cmdStr.length} chars): $cmdStr")
}
}
override fun onConnecting(relay: IRelayClient) {
Log.d(logTag(relay.url), "Connecting...")
}
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
Log.d(logTag(relay.url), "OnOpen (ping: ${pingMillis}ms${if (compressed) ", using compression" else ""})")
}
override fun onDisconnected(relay: IRelayClient) {
Log.d(logTag(relay.url), "Disconnected")
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
super.onCannotConnect(relay, errorMessage)
Log.e(logTag(relay.url), errorMessage)
}
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
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.toClient.NotifyMessage
import com.vitorpamplona.quartz.utils.Log
/**
@@ -33,16 +35,20 @@ class RelayNotifier(
val notify: (message: String, relay: IRelayClient) -> Unit,
) {
companion object {
val TAG = "RelayNotifier"
const val TAG = "RelayNotifier"
}
private val clientListener =
object : IRelayClientListener {
override fun onNotify(
override fun onIncomingMessage(
relay: IRelayClient,
message: String,
msgStr: String,
msg: Message,
) {
notify(message, relay)
super.onIncomingMessage(relay, msgStr, msg)
if (msg is NotifyMessage) {
notify(msg.message, relay)
}
}
}
@@ -0,0 +1,80 @@
/**
* 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.auth
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
class RelayAuthStatus {
// Keeps track of auth responses to update the relay with all filters
// after the authentication happen
private val authResponseWatcher: MutableMap<HexKey, AuthEventReceiptStatus> = mutableMapOf()
// Avoids sending multiple replies for each auth.
private val uniqueAuthChallengesSent: MutableSet<ChallengePair> = mutableSetOf()
enum class AuthEventReceiptStatus {
AUTHENTICATING,
AUTHENTICATED,
NOT_AUTHENTICATED,
}
data class ChallengePair(
val user: HexKey,
val challenge: String,
)
fun saveAuthSubmission(authEvent: RelayAuthEvent): Boolean {
val challenge = authEvent.challenge()
if (challenge == null) return false
val challengePair = ChallengePair(authEvent.pubKey, challenge)
// only send replies to new challenges to avoid infinite loop:
return if (challengePair !in uniqueAuthChallengesSent) {
authResponseWatcher[authEvent.id] = AuthEventReceiptStatus.AUTHENTICATING
uniqueAuthChallengesSent.add(challengePair)
true
} else {
false
}
}
fun checkAuthResults(
eventId: HexKey,
success: Boolean,
): Boolean =
if (authResponseWatcher.containsKey(eventId)) {
val wasAlreadyAuthenticated = authResponseWatcher[eventId]
if (success) {
authResponseWatcher.put(eventId, AuthEventReceiptStatus.AUTHENTICATED)
} else {
authResponseWatcher.put(eventId, AuthEventReceiptStatus.NOT_AUTHENTICATED)
}
wasAlreadyAuthenticated != AuthEventReceiptStatus.AUTHENTICATED && success
} else {
false
}
fun hasFinishedAllAuths() = authResponseWatcher.all { it.value != AuthEventReceiptStatus.AUTHENTICATING }
}
@@ -0,0 +1,111 @@
/**
* 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.auth
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
interface IAuthStatus {
fun hasFinishedAuthentication(relay: NormalizedRelayUrl): Boolean
}
object EmptyIAuthStatus : IAuthStatus {
override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = true
}
class RelayAuthenticator(
val client: INostrClient,
val scope: CoroutineScope,
val signWithAllLoggedInUsers: suspend (EventTemplate<RelayAuthEvent>) -> List<RelayAuthEvent>,
) : IAuthStatus {
private val authStatus = mutableMapOf<NormalizedRelayUrl, RelayAuthStatus>()
private val clientListener =
object : IRelayClientListener {
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
when (msg) {
is AuthMessage -> authenticate(relay, msg)
is OkMessage -> checkAuthResults(relay, msg)
}
}
override fun onConnecting(relay: IRelayClient) {
authStatus.put(relay.url, RelayAuthStatus())
}
override fun onDisconnected(relay: IRelayClient) {
authStatus.remove(relay.url)
}
}
private fun authenticate(
relay: IRelayClient,
msg: AuthMessage,
) {
scope.launch {
val ev = RelayAuthEvent.build(relay.url, msg.challenge)
signWithAllLoggedInUsers(ev).forEach { authEvent ->
// only send replies to new challenges to avoid infinite loop:
if (authStatus[relay.url]?.saveAuthSubmission(authEvent) == true) {
relay.sendIfConnected(AuthCmd(authEvent))
}
}
}
}
private fun checkAuthResults(
relay: IRelayClient,
msg: OkMessage,
) {
// if this is the OK of an auth event, renew all subscriptions and resend all outgoing events.
if (authStatus[relay.url]?.checkAuthResults(msg.eventId, msg.success) == true) {
client.renewFilters(relay)
}
}
override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus[relay]?.hasFinishedAllAuths() != false
init {
Log.d("RelayAuthenticator", "Init, Subscribe")
client.subscribe(clientListener)
}
fun destroy() {
// makes sure to run
Log.d("RelayAuthenticator", "Destroy, Unsubscribe")
client.unsubscribe(clientListener)
}
}
@@ -0,0 +1,69 @@
/**
* 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.counts
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
class CountQueryState<T> {
// Logs the state of each channel to:
// 1. inform when an event is received as live
// 2. to block COUNTs being sent before finished (receiving an COUNT or Closed)
//
// If 2 happens, the relay might send multiple COUNTs in sequence
// for the same sub and we won't know which COUNT was it for.
private val queryStates = mutableMapOf<T, CountQueryStatus>()
private val filterStates = mutableMapOf<T, List<Filter>>()
fun currentFilters() = filterStates
fun currentFilters(reference: T) = filterStates[reference]
fun currentState(reference: T) = queryStates[reference]
fun onCountReply(reference: T) {
queryStates.put(reference, CountQueryStatus.RECEIVED)
}
fun onClosed(reference: T) {
queryStates.put(reference, CountQueryStatus.CLOSED)
}
fun onQuery(
reference: T,
filters: List<Filter>,
) {
queryStates.put(reference, CountQueryStatus.SENT)
filterStates.put(reference, filters)
}
fun onCloseQuery(reference: T) {
queryStates.put(reference, CountQueryStatus.CLOSED)
}
fun connecting(reference: T) {
queryStates.remove(reference)
filterStates.remove(reference)
}
fun disconnected(reference: T) {
queryStates.remove(reference)
}
}
@@ -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.counts
enum class CountQueryStatus {
SENT,
RECEIVED,
CLOSED,
}
@@ -0,0 +1,86 @@
/**
* 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.counts
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
class RelayActiveCountStates(
val client: INostrClient,
) {
private var queryStates = mutableMapOf<NormalizedRelayUrl, CountQueryState<String>>()
fun subGetOrCreate(relay: NormalizedRelayUrl): CountQueryState<String> = queryStates[relay] ?: CountQueryState<String>().also { queryStates.put(relay, it) }
private val clientListener =
object : IRelayClientListener {
override fun onConnecting(relay: IRelayClient) {
queryStates.put(relay.url, CountQueryState())
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
when (msg) {
is CountMessage -> subGetOrCreate(relay.url).onCountReply(msg.queryId)
is ClosedMessage -> subGetOrCreate(relay.url).onClosed(msg.subId)
}
}
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
when (cmd) {
is ReqCmd -> subGetOrCreate(relay.url).onQuery(cmd.subId, cmd.filters)
is CloseCmd -> subGetOrCreate(relay.url).onCloseQuery(cmd.subId)
}
}
override fun onDisconnected(relay: IRelayClient) {
queryStates.remove(relay.url)
}
}
init {
Log.d("RelaySubStateMachine", "Init, Subscribe")
client.subscribe(clientListener)
}
fun destroy() {
// makes sure to run
Log.d("RelaySubStateMachine", "Destroy, Unsubscribe")
client.unsubscribe(clientListener)
}
}
@@ -20,118 +20,55 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.listeners
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
enum class RelayState {
// Websocket connected
CONNECTED,
// Websocket disconnecting
DISCONNECTING,
// Websocket disconnected
DISCONNECTED,
}
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
interface IRelayClientListener {
fun onConnecting(relay: IRelayClient) {}
/**
* New Event arrives from the relay.
* Relay just connected. Use this to send all
* filters and events you need.
*/
fun onEvent(
fun onConnected(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
pingMillis: Int,
compressed: Boolean,
) {}
/**
* New EOSE command arrives for a subscription
* Triggers after the event has been sent.
* Success means that the event was successfully sent, not
* that it received receipt confirmation from the relay
*/
fun onEOSE(
fun onSent(
relay: IRelayClient,
subId: String,
arrivalTime: Long,
cmdStr: String,
cmd: Command,
success: Boolean,
) {}
/**
* New error
*/
fun onError(
fun onIncomingMessage(
relay: IRelayClient,
subId: String,
error: Error,
msgStr: String,
msg: Message,
) {}
/**
* Relay is requesting authentication with the given challenge.
* Relay just diconnected.
*/
fun onAuth(
relay: IRelayClient,
challenge: String,
) {}
fun onDisconnected(relay: IRelayClient) {}
/**
* called after the relay receives the OK from an Auth message
* The url is invalid or the server is unreachable.
*/
fun onAuthed(
fun onCannotConnect(
relay: IRelayClient,
eventId: String,
success: Boolean,
message: String,
) {}
/**
* RelayState changes
*/
fun onRelayStateChange(
relay: IRelayClient,
type: RelayState,
) {}
/**
* NOTIFY command has arrived.
*/
fun onNotify(
relay: IRelayClient,
description: String,
) {}
/**
* Relay closed the subscription
*/
fun onClosed(
relay: IRelayClient,
subId: String,
message: String,
) {}
/**
* Triggers this before sending the event.
*/
fun onBeforeSend(
relay: IRelayClient,
event: Event,
) {}
/**
* Triggers after the event has been sent.
*/
fun onSend(
relay: IRelayClient,
msg: String,
success: Boolean,
) {}
/**
* Relay accepted or rejected the event
*/
fun onSendResponse(
relay: IRelayClient,
eventId: String,
success: Boolean,
message: String,
errorMessage: String,
) {}
}
@@ -20,75 +20,50 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.listeners
import com.vitorpamplona.quartz.nip01Core.core.Event
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
open class RedirectRelayClientListener(
val listener: IRelayClientListener,
) : IRelayClientListener {
override fun onEvent(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
) = listener.onEvent(relay, subId, event, arrivalTime, afterEOSE)
override fun onConnecting(relay: IRelayClient) {
listener.onConnecting(relay)
}
override fun onEOSE(
override fun onConnected(
relay: IRelayClient,
subId: String,
arrivalTime: Long,
) = listener.onEOSE(relay, subId, arrivalTime)
pingMillis: Int,
compressed: Boolean,
) {
listener.onConnected(relay, pingMillis, compressed)
}
override fun onError(
override fun onSent(
relay: IRelayClient,
subId: String,
error: Error,
) = listener.onError(relay, subId, error)
override fun onAuth(
relay: IRelayClient,
challenge: String,
) = listener.onAuth(relay, challenge)
override fun onAuthed(
relay: IRelayClient,
eventId: String,
cmdStr: String,
cmd: Command,
success: Boolean,
message: String,
) = listener.onAuthed(relay, eventId, success, message)
) {
listener.onSent(relay, cmdStr, cmd, success)
}
override fun onRelayStateChange(
override fun onIncomingMessage(
relay: IRelayClient,
type: RelayState,
) = listener.onRelayStateChange(relay, type)
msgStr: String,
msg: Message,
) {
listener.onIncomingMessage(relay, msgStr, msg)
}
override fun onNotify(
relay: IRelayClient,
description: String,
) = listener.onNotify(relay, description)
override fun onDisconnected(relay: IRelayClient) {
listener.onDisconnected(relay)
}
override fun onClosed(
override fun onCannotConnect(
relay: IRelayClient,
subId: String,
message: String,
) = listener.onClosed(relay, subId, message)
override fun onBeforeSend(
relay: IRelayClient,
event: Event,
) = listener.onBeforeSend(relay, event)
override fun onSend(
relay: IRelayClient,
msg: String,
success: Boolean,
) = listener.onSend(relay, msg, success)
override fun onSendResponse(
relay: IRelayClient,
eventId: String,
success: Boolean,
message: String,
) = listener.onSendResponse(relay, eventId, success, message)
errorMessage: String,
) {
listener.onCannotConnect(relay, errorMessage)
}
}
@@ -0,0 +1,83 @@
/**
* 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.pool
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
object FiltersChanged {
fun needsToResendRequest(
oldFilters: List<Filter>,
newFilters: List<Filter>,
): Boolean {
if (oldFilters.size != newFilters.size) return true
oldFilters.forEachIndexed { index, oldFilter ->
val newFilter = newFilters.getOrNull(index) ?: return true
return needsToResendRequest(oldFilter, newFilter)
}
return false
}
/**
* Checks if the filter has changed, with a special case for when the since changes due to new
* EOSE times.
*/
fun needsToResendRequest(
oldFilter: Filter,
newFilter: Filter,
): Boolean {
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
// fast check
if (oldFilter.authors?.size != newFilter.authors?.size ||
oldFilter.ids?.size != newFilter.ids?.size ||
oldFilter.tags?.size != newFilter.tags?.size ||
oldFilter.kinds?.size != newFilter.kinds?.size ||
oldFilter.limit != newFilter.limit ||
oldFilter.search?.length != newFilter.search?.length ||
oldFilter.until != newFilter.until
) {
return true
}
// deep check
if (oldFilter.ids != newFilter.ids ||
oldFilter.authors != newFilter.authors ||
oldFilter.tags != newFilter.tags ||
oldFilter.kinds != newFilter.kinds ||
oldFilter.search != newFilter.search
) {
return true
}
if (oldFilter.since != null) {
if (newFilter.since == null) {
// went was checking the future only and now wants everything
return true
} else if (oldFilter.since > newFilter.since) {
// went backwards in time, forces update
return true
}
}
return false
}
}
@@ -0,0 +1,216 @@
/**
* 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.pool
import com.vitorpamplona.quartz.nip01Core.relay.client.counts.CountQueryState
import com.vitorpamplona.quartz.nip01Core.relay.client.counts.CountQueryStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.collections.plus
class PoolCounts {
private var queries = LargeCache<String, Map<NormalizedRelayUrl, List<Filter>>>()
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
private val relayState = LargeCache<String, CountQueryState<NormalizedRelayUrl>>()
fun subState(subId: String): CountQueryState<NormalizedRelayUrl> = relayState.getOrCreate(subId) { CountQueryState() }
private fun updateRelays() {
val myRelays = mutableSetOf<NormalizedRelayUrl>()
queries.forEach { queryId, perRelayFilters ->
myRelays.addAll(perRelayFilters.keys)
}
if (relays.value != myRelays) {
relays.tryEmit(myRelays)
}
}
fun activeFiltersFor(url: NormalizedRelayUrl): Map<String, List<Filter>> {
val myRelays = mutableMapOf<String, List<Filter>>()
queries.forEach { sub, perRelayFilters ->
val filters = perRelayFilters.get(url)
if (filters != null) {
myRelays.put(sub, filters)
}
}
return myRelays
}
/**
* Adds a new query to the pool, and returns the relays that need to be updated.
*/
fun addOrUpdate(
subscriptionId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
): Set<NormalizedRelayUrl> {
val oldRelays = queries.get(subscriptionId)?.keys ?: emptySet()
queries.put(subscriptionId, filters)
updateRelays()
return oldRelays + filters.keys
}
/**
* Removes the query from the pool, and returns the relays that need to be updated.
*/
fun remove(queryId: String): Set<NormalizedRelayUrl> =
if (queries.containsKey(queryId)) {
val oldRelays = queries.get(queryId)?.keys ?: emptySet()
queries.remove(queryId)
updateRelays()
oldRelays
} else {
emptySet()
}
fun getSubscriptionFiltersOrNull(queryId: String): Map<NormalizedRelayUrl, List<Filter>>? = queries.get(queryId)
// --------------------------
// State management functions
// --------------------------
fun onConnecting(url: NormalizedRelayUrl) {
relayState.forEach { subId, state ->
state.connecting(url)
}
}
suspend fun syncState(
relay: NormalizedRelayUrl,
sync: (Command) -> Unit,
) {
queries.forEach { subId, filters ->
val filters = filters[relay]
if (!filters.isNullOrEmpty()) {
sync(CountCmd(subId, filters))
} else {
null
}
}
}
fun onSent(
relay: NormalizedRelayUrl,
cmd: Command,
) {
when (cmd) {
is CountCmd -> subState(cmd.queryId).onQuery(relay, cmd.filters)
is CloseCmd -> subState(cmd.subId).onCloseQuery(relay)
}
}
fun onIncomingMessage(
relay: IRelayClient,
msg: Message,
) {
when (msg) {
is CountMessage -> {
subState(msg.queryId).onCountReply(relay.url)
sendToRelayIfChanged(msg.queryId, relay.url) { cmd ->
relay.sendOrConnectAndSync(cmd)
}
}
is ClosedMessage -> {
subState(msg.subId).onClosed(relay.url)
sendToRelayIfChanged(msg.subId, relay.url) { cmd ->
relay.sendOrConnectAndSync(cmd)
}
}
}
}
fun onDisconnected(url: NormalizedRelayUrl) {
relayState.forEach { subId, state ->
state.disconnected(url)
}
}
fun sendToRelayIfChanged(
queryId: String,
relaysToUpdate: Set<NormalizedRelayUrl>,
sync: (NormalizedRelayUrl, Command) -> Unit,
) {
relaysToUpdate.forEach { relay ->
val currentState = relayState.get(queryId)?.currentState(relay)
if (currentState == CountQueryStatus.SENT) {
// sending multiple REQs triggers multiple EOSEs back and we then don't know which
// one is which.
} else {
sendToRelayIfChanged(queryId, relay) { cmd ->
sync(relay, cmd)
}
}
}
}
fun sendToRelayIfChanged(
queryId: String,
relay: NormalizedRelayUrl,
sync: (Command) -> Unit,
) {
val oldFilters = relayState.get(queryId)?.currentFilters(relay)
val newFilters = queries.get(queryId)?.get(relay)
sendToRelayIfChanged(queryId, oldFilters, newFilters, sync)
}
fun sendToRelayIfChanged(
queryId: String,
oldFilters: List<Filter>?,
newFilters: List<Filter>?,
sync: (Command) -> Unit,
) {
if (newFilters.isNullOrEmpty()) {
// some relays are not in this sub anymore. Stop their subscriptions
if (!oldFilters.isNullOrEmpty()) {
// only update if the old filters are not already closed.
sync(CloseCmd(queryId))
}
} else if (oldFilters.isNullOrEmpty()) {
// new relays were added. Start a new sub in them
sync(CountCmd(queryId, newFilters))
} else if (FiltersChanged.needsToResendRequest(oldFilters, newFilters)) {
// filters were changed enough (not only an update in since) to warn a new update
sync(CountCmd(queryId, newFilters))
} else {
// They are the same don't do anything.
}
}
/**
* If cannot connect, closes subs
*/
fun onCannotConnect(
relay: NormalizedRelayUrl,
errorMessage: String,
) {
// mark as impossible to get count from this relay
}
}
@@ -21,70 +21,131 @@
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
class PoolEventOutbox(
val event: Event,
var relays: Set<NormalizedRelayUrl>,
) {
private val tries = mutableMapOf<NormalizedRelayUrl, Tries>()
class PoolEventOutbox {
private var eventOutbox = mapOf<HexKey, PoolEventOutboxState>()
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
fun updateRelays(newRelays: Set<NormalizedRelayUrl>) {
relays = newRelays
}
fun updateRelays() {
val myRelays = mutableSetOf<NormalizedRelayUrl>()
eventOutbox.values.forEach {
myRelays.addAll(it.relaysLeft())
}
fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false
fun isDone() = relays.all { isDone(it) }
fun relaysLeft(): Set<NormalizedRelayUrl> = relays.filterTo(mutableSetOf()) { !isDone(it) }
fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url)
fun forEachUnsentEvent(
url: NormalizedRelayUrl,
run: (url: Event) -> Unit,
) = if (isSupposedToGo(url)) run(event) else null
fun newTry(url: NormalizedRelayUrl) {
val currentTries = tries[url]
if (currentTries != null) {
currentTries.tries.add(TimeUtils.now())
} else {
tries.put(url, Tries(mutableListOf(TimeUtils.now())))
if (relays.value != myRelays) {
relays.tryEmit(myRelays)
}
}
fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set<HexKey> {
val myEvents = mutableSetOf<HexKey>()
eventOutbox.forEach { (eventId, outboxCache) ->
if (url in outboxCache.relays) {
myEvents.add(eventId)
}
}
return myEvents
}
fun markAsSending(
event: Event,
relays: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> {
val currentOutbox = eventOutbox[event.id]
if (currentOutbox == null) {
eventOutbox = eventOutbox + Pair(event.id, PoolEventOutboxState(event, relays))
} else {
currentOutbox.updateRelays(relays)
}
updateRelays()
return eventOutbox[event.id]?.remainingRelays() ?: emptySet()
}
fun newTry(
id: HexKey,
url: NormalizedRelayUrl,
) {
eventOutbox[id]?.newTry(url)
}
fun newResponse(
id: HexKey,
url: NormalizedRelayUrl,
success: Boolean,
message: String,
) {
val currentTries = tries[url]
if (currentTries != null) {
currentTries.responses.add(Response(success, message))
} else {
tries.put(
url,
Tries(
mutableListOf(TimeUtils.now() - 1),
mutableListOf(Response(success, message)),
),
)
val waiting = eventOutbox[id]
if (waiting != null) {
waiting.newResponse(url, success, message)
clear()
}
}
// Tries 3 times
class Tries(
val tries: MutableList<Long> = mutableListOf(),
val responses: MutableList<Response> = mutableListOf(),
) {
fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3
fun clear() {
eventOutbox = eventOutbox.filter { !it.value.isDone() }
updateRelays()
}
class Response(
val success: Boolean,
val message: String,
)
// --------------------------
// State management functions
// --------------------------
suspend fun syncState(
relay: NormalizedRelayUrl,
sync: (Command) -> Unit,
) {
eventOutbox.forEach {
it.value.forEachUnsentEvent(relay) {
sync(EventCmd(it))
}
}
}
fun onIncomingMessage(
relay: NormalizedRelayUrl,
msg: Message,
) {
when (msg) {
is OkMessage -> newResponse(msg.eventId, relay, msg.success, msg.message)
}
}
fun onSent(
relay: NormalizedRelayUrl,
cmd: Command,
) {
if (cmd is EventCmd) {
newTry(cmd.event.id, relay)
}
}
fun sendToRelayIfChanged(
event: Event,
relaysToUpdate: Set<NormalizedRelayUrl>,
sync: (NormalizedRelayUrl, Command) -> Unit,
) {
relaysToUpdate.forEach { relay ->
sync(relay, EventCmd(event))
}
}
/**
* If cannot connect, closes subs
*/
fun onCannotConnect(
relay: NormalizedRelayUrl,
errorMessage: String,
) {
eventOutbox.forEach {
if (relay in it.value.relays) {
newResponse(it.key, relay, false, errorMessage)
}
}
}
}
@@ -1,99 +0,0 @@
/**
* 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.pool
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
class PoolEventOutboxRepository {
private var eventOutbox = mapOf<HexKey, PoolEventOutbox>()
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
fun updateRelays() {
val myRelays = mutableSetOf<NormalizedRelayUrl>()
eventOutbox.values.forEach {
myRelays.addAll(it.relaysLeft())
}
if (relays.value != myRelays) {
relays.tryEmit(myRelays)
}
}
fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set<HexKey> {
val myEvents = mutableSetOf<HexKey>()
eventOutbox.forEach { (eventId, outboxCache) ->
if (url in outboxCache.relays) {
myEvents.add(eventId)
}
}
return myEvents
}
fun markAsSending(
event: Event,
relays: Set<NormalizedRelayUrl>,
) {
val currentOutbox = eventOutbox[event.id]
if (currentOutbox == null) {
eventOutbox = eventOutbox + Pair(event.id, PoolEventOutbox(event, relays))
} else {
currentOutbox.updateRelays(relays)
}
updateRelays()
}
fun newTry(
id: HexKey,
url: NormalizedRelayUrl,
) {
eventOutbox[id]?.newTry(url)
}
fun newResponse(
id: HexKey,
url: NormalizedRelayUrl,
success: Boolean,
message: String,
) {
val waiting = eventOutbox[id]
if (waiting != null) {
waiting.newResponse(url, success, message)
clear()
}
}
fun clear() {
eventOutbox = eventOutbox.filter { !it.value.isDone() }
updateRelays()
}
fun forEachUnsentEvent(
url: NormalizedRelayUrl,
run: (url: Event) -> Unit,
) {
eventOutbox.forEach {
it.value.forEachUnsentEvent(url, run)
}
}
}
@@ -0,0 +1,92 @@
/**
* 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.pool
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
class PoolEventOutboxState(
val event: Event,
var relays: Set<NormalizedRelayUrl>,
) {
private val tries = mutableMapOf<NormalizedRelayUrl, Tries>()
fun updateRelays(newRelays: Set<NormalizedRelayUrl>) {
relays = newRelays
}
fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false
fun isDone() = relays.all { isDone(it) }
fun relaysLeft(): Set<NormalizedRelayUrl> = relays.filterTo(mutableSetOf()) { !isDone(it) }
fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url)
fun forEachUnsentEvent(
url: NormalizedRelayUrl,
run: (url: Event) -> Unit,
) = if (isSupposedToGo(url)) run(event) else null
fun remainingRelays() = relays.filterTo(mutableSetOf(), ::isSupposedToGo)
fun newTry(url: NormalizedRelayUrl) {
val currentTries = tries[url]
if (currentTries != null) {
currentTries.tries.add(TimeUtils.now())
} else {
tries.put(url, Tries(mutableListOf(TimeUtils.now())))
}
}
fun newResponse(
url: NormalizedRelayUrl,
success: Boolean,
message: String,
) {
val currentTries = tries[url]
if (currentTries != null) {
currentTries.responses.add(Response(success, message))
} else {
tries.put(
url,
Tries(
mutableListOf(TimeUtils.now() - 1),
mutableListOf(Response(success, message)),
),
)
}
}
// Tries 3 times
class Tries(
val tries: MutableList<Long> = mutableListOf(),
val responses: MutableList<Response> = mutableListOf(),
) {
fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3
}
class Response(
val success: Boolean,
val message: String,
)
}
@@ -0,0 +1,278 @@
/**
* 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.pool
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.ReqSubStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.RequestSubscriptionState
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
class PoolRequests {
/**
* Desired subs and listeners are changed immediately when the local code requests
*/
private val desiredSubs = LargeCache<String, Map<NormalizedRelayUrl, List<Filter>>>()
private val desiredSubListeners = LargeCache<String, IRequestListener>()
val desiredRelays = MutableStateFlow(setOf<NormalizedRelayUrl>())
/**
* relay states are kept and only removed after everything is processed.
*/
private val relayState = LargeCache<String, RequestSubscriptionState<NormalizedRelayUrl>>()
fun subState(subId: String): RequestSubscriptionState<NormalizedRelayUrl> = relayState.getOrCreate(subId) { RequestSubscriptionState() }
private fun updateRelays() {
val myRelays = mutableSetOf<NormalizedRelayUrl>()
desiredSubs.forEach { sub, perRelayFilters ->
myRelays.addAll(perRelayFilters.keys)
}
if (desiredRelays.value != myRelays) {
desiredRelays.tryEmit(myRelays)
}
}
fun activeFiltersFor(url: NormalizedRelayUrl): Map<String, List<Filter>> {
val myRelays = mutableMapOf<String, List<Filter>>()
desiredSubs.forEach { sub, perRelayFilters ->
val filters = perRelayFilters[url]
if (filters != null) {
myRelays.put(sub, filters)
}
}
return myRelays
}
/**
* Adds a new filter to the pool, and returns the relays that need to be updated.
*/
fun addOrUpdate(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: IRequestListener?,
): Set<NormalizedRelayUrl> {
// saves old relays
val oldRelays = desiredSubs.get(subId)?.keys ?: emptySet()
// update filters
desiredSubs.put(subId, filters)
// update listener
if (listener != null) {
desiredSubListeners.put(subId, listener)
}
// create sub state
subState(subId)
// update relays for pool
updateRelays()
// return all affected relays
return oldRelays + filters.keys
}
/**
* Removes the sub from the pool, and returns the relays that need to be updated.
*/
fun remove(subId: String): Set<NormalizedRelayUrl> =
if (desiredSubs.containsKey(subId)) {
// saves old relays
val oldRelays = desiredSubs.get(subId)?.keys ?: emptySet()
// update filters
desiredSubs.remove(subId)
// update listener
desiredSubListeners.remove(subId)
// remove states
relayState.remove(subId)
// update relays for pool
updateRelays()
// return all affected relays
oldRelays
} else {
emptySet()
}
fun getSubscriptionFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = desiredSubs.get(subId)
// --------------------------
// State management functions
// --------------------------
fun onConnecting(url: NormalizedRelayUrl) {
relayState.forEach { subId, state ->
state.connecting(url)
}
}
fun syncState(
relay: NormalizedRelayUrl,
sync: (Command) -> Unit,
) {
desiredSubs.forEach { subId, filters ->
val filters = filters[relay]
if (!filters.isNullOrEmpty()) {
sync(ReqCmd(subId, filters))
} else {
null
}
}
}
fun onSent(
relay: NormalizedRelayUrl,
cmd: Command,
) {
when (cmd) {
is ReqCmd -> subState(cmd.subId).onOpenReq(relay, cmd.filters)
is CloseCmd -> subState(cmd.subId).onCloseReq(relay)
}
}
fun onIncomingMessage(
relay: IRelayClient,
msg: Message,
) {
when (msg) {
is EventMessage -> {
val state = relayState.get(msg.subId)
state?.onNewEvent(relay.url)
desiredSubListeners.get(msg.subId)?.onEvent(
event = msg.event,
isLive = state?.currentState(relay.url) == ReqSubStatus.LIVE,
relay = relay.url,
forFilters = state?.currentFilters(relay.url),
)
}
is EoseMessage -> {
val state = relayState.get(msg.subId)
state?.onEose(relay.url)
desiredSubListeners.get(msg.subId)?.onEose(
relay = relay.url,
forFilters = state?.currentFilters(relay.url),
)
// send a newer version when done
sendToRelayIfChanged(msg.subId, relay.url) { cmd ->
relay.sendOrConnectAndSync(cmd)
}
}
is ClosedMessage -> {
val state = relayState.get(msg.subId)
state?.onClosed(relay.url)
desiredSubListeners.get(msg.subId)?.onClosed(
message = msg.message,
relay = relay.url,
forFilters = state?.currentFilters(relay.url),
)
// send a newer version when done
sendToRelayIfChanged(msg.subId, relay.url) { cmd ->
// don't send a close if just closed
if (cmd !is CloseCmd) {
relay.sendOrConnectAndSync(cmd)
}
}
}
}
}
fun onDisconnected(url: NormalizedRelayUrl) {
relayState.forEach { subId, state ->
state.disconnected(url)
}
}
/**
* If cannot connect, closes subs
*/
fun onCannotConnect(
url: NormalizedRelayUrl,
errorMessage: String,
) {
relayState.forEach { subId, state ->
desiredSubListeners.get(subId)?.onCannotConnect(
message = errorMessage,
relay = url,
forFilters = state.currentFilters(url),
)
}
}
fun sendToRelayIfChanged(
subId: String,
relaysToUpdate: Set<NormalizedRelayUrl>,
sync: (NormalizedRelayUrl, Command) -> Unit,
) {
relaysToUpdate.forEach { relay ->
val currentState = relayState.get(subId)?.currentState(relay)
if (currentState == ReqSubStatus.SENT || currentState == ReqSubStatus.QUERYING_PAST) {
// sending multiple REQs triggers multiple EOSEs back and we then don't know which
// one is which.
} else {
sendToRelayIfChanged(subId, relay) { cmd ->
sync(relay, cmd)
}
}
}
}
fun sendToRelayIfChanged(
subId: String,
relay: NormalizedRelayUrl,
sync: (Command) -> Unit,
) {
val oldFilters = relayState.get(subId)?.currentFilters(relay)
val newFilters = desiredSubs.get(subId)?.get(relay)
sendToRelayIfChanged(subId, oldFilters, newFilters, sync)
}
fun sendToRelayIfChanged(
subId: String,
oldFilters: List<Filter>?,
newFilters: List<Filter>?,
sync: (Command) -> Unit,
) {
if (newFilters.isNullOrEmpty()) {
// some relays are not in this sub anymore. Stop their subscriptions
if (!oldFilters.isNullOrEmpty()) {
// only update if the old filters are not already closed.
sync(CloseCmd(subId))
}
} else if (oldFilters.isNullOrEmpty()) {
// new relays were added. Start a new sub in them
sync(ReqCmd(subId, newFilters))
} else if (FiltersChanged.needsToResendRequest(oldFilters, newFilters)) {
// filters were changed enough (not only an update in since) to warn a new update
sync(ReqCmd(subId, newFilters))
} else {
// They are the same don't do anything.
}
}
}
@@ -1,84 +0,0 @@
/**
* 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.pool
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
class PoolSubscriptionRepository {
private var subscriptions = LargeCache<String, Map<NormalizedRelayUrl, List<Filter>>>()
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
fun updateRelays() {
val myRelays = mutableSetOf<NormalizedRelayUrl>()
subscriptions.forEach { sub, perRelayFilters ->
myRelays.addAll(perRelayFilters.keys)
}
if (relays.value != myRelays) {
relays.tryEmit(myRelays)
}
}
fun activeFiltersFor(url: NormalizedRelayUrl): Map<String, List<Filter>> {
val myRelays = mutableMapOf<String, List<Filter>>()
subscriptions.forEach { sub, perRelayFilters ->
val filters = perRelayFilters.get(url)
if (filters != null) {
myRelays.put(sub, filters)
}
}
return myRelays
}
fun addOrUpdate(
subscriptionId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {
subscriptions.put(subscriptionId, filters)
updateRelays()
}
fun remove(subscriptionId: String) {
if (subscriptions.containsKey(subscriptionId)) {
subscriptions.remove(subscriptionId)
updateRelays()
}
}
fun forEachSub(
relay: NormalizedRelayUrl,
run: (String, List<Filter>) -> Unit,
) {
subscriptions.forEach { subId, filters ->
val filters = filters[relay]
if (!filters.isNullOrEmpty()) {
run(subId, filters)
} else {
null
}
}
}
fun getSubscriptionFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = subscriptions.get(subId)
}
@@ -21,22 +21,19 @@
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
val UnsupportedRelayCreation: (url: NormalizedRelayUrl) -> IRelayClient = {
throw UnsupportedOperationException("Cannot create new relays")
}
/**
* RelayPool manages a collection of Nostr relays, abstracting individual connections and providing
* unified methods for sending events, managing subscriptions, and tracking relay states.
@@ -59,8 +56,8 @@ val UnsupportedRelayCreation: (url: NormalizedRelayUrl) -> IRelayClient = {
* - Maintaining optimal relay connections (updatePool/addRelay/removeRelay methods)
*/
class RelayPool(
val websocketBuilder: WebsocketBuilder,
val listener: IRelayClientListener = EmptyClientListener,
val createNewRelay: (url: NormalizedRelayUrl) -> IRelayClient = UnsupportedRelayCreation,
) : IRelayClientListener {
private val relays = LargeCache<NormalizedRelayUrl, IRelayClient>()
@@ -70,6 +67,13 @@ class RelayPool(
fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url)
private fun createNewRelay(url: NormalizedRelayUrl) =
BasicRelayClient(
url = url,
socketBuilder = websocketBuilder,
listener = this,
)
fun reconnectIfNeedsTo(ignoreRetryDelays: Boolean = false) {
relays.forEach { url, relay ->
if (relay.isConnected()) {
@@ -109,71 +113,40 @@ class RelayPool(
updateStatus()
}
fun sendRequest(
fun sendOrConnectAndSync(
relay: NormalizedRelayUrl,
subId: String,
filters: List<Filter>,
) {
getOrCreateRelay(relay).sendRequest(subId, filters)
}
cmd: Command,
) = getOrCreateRelay(relay).sendOrConnectAndSync(cmd)
fun sendRequest(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {
relays.forEach { url, relay ->
val filters = filters[relay.url]
if (!filters.isNullOrEmpty()) {
relay.sendRequest(subId, filters)
}
}
}
fun sendCount(
fun sendIfConnected(
relay: NormalizedRelayUrl,
subId: String,
filters: List<Filter>,
) {
getOrCreateRelay(relay).sendCount(subId, filters)
}
cmd: Command,
) = getOrCreateRelay(relay).sendIfConnected(cmd)
fun sendCount(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {
relays.forEach { url, relay ->
val filters = filters[relay.url]
if (!filters.isNullOrEmpty()) {
relay.sendCount(subId, filters)
}
}
}
fun close(subscriptionId: String) =
relays.forEach { url, relay ->
relay.close(subscriptionId)
}
fun close(
relay: NormalizedRelayUrl,
subscriptionId: String,
) = relays.get(relay)?.close(subscriptionId)
fun send(
signedEvent: Event,
fun sendOrConnectAndSync(
list: Set<NormalizedRelayUrl>,
cmd: Command,
) {
list.forEach {
getOrCreateRelay(it).send(signedEvent)
getOrCreateRelay(it).sendOrConnectAndSync(cmd)
}
}
fun sendIfConnected(
list: Set<NormalizedRelayUrl>,
cmd: Command,
) {
list.forEach {
getOrCreateRelay(it).sendIfConnected(cmd)
}
}
// --------------------
// Pool Maintenance
// --------------------
fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, createNewRelay)
fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, ::createNewRelay)
fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, createNewRelay)
fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, ::createNewRelay)
/**
* Updates the pool of relays without disconnecting the existing ones.
@@ -244,75 +217,39 @@ class RelayPool(
// --------------------
// Listener Interceptor
// --------------------
override fun onConnecting(relay: IRelayClient) = listener.onConnecting(relay)
override fun onEvent(
override fun onConnected(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
pingMillis: Int,
compressed: Boolean,
) {
listener.onEvent(relay, subId, event, arrivalTime, afterEOSE)
}
override fun onError(
relay: IRelayClient,
subId: String,
error: Error,
) {
listener.onError(relay, subId, error)
updateStatus()
listener.onConnected(relay, pingMillis, compressed)
}
override fun onEOSE(
relay: IRelayClient,
subId: String,
arrivalTime: Long,
) {
listener.onEOSE(relay, subId, arrivalTime)
}
override fun onRelayStateChange(
relay: IRelayClient,
type: RelayState,
) {
listener.onRelayStateChange(relay, type)
override fun onDisconnected(relay: IRelayClient) {
updateStatus()
listener.onDisconnected(relay)
}
override fun onSendResponse(
override fun onIncomingMessage(
relay: IRelayClient,
eventId: String,
msgStr: String,
msg: Message,
) = listener.onIncomingMessage(relay, msgStr, msg)
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) = listener.onCannotConnect(relay, errorMessage)
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
message: String,
) = listener.onSendResponse(relay, eventId, success, message)
override fun onAuth(
relay: IRelayClient,
challenge: String,
) = listener.onAuth(relay, challenge)
override fun onNotify(
relay: IRelayClient,
description: String,
) = listener.onNotify(relay, description)
override fun onClosed(
relay: IRelayClient,
subId: String,
message: String,
) = listener.onClosed(relay, subId, message)
override fun onSend(
relay: IRelayClient,
msg: String,
success: Boolean,
) = listener.onSend(relay, msg, success)
override fun onBeforeSend(
relay: IRelayClient,
event: Event,
) = listener.onBeforeSend(relay, event)
) = listener.onSent(relay, cmdStr, cmd, success)
// ---------------
// STATUS Reports
@@ -18,29 +18,34 @@
* 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.single.simple
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
/**
* This relay client saves any event that will be sent in an outbox
* waits for auth and sends it again to make sure it is delivered.
*/
class SimpleRelayClient(
url: NormalizedRelayUrl,
socketBuilder: WebsocketBuilder,
listener: IRelayClientListener,
stats: RelayStat = RelayStat(),
defaultOnConnect: (BasicRelayClient) -> Unit = { },
) : IRelayClient by BasicRelayClient(
url,
socketBuilder,
OutboxCache(listener),
stats,
defaultOnConnect,
)
interface IRequestListener {
fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {}
fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {}
fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {}
fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {}
}
@@ -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.nip01Core.relay.client.reqs
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
class RelayActiveRequestStates(
val client: INostrClient,
) {
private var subStates = mutableMapOf<NormalizedRelayUrl, RequestSubscriptionState<String>>()
fun subGetOrCreate(relay: NormalizedRelayUrl): RequestSubscriptionState<String> = subStates[relay] ?: RequestSubscriptionState<String>().also { subStates.put(relay, it) }
private val clientListener =
object : IRelayClientListener {
override fun onConnecting(relay: IRelayClient) {
subStates.put(relay.url, RequestSubscriptionState())
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
when (msg) {
is EventMessage -> subGetOrCreate(relay.url).onNewEvent(msg.subId)
is EoseMessage -> subGetOrCreate(relay.url).onEose(msg.subId)
is ClosedMessage -> subGetOrCreate(relay.url).onClosed(msg.subId)
}
}
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
when (cmd) {
is ReqCmd -> subGetOrCreate(relay.url).onOpenReq(cmd.subId, cmd.filters)
is CloseCmd -> subGetOrCreate(relay.url).onCloseReq(cmd.subId)
}
}
override fun onDisconnected(relay: IRelayClient) {
subStates.remove(relay.url)
}
}
init {
Log.d("RelaySubStateMachine", "Init, Subscribe")
client.subscribe(clientListener)
}
fun destroy() {
// makes sure to run
Log.d("RelaySubStateMachine", "Destroy, Unsubscribe")
client.unsubscribe(clientListener)
}
}
@@ -0,0 +1,28 @@
/**
* 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
enum class ReqSubStatus {
SENT,
QUERYING_PAST,
LIVE,
CLOSED,
}
@@ -0,0 +1,77 @@
/**
* 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.relay.filters.Filter
class RequestSubscriptionState<T> {
// Logs the state of each channel to:
// 1. inform when an event is received as live
// 2. to block REQs being sent before finished (receiving an EOSE or Closed)
//
// If 2 happens, the relay might send multiple EOSEs in sequence
// for the same sub and we won't know which REQ was it for.
private val subStates = mutableMapOf<T, ReqSubStatus>()
private val filterStates = mutableMapOf<T, List<Filter>>()
fun currentFilters() = filterStates
fun currentFilters(reference: T) = filterStates[reference]
fun currentState(reference: T) = subStates[reference]
fun onNewEvent(reference: T) {
if (subStates[reference] == ReqSubStatus.SENT) {
subStates.put(reference, ReqSubStatus.QUERYING_PAST)
}
}
fun onEose(reference: T) {
subStates.put(reference, ReqSubStatus.LIVE)
}
fun onClosed(reference: T) {
subStates.put(reference, ReqSubStatus.CLOSED)
}
fun onOpenReq(
reference: T,
filters: List<Filter>,
) {
subStates.put(reference, ReqSubStatus.SENT)
filterStates.put(reference, filters)
}
fun onCloseReq(reference: T) {
subStates.put(reference, ReqSubStatus.CLOSED)
}
fun connecting(reference: T) {
subStates.remove(reference)
filterStates.remove(reference)
}
fun disconnected(reference: T) {
// Can't remove filterStates because disconnections happen
// before processing all the events in the channel queue.
subStates.remove(reference)
}
}
@@ -18,54 +18,50 @@
* 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
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.utils.Log
/**
* Listens to NostrClient's onNotify messages from the relay
*/
class RelayLogger(
class RelayReqStats(
val client: INostrClient,
) {
companion object {
val TAG = RelayLogger::class.java.simpleName
}
private val stats = ReqStatsRepository()
private val clientListener =
object : IRelayClientListener {
/** A new message was received */
override fun onEvent(
override fun onIncomingMessage(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
msgStr: String,
msg: Message,
) {
Log.d(TAG, "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}")
}
override fun onSend(
relay: IRelayClient,
msg: String,
success: Boolean,
) {
Log.d(TAG, "Relay send ${relay.url} (${msg.length} chars) $msg")
super.onIncomingMessage(relay, msgStr, msg)
if (msg is EventMessage) {
stats.add(msg.subId, msg.event.kind)
}
}
}
fun printStats() =
stats.printCounter { subId, kind, counter ->
Log.d("RelaySubStats", "$subId, kind $kind: $counter")
}
init {
Log.d(TAG, "Init, Subscribe")
Log.d("RelaySubStats", "Init, Subscribe")
client.subscribe(clientListener)
}
fun destroy() {
// makes sure to run
Log.d(TAG, "Destroy, Unsubscribe")
Log.d("RelaySubStats", "Destroy, Unsubscribe")
client.unsubscribe(clientListener)
}
}
@@ -18,12 +18,11 @@
* 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.subscriptions
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
class SubscriptionStats {
class ReqStatsRepository {
data class Counter(
val subscriptionId: String,
val eventKind: Int,
@@ -47,12 +46,9 @@ class SubscriptionStats {
stats.counter++
}
fun printCounter(tag: String) {
fun printCounter(action: (subId: String, kind: Int, counter: Int) -> Unit) {
eventCounter.forEach { _, stats ->
Log.d(
tag,
"Received Events ${stats.subscriptionId} ${stats.eventKind}: ${stats.counter}",
)
action(stats.subscriptionId, stats.eventKind, stats.counter)
}
}
}
@@ -20,10 +20,8 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.single
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
interface IRelayClient {
val url: NormalizedRelayUrl
@@ -32,29 +30,13 @@ interface IRelayClient {
fun needsToReconnect(): Boolean
fun connectAndRunAfterSync(onConnected: () -> Unit)
fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean = false)
fun isConnected(): Boolean
fun sendRequest(
subId: String,
filters: List<Filter>,
)
fun sendOrConnectAndSync(cmd: Command)
fun sendCount(
subId: String,
filters: List<Filter>,
)
fun send(event: Event)
fun sendAuth(signedEvent: RelayAuthEvent)
fun sendEvent(event: Event)
fun close(subscriptionId: String)
fun sendIfConnected(cmd: Command)
fun disconnect()
}
@@ -20,37 +20,18 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.single.basic
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.Companion.DELAY_TO_RECONNECT_IN_SECS
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.cancellation.CancellationException
@@ -62,8 +43,6 @@ import kotlin.coroutines.cancellation.CancellationException
* @property url The relay's normalized URL.
* @property socketBuilder Provides the WebSocket instance for connection.
* @property listener Interface to notify the application of relay events and errors.
* @property stats Tracks operational statistics of the relay connection.
* @property defaultOnConnect Callback executed after a successful connection, allowing subclasses to add initialization logic.
*
* Reconnection Strategy:
* - Uses exponential backoff to retry connections, starting with [DELAY_TO_RECONNECT_IN_SECS] (500ms).
@@ -78,28 +57,25 @@ open class BasicRelayClient(
override val url: NormalizedRelayUrl,
val socketBuilder: WebsocketBuilder,
val listener: IRelayClientListener,
val stats: RelayStat = RelayStat(),
val defaultOnConnect: (BasicRelayClient) -> Unit = { },
) : IRelayClient {
companion object {
// minimum wait time to reconnect: 1 second
const val DELAY_TO_RECONNECT_IN_SECS = 1
}
private val logTag = "Relay ${url.displayUrl()}"
private var socket: WebSocket? = null
// True if it has received the onOpen call from the socket.
private var isReady: Boolean = false
private var usingCompression: Boolean = false
// keeps increasing the delay to connect when errors happen.
// This avoids the constant desire to connect when the server is
// having trouble or offline.
private var lastConnectTentativeInSeconds: Long = 0L // the beginning of time.
private var delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
private val authResponseWatcher = mutableMapOf<HexKey, Boolean>()
private val authChallengesSent = mutableSetOf<String>()
// Makes sure only one socket is open for each url
private var connectingMutex = AtomicBoolean(false)
fun isConnectionStarted(): Boolean = socket != null
@@ -108,19 +84,7 @@ open class BasicRelayClient(
override fun needsToReconnect() = socket?.needsReconnect() ?: true
override fun connect() =
connectAndRunOverride {
defaultOnConnect(this)
}
override fun connectAndRunAfterSync(onConnected: () -> Unit) {
connectAndRunOverride {
defaultOnConnect(this)
onConnected()
}
}
fun connectAndRunOverride(onConnected: () -> Unit) {
override fun connect() {
// If there is a connection, don't wait.
if (connectingMutex.exchange(true)) {
return
@@ -132,52 +96,49 @@ open class BasicRelayClient(
return
}
Log.d(logTag, "Connecting...")
listener.onConnecting(this)
lastConnectTentativeInSeconds = TimeUtils.now()
socket = socketBuilder.build(url, MyWebsocketListener(onConnected))
socket = socketBuilder.build(url, MyWebsocketListener())
socket?.connect()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w(logTag, "Crash before connecting", e)
stats.newError(e.message ?: "Error trying to connect: $e")
listener.onCannotConnect(this, "Error when trying to connect: ${e.message}")
listener.onDisconnected(this)
dontTryAgainForALongTime()
markConnectionAsClosed()
} finally {
connectingMutex.store(false)
}
}
inner class MyWebsocketListener(
val onConnected: () -> Unit,
) : WebSocketListener {
inner class MyWebsocketListener : WebSocketListener {
override fun onOpen(
pingMillis: Int,
compression: Boolean,
) {
Log.d(logTag, "OnOpen (ping: ${pingMillis}ms${if (compression) ", using compression" else ""})")
markConnectionAsReady(pingMillis, compression)
onConnected()
listener.onRelayStateChange(this@BasicRelayClient, RelayState.CONNECTED)
markConnectionAsReady(compression)
listener.onConnected(this@BasicRelayClient, pingMillis, compression)
}
override fun onMessage(text: String) {
consumeIncomingMessage(text, onConnected)
try {
val msg = OptimizedJsonMapper.fromJsonTo<Message>(text)
listener.onIncomingMessage(this@BasicRelayClient, text, msg)
} catch (e: Throwable) {
if (e is CancellationException) throw e
// doesn't expose parsing errors to lib users as errors
Log.e("BasicRelayClient", "Failure to parse message from Relay", e)
}
}
override fun onClosed(
code: Int,
reason: String,
) {
Log.w(logTag, "OnClosed $reason")
markConnectionAsClosed()
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
listener.onDisconnected(this@BasicRelayClient)
}
override fun onFailure(
@@ -190,192 +151,65 @@ open class BasicRelayClient(
if (socket == null) {
// comes from the disconnect action.
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
listener.onDisconnected(this@BasicRelayClient)
} else {
socket?.disconnect()
val msg = t.message
// checks if this is an actual failure. Closing the socket generates an onFailure as well.
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) {
stats.newError(response ?: t.message ?: "onFailure event from server: $t")
// ignore tor errors.
if (msg == null ||
(
!msg.startsWith("failed to connect to /127.0.0.1") &&
msg != "Socket closed" &&
msg != "Socket is closed" &&
msg != "Cancelled"
)
) {
if (code != null || response != null) {
listener.onCannotConnect(this@BasicRelayClient, "Server Misconfigured. Response: $code $response. Exception: ${t.message}")
} else {
listener.onCannotConnect(this@BasicRelayClient, "WebSocket Failure: ${t.message}")
}
} else {
// ignore local disconnect requests and tor errors
// listener.onServerError(this@BasicRelayClient, Error("Ignored Error $code $response. Exception: ${t.message}"))
}
// Failures disconnect the relay.
markConnectionAsClosed()
Log.w(logTag, "OnFailure $code $response ${t.message}")
if (code == 403 || code == 502 || code == 503 || code == 402 || code == 410 || t.message == "SOCKS: Host unreachable") {
if (code != null || t.message?.endsWith("Host unreachable") == true) {
dontTryAgainForALongTime()
}
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
listener.onError(this@BasicRelayClient, "", Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t))
listener.onDisconnected(this@BasicRelayClient)
}
}
}
fun consumeIncomingMessage(
text: String,
onConnected: () -> Unit,
) {
// Log.d(logTag, "Processing: $text")
stats.addBytesReceived(text.bytesUsedInMemory())
try {
when (val msg = OptimizedJsonMapper.fromJsonTo<Message>(text)) {
is EventMessage -> processEvent(msg)
is EoseMessage -> processEose(msg)
is NoticeMessage -> processNotice(msg)
is OkMessage -> processOk(msg, onConnected)
is AuthMessage -> processAuth(msg)
is NotifyMessage -> processNotify(msg)
is ClosedMessage -> processClosed(msg)
else -> processUnknownMessage(text)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
stats.newError("Error processing: $text")
Log.e(logTag, "Error processing: $text", e)
listener.onError(this@BasicRelayClient, "", Error("Error processing $text"))
}
}
fun markConnectionAsReady(
pingInMs: Int,
usingCompression: Boolean,
) {
this.resetEOSEStatuses()
fun markConnectionAsReady(usingCompression: Boolean) {
this.isReady = true
this.usingCompression = usingCompression
// resets any extra delays added during on offline state
this.delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
stats.pingInMs = pingInMs
}
fun markConnectionAsClosed() {
this.socket = null
this.isReady = false
this.usingCompression = false
this.resetEOSEStatuses()
}
private fun processEvent(msg: EventMessage) {
// Log.w(logTag, "Event ${msg.subId} ${msg.event.toJson()}")
listener.onEvent(
relay = this,
subId = msg.subId,
event = msg.event,
arrivalTime = TimeUtils.now(),
afterEOSE = afterEOSEPerSubscription[msg.subId] == true,
)
}
private fun processEose(msg: EoseMessage) {
Log.d(logTag, "EOSE ${msg.subId}")
afterEOSEPerSubscription[msg.subId] = true
listener.onEOSE(this, msg.subId, TimeUtils.now())
}
private fun processNotice(msg: NoticeMessage) {
Log.w(logTag, "Notice ${msg.message}")
stats.newNotice(msg.message)
listener.onError(this@BasicRelayClient, msg.message, Error("Relay sent notice: $msg.message"))
}
private fun processOk(
msg: OkMessage,
onConnected: () -> Unit,
) {
Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}")
// if this is the OK of an auth event, renew all subscriptions and resend all outgoing events.
if (authResponseWatcher.containsKey(msg.eventId)) {
val wasAlreadyAuthenticated = authResponseWatcher[msg.eventId]
authResponseWatcher.put(msg.eventId, msg.success)
if (wasAlreadyAuthenticated != true && msg.success) {
onConnected()
listener.onAuthed(this@BasicRelayClient, msg.eventId, msg.success, msg.message)
}
}
if (!msg.success) {
stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}")
}
listener.onSendResponse(this@BasicRelayClient, msg.eventId, msg.success, msg.message)
}
private fun processAuth(msg: AuthMessage) {
Log.d(logTag, "Auth ${msg.challenge}")
listener.onAuth(this@BasicRelayClient, msg.challenge)
}
private fun processNotify(msg: NotifyMessage) {
// Log.w(logTag, "Notify $newMessage")
listener.onNotify(this@BasicRelayClient, msg.message)
}
private fun processClosed(msg: ClosedMessage) {
Log.w(logTag, "Relay Closed Subscription ${msg.subId} ${msg.message}")
stats.newNotice("Subscription closed: ${msg.subId} ${msg.message}")
afterEOSEPerSubscription[msg.subId] = false
listener.onClosed(this@BasicRelayClient, msg.subId, msg.message)
}
private fun processUnknownMessage(newMessage: String) {
stats.newError("Unsupported message: $newMessage")
Log.w(logTag, "Unsupported message: $newMessage")
listener.onError(this, "", Error("Unsupported message: $newMessage"))
}
override fun disconnect() {
Log.d(logTag, "Disconnecting...")
lastConnectTentativeInSeconds = 0L // this is not an error, so prepare to reconnect as soon as requested.
delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
socket?.disconnect()
socket = null
isReady = false
usingCompression = false
resetEOSEStatuses()
}
fun resetEOSEStatuses() {
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
authResponseWatcher.clear()
authChallengesSent.clear()
}
override fun sendRequest(
subId: String,
filters: List<Filter>,
) {
if (isConnectionStarted()) {
if (isReady) {
if (filters.isNotEmpty()) {
afterEOSEPerSubscription[subId] = false
writeToSocket(ReqCmd(subId, filters))
}
}
} else {
connectAndSyncFiltersIfDisconnected()
}
}
override fun sendCount(
subId: String,
filters: List<Filter>,
) {
if (isConnectionStarted()) {
if (isReady) {
if (filters.isNotEmpty()) {
afterEOSEPerSubscription[subId] = false
writeToSocket(CountCmd(subId, filters))
}
}
} else {
connectAndSyncFiltersIfDisconnected()
}
}
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) {
@@ -398,62 +232,21 @@ open class BasicRelayClient(
delayToConnectInSeconds = TimeUtils.ONE_DAY
}
override fun send(event: Event) {
listener.onBeforeSend(this@BasicRelayClient, event)
if (event is RelayAuthEvent) {
sendAuth(event)
} else {
sendEvent(event)
}
}
override fun sendAuth(signedEvent: RelayAuthEvent) {
val challenge = signedEvent.challenge()
// only send replies to new challenges to avoid infinite loop:
if (challenge != null && challenge !in authChallengesSent) {
authResponseWatcher[signedEvent.id] = false
authChallengesSent.add(challenge)
writeToSocket(AuthCmd(signedEvent))
}
}
override fun sendEvent(event: Event) {
override fun sendOrConnectAndSync(cmd: Command) {
if (isConnectionStarted()) {
if (isReady) {
writeToSocket(EventCmd(event))
if (isReady && cmd.isValid()) {
sendIfConnected(cmd)
}
} else {
connectAndSyncFiltersIfDisconnected()
}
}
private fun writeToSocket(cmd: Command) {
writeToSocket(OptimizedJsonMapper.toJson(cmd))
}
private fun writeToSocket(str: String) {
if (socket == null) {
listener.onError(
this@BasicRelayClient,
"",
Error("Failed to send $str. Relay is not connected."),
)
}
override fun sendIfConnected(cmd: Command) {
socket?.let {
// Log.d(logTag, "Sending (${str.length} chars): $str")
val result = it.send(str)
listener.onSend(this@BasicRelayClient, str, result)
stats.addBytesSent(str.bytesUsedInMemory())
}
}
override fun close(subscriptionId: String) {
// avoids sending closes for subscriptions that were never sent to this relay.
if (afterEOSEPerSubscription.containsKey(subscriptionId)) {
writeToSocket(CloseCmd(subscriptionId))
afterEOSEPerSubscription[subscriptionId] = false
val msg = OptimizedJsonMapper.toJson(cmd)
val success = it.send(msg)
listener.onSent(this@BasicRelayClient, msg, cmd, success)
}
}
}
@@ -1,90 +0,0 @@
/**
* 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.single.simple
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.cache.LargeCache
class OutboxCache(
listener: IRelayClientListener,
) : RedirectRelayClientListener(listener) {
/**
* Auth procedures require us to keep track of the outgoing events
* to make sure the relay waits for the auth to finish and send them.
*/
private val outboxCache = LargeCache<HexKey, Event>()
override fun onRelayStateChange(
relay: IRelayClient,
type: RelayState,
) {
if (type == RelayState.CONNECTED) {
outboxCache.forEach { id, event ->
relay.sendEvent(event)
}
}
super.onRelayStateChange(relay, type)
}
override fun onAuthed(
relay: IRelayClient,
eventId: String,
success: Boolean,
message: String,
) {
super.onAuthed(relay, eventId, success, message)
outboxCache.forEach { id, event ->
relay.sendEvent(event)
}
}
override fun onBeforeSend(
relay: IRelayClient,
event: Event,
) {
if (event !is RelayAuthEvent) {
outboxCache.put(event.id, event)
}
super.onBeforeSend(relay, event)
}
override fun onSendResponse(
relay: IRelayClient,
eventId: String,
success: Boolean,
message: String,
) {
// remove from cache for any error that is not an auth required error.
// for auth required, we will do the auth and try to send again.
if (outboxCache.containsKey(eventId) && !message.startsWith("auth-required")) {
outboxCache.remove(eventId)
}
super.onSendResponse(relay, eventId, success, message)
}
}
@@ -0,0 +1,136 @@
/**
* 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.single.standalone
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.cache.LargeCache
/**
* This relay client saves any event that will be sent in an outbox
* waits for auth and sends it again to make sure it is delivered.
*/
class StandaloneRelayClient(
url: NormalizedRelayUrl,
socketBuilder: WebsocketBuilder,
listener: IRelayClientListener = EmptyClientListener,
) {
private val outbox = LargeCache<HexKey, Event>()
private val reqs = LargeCache<String, List<Filter>>()
private val counts = LargeCache<String, List<Filter>>()
val client =
BasicRelayClient(
url,
socketBuilder,
object : RedirectRelayClientListener(listener) {
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
super.onConnected(relay, pingMillis, compressed)
renewFilters()
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
if (msg is OkMessage) {
// remove from cache for any error that is not an auth required error.
// for auth required, we will do the auth and try to send again.
if (outbox.containsKey(msg.eventId) && !msg.message.startsWith("auth-required")) {
outbox.remove(msg.eventId)
}
}
super.onIncomingMessage(relay, msgStr, msg)
}
},
)
fun renewFilters() {
outbox.forEach { id, event ->
client.sendOrConnectAndSync(EventCmd(event))
}
reqs.forEach { subId, filters ->
client.sendOrConnectAndSync(ReqCmd(subId, filters))
}
counts.forEach { subId, filters ->
client.sendOrConnectAndSync(CountCmd(subId, filters))
}
}
fun connect() = client.connect()
fun needsToReconnect() = client.needsToReconnect()
fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = client.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays)
fun isConnected() = client.isConnected()
fun sendRequest(
subId: String,
filters: List<Filter>,
) {
reqs.put(subId, filters)
client.sendOrConnectAndSync(ReqCmd(subId, filters))
}
fun sendCount(
queryId: String,
filters: List<Filter>,
) {
counts.put(queryId, filters)
client.sendOrConnectAndSync(CountCmd(queryId, filters))
}
fun send(event: Event) {
if (event !is RelayAuthEvent) {
outbox.put(event.id, event)
}
client.sendOrConnectAndSync(EventCmd(event))
}
fun close(subscriptionId: String) {
reqs.remove(subscriptionId)
counts.remove(subscriptionId)
client.sendIfConnected(CloseCmd(subscriptionId))
}
fun disconnect() = client.disconnect()
}
@@ -29,6 +29,7 @@ class RelayStat(
var spamCounter: Int = 0,
var errorCounter: Int = 0,
var pingInMs: Int = 0,
var compression: Boolean = false,
) {
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100)
@@ -22,7 +22,17 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.stats
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
class RelayStats(
val client: INostrClient,
@@ -32,47 +42,67 @@ class RelayStats(
override fun create(key: NormalizedRelayUrl): RelayStat = RelayStat()
}
fun get(url: NormalizedRelayUrl): RelayStat = innerCache.get(url) ?: throw IllegalArgumentException("Should never happen")
fun get(url: NormalizedRelayUrl): RelayStat = innerCache[url] ?: throw IllegalArgumentException("Should never happen")
fun addBytesReceived(
url: NormalizedRelayUrl,
bytesUsedInMemory: Int,
) {
get(url).addBytesReceived(bytesUsedInMemory)
private val clientListener =
object : IRelayClientListener {
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
with(get(relay.url)) {
pingInMs = pingMillis
compression = compressed
}
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
get(relay.url).newError(errorMessage)
}
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
get(relay.url).addBytesSent(cmdStr.bytesUsedInMemory())
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
val stat = get(relay.url)
stat.addBytesReceived(msgStr.bytesUsedInMemory())
when (msg) {
is NoticeMessage -> stat.newNotice(msg.message)
is NotifyMessage -> stat.newNotice("Notify user: " + msg.message)
is OkMessage -> {
if (!msg.success) {
stat.newNotice("Rejected event ${msg.eventId}: ${msg.message}")
}
}
is ClosedMessage -> stat.newNotice("Subscription closed: ${msg.subId} ${msg.message}")
}
}
}
init {
Log.d("RelayStats", "Init, Subscribe")
client.subscribe(clientListener)
}
fun addBytesSent(
url: NormalizedRelayUrl,
bytesUsedInMemory: Int,
) {
get(url).addBytesSent(bytesUsedInMemory)
}
fun newError(
url: NormalizedRelayUrl,
error: String?,
) {
get(url).newError(error)
}
fun newNotice(
url: NormalizedRelayUrl,
notice: String?,
) {
get(url).newNotice(notice)
}
fun setPing(
url: NormalizedRelayUrl,
pingInMs: Int,
) {
get(url).pingInMs = pingInMs
}
fun newSpam(
url: NormalizedRelayUrl,
explanation: String,
) {
get(url).newSpam(explanation)
fun destroy() {
// makes sure to run
Log.d("RelayStats", "Destroy, Unsubscribe")
client.unsubscribe(clientListener)
}
}
@@ -20,30 +20,24 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
data class Subscription(
val id: String = newSubId(),
val onEose: ((time: Long, relayUrl: NormalizedRelayUrl) -> Unit)? = null,
val listener: IRequestListener,
) {
private var filters: Map<NormalizedRelayUrl, List<Filter>>? = null // Inactive when null
private var currentVersion: Map<NormalizedRelayUrl, List<Filter>>? = null // Inactive when null
fun reset() {
filters = null
currentVersion = null
}
fun updateFilters(newFilters: Map<NormalizedRelayUrl, List<Filter>>?) {
filters = newFilters
currentVersion = newFilters
}
fun filters() = filters
fun callEose(
time: Long,
relay: NormalizedRelayUrl,
) {
onEose?.let { it(time, relay) }
}
fun filters() = currentVersion
}
@@ -20,10 +20,8 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
@@ -51,47 +49,12 @@ class SubscriptionController(
) {
private val subscriptions = LargeCache<String, Subscription>()
private val clientListener =
object : IRelayClientListener {
override fun onEvent(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
) {
if (subscriptions.containsKey(subId)) {
if (afterEOSE) {
subscriptions.get(subId)?.callEose(arrivalTime, relay.url)
}
}
}
override fun onEOSE(
relay: IRelayClient,
subId: String,
arrivalTime: Long,
) {
if (subscriptions.containsKey(subId)) {
subscriptions.get(subId)?.callEose(arrivalTime, relay.url)
}
}
}
init {
client.subscribe(clientListener)
}
fun destroy() {
client.unsubscribe(clientListener)
}
fun getSub(subId: String) = subscriptions.get(subId)
fun requestNewSubscription(
subId: String,
onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null,
): Subscription = Subscription(subId, onEose = onEOSE).also { subscriptions.put(it.id, it) }
listener: IRequestListener,
): Subscription = Subscription(subId, listener).also { subscriptions.put(it.id, it) }
fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) }
@@ -108,28 +71,29 @@ class SubscriptionController(
}
subscriptions.forEach { id, sub ->
updateRelaysIfNeeded(id, sub.filters(), currentFilters[id])
updateRelaysIfNeeded(id, sub.listener, sub.filters(), currentFilters[id])
}
}
fun updateRelaysIfNeeded(
subId: String,
updatedFilters: Map<NormalizedRelayUrl, List<Filter>>?,
currentFilters: Map<NormalizedRelayUrl, List<Filter>>?,
listener: IRequestListener,
newFilters: Map<NormalizedRelayUrl, List<Filter>>?,
oldFilters: Map<NormalizedRelayUrl, List<Filter>>?,
) {
if (currentFilters != null) {
if (updatedFilters == null) {
if (oldFilters != null) {
if (newFilters == null) {
// was active and is not active anymore, just close.
client.close(subId)
} else {
client.openReqSubscription(subId, updatedFilters)
client.openReqSubscription(subId, newFilters, listener)
}
} else {
if (updatedFilters == null) {
if (newFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the entire filter.
client.openReqSubscription(subId, updatedFilters)
client.openReqSubscription(subId, newFilters, listener)
}
}
}
@@ -23,12 +23,12 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
class CountCmd(
val subId: String,
val queryId: String,
val filters: List<Filter>,
) : Command {
override fun label(): String = LABEL
override fun isValid() = subId.isNotEmpty() && filters.isNotEmpty()
override fun isValid() = queryId.isNotEmpty() && filters.isNotEmpty()
companion object {
const val LABEL = "COUNT"
@@ -61,7 +61,7 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
}
CountCmd.LABEL -> {
val subId = jp.nextTextValue()
val queryId = jp.nextTextValue()
val filters = mutableListOf<Filter>()
while (jp.nextToken() != JsonToken.END_ARRAY) {
@@ -71,7 +71,7 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
}
CountCmd(
subId = subId,
queryId = queryId,
filters = filters,
)
}
@@ -59,7 +59,7 @@ class CommandSerializer : StdSerializer<Command>(Command::class.java) {
}
is CountCmd -> {
gen.writeString(cmd.subId)
gen.writeString(cmd.queryId)
cmd.filters.forEach {
filterSerializer.serialize(it, gen, provider)
}
@@ -23,9 +23,9 @@ 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.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -50,32 +50,24 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
val mySubId = "test-sub-id-1"
val listener =
object : IRelayClientListener {
object : IRequestListener {
override fun onEvent(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (mySubId == subId) {
resultChannel.trySend(event.id)
}
resultChannel.trySend(event.id)
}
override fun onEOSE(
relay: IRelayClient,
subId: String,
arrivalTime: Long,
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (mySubId == subId) {
resultChannel.trySend("EOSE")
}
resultChannel.trySend("EOSE")
}
}
client.subscribe(listener)
val filters =
mapOf(
RelayUrlNormalizer.normalize("wss://relay.damus.io") to
@@ -87,7 +79,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
),
)
client.openReqSubscription(mySubId, filters)
client.openReqSubscription(mySubId, filters, listener)
withTimeoutOrNull(30000) {
while (events.size < 101) {
@@ -99,7 +91,6 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
resultChannel.close()
client.close(mySubId)
client.unsubscribe(listener)
client.disconnect()
appScope.cancel()
@@ -20,11 +20,13 @@
*/
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.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
@@ -55,25 +57,21 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
val listener =
object : IRelayClientListener {
override fun onEvent(
override fun onIncomingMessage(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
msgStr: String,
msg: Message,
) {
if (mySubId == subId) {
resultChannel.trySend(event.id)
}
}
override fun onEOSE(
relay: IRelayClient,
subId: String,
arrivalTime: Long,
) {
if (mySubId == subId) {
resultChannel.trySend("EOSE")
Log.d("Test", "Receiving message: $msgStr")
when (msg) {
is EventMessage ->
if (mySubId == msg.subId) {
resultChannel.trySend(msg.event.id)
}
is EoseMessage ->
if (mySubId == msg.subId) {
resultChannel.trySend("EOSE")
}
}
}
}
@@ -91,13 +89,24 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
),
)
val filters2 =
val filtersShouldIgnore =
mapOf(
RelayUrlNormalizer.normalize("wss://relay.damus.io") to
listOf(
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 100,
limit = 500,
),
),
)
val filtersShouldSendAfterEOSE =
mapOf(
RelayUrlNormalizer.normalize("wss://relay.damus.io") to
listOf(
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 10,
),
),
)
@@ -105,14 +114,16 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
coroutineScope {
launch {
withTimeoutOrNull(30000) {
while (events.size < 202) {
while (events.size < 112) {
Log.d("Test", "Processing message ${events.size}")
// simulates an update in the middle of the sub
if (events.size == 1) {
client.openReqSubscription(mySubId, filters2)
client.openReqSubscription(mySubId, filtersShouldIgnore)
}
val event = resultChannel.receive()
Log.d("OkHttpWebsocketListener", "Processing: ${events.size} $event")
events.add(event)
if (events.size == 5) {
client.openReqSubscription(mySubId, filtersShouldSendAfterEOSE)
}
events.add(resultChannel.receive())
}
}
}
@@ -128,10 +139,15 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
appScope.cancel()
assertEquals(202, events.size)
// gets all 113 messages (100 events + 1 EOSE + 10 events + 1 EOSE)
assertEquals(112, events.size)
// checks if first 100 have Ids
assertEquals(true, events.take(100).all { it.length == 64 })
// checks if EOSE is after the first 100
assertEquals("EOSE", events[100])
assertEquals(true, events.drop(101).take(100).all { it.length == 64 })
assertEquals("EOSE", events[201])
// checks if next 10 have Ids
assertEquals(true, events.drop(101).take(10).all { it.length == 64 })
// checks if EOSE is after the next 10
assertEquals("EOSE", events[111])
}
}