refactor: move per-user metadata finder to commons on the new upstream model (Phase 2)

Move UserFinderFilterAssembler(+QueryState), the four sub-assemblers
(UserOutboxFinder, UserWatcher, UserReports, UserCards), FilterUserMetadataForKey,
and FilterReportsToKey from amethyst/reqCommand/user/ into commons/relayClient/user/,
plus the inner Account-free pickRelaysToLoadUsers overload into commons
PickRelaysToLoadUsers.kt.

Reconciled with upstream's re-architecture:
- UserFinderQueryState carries the narrow UserFinderAccount (Phase 1) instead of the
  full Account; DROPS AccountScopedQuery. The finder sub-assemblers extend the commons
  base managers and attribute inline via soleAccountPubKey — now sourced from
  UserFinderAccount.userFinderPubkeyHex — so upstream's per-account attribution +
  ExplainedFilter/SubPurpose tags are preserved unchanged.
- cache: LocalCache -> ICacheProvider; FilterUserMetadataForKey + pickRelaysToLoadUsers
  take an injected relayHints: HintIndexer instead of the static LocalCache.relayHints.
- UserOutboxFinderSubAssembler inlines the relay-tier union over the UserFinderAccount
  getters (behaviour-preserving vs the old Account-based outer overload).

Android unchanged: UserFinderShims.kt typealiases keep call sites (incl. reqCommand/event
EventFinder*) compiling; UserFinderFilterAssemblerSubscription + UserObservers stay in
amethyst (Account is-a UserFinderAccount). New commons UserFinderSubscription
(LocalUserFinder/LocalUserFinderAccount + overloads) and UserMetadataObservers
(observeUser*) added for Desktop. amethyst FilterFindFollowMetadataForKey's outer
overload now delegates to the commons inner one.

Green: commons JVM + iOS purity (verifyKmpPurity), :amethyst compilePlayDebugKotlin,
:desktopApp compile, spotless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-08-10 11:38:09 +03:00
co-authored by Claude Opus 4.8
parent 1511a8018b
commit 983fc1aab2
12 changed files with 521 additions and 184 deletions
@@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follow
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers
import com.vitorpamplona.amethyst.model.Account
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.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
fun pickRelaysToLoadUsers(
users: Set<User>,
@@ -68,6 +68,7 @@ fun pickRelaysToLoadUsers(
return pickRelaysToLoadUsers(
users,
LocalCache.relayHints,
indexRelays - cannotConnectRelays,
homeRelays - cannotConnectRelays,
searchRelays - cannotConnectRelays,
@@ -77,121 +78,3 @@ fun pickRelaysToLoadUsers(
hasTried,
)
}
fun pickRelaysToLoadUsers(
users: Set<User>,
indexRelays: Set<NormalizedRelayUrl>,
homeRelays: Set<NormalizedRelayUrl>,
searchRelays: Set<NormalizedRelayUrl>,
connected: Set<NormalizedRelayUrl>,
commonRelays: Set<NormalizedRelayUrl>,
cannotConnectRelays: Set<NormalizedRelayUrl>,
hasTried: EOSEAccountFast<User>,
): Map<NormalizedRelayUrl, Set<HexKey>> =
mapOfSet {
users.forEachIndexed { _, key ->
val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays
val outbox = key.authorRelayList()?.writeRelaysNorm()
if (!outbox.isNullOrEmpty()) {
// 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.allUsedRelays() + 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)
}
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
// 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)
}
}
}
}
}
}
}
@@ -0,0 +1,30 @@
/*
* 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
/**
* Back-compat aliases: the per-user metadata finder moved to commons
* (`com.vitorpamplona.amethyst.commons.relayClient.user`). Existing Android call
* sites that reference these by their old names resolve here.
*/
typealias UserFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
typealias UserFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
@@ -0,0 +1,147 @@
/*
* 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.commons.relayClient.user
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
fun pickRelaysToLoadUsers(
users: Set<User>,
relayHints: HintIndexer,
indexRelays: Set<NormalizedRelayUrl>,
homeRelays: Set<NormalizedRelayUrl>,
searchRelays: Set<NormalizedRelayUrl>,
connected: Set<NormalizedRelayUrl>,
commonRelays: Set<NormalizedRelayUrl>,
cannotConnectRelays: Set<NormalizedRelayUrl>,
hasTried: EOSEAccountFast<User>,
): Map<NormalizedRelayUrl, Set<HexKey>> =
mapOfSet {
users.forEachIndexed { _, key ->
val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays
val outbox = key.authorRelayList()?.writeRelaysNorm()
if (!outbox.isNullOrEmpty()) {
// 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.allUsedRelays() + 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)
}
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
// 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)
}
}
}
}
}
}
}
@@ -18,18 +18,16 @@
* 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
package com.vitorpamplona.amethyst.commons.relayClient.user
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler
import com.vitorpamplona.amethyst.commons.relayClient.user.loaders.UserOutboxFinderSubAssembler
import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserCardsSubAssembler
import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserReportsSubAssembler
import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserWatcherSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
@@ -37,13 +35,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
@Stable
class UserFinderQueryState(
val user: User,
override val account: Account,
) : AccountScopedQuery
val account: UserFinderAccount,
)
@Stable
class UserFinderFilterAssembler(
client: INostrClient,
cache: LocalCache,
cache: ICacheProvider,
failureTracker: RelayOfflineTracker,
) : ComposeSubscriptionManager<UserFinderQueryState>() {
val group =
@@ -0,0 +1,85 @@
/*
* 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.commons.relayClient.user
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
/**
* The shared per-user metadata data source for the current front end. A front
* end provides this once near its composition root (Android via AppModules,
* Desktop via its subscriptions coordinator). Reading it without a provider is
* a programming error the `observeUser*` composables must never be reachable
* from a composition that has no relay client (e.g. the Android `:napplet`
* sandbox process).
*/
val LocalUserFinder =
staticCompositionLocalOf<UserFinderFilterAssembler> {
error("LocalUserFinder not provided")
}
/**
* The current logged-in account, in the narrow [UserFinderAccount] view the
* finder needs to route REQs. Provided alongside [LocalUserFinder].
*/
val LocalUserFinderAccount =
staticCompositionLocalOf<UserFinderAccount> {
error("LocalUserFinderAccount not provided")
}
/**
* Subscribes to relay updates for [user]'s metadata (and relay list / reports /
* contact cards) for as long as this composable is in composition, coalesced
* with every other on-screen user into batched REQs by [dataSource].
*
* Because a `LazyColumn` composes only the visible window (+ a small prefetch
* buffer), this naturally means "load metadata only for users currently on
* screen" — the [LifecycleAwareKeyDataSourceSubscription] unsubscribes ~30s
* after the row leaves composition or the app is backgrounded.
*/
@Composable
fun UserFinderFilterAssemblerSubscription(
user: User,
account: UserFinderAccount,
dataSource: UserFinderFilterAssembler,
) {
// Different screens get their own query-state instance even when tracking
// the same user; the assembler dedups to one REQ per pubkey.
val state = remember(user, account) { UserFinderQueryState(user, account) }
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
/**
* Convenience overload that reads the front end's [LocalUserFinder] and
* [LocalUserFinderAccount] from the composition.
*/
@Composable
fun UserFinderFilterAssemblerSubscription(user: User) {
UserFinderFilterAssemblerSubscription(
user = user,
account = LocalUserFinderAccount.current,
dataSource = LocalUserFinder.current,
)
}
@@ -0,0 +1,177 @@
/*
* 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.commons.relayClient.user
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Shared, platform-agnostic observers for a single user's metadata (kind 0).
*
* Each observer both (a) opens a composition-scoped relay subscription for the
* user via [UserFinderFilterAssemblerSubscription] so metadata is fetched only
* while the user is on screen and (b) collects the resulting cache flow so the
* UI recomposes when the metadata arrives. The `(user)` overloads read the
* front end's [LocalUserFinder] / [LocalUserFinderAccount]; the explicit-param
* overloads are for callers that already hold both (and for tests).
*
* These are metadata-only. Richer per-user observers that depend on account
* subsystems not yet in commons (contact-card petnames, follow counts,
* bookmarks, statuses) remain in the Android layer for now and layer on top of
* the same subscription.
*/
@Composable
fun observeUserInfo(
user: User,
userFinder: UserFinderFilterAssembler,
account: UserFinderAccount,
): State<UserInfo?> {
UserFinderFilterAssemblerSubscription(user, account, userFinder)
return user.metadata().flow.collectAsStateWithLifecycle()
}
@Composable
fun observeUserInfo(user: User): State<UserInfo?> = observeUserInfo(user, LocalUserFinder.current, LocalUserFinderAccount.current)
@Composable
fun observeUserPicture(
user: User,
userFinder: UserFinderFilterAssembler,
account: UserFinderAccount,
): State<String?> {
UserFinderFilterAssemblerSubscription(user, account, userFinder)
val flow =
remember(user) {
user
.metadata()
.flow
.map { it?.info?.picture }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(
user
.metadataOrNull()
?.flow
?.value
?.info
?.picture,
)
}
@Composable
fun observeUserPicture(user: User): State<String?> = observeUserPicture(user, LocalUserFinder.current, LocalUserFinderAccount.current)
@Composable
fun observeUserBanner(
user: User,
userFinder: UserFinderFilterAssembler,
account: UserFinderAccount,
): State<String?> {
UserFinderFilterAssemblerSubscription(user, account, userFinder)
val flow =
remember(user) {
user
.metadata()
.flow
.map { it?.info?.banner }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(
user
.metadataOrNull()
?.flow
?.value
?.info
?.banner,
)
}
@Composable
fun observeUserBanner(user: User): State<String?> = observeUserBanner(user, LocalUserFinder.current, LocalUserFinderAccount.current)
@Composable
fun observeUserAboutMe(
user: User,
userFinder: UserFinderFilterAssembler,
account: UserFinderAccount,
): State<String> {
UserFinderFilterAssemblerSubscription(user, account, userFinder)
val flow =
remember(user) {
user
.metadata()
.flow
.map { it?.info?.about ?: "" }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(
user
.metadataOrNull()
?.flow
?.value
?.info
?.about ?: "",
)
}
@Composable
fun observeUserAboutMe(user: User): State<String> = observeUserAboutMe(user, LocalUserFinder.current, LocalUserFinderAccount.current)
/**
* The user's best available display name from their own metadata (kind 0),
* falling back to a truncated pubkey. Metadata-only: it does NOT apply the
* viewing account's private contact-card petname (that stays in the Android
* layer, which wraps this).
*/
@Composable
fun observeUserName(
user: User,
userFinder: UserFinderFilterAssembler,
account: UserFinderAccount,
): State<String> {
UserFinderFilterAssemblerSubscription(user, account, userFinder)
val flow =
remember(user) {
user
.metadata()
.flow
.map { it?.info?.bestName() ?: user.toBestDisplayName() }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(user.toBestDisplayName())
}
@Composable
fun observeUserName(user: User): State<String> = observeUserName(user, LocalUserFinder.current, LocalUserFinderAccount.current)
@@ -18,18 +18,18 @@
* 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
package com.vitorpamplona.amethyst.commons.relayClient.user.loaders
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
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.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class UserOutboxFinderSubAssembler(
client: INostrClient,
val cache: LocalCache,
val cache: ICacheProvider,
val failureTracker: RelayOfflineTracker,
allKeys: () -> Set<UserFinderQueryState>,
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
@@ -108,19 +108,37 @@ class UserOutboxFinderSubAssembler(
// and the users being resolved are whoever is on screen rather than anyone's follow list — so
// with several accounts active there is no single honest owner for a given filter, and
// splitting the sweep per account would re-issue the same lookups once per account.
// Deduped by pubkey, not by `Account`: that class uses identity equality, so two objects for
// the same logged-in user would look like two accounts and suppress attribution entirely.
// Deduped by pubkey, not by account identity: two objects for the same logged-in user would
// look like two accounts and suppress attribution entirely.
val soleAccountPubKey =
accounts
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
.mapTo(mutableSetOf()) { it.userFinderPubkeyHex }
.singleOrNull()
// Union of every asking account's relay tiers. The UserFinderAccount getters already apply the
// platform default fallbacks (index/search), matching the prior outer pickRelaysToLoadUsers.
val cannotConnect = failureTracker.cannotConnectRelays
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
val homeRelays = mutableSetOf<NormalizedRelayUrl>()
val searchRelays = mutableSetOf<NormalizedRelayUrl>()
val commonRelays = mutableSetOf<NormalizedRelayUrl>()
accounts.forEach { account ->
indexRelays.addAll(account.indexRelays())
homeRelays.addAll(account.outboxHomeRelays())
searchRelays.addAll(account.searchRelays())
commonRelays.addAll(account.commonRelays())
}
val perRelayKeysBoth =
pickRelaysToLoadUsers(
noOutboxList,
accounts,
cache.relayHints,
indexRelays - cannotConnect,
homeRelays - cannotConnect,
searchRelays - cannotConnect,
connectedRelays,
failureTracker.cannotConnectRelays,
commonRelays - cannotConnect,
cannotConnect,
hasTried,
)
@@ -18,7 +18,7 @@
* 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.watchers
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
@@ -18,16 +18,16 @@
* 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.watchers
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -60,6 +60,7 @@ val UserMetadataForKeyKinds =
fun filterUserMetadataForKey(
authors: Set<User>,
relayHints: HintIndexer,
indexRelays: Set<NormalizedRelayUrl>,
cannotConnectRelays: Set<NormalizedRelayUrl>,
since: EOSEAccountFast<User>,
@@ -72,7 +73,7 @@ fun filterUserMetadataForKey(
val relays =
when {
outbox == null ->
key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays
key.allUsedRelays() + relayHints.hintsForKey(key.pubkeyHex) + indexRelays
// Outbox is published but exhausted (every relay either EOSE'd
// or is known-unreachable) and metadata is still missing —
// widen to indexers so a misconfigured outbox doesn't strand
@@ -18,16 +18,16 @@
* 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.watchers
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.toHexSet
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relays.MutableTime
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.relays.MutableTime
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.utils.mapOfSet
class UserCardsSubAssembler(
client: INostrClient,
val cache: LocalCache,
val cache: ICacheProvider,
allKeys: () -> Set<UserFinderQueryState>,
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) {
override fun newEose(
@@ -74,22 +74,22 @@ class UserCardsSubAssembler(
// accounts, so with several active none of them owns a given filter.
val soleAccountPubKey =
accounts
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
.mapTo(mutableSetOf()) { it.userFinderPubkeyHex }
.singleOrNull()
val trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>> =
mapOfSet {
accounts.forEach { account ->
account.homeRelays.flow.value.forEach {
add(it, account.userProfile().pubkeyHex)
account.cardHomeRelays().forEach {
add(it, account.userFinderPubkeyHex)
}
}
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider ->
accounts.map { it.trustProvider() }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
}
accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider ->
accounts.map { it.followerCountProvider() }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
@@ -18,16 +18,16 @@
* 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.watchers
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.toHexSet
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relays.MutableTime
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.relays.MutableTime
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
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
@@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class UserReportsSubAssembler(
client: INostrClient,
val cache: LocalCache,
val cache: ICacheProvider,
allKeys: () -> Set<UserFinderQueryState>,
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) {
override fun newEose(
@@ -72,12 +72,13 @@ class UserReportsSubAssembler(
}
private fun filtersFor(
account: Account,
account: UserFinderAccount,
lastUsersOnFilter: Set<User>,
): List<RelayBasedFilter> {
val accountPubKey = account.userProfile().pubkeyHex
val accountPubKey = account.userFinderPubkeyHex
return account.declaredFollowsPerOutboxRelay.value
return account
.declaredFollowsByOutboxRelay()
.flatMap { (relay, trustedUsersInThisRelay) ->
// this relay + accounts are where we could find reports.
// we might have already loaded them, so let's separate new targets that were checked before from the others
@@ -18,14 +18,13 @@
* 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.watchers
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
@@ -37,7 +36,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class UserWatcherSubAssembler(
client: INostrClient,
val cache: LocalCache,
val cache: ICacheProvider,
val failureTracker: RelayOfflineTracker,
allKeys: () -> Set<UserFinderQueryState>,
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
@@ -100,20 +99,18 @@ class UserWatcherSubAssembler(
// account and the users are whoever is on screen, so with several askers none of them owns it.
val soleAccountPubKey =
keys
.mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex }
.mapTo(mutableSetOf()) { it.account.userFinderPubkeyHex }
.singleOrNull()
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
keys.mapTo(mutableSetOf()) { it.account }.forEach {
indexRelays.addAll(
it.indexerRelayList.flow.value
.ifEmpty { DefaultIndexerRelayList },
)
indexRelays.addAll(it.indexRelays())
}
val newFilters =
filterUserMetadataForKey(
users,
cache.relayHints,
indexRelays,
failureTracker.cannotConnectRelays,
latestEOSEs,