diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index bbfcdbaf7..331f2dc82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -60,6 +60,7 @@ 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.accessories.RelayOfflineTracker 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 @@ -192,14 +193,26 @@ class AppModules( // Tries to verify new OTS events when they arrive. val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationDefaultScope) + // Tracks if it is possible to connect to relays. + val failureTracker = RelayOfflineTracker(client) + + // Captures statistics about relays val relayStats = RelayStats(client) + // Logs debug messages when needed 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, authCoordinator.receiver, applicationDefaultScope) + val sources: RelaySubscriptionsCoordinator = + RelaySubscriptionsCoordinator( + LocalCache, + client, + authCoordinator.receiver, + failureTracker, + applicationDefaultScope, + ) // keeps all accounts live val accountsCache = 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 17a8d2dd2..88c902298 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,6 +40,7 @@ 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.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus import kotlinx.coroutines.CoroutineScope @@ -47,10 +48,11 @@ class RelaySubscriptionsCoordinator( cache: LocalCache, client: INostrClient, authenticator: IAuthStatus, + failureTracker: RelayOfflineTracker, scope: CoroutineScope, ) { // main one: notifications, dms and account settings - val account = AccountFilterAssembler(client, cache, authenticator, scope) + val account = AccountFilterAssembler(client, cache, authenticator, failureTracker, scope) // always running, feed assemblers. val home = HomeFilterAssembler(client) @@ -62,7 +64,7 @@ class RelaySubscriptionsCoordinator( // they are active when looking at events, users, channels. val channelFinder = ChannelFinderFilterAssemblyGroup(client) val eventFinder = EventFinderFilterAssembler(client) - val userFinder = UserFinderFilterAssembler(client, cache) + val userFinder = UserFinderFilterAssembler(client, cache, failureTracker) // 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 7ba6bc823..b62529dcc 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 @@ -32,6 +32,7 @@ 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.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus import kotlinx.coroutines.CoroutineScope @@ -49,12 +50,13 @@ class AccountFilterAssembler( client: INostrClient, cache: LocalCache, authenticator: IAuthStatus, + failureTracker: RelayOfflineTracker, scope: CoroutineScope, ) : ComposeSubscriptionManager() { val group = listOf( AccountMetadataEoseManager(client, ::allKeys), - AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, ::allKeys), + AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, failureTracker, ::allKeys), AccountGiftWrapsEoseManager(client, ::allKeys), AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(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 index 448528c29..fc7bcc80b 100644 --- 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 @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follow 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 @@ -32,6 +30,7 @@ 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.accessories.RelayOfflineTracker 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 @@ -51,7 +50,6 @@ 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. @@ -62,13 +60,12 @@ class AccountFollowsLoaderSubAssembler( val cache: LocalCache, val scope: CoroutineScope, val authStatus: IAuthStatus, + val failureTracker: RelayOfflineTracker, 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) @@ -141,56 +138,28 @@ class AccountFollowsLoaderSubAssembler( 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() - + val users = mutableSetOf() accounts.forEach { key -> key.kind3FollowList.userList.value.forEach { user -> if (user.authorRelayList() == null) { - noOutboxList.add(user) + users.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 + if (users.isEmpty()) return null - val perRelay = pickRelaysToLoadUsers(noOutboxList, indexRelays, homeRelays, searchRelays, commonRelays, hasTried) + println("AccountFollowNeeds ${users.size}") - hasTried.removeEveryoneBut(noOutboxList) + val connectedRelays = client.relayStatusFlow().value.connected + + val perRelay = pickRelaysToLoadUsers(users, accounts, connectedRelays, failureTracker.cannotConnectRelays, hasTried) + + hasTried.removeEveryoneBut(users) return perRelay.mapNotNull { (relay, users) -> if (users.isNotEmpty()) { 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 index 07a35693c..3ed810db1 100644 --- 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 @@ -20,71 +20,62 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Constants +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.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 +import kotlin.collections.ifEmpty -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, +fun pickRelaysToLoadUsers( + users: Set, + accounts: Collection, + connected: Set, + cannotConnectRelays: Set, hasTried: EOSEAccountFast, -): List { - val both = loadMetadata.intersect(loadOutbox) - val onlyMetadata = loadMetadata - both - val onlyOutbox = loadOutbox - both +): Map> { + val indexRelays = mutableSetOf() + val homeRelays = mutableSetOf() + val searchRelays = mutableSetOf() + val commonRelays = mutableSetOf() - 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) + accounts.forEach { key -> + indexRelays.addAll( + key.indexerRelayList.flow.value + .ifEmpty { DefaultIndexerRelayList }, + ) - 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 - } - } + 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 + .ifEmpty { Constants.eventFinderRelays }, + ) + } + + return pickRelaysToLoadUsers( + users, + indexRelays - cannotConnectRelays, + homeRelays - cannotConnectRelays, + searchRelays - cannotConnectRelays, + connected, + commonRelays - cannotConnectRelays, + hasTried, + ) } fun pickRelaysToLoadUsers( @@ -92,6 +83,7 @@ fun pickRelaysToLoadUsers( indexRelays: Set, homeRelays: Set, searchRelays: Set, + connected: Set, commonRelays: Set, hasTried: EOSEAccountFast, ): Map> = @@ -166,6 +158,23 @@ fun pickRelaysToLoadUsers( add(it, key.pubkeyHex) } + val connectedRelaysLeftToTry = + (connected - tried) + .sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + }.take(100) + + // picks one at random to avoid overloading these relays + if (users.size > 300) { + connectedRelaysLeftToTry.take(20).forEach { + add(it, key.pubkeyHex) + } + } else { + connectedRelaysLeftToTry.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 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 680a4ed98..63efc0808 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 @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.Us 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 +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker // This allows multiple screen to be listening to tags, even the same tag class UserFinderQueryState( @@ -38,10 +39,11 @@ class UserFinderQueryState( class UserFinderFilterAssembler( client: INostrClient, cache: LocalCache, + failureTracker: RelayOfflineTracker, ) : ComposeSubscriptionManager() { val group = listOf( - UserOutboxFinderSubAssembler(client, cache, ::allKeys), + UserOutboxFinderSubAssembler(client, cache, failureTracker, ::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 deleted file mode 100644 index 594252524..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt +++ /dev/null @@ -1,55 +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.User -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 - -val RelayListKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND) - -fun findFindUserOutBox( - authors: Set, - connectedRelays: Set, - indexRelays: Set, - searchRelays: Set, - allRelays: Set, - hasTried: EOSEAccountFast, -): List { - val perRelayKeysBoth = pickRelaysToLoadUsers(authors, connectedRelays, indexRelays, searchRelays, allRelays, hasTried) - - return perRelayKeysBoth.mapNotNull { - val sortedUsers = it.value.sorted() - if (sortedUsers.isNotEmpty()) { - RelayBasedFilter( - relay = it.key, - filter = Filter(kinds = RelayListKinds, authors = sortedUsers), - ) - } else { - null - } - } -} 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 index dd2a54b79..a540b972b 100644 --- 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 @@ -20,27 +20,32 @@ */ 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.account.follows.pickRelaysToLoadUsers import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker 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.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.collections.ifEmpty class UserOutboxFinderSubAssembler( client: INostrClient, val cache: LocalCache, + val failureTracker: RelayOfflineTracker, allKeys: () -> Set, ) : BaseEoseManager(client, allKeys) { + val relayListKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND) + /** * 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). @@ -91,35 +96,28 @@ class UserOutboxFinderSubAssembler( if (noOutboxList.isEmpty()) return null - val indexRelays = mutableSetOf() - val searchRelays = mutableSetOf() - val allRelays = mutableSetOf() + val accounts = keys.mapTo(mutableSetOf()) { it.account } + val connectedRelays = client.relayStatusFlow().value.connected - keys.mapTo(mutableSetOf()) { it.account }.forEach { acc -> - // broadens the search as it goes. - indexRelays.addAll( - acc.indexerRelayList.flow.value - .ifEmpty { DefaultIndexerRelayList }, + val perRelayKeysBoth = + pickRelaysToLoadUsers( + noOutboxList, + accounts, + connectedRelays, + failureTracker.cannotConnectRelays, + hasTried, ) - 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) + return perRelayKeysBoth.mapNotNull { + val sortedUsers = it.value.sorted() + if (sortedUsers.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = Filter(kinds = relayListKinds, authors = sortedUsers), + ) + } else { + null + } } - 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/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index ada2d229f..b678b35aa 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 @@ -98,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.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -1678,6 +1679,7 @@ fun mockAccountViewModel(): AccountViewModel { val authenticator = EmptyIAuthStatus val nwcFilters = NWCPaymentFilterAssembler(client) + val failureTracker = RelayOfflineTracker(client) val account = Account( @@ -1696,7 +1698,7 @@ fun mockAccountViewModel(): AccountViewModel { settings = uiState, torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), - dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, scope), + dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), ).also { mockedCache = it } @@ -1727,6 +1729,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { val authenticator = EmptyIAuthStatus val nwcFilters = NWCPaymentFilterAssembler(client) + val failureTracker = RelayOfflineTracker(client) val account = Account( @@ -1745,7 +1748,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { settings = uiState, torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), - dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, scope), + dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), ).also { vitorCache = it } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt new file mode 100644 index 000000000..3af03e46f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt @@ -0,0 +1,71 @@ +/** + * 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.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log + +/** + * Listens to NostrClient's onNotify messages from the relay + */ +class RelayOfflineTracker( + val client: INostrClient, +) { + companion object { + const val TAG = "RelayOfflineTracker" + } + + val cannotConnectRelays = mutableSetOf() + + private val clientListener = + object : IRelayClientListener { + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + cannotConnectRelays.remove(relay.url) + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + cannotConnectRelays.add(relay.url) + } + } + + init { + Log.d(TAG, "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d(TAG, "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +}