diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index fde96059e..241ffd9a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f888022cd..851b58367 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 2aa6ecf86..13714ae39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListReusedOutboxOrProxyRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListReusedOutboxOrProxyRelays.kt new file mode 100644 index 000000000..78d5beec0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListReusedOutboxOrProxyRelays.kt @@ -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> = + 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> = + 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> = + 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> = + flow + .map { relayList -> + relayList.mapTo(mutableSetOf()) { it.url } + }.onStart { + emit(flow.value.mapTo(mutableSetOf()) { it.url }) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt index 3051ab552..a57ce9dce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 8cbeef972..53edb8d97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -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 { 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt index 21c789a93..d59b82205 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt @@ -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( 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( override fun destroy() { bundler.cancel() - orchestrator.destroy() if (isDebug) { Log.d(logTag, "Destroy, Unsubscribe") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index 70be7984b..4c14a918d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -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( key: T, relayUrl: NormalizedRelayUrl, time: Long, + filters: List? = null, ) { latestEOSEs.newEose(id(key), relayUrl, time) if (invalidateAfterEose) { @@ -61,9 +66,27 @@ abstract class PerUniqueIdEoseManager( } open fun newSub(key: T): Subscription = - requestNewSubscription { time, relayUrl -> - newEose(key, relayUrl, time) - } + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + } + }, + ) open fun endSub( key: U, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index e696f351f..ff2b8411a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -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( key: T, relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(user(key), list(key), relay, time) + filters: List? = 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?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + } + }, + ) open fun endSub( key: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index 4cc4438c3..3b5583f38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -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( 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? = 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?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + } + }, + ) open fun endSub( key: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt index d633d0d53..795af8564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -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( open fun newEose( relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(relay, time) + filters: List? = 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?, + ) { + newEose(relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(relay, TimeUtils.now(), forFilters) + } + } + }, + ) override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index c07a30266..2a93ccfd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -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( val invalidateAfterEose: Boolean = false, ) : BaseEoseManager(client, allKeys) { val sub = - requestNewSubscription { time, relayUrl -> - if (invalidateAfterEose) { - invalidateFilters() - } - } + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (invalidateAfterEose) { + invalidateFilters() + } + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + if (invalidateAfterEose) { + invalidateFilters() + } + } + } + }, + ) override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 27651ce0a..17a8d2dd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 562eb7ca8..c68b32def 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -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() { val group = listOf( AccountMetadataEoseManager(client, ::allKeys), + AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, ::allKeys), AccountGiftWrapsEoseManager(client, ::allKeys), + AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt new file mode 100644 index 000000000..448528c29 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -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, +) : IEoseManager { + private val logTag = "AccountFollowsLoaderSubAssembler" + private val orchestrator = SubscriptionController(client) + + private val cannotConnectRelays = mutableSetOf() + + // 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 = EOSEAccountFast(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?, + ) { + 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?, + ) { + newEose(TimeUtils.now(), relay, forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(TimeUtils.now(), relay, forFilters) + } + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // 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?, + ) { + cannotConnectRelays.add(relay) + } + }, + ) + + fun updateFilterForAllAccounts(accounts: Collection): List? { + val noOutboxList = mutableSetOf() + + val indexRelays = mutableSetOf() + val homeRelays = mutableSetOf() + val searchRelays = mutableSetOf() + val commonRelays = mutableSetOf() + + 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) { + 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() + + @OptIn(FlowPreview::class) + fun newWatcher( + user: User, + followList: Flow>, + ) { + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt new file mode 100644 index 000000000..07a35693c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -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, + loadOutbox: Set, + connectedRelays: Set, + indexRelays: Set, + searchRelays: Set, + allRelays: Set, + hasTried: EOSEAccountFast, +): List { + 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, + indexRelays: Set, + homeRelays: Set, + searchRelays: Set, + commonRelays: Set, + hasTried: EOSEAccountFast, +): Map> = + 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) + } + } + } + } + } + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index d1760b322..edf756ce4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -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?, ) { lastNotesOnFilter.forEach { latestEOSEs.newEose(it, relay, time) } - super.newEose(relay, time) + super.newEose(relay, time, filters) } override fun updateFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index 947975270..2b22aeabe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -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() { val group = listOf( - UserLoaderSubAssembler(client, ::allKeys), - UserWatcherSubAssembler(client, ::allKeys), + UserOutboxFinderSubAssembler(client, cache, ::allKeys), + UserWatcherSubAssembler(client, cache, ::allKeys), UserReportsSubAssembler(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt index b20cc7dcc..594252524 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt @@ -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, - defaultRelays: Set, -): List = - LocalCache.checkGetOrCreateUser(author)?.let { - filterFindUserMetadataForKey(setOf(it), indexRelays, defaultRelays) - } ?: emptyList() - -fun filterFindUserMetadataForKey( +fun findFindUserOutBox( authors: Set, + connectedRelays: Set, indexRelays: Set, - defaultRelays: Set, + searchRelays: Set, + allRelays: Set, + hasTried: EOSEAccountFast, ): List { - 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt deleted file mode 100644 index 04057d91a..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt +++ /dev/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, -) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun updateFilter(keys: List): List? { - val firstTimers = mutableSetOf() - - keys.forEach { - if (it.user.latestMetadata == null) { - firstTimers.add(it.user) - } else { - null - } - } - - val indexRelays = mutableSetOf() - val defaultRelays = mutableSetOf() - - 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 -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt new file mode 100644 index 000000000..dd2a54b79 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -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, +) : BaseEoseManager(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 = EOSEAccountFast(200) + + fun newEose( + relay: NormalizedRelayUrl, + time: Long, + filters: List? = 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?, + ) { + newEose(relay, TimeUtils.now(), forFilters) + } + }, + ) + + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { it.user } + val newFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null } + sub.updateFilters(newFilters?.groupByRelay()) + } + + fun updateFilter(keys: List): List? { + val noOutboxList = mutableSetOf() + + keys.forEach { + if (it.user.authorRelayList() == null) { + noOutboxList.add(it.user) + } + } + + if (noOutboxList.isEmpty()) return null + + val indexRelays = mutableSetOf() + val searchRelays = mutableSetOf() + val allRelays = mutableSetOf() + + 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index f688f6a3b..ec296eeca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -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, - since: SincePerRelayMap?, + authors: Set, + indexRelays: Set, + since: EOSEAccountFast, ): List { - 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() + val updates = mutableSetOf() + + 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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index 6b0c40e62..0ef0244ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -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?, ) { lastUsersOnFilter.forEach { latestEOSEs.newEose(it, relay, time) } - super.newEose(relay, time) + super.newEose(relay, time, filters) } override fun updateFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index 759d76768..d3dd7e145 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -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, -) : SingleSubEoseManager(client, allKeys) { - var lastUsersOnFilter: Set = emptySet() - +) : BaseEoseManager(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 = EOSEAccountFast(2000) + var latestEOSEs: EOSEAccountFast = 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, - since: SincePerRelayMap?, - ): List? { - 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, - eoseCache: EOSEAccountFast, - ): Collection> = - 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, - eoseCache: EOSEAccountFast, - ): SincePerRelayMap { - val minLatestEOSEs = mutableMapOf() - - 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? = 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?, + ) { + newEose(relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(relay, TimeUtils.now(), forFilters) + } + } + }, + ) + + override fun updateSubscriptions(keys: Set) { + val users = keys.mapTo(mutableSetOf()) { it.user } + + if (users.isEmpty()) { + sub.updateFilters(null) + return + } + + // assembles all index relays from all accounts + val indexRelays = mutableSetOf() + 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()) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt index 916bfa09c..c453f70f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt @@ -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, defaultRelays: Set, -) = 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt index c4397535b..8883fe5d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt @@ -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()) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index ba3cb4d3e..26b4398f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -99,6 +99,7 @@ class MainActivity : AppCompatActivity() { @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) + Amethyst.instance.relayReqStats?.printStats() } super.onPause() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 46f93d204..6040d63ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index 6e86149b5..41ac7b89a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -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>, + listener: IRequestListener? = null, ) - fun openCountSubscription( + fun queryCount( subId: String = newSubId(), filters: Map>, ) - 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>, + listener: IRequestListener?, ) { } - override fun openCountSubscription( + override fun queryCount( subId: String, filters: Map>, ) { } - override fun close(subscriptionId: String) { } - - override fun sendIfExists( - event: Event, - connectedRelay: NormalizedRelayUrl, - ) { } + override fun close(subId: String) { } override fun send( event: Event, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 2db300300..1c818969f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -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() // 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, - newFilters: List, - ): 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>, + 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>, ) { - 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, ) { - 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) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt index c186cbe92..ea3519b99 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt @@ -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> = { 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?, ) { - 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() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt index a3a9c63c6..5afc9cf01 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt @@ -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) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt index 81344f075..17219af0c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt @@ -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}") + } + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt index cfcc7fe00..f1be2aa3b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt @@ -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(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?, ) { - 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt deleted file mode 100644 index d7a53b8ad..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt +++ /dev/null @@ -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) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt index edbf77f16..415fae803 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt @@ -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) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index 8bb97f855..aa96126f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -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) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt index a646d2414..e9425b3a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt @@ -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) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt new file mode 100644 index 000000000..64cf00d10 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt @@ -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 = mutableMapOf() + + // Avoids sending multiple replies for each auth. + private val uniqueAuthChallengesSent: MutableSet = 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 } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt new file mode 100644 index 000000000..0574ef147 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -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) -> List, +) : IAuthStatus { + private val authStatus = mutableMapOf() + + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryState.kt new file mode 100644 index 000000000..e03634e23 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryState.kt @@ -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 { + // 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() + private val filterStates = mutableMapOf>() + + 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, + ) { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryStatus.kt new file mode 100644 index 000000000..49789b5f8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryStatus.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.counts + +enum class CountQueryStatus { + SENT, + RECEIVED, + CLOSED, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt new file mode 100644 index 000000000..f24cea6e6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt @@ -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>() + + fun subGetOrCreate(relay: NormalizedRelayUrl): CountQueryState = queryStates[relay] ?: CountQueryState().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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt index 364776fe5..a74419013 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt @@ -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, ) {} } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt index 75c14b13c..5e42d4307 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt @@ -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) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt new file mode 100644 index 000000000..723aca519 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt @@ -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, + newFilters: List, + ): 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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt new file mode 100644 index 000000000..81d7789b1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt @@ -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>>() + val relays = MutableStateFlow(setOf()) + + private val relayState = LargeCache>() + + fun subState(subId: String): CountQueryState = relayState.getOrCreate(subId) { CountQueryState() } + + private fun updateRelays() { + val myRelays = mutableSetOf() + queries.forEach { queryId, perRelayFilters -> + myRelays.addAll(perRelayFilters.keys) + } + + if (relays.value != myRelays) { + relays.tryEmit(myRelays) + } + } + + fun activeFiltersFor(url: NormalizedRelayUrl): Map> { + val myRelays = mutableMapOf>() + 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>, + ): Set { + 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 = + if (queries.containsKey(queryId)) { + val oldRelays = queries.get(queryId)?.keys ?: emptySet() + queries.remove(queryId) + updateRelays() + oldRelays + } else { + emptySet() + } + + fun getSubscriptionFiltersOrNull(queryId: String): Map>? = 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, + 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?, + newFilters: List?, + 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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt index cef3107a3..bea12865f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -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, -) { - private val tries = mutableMapOf() +class PoolEventOutbox { + private var eventOutbox = mapOf() + val relays = MutableStateFlow(setOf()) - fun updateRelays(newRelays: Set) { - relays = newRelays - } + fun updateRelays() { + val myRelays = mutableSetOf() + 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 = 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 { + val myEvents = mutableSetOf() + eventOutbox.forEach { (eventId, outboxCache) -> + if (url in outboxCache.relays) { + myEvents.add(eventId) + } + } + return myEvents + } + + fun markAsSending( + event: Event, + relays: Set, + ): Set { + 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 = mutableListOf(), - val responses: MutableList = 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, + 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) + } + } + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt deleted file mode 100644 index 67356b2ed..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt +++ /dev/null @@ -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() - val relays = MutableStateFlow(setOf()) - - fun updateRelays() { - val myRelays = mutableSetOf() - eventOutbox.values.forEach { - myRelays.addAll(it.relaysLeft()) - } - - if (relays.value != myRelays) { - relays.tryEmit(myRelays) - } - } - - fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set { - val myEvents = mutableSetOf() - eventOutbox.forEach { (eventId, outboxCache) -> - if (url in outboxCache.relays) { - myEvents.add(eventId) - } - } - return myEvents - } - - fun markAsSending( - event: Event, - relays: Set, - ) { - 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) - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt new file mode 100644 index 000000000..195c3f909 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -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, +) { + private val tries = mutableMapOf() + + fun updateRelays(newRelays: Set) { + relays = newRelays + } + + fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false + + fun isDone() = relays.all { isDone(it) } + + fun relaysLeft(): Set = 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 = mutableListOf(), + val responses: MutableList = mutableListOf(), + ) { + fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3 + } + + class Response( + val success: Boolean, + val message: String, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt new file mode 100644 index 000000000..0e505b132 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -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>>() + private val desiredSubListeners = LargeCache() + val desiredRelays = MutableStateFlow(setOf()) + + /** + * relay states are kept and only removed after everything is processed. + */ + private val relayState = LargeCache>() + + fun subState(subId: String): RequestSubscriptionState = relayState.getOrCreate(subId) { RequestSubscriptionState() } + + private fun updateRelays() { + val myRelays = mutableSetOf() + desiredSubs.forEach { sub, perRelayFilters -> + myRelays.addAll(perRelayFilters.keys) + } + + if (desiredRelays.value != myRelays) { + desiredRelays.tryEmit(myRelays) + } + } + + fun activeFiltersFor(url: NormalizedRelayUrl): Map> { + val myRelays = mutableMapOf>() + 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>, + listener: IRequestListener?, + ): Set { + // 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 = + 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>? = 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, + 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?, + newFilters: List?, + 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. + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt deleted file mode 100644 index 62b353a12..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt +++ /dev/null @@ -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>>() - val relays = MutableStateFlow(setOf()) - - fun updateRelays() { - val myRelays = mutableSetOf() - subscriptions.forEach { sub, perRelayFilters -> - myRelays.addAll(perRelayFilters.keys) - } - - if (relays.value != myRelays) { - relays.tryEmit(myRelays) - } - } - - fun activeFiltersFor(url: NormalizedRelayUrl): Map> { - val myRelays = mutableMapOf>() - subscriptions.forEach { sub, perRelayFilters -> - val filters = perRelayFilters.get(url) - if (filters != null) { - myRelays.put(sub, filters) - } - } - return myRelays - } - - fun addOrUpdate( - subscriptionId: String, - filters: Map>, - ) { - subscriptions.put(subscriptionId, filters) - updateRelays() - } - - fun remove(subscriptionId: String) { - if (subscriptions.containsKey(subscriptionId)) { - subscriptions.remove(subscriptionId) - updateRelays() - } - } - - fun forEachSub( - relay: NormalizedRelayUrl, - run: (String, List) -> Unit, - ) { - subscriptions.forEach { subId, filters -> - val filters = filters[relay] - if (!filters.isNullOrEmpty()) { - run(subId, filters) - } else { - null - } - } - } - - fun getSubscriptionFiltersOrNull(subId: String): Map>? = subscriptions.get(subId) -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index d91cb08d9..40f6b9c4d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -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() @@ -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, - ) { - getOrCreateRelay(relay).sendRequest(subId, filters) - } + cmd: Command, + ) = getOrCreateRelay(relay).sendOrConnectAndSync(cmd) - fun sendRequest( - subId: String, - filters: Map>, - ) { - 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, - ) { - getOrCreateRelay(relay).sendCount(subId, filters) - } + cmd: Command, + ) = getOrCreateRelay(relay).sendIfConnected(cmd) - fun sendCount( - subId: String, - filters: Map>, - ) { - 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, + cmd: Command, ) { list.forEach { - getOrCreateRelay(it).send(signedEvent) + getOrCreateRelay(it).sendOrConnectAndSync(cmd) + } + } + + fun sendIfConnected( + list: Set, + 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt similarity index 55% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt index 6064dbc2a..2acd595ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt @@ -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?, + ) {} + + fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) {} + + fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) {} + + fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) {} +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt new file mode 100644 index 000000000..b27dc1efd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt @@ -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>() + + fun subGetOrCreate(relay: NormalizedRelayUrl): RequestSubscriptionState = subStates[relay] ?: RequestSubscriptionState().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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/ReqSubStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/ReqSubStatus.kt new file mode 100644 index 000000000..17fc06d00 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/ReqSubStatus.kt @@ -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, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt new file mode 100644 index 000000000..f2c7b253b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt @@ -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 { + // 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() + private val filterStates = mutableMapOf>() + + 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, + ) { + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt index 546c8b0f7..8f0a2e221 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt @@ -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) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/ReqStatsRepository.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/ReqStatsRepository.kt index 246e2cbb9..881a7c208 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/ReqStatsRepository.kt @@ -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) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt index dc2f6e268..ebca4725a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt @@ -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, - ) + fun sendOrConnectAndSync(cmd: Command) - fun sendCount( - subId: String, - filters: List, - ) - - fun send(event: Event) - - fun sendAuth(signedEvent: RelayAuthEvent) - - fun sendEvent(event: Event) - - fun close(subscriptionId: String) + fun sendIfConnected(cmd: Command) fun disconnect() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 62efdae2d..4dfb17704 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -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() - - private val authResponseWatcher = mutableMapOf() - private val authChallengesSent = mutableSetOf() - + // 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(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(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, - ) { - if (isConnectionStarted()) { - if (isReady) { - if (filters.isNotEmpty()) { - afterEOSEPerSubscription[subId] = false - writeToSocket(ReqCmd(subId, filters)) - } - } - } else { - connectAndSyncFiltersIfDisconnected() - } - } - - override fun sendCount( - subId: String, - filters: List, - ) { - 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) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt deleted file mode 100644 index 263d0dbe2..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt +++ /dev/null @@ -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() - - 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) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt new file mode 100644 index 000000000..65ff8f7d0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt @@ -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() + private val reqs = LargeCache>() + private val counts = LargeCache>() + + 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, + ) { + reqs.put(subId, filters) + client.sendOrConnectAndSync(ReqCmd(subId, filters)) + } + + fun sendCount( + queryId: String, + filters: List, + ) { + 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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index 13be36066..c7576094f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -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(100) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index 8a5bc2343..db1e5a926 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -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) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt index 8ed376769..47e94c0e5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt @@ -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>? = null // Inactive when null + private var currentVersion: Map>? = null // Inactive when null fun reset() { - filters = null + currentVersion = null } fun updateFilters(newFilters: Map>?) { - filters = newFilters + currentVersion = newFilters } - fun filters() = filters - - fun callEose( - time: Long, - relay: NormalizedRelayUrl, - ) { - onEose?.let { it(time, relay) } - } + fun filters() = currentVersion } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt index 1ca12b2db..c87bb5158 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt @@ -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() - 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>?, - currentFilters: Map>?, + listener: IRequestListener, + newFilters: Map>?, + oldFilters: Map>?, ) { - 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) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt index e1a04b381..5ee890bcc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt @@ -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, ) : 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" diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt index 1ae9543a6..268f20d2e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt @@ -61,7 +61,7 @@ class CommandDeserializer : StdDeserializer(Command::class.java) { } CountCmd.LABEL -> { - val subId = jp.nextTextValue() + val queryId = jp.nextTextValue() val filters = mutableListOf() while (jp.nextToken() != JsonToken.END_ARRAY) { @@ -71,7 +71,7 @@ class CommandDeserializer : StdDeserializer(Command::class.java) { } CountCmd( - subId = subId, + queryId = queryId, filters = filters, ) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt index 47775b850..b1ffa8e0b 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt @@ -59,7 +59,7 @@ class CommandSerializer : StdSerializer(Command::class.java) { } is CountCmd -> { - gen.writeString(cmd.subId) + gen.writeString(cmd.queryId) cmd.filters.forEach { filterSerializer.serialize(it, gen, provider) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index 287673f4d..111a05ddd 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -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?, ) { - 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?, ) { - 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() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index 1fa725e3c..c88551e13 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -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]) } }