- Separates a relay failure tracker to a module

- Updates finding the outbox relay set to include the set of connected relays before all of the relays.
This commit is contained in:
Vitor Pamplona
2025-10-21 15:55:32 -04:00
parent a27df3a9b6
commit e2b2b10994
10 changed files with 201 additions and 187 deletions
@@ -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.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient 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.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.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
@@ -192,14 +193,26 @@ class AppModules(
// Tries to verify new OTS events when they arrive. // Tries to verify new OTS events when they arrive.
val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationDefaultScope) 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) val relayStats = RelayStats(client)
// Logs debug messages when needed
val detailedLogger = if (isDebug) RelayLogger(client, true, false) else null val detailedLogger = if (isDebug) RelayLogger(client, true, false) else null
val relayReqStats = if (isDebug) RelayReqStats(client) else null val relayReqStats = if (isDebug) RelayReqStats(client) else null
val logger = if (isDebug) RelaySpeedLogger(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null
// Coordinates all subscriptions for the Nostr Client // 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 // keeps all accounts live
val accountsCache = val accountsCache =
@@ -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.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler 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.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.auth.IAuthStatus
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -47,10 +48,11 @@ class RelaySubscriptionsCoordinator(
cache: LocalCache, cache: LocalCache,
client: INostrClient, client: INostrClient,
authenticator: IAuthStatus, authenticator: IAuthStatus,
failureTracker: RelayOfflineTracker,
scope: CoroutineScope, scope: CoroutineScope,
) { ) {
// main one: notifications, dms and account settings // 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. // always running, feed assemblers.
val home = HomeFilterAssembler(client) val home = HomeFilterAssembler(client)
@@ -62,7 +64,7 @@ class RelaySubscriptionsCoordinator(
// they are active when looking at events, users, channels. // they are active when looking at events, users, channels.
val channelFinder = ChannelFinderFilterAssemblyGroup(client) val channelFinder = ChannelFinderFilterAssemblyGroup(client)
val eventFinder = EventFinderFilterAssembler(client) val eventFinder = EventFinderFilterAssembler(client)
val userFinder = UserFinderFilterAssembler(client, cache) val userFinder = UserFinderFilterAssembler(client, cache, failureTracker)
// active when searching or tagging users. // active when searching or tagging users.
val search = SearchFilterAssembler(client, scope, cache) val search = SearchFilterAssembler(client, scope, cache)
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59Gi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient 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.auth.IAuthStatus
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -49,12 +50,13 @@ class AccountFilterAssembler(
client: INostrClient, client: INostrClient,
cache: LocalCache, cache: LocalCache,
authenticator: IAuthStatus, authenticator: IAuthStatus,
failureTracker: RelayOfflineTracker,
scope: CoroutineScope, scope: CoroutineScope,
) : ComposeSubscriptionManager<AccountQueryState>() { ) : ComposeSubscriptionManager<AccountQueryState>() {
val group = val group =
listOf( listOf(
AccountMetadataEoseManager(client, ::allKeys), AccountMetadataEoseManager(client, ::allKeys),
AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, ::allKeys), AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, failureTracker, ::allKeys),
AccountGiftWrapsEoseManager(client, ::allKeys), AccountGiftWrapsEoseManager(client, ::allKeys),
AccountDraftsEoseManager(client, ::allKeys), AccountDraftsEoseManager(client, ::allKeys),
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
@@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follow
import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account 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.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.IEoseManager 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.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient 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.auth.IAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter 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.pool.groupByRelay
@@ -51,7 +50,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.collections.forEach import kotlin.collections.forEach
import kotlin.collections.ifEmpty
/** /**
* This downloads the outbox events for all Follows of all users. * This downloads the outbox events for all Follows of all users.
@@ -62,13 +60,12 @@ class AccountFollowsLoaderSubAssembler(
val cache: LocalCache, val cache: LocalCache,
val scope: CoroutineScope, val scope: CoroutineScope,
val authStatus: IAuthStatus, val authStatus: IAuthStatus,
val failureTracker: RelayOfflineTracker,
val allKeys: () -> Set<AccountQueryState>, val allKeys: () -> Set<AccountQueryState>,
) : IEoseManager { ) : IEoseManager {
private val logTag = "AccountFollowsLoaderSubAssembler" private val logTag = "AccountFollowsLoaderSubAssembler"
private val orchestrator = SubscriptionController(client) private val orchestrator = SubscriptionController(client)
private val cannotConnectRelays = mutableSetOf<NormalizedRelayUrl>()
// Refreshes observers in batches of 500ms // Refreshes observers in batches of 500ms
private val bundler = BundledUpdate(500, Dispatchers.Default) private val bundler = BundledUpdate(500, Dispatchers.Default)
@@ -141,56 +138,28 @@ class AccountFollowsLoaderSubAssembler(
newEose(TimeUtils.now(), relay, forFilters) newEose(TimeUtils.now(), relay, forFilters)
} }
} }
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
cannotConnectRelays.add(relay)
}
}, },
) )
fun updateFilterForAllAccounts(accounts: Collection<Account>): List<RelayBasedFilter>? { fun updateFilterForAllAccounts(accounts: Collection<Account>): List<RelayBasedFilter>? {
val noOutboxList = mutableSetOf<User>() val users = mutableSetOf<User>()
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
val homeRelays = mutableSetOf<NormalizedRelayUrl>()
val searchRelays = mutableSetOf<NormalizedRelayUrl>()
val commonRelays = mutableSetOf<NormalizedRelayUrl>()
accounts.forEach { key -> accounts.forEach { key ->
key.kind3FollowList.userList.value.forEach { user -> key.kind3FollowList.userList.value.forEach { user ->
if (user.authorRelayList() == null) { 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) -> return perRelay.mapNotNull { (relay, users) ->
if (users.isNotEmpty()) { if (users.isNotEmpty()) {
@@ -20,71 +20,62 @@
*/ */
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows 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.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.mapOfSet import com.vitorpamplona.quartz.utils.mapOfSet
import kotlin.collections.ifEmpty
val MetadataOnlyKinds = listOf(MetadataEvent.KIND) fun pickRelaysToLoadUsers(
val OutboxOnlyKinds = listOf(AdvertisedRelayListEvent.KIND) users: Set<User>,
val BothKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND) accounts: Collection<Account>,
connected: Set<NormalizedRelayUrl>,
fun filterFindFollowMetadataForKey( cannotConnectRelays: Set<NormalizedRelayUrl>,
loadMetadata: Set<User>,
loadOutbox: Set<User>,
connectedRelays: Set<NormalizedRelayUrl>,
indexRelays: Set<NormalizedRelayUrl>,
searchRelays: Set<NormalizedRelayUrl>,
allRelays: Set<NormalizedRelayUrl>,
hasTried: EOSEAccountFast<User>, hasTried: EOSEAccountFast<User>,
): List<RelayBasedFilter> { ): Map<NormalizedRelayUrl, Set<HexKey>> {
val both = loadMetadata.intersect(loadOutbox) val indexRelays = mutableSetOf<NormalizedRelayUrl>()
val onlyMetadata = loadMetadata - both val homeRelays = mutableSetOf<NormalizedRelayUrl>()
val onlyOutbox = loadOutbox - both val searchRelays = mutableSetOf<NormalizedRelayUrl>()
val commonRelays = mutableSetOf<NormalizedRelayUrl>()
val perRelayKeysBoth = pickRelaysToLoadUsers(both, connectedRelays, indexRelays, searchRelays, allRelays, hasTried) accounts.forEach { key ->
val perRelayKeysOnlyMetadata = pickRelaysToLoadUsers(onlyMetadata, connectedRelays, indexRelays, searchRelays, allRelays, hasTried) indexRelays.addAll(
val perRelayKeysOnlyOutbox = pickRelaysToLoadUsers(onlyOutbox, connectedRelays, indexRelays, searchRelays, allRelays, hasTried) key.indexerRelayList.flow.value
.ifEmpty { DefaultIndexerRelayList },
)
return perRelayKeysBoth.mapNotNull { homeRelays.addAll(key.nip65RelayList.allFlowNoDefaults.value)
val sortedUsers = it.value.sorted() homeRelays.addAll(key.privateStorageRelayList.flow.value)
if (sortedUsers.isNotEmpty()) { homeRelays.addAll(key.localRelayList.flow.value)
RelayBasedFilter(
relay = it.key, searchRelays.addAll(key.trustedRelayList.flow.value)
filter = Filter(kinds = BothKinds, authors = sortedUsers), searchRelays.addAll(
) key.searchRelayList.flow.value
} else { .ifEmpty { DefaultSearchRelayList },
null )
}
} + // uses followShared to ignore personal relays when finding users.
perRelayKeysOnlyMetadata.mapNotNull { commonRelays.addAll(
val sortedUsers = it.value.sorted() key.followSharedOutboxesOrProxy.flow.value
if (sortedUsers.isNotEmpty()) { .ifEmpty { Constants.eventFinderRelays },
RelayBasedFilter( )
relay = it.key, }
filter = Filter(kinds = MetadataOnlyKinds, authors = sortedUsers),
) return pickRelaysToLoadUsers(
} else { users,
null indexRelays - cannotConnectRelays,
} homeRelays - cannotConnectRelays,
} + searchRelays - cannotConnectRelays,
perRelayKeysOnlyOutbox.mapNotNull { connected,
val sortedUsers = it.value.sorted() commonRelays - cannotConnectRelays,
if (sortedUsers.isNotEmpty()) { hasTried,
RelayBasedFilter( )
relay = it.key,
filter = Filter(kinds = OutboxOnlyKinds, authors = sortedUsers),
)
} else {
null
}
}
} }
fun pickRelaysToLoadUsers( fun pickRelaysToLoadUsers(
@@ -92,6 +83,7 @@ fun pickRelaysToLoadUsers(
indexRelays: Set<NormalizedRelayUrl>, indexRelays: Set<NormalizedRelayUrl>,
homeRelays: Set<NormalizedRelayUrl>, homeRelays: Set<NormalizedRelayUrl>,
searchRelays: Set<NormalizedRelayUrl>, searchRelays: Set<NormalizedRelayUrl>,
connected: Set<NormalizedRelayUrl>,
commonRelays: Set<NormalizedRelayUrl>, commonRelays: Set<NormalizedRelayUrl>,
hasTried: EOSEAccountFast<User>, hasTried: EOSEAccountFast<User>,
): Map<NormalizedRelayUrl, Set<HexKey>> = ): Map<NormalizedRelayUrl, Set<HexKey>> =
@@ -166,6 +158,23 @@ fun pickRelaysToLoadUsers(
add(it, key.pubkeyHex) 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) { if (searchRelaysLeftToTry.size < 2) {
// This creates a pre-deterministic order of the array such that // This creates a pre-deterministic order of the array such that
// if this function is called twice, it returns the same arrays // if this function is called twice, it returns the same arrays
@@ -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.UserReportsSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler 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.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
// This allows multiple screen to be listening to tags, even the same tag // This allows multiple screen to be listening to tags, even the same tag
class UserFinderQueryState( class UserFinderQueryState(
@@ -38,10 +39,11 @@ class UserFinderQueryState(
class UserFinderFilterAssembler( class UserFinderFilterAssembler(
client: INostrClient, client: INostrClient,
cache: LocalCache, cache: LocalCache,
failureTracker: RelayOfflineTracker,
) : ComposeSubscriptionManager<UserFinderQueryState>() { ) : ComposeSubscriptionManager<UserFinderQueryState>() {
val group = val group =
listOf( listOf(
UserOutboxFinderSubAssembler(client, cache, ::allKeys), UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys),
UserWatcherSubAssembler(client, cache, ::allKeys), UserWatcherSubAssembler(client, cache, ::allKeys),
UserReportsSubAssembler(client, ::allKeys), UserReportsSubAssembler(client, ::allKeys),
) )
@@ -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<User>,
connectedRelays: Set<NormalizedRelayUrl>,
indexRelays: Set<NormalizedRelayUrl>,
searchRelays: Set<NormalizedRelayUrl>,
allRelays: Set<NormalizedRelayUrl>,
hasTried: EOSEAccountFast<User>,
): List<RelayBasedFilter> {
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
}
}
}
@@ -20,27 +20,32 @@
*/ */
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders 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.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager 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.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast 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.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.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.collections.ifEmpty import kotlin.collections.ifEmpty
class UserOutboxFinderSubAssembler( class UserOutboxFinderSubAssembler(
client: INostrClient, client: INostrClient,
val cache: LocalCache, val cache: LocalCache,
val failureTracker: RelayOfflineTracker,
allKeys: () -> Set<UserFinderQueryState>, allKeys: () -> Set<UserFinderQueryState>,
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) { ) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
val relayListKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND)
/** /**
* This assembler saves the EOSE per user key. That EOSE includes their metadata, etc * 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). * and reports, but only from trusted accounts (follows of all logged in users).
@@ -91,35 +96,28 @@ class UserOutboxFinderSubAssembler(
if (noOutboxList.isEmpty()) return null if (noOutboxList.isEmpty()) return null
val indexRelays = mutableSetOf<NormalizedRelayUrl>() val accounts = keys.mapTo(mutableSetOf()) { it.account }
val searchRelays = mutableSetOf<NormalizedRelayUrl>() val connectedRelays = client.relayStatusFlow().value.connected
val allRelays = mutableSetOf<NormalizedRelayUrl>()
keys.mapTo(mutableSetOf()) { it.account }.forEach { acc -> val perRelayKeysBoth =
// broadens the search as it goes. pickRelaysToLoadUsers(
indexRelays.addAll( noOutboxList,
acc.indexerRelayList.flow.value accounts,
.ifEmpty { DefaultIndexerRelayList }, connectedRelays,
failureTracker.cannotConnectRelays,
hasTried,
) )
indexRelays.addAll(acc.proxyRelayList.flow.value)
searchRelays.addAll(acc.nip65RelayList.allFlowNoDefaults.value) return perRelayKeysBoth.mapNotNull {
searchRelays.addAll(acc.privateStorageRelayList.flow.value) val sortedUsers = it.value.sorted()
searchRelays.addAll(acc.localRelayList.flow.value) if (sortedUsers.isNotEmpty()) {
searchRelays.addAll(acc.searchRelayList.flow.value) RelayBasedFilter(
searchRelays.addAll(acc.trustedRelayList.flow.value) relay = it.key,
filter = Filter(kinds = relayListKinds, authors = sortedUsers),
allRelays.addAll(acc.followOutboxesOrProxy.flow.value) )
} else {
null
}
} }
allRelays.addAll(Constants.eventFinderRelays)
return findFindUserOutBox(
noOutboxList,
client.relayStatusFlow().value.connected,
indexRelays,
searchRelays,
allRelays,
hasTried,
)
} }
} }
@@ -98,6 +98,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient 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.client.auth.EmptyIAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -1678,6 +1679,7 @@ fun mockAccountViewModel(): AccountViewModel {
val authenticator = EmptyIAuthStatus val authenticator = EmptyIAuthStatus
val nwcFilters = NWCPaymentFilterAssembler(client) val nwcFilters = NWCPaymentFilterAssembler(client)
val failureTracker = RelayOfflineTracker(client)
val account = val account =
Account( Account(
@@ -1696,7 +1698,7 @@ fun mockAccountViewModel(): AccountViewModel {
settings = uiState, settings = uiState,
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, scope), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope),
).also { ).also {
mockedCache = it mockedCache = it
} }
@@ -1727,6 +1729,7 @@ fun mockVitorAccountViewModel(): AccountViewModel {
val authenticator = EmptyIAuthStatus val authenticator = EmptyIAuthStatus
val nwcFilters = NWCPaymentFilterAssembler(client) val nwcFilters = NWCPaymentFilterAssembler(client)
val failureTracker = RelayOfflineTracker(client)
val account = val account =
Account( Account(
@@ -1745,7 +1748,7 @@ fun mockVitorAccountViewModel(): AccountViewModel {
settings = uiState, settings = uiState,
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, scope), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope),
).also { ).also {
vitorCache = it vitorCache = it
} }
@@ -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<NormalizedRelayUrl>()
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)
}
}