fix(relays): attribute every subscription to the account that asked for it

Filters carry an accountPubKey so the relay screens can group by account,
but attribution only happened for keys implementing AccountScopedQuery —
and ~50 query states held an `account` without declaring it, so the cast
failed silently and their filters showed as unattributed.

Two of the gaps were real bugs rather than display issues:

- CashuWalletFilterAssembler took `keys.first().pubkey` while flat-mapping
  every account's relays, so with two wallets logged in the second was
  never subscribed and its inbox relays were queried for the first
  account's nutzaps. Now built per account.
- UserReportsSubAssembler unioned every account's follow list into one
  per-relay map, asking one account's follows of another's outbox relays.
  Now one pass per account.

Where a subscription genuinely pools accounts (outbox discovery, on-screen
event watching, profile metadata), it is attributed only when a single
account is asking rather than inventing an owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-30 21:00:03 -04:00
co-authored by Claude Opus 5
parent b6808959cd
commit 4d53bbea9e
75 changed files with 484 additions and 144 deletions
@@ -3191,9 +3191,14 @@ class Account(
val filters =
entries.flatMap { entry ->
val state = concordSessions.sessionFor(entry.id)?.state?.value ?: return@flatMap emptyList()
ConcordSubscriptionPlanner.channelPreviewFilters(entry, state, lastReadFor = { channelIdHex ->
loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex))
})
ConcordSubscriptionPlanner.channelPreviewFilters(
entry,
state,
lastReadFor = { channelIdHex ->
loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex))
},
accountPubKey = userProfile().pubkeyHex,
)
}
if (filters.isEmpty()) return
val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } }
@@ -0,0 +1,39 @@
/*
* 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
import com.vitorpamplona.amethyst.model.Account
/**
* A subscription query state that belongs to one logged-in account.
*
* Around 66 query-state classes already carry an `account`, but nothing tied them together, so a
* subscription manager could not ask "whose subscription is this?" without knowing the concrete
* type. That is why the Active Relay Subscriptions screen filed the home feed under "not attributed"
* despite it being built from one specific person's follow list: the base manager checked for
* `AccountQueryState` and the home feed uses `HomeQueryState`.
*
* Implement this on any query state whose subscriptions belong to a single account, and the base
* managers attribute them automatically.
*/
interface AccountScopedQuery {
val account: Account
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relays.EOSEByKey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -114,7 +116,13 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
uniqueSubscribedAccounts.forEach {
val mainKey = id(it)
val newFilters = updateFilter(it, since(it))?.ifEmpty { null }
val newFilters =
updateFilter(it, since(it))
?.ifEmpty { null }
// Attribute to the account that owns this subscription, once, here — rather than
// threading a pubkey through every filter builder underneath. Builders that already
// know their account keep what they set.
?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f }
findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay())
updated.add(mainKey)
@@ -131,4 +139,13 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
): List<RelayBasedFilter>?
abstract fun id(key: T): U
/**
* The account behind [key], when the key is account-scoped. Null for keys about other users.
*
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
* other under "not attributed" despite both being built from a single account's data.
*/
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
}
@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -127,7 +129,13 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
uniqueSubscribedAccounts.forEach {
val user = user(it)
val sub = findOrCreateSubFor(it)
val newFilters = updateFilter(it, since(it))?.ifEmpty { null }
val newFilters =
updateFilter(it, since(it))
?.ifEmpty { null }
// Attribute to the account that owns this subscription, once, here — rather than
// threading a pubkey through every filter builder underneath. Builders that already
// know their account keep what they set.
?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f }
sub.updateFilters(newFilters?.groupByRelay())
updated.add(user)
}
@@ -145,4 +153,13 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
abstract fun user(key: T): User
abstract fun list(key: T): U
/**
* The account behind [key], when the key is account-scoped. Null for keys about other users.
*
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
* other under "not attributed" despite both being built from a single account's data.
*/
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
}
@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -113,7 +115,13 @@ abstract class PerUserEoseManager<T>(
uniqueSubscribedAccounts.forEach {
val user = user(it)
val newFilters = updateFilter(it, since(it))?.ifEmpty { null }
val newFilters =
updateFilter(it, since(it))
?.ifEmpty { null }
// Attribute to the account that owns this subscription, once, here — rather than
// threading a pubkey through every filter builder underneath. Builders that already
// know their account keep what they set.
?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f }
findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay())
@@ -131,4 +139,13 @@ abstract class PerUserEoseManager<T>(
): List<RelayBasedFilter>?
abstract fun user(key: T): User
/**
* The account behind [key], when the key is account-scoped. Null for keys about other users.
*
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
* other under "not attributed" despite both being built from a single account's data.
*/
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -66,11 +68,26 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) }
val newFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null }
val newFilters =
updateFilter(uniqueSubscribedAccounts)
?.ifEmpty { null }
// Attribute to the account that owns this subscription, once, here — rather than
// threading a pubkey through every filter builder underneath. Builders that already
// know their account keep what they set.
?.let { f -> accountPubKeyOf(uniqueSubscribedAccounts.firstOrNull())?.let { pk -> f.attributedTo(pk) } ?: f }
sub.updateFilters(newFilters?.groupByRelay())
}
abstract fun updateFilter(keys: List<T>): List<RelayBasedFilter>?
abstract fun distinct(key: T): Any
/**
* The account behind [key], when the key is account-scoped. Null for keys about other users.
*
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
* other under "not attributed" despite both being built from a single account's data.
*/
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
@@ -38,10 +39,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// This allows multiple screen to be listening to logged-in accounts.
@Stable
class AccountQueryState(
val account: Account,
override val account: Account,
val feedContentStates: AccountFeedContentStates,
val otherAccounts: Set<HexKey>,
)
) : AccountScopedQuery
/**
* Always-on account loaders: metadata, gift wraps, drafts, inbox-relay
@@ -159,6 +159,15 @@ class AccountFollowsLoaderSubAssembler(
val connectedRelays = client.connectedRelaysFlow().value
// Attributed only when one account is asking. The follow lists above are unioned across every
// logged-in account and the relays are then picked from that union, so with several accounts
// active no single one owns a given filter. Deduped by pubkey because `Account` uses identity
// equality.
val soleAccountPubKey =
accounts
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
.singleOrNull()
val perRelay = pickRelaysToLoadUsers(users, accounts, connectedRelays, failureTracker.cannotConnectRelays, hasTried)
hasTried.removeEveryoneBut(users)
@@ -167,7 +176,13 @@ class AccountFollowsLoaderSubAssembler(
if (users.isNotEmpty()) {
RelayBasedFilter(
relay = relay,
filter = ExplainedFilter(purpose = SubPurpose.FOLLOW_LISTS, kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted()),
filter =
ExplainedFilter(
purpose = SubPurpose.RELAY_LISTS,
kinds = listOf(AdvertisedRelayListEvent.KIND),
authors = users.sorted(),
accountPubKey = soleAccountPubKey,
),
)
} else {
null
@@ -91,7 +91,7 @@ class MarmotGroupEventsEoseManager(
"(metadataRelays=${groupRelays?.size ?: 0}, usingFallback=${groupRelays.isNullOrEmpty()}): ${relaysForGroup.map { it.url }}"
}
for (relay in relaysForGroup) {
result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(filter, SubPurpose.ENCRYPTED_GROUPS, "MLS group messages")))
result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(filter, SubPurpose.ENCRYPTED_GROUPS, "MLS group messages", entityIds = listOf(groupId))))
}
}
@@ -90,7 +90,7 @@ class NwcNotificationsEoseManager(
relay = wallet.uri.relayUri,
filter =
ExplainedFilter(
purpose = SubPurpose.WALLET,
purpose = SubPurpose.NWC,
kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND),
authors = listOf(wallet.uri.pubKeyHex),
tags = mapOf("p" to listOf(signer.pubKey)),
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -32,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@Stable
class ChannelFinderQueryState(
val channel: Channel,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class ChannelFinderFilterAssemblyGroup(
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.AddressableAuthorRelayLoaderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler
@@ -35,8 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@Stable
class EventFinderQueryState(
val note: Note,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class EventFinderFilterAssembler(
@@ -60,6 +60,16 @@ class EventWatcherSubAssembler(
lastNotesOnFilter = keys.map { it.note }
// Attributed only when one account is watching. The notes here are whatever is rendered, not
// anything an account owns, so with several accounts active no single one is the honest owner —
// and splitting would re-request the same replies/zaps 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.
val soleAccountPubKey =
keys
.mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex }
.singleOrNull()
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
.map { group ->
if (group.isNotEmpty()) {
@@ -67,8 +77,8 @@ class EventWatcherSubAssembler(
val events = group.mapNotNull { if (it !is AddressableNote) it else null }
listOfNotNull(
filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs)),
filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs)),
filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs), soleAccountPubKey),
filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs), soleAccountPubKey),
).flatten()
} else {
emptyList()
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -72,6 +73,7 @@ val TextNoteKindList = listOf(TextNoteEvent.KIND)
fun filterRepliesAndReactionsToAddresses(
keys: List<AddressableNote>,
since: SincePerRelayMap?,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter>? {
if (keys.isEmpty()) return null
@@ -94,6 +96,7 @@ fun filterRepliesAndReactionsToAddresses(
filter =
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKey = accountPubKey,
kinds = RepliesAndReactionsToAddressesKinds1,
tags = mapOf("a" to sortedList),
since = since,
@@ -106,6 +109,7 @@ fun filterRepliesAndReactionsToAddresses(
filter =
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKey = accountPubKey,
kinds = PostsAndChatMessagesToAddresses,
tags = mapOf("a" to sortedList),
since = since,
@@ -118,6 +122,7 @@ fun filterRepliesAndReactionsToAddresses(
filter =
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKey = accountPubKey,
kinds = DeletionKindList,
tags = mapOf("a" to sortedList),
since = since,
@@ -130,6 +135,7 @@ fun filterRepliesAndReactionsToAddresses(
filter =
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKey = accountPubKey,
kinds = TextNoteKindList,
tags = mapOf("q" to sortedList),
since = since,
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
@@ -78,6 +79,7 @@ val RepliesAndReactionsKinds2 =
fun filterRepliesAndReactionsToNotes(
events: List<Note>,
since: SincePerRelayMap?,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter>? {
if (events.isEmpty()) return null
@@ -101,6 +103,7 @@ fun filterRepliesAndReactionsToNotes(
filter =
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKey = accountPubKey,
kinds = RepliesAndReactionsKinds,
tags = mapOf("e" to sortedList),
since = since,
@@ -113,6 +116,7 @@ fun filterRepliesAndReactionsToNotes(
filter =
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKey = accountPubKey,
kinds = RepliesAndReactionsKinds2,
tags = mapOf("e" to sortedList),
since = since,
@@ -124,6 +128,7 @@ fun filterRepliesAndReactionsToNotes(
filter =
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKey = accountPubKey,
kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND),
tags = mapOf("q" to sortedList),
since = since,
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.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
@@ -36,8 +37,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
@Stable
class UserFinderQueryState(
val user: User,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class UserFinderFilterAssembler(
@@ -103,6 +103,18 @@ class UserOutboxFinderSubAssembler(
val accounts = keys.mapTo(mutableSetOf()) { it.account }
val connectedRelays = client.connectedRelaysFlow().value
// Attributed only when one account is asking. Unlike the reports sweep, the relay choice here
// is made from the *union* of every logged-in account's tiers (`pickRelaysToLoadUsers` below),
// 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.
val soleAccountPubKey =
accounts
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
.singleOrNull()
val perRelayKeysBoth =
pickRelaysToLoadUsers(
noOutboxList,
@@ -118,7 +130,13 @@ class UserOutboxFinderSubAssembler(
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = ExplainedFilter(kinds = relayListKinds, authors = sortedUsers, purpose = SubPurpose.RELAY_LISTS),
filter =
ExplainedFilter(
kinds = relayListKinds,
authors = sortedUsers,
purpose = SubPurpose.RELAY_LISTS,
accountPubKey = soleAccountPubKey,
),
)
} else {
null
@@ -148,7 +166,14 @@ class UserOutboxFinderSubAssembler(
fallbackRelays.map { relay ->
RelayBasedFilter(
relay = relay,
filter = ExplainedFilter(kinds = relayListKinds, authors = sortedAbandoned, purpose = SubPurpose.RELAY_LISTS, purposeDetail = "outbox discovery for users whose relay list we lost"),
filter =
ExplainedFilter(
kinds = relayListKinds,
authors = sortedAbandoned,
purpose = SubPurpose.RELAY_LISTS,
purposeDetail = "outbox discovery for users whose relay list we lost",
accountPubKey = soleAccountPubKey,
),
)
}
@@ -34,6 +34,7 @@ fun filterReportsToKeysFromTrusted(
trustedAccounts: List<HexKey>,
relay: NormalizedRelayUrl,
since: Long?,
accountPubKey: HexKey? = null,
): RelayBasedFilter? {
if (targets.isEmpty() || trustedAccounts.isEmpty()) return null
return RelayBasedFilter(
@@ -43,6 +44,7 @@ fun filterReportsToKeysFromTrusted(
purpose = SubPurpose.MODERATION,
kinds = ReportKindList,
authors = trustedAccounts,
accountPubKey = accountPubKey,
tags = mapOf("p" to targets.sorted()),
since = since,
),
@@ -63,6 +63,7 @@ fun filterUserMetadataForKey(
indexRelays: Set<NormalizedRelayUrl>,
cannotConnectRelays: Set<NormalizedRelayUrl>,
since: EOSEAccountFast<User>,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter> {
val perRelayUsers =
mapOfSet {
@@ -114,6 +115,7 @@ fun filterUserMetadataForKey(
filter =
ExplainedFilter(
purpose = SubPurpose.PROFILE_METADATA,
accountPubKey = accountPubKey,
kinds = UserMetadataForKeyKinds,
authors = firstTimers.sorted(),
),
@@ -127,6 +129,7 @@ fun filterUserMetadataForKey(
filter =
ExplainedFilter(
purpose = SubPurpose.PROFILE_METADATA,
accountPubKey = accountPubKey,
kinds = UserMetadataForKeyKinds,
authors = updates.sorted(),
since = minimumTime,
@@ -70,6 +70,13 @@ class UserCardsSubAssembler(
val accounts = keys.mapTo(mutableSetOf()) { it.account }
// Attributed only when one account is asking: the trusted-author sets below are pooled across
// accounts, so with several active none of them owns a given filter.
val soleAccountPubKey =
accounts
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
.singleOrNull()
val trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>> =
mapOfSet {
accounts.forEach { account ->
@@ -92,12 +99,14 @@ class UserCardsSubAssembler(
val trustedAccounts = trustedUsersInThisRelay.sorted()
listOfNotNull(
filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
accountPubKey = soleAccountPubKey,
targets = groups.usersWithoutEose.toHexSet(),
trustedAccounts = trustedAccounts,
relay = relay,
since = null,
),
filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
accountPubKey = soleAccountPubKey,
targets = groups.usersWithEose.toHexSet(),
trustedAccounts = trustedAccounts,
relay = relay,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
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
@@ -31,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
class UserReportsSubAssembler(
client: INostrClient,
@@ -65,14 +65,19 @@ class UserReportsSubAssembler(
if (lastUsersOnFilter.isEmpty()) return null
val trustedAccountsPerRelay =
mapOfSet {
accounts.map { it.declaredFollowsPerOutboxRelay.value }.forEach {
add(it)
}
}
// One pass per account, never a union. "Whose follows wrote this report" is the whole point of
// the filter, so merging every logged-in account's follow list into one per-relay map both made
// the result unattributable and asked one account's follows of another account's outbox relays.
return accounts.flatMap { account -> filtersFor(account, lastUsersOnFilter) }.ifEmpty { null }
}
return trustedAccountsPerRelay
private fun filtersFor(
account: Account,
lastUsersOnFilter: Set<User>,
): List<RelayBasedFilter> {
val accountPubKey = account.userProfile().pubkeyHex
return account.declaredFollowsPerOutboxRelay.value
.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
@@ -84,12 +89,14 @@ class UserReportsSubAssembler(
trustedAccounts = trustedAccounts,
relay = relay,
since = null,
accountPubKey = accountPubKey,
),
filterReportsToKeysFromTrusted(
targets = groups.usersWithEose.toHexSet(),
trustedAccounts = trustedAccounts,
relay = relay,
since = findMinimumEOSEsForUsers(groups.usersWithEose, relay),
accountPubKey = accountPubKey,
),
)
}
@@ -96,6 +96,13 @@ class UserWatcherSubAssembler(
}
// assembles all index relays from all accounts
// Attributed only when one account is looking: the index relays below are pooled across every
// 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 }
.singleOrNull()
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
keys.mapTo(mutableSetOf()) { it.account }.forEach {
indexRelays.addAll(
@@ -110,6 +117,7 @@ class UserWatcherSubAssembler(
indexRelays,
failureTracker.cannotConnectRelays,
latestEOSEs,
soleAccountPubKey,
).ifEmpty { null }
sub.updateFilters(newFilters?.groupByRelay())
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchPostWatcherSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchUserWatcherSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -35,8 +36,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Stable
class SearchQueryState(
val searchQuery: MutableStateFlow<String>,
val account: Account,
) : MutableQueryState {
override val account: Account,
) : MutableQueryState,
AccountScopedQuery {
override fun flow(): Flow<String> = searchQuery
}
@@ -23,11 +23,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.datas
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
class ProfileAppRecommendationsQueryState(
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class ProfileAppRecommendationsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class ArticlesQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class ArticlesFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class BadgesQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class BadgesFilterAssembler(
@@ -23,11 +23,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
class ProfileBadgesQueryState(
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class ProfileBadgesFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class CalendarsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class CalendarsFilterAssembler(
@@ -22,14 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
// This allows multiple screen to be listening to tags, even the same tag
class ChatroomQueryState(
val room: ChatroomKey,
val account: Account,
) {
override val account: Account,
) : AccountScopedQuery {
val listId = room.hashCode().toString()
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
@@ -34,8 +35,8 @@ import kotlinx.coroutines.Job
/** One screen's request to keep the user's joined Concord Channels live. */
class ConcordChannelQueryState(
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
/**
* Keeps every joined Concord community's planes live while a Concord-bearing
@@ -81,7 +82,11 @@ class ConcordChannelSubAssembler(
// per-filter result cap (the "Soapbox shows 1 of 12 channels" bug). See
// [ConcordSubscriptionPlanner.controlIsolatedFilters].
val all =
ConcordSubscriptionPlanner.controlIsolatedFilters(entries, since = since) { entry ->
ConcordSubscriptionPlanner.controlIsolatedFilters(
entries,
since = since,
accountPubKey = account.userProfile().pubkeyHex,
) { entry ->
account.concordSessions
.sessionFor(entry.id)
?.state
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
@@ -44,10 +45,10 @@ import kotlinx.coroutines.flow.StateFlow
/** One open Concord Channel whose older history the screen wants paged in. */
class ConcordChannelHistoryQueryState(
val account: Account,
override val account: Account,
val communityId: String,
val channelId: String,
)
) : AccountScopedQuery
/**
* Mounts the on-demand **history** pager for whichever Concord Channel screen is open. The live
@@ -133,6 +134,8 @@ class ConcordChannelHistorySubAssembler(
filter =
ExplainedFilter(
purpose = SubPurpose.COMMUNITY_CHATS,
purposeDetail = "concord channel history",
entityIds = listOf(key.communityId),
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
// All epoch planes at once: the relay serves them interleaved by created_at, so
// one backward cursor walks across the Refounding boundaries; "exhausted" then
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker
import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
@@ -40,9 +41,9 @@ import kotlinx.coroutines.flow.StateFlow
/** One Buzz DM channel whose recent chat is kept live. */
class BuzzDmJoinedChatTailQueryState(
val account: Account,
override val account: Account,
val groupId: GroupId,
)
) : AccountScopedQuery
/**
* Always-on **live tail** for the recent chat of every Buzz DM channel the viewer belongs to — the DM
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker
import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
@@ -41,9 +42,9 @@ import kotlinx.coroutines.flow.StateFlow
/** One joined group whose recent chat is kept live for the Messages-list preview. */
class RelayGroupJoinedChatTailQueryState(
val account: Account,
override val account: Account,
val groupId: GroupId,
)
) : AccountScopedQuery
/**
* Always-on **live tail** for the recent chat of every NIP-29 group the user has joined — the group
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
@@ -33,8 +34,8 @@ import kotlinx.coroutines.Job
/** One request to keep the relay-signed state of the user's joined groups live. */
class RelayGroupJoinedStateQueryState(
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
/**
* Keeps the relay-signed **state** of every group the user has joined live and current — name, picture,
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -40,9 +41,9 @@ import kotlinx.coroutines.flow.StateFlow
/** One open NIP-29 group whose older history the chat screen wants paged in. */
class RelayGroupOpenChatHistoryQueryState(
val account: Account,
override val account: Account,
val groupId: GroupId,
)
) : AccountScopedQuery
/**
* Mounts the on-demand **history** pager for whichever NIP-29 group chat screen is open. The live
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker
import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -37,9 +38,9 @@ import kotlinx.coroutines.flow.StateFlow
/** One open NIP-29 group whose recent chat the screen wants live. */
class RelayGroupOpenChatTailQueryState(
val account: Account,
override val account: Account,
val groupId: GroupId,
)
) : AccountScopedQuery
/**
* Per-open-group **live tail**: the recent chat window of the *currently open* group, `#h`-scoped on
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -40,9 +41,9 @@ import kotlinx.coroutines.flow.StateFlow
/** One open NIP-29 group whose older forum threads the Threads tab wants paged in. */
class RelayGroupOpenThreadsHistoryQueryState(
val account: Account,
override val account: Account,
val groupId: GroupId,
)
) : AccountScopedQuery
/**
* Mounts the on-demand **history** pager for whichever NIP-29 group's Threads tab is open. The Threads
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class RelayGroupsDiscoveryQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class RelayGroupsDiscoveryFilterAssembler(
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -31,8 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/** One screen's request for the full channel directory of a single relay. */
class RelayGroupsOnRelayQueryState(
val relay: NormalizedRelayUrl,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
/**
* Subscribes to the relay-signed directory (kinds 39000-39003) of a single relay,
@@ -23,13 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// This allows multiple screen to be listening to tags, even the same tag
@Stable
class ChatroomListState(
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class ChatroomListFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasourc
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class CommunitiesListQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class CommunitiesListFilterAssembler(
@@ -22,16 +22,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
// This allows multiple screen to be listening to tags, even the same tag
class DiscoveryQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
class DiscoveryFilterAssembler(
client: INostrClient,
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasour
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class BrowseEmojiSetsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class BrowseEmojiSetsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasourc
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class FollowPacksQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class FollowPacksFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class GitRepositoriesQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class GitRepositoriesFilterAssembler(
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -29,8 +30,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class HashtagQueryState(
val hashtag: String,
val relays: Set<NormalizedRelayUrl>,
val account: Account,
) {
override val account: Account,
) : AccountScopedQuery {
val lowercaseHashtag = hashtag.lowercase()
}
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class HighlightsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class HighlightsFilterAssembler(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxEventsEoseManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -32,10 +33,10 @@ import kotlinx.coroutines.CoroutineScope
@Stable
class HomeQueryState(
val account: Account,
override val account: Account,
val feedState: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class HomeFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class LiveStreamsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class LiveStreamsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class LongsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class LongsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class MusicPlaylistsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class MusicPlaylistsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class MusicTracksQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class MusicTracksFilterAssembler(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -32,9 +33,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
* been granted permissions in the user's ledger.
*/
class ConnectedAppsQueryState(
val account: Account,
override val account: Account,
val authors: Set<HexKey>,
)
) : AccountScopedQuery
/**
* Live subscription for NIP-5D napplet manifests (kinds 15129/35129) while
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
@@ -33,9 +34,9 @@ import kotlinx.coroutines.CoroutineScope
* selection) and a scope.
*/
class NappletsQueryState(
val account: Account,
override val account: Account,
val scope: CoroutineScope,
)
) : AccountScopedQuery
/**
* Subscribes to NIP-5D napplet manifests (kinds 15129/35129) while a napplet screen is open,
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwa
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -53,8 +54,8 @@ import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
@Stable
class NestRoomQueryState(
val note: AddressableNote,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
/**
* Single per-room sub-assembler. Issues two [Filter]s per outbox
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwa
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -51,8 +52,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresence
@Stable
class NestRoomLivenessQueryState(
val note: AddressableNote,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
/**
* Single per-room sub-assembler for the feed thumbnail liveness
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class NestsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class NestsFilterAssembler(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
@@ -33,9 +34,9 @@ import kotlinx.coroutines.CoroutineScope
* scope.
*/
class NsitesQueryState(
val account: Account,
override val account: Account,
val scope: CoroutineScope,
)
) : AccountScopedQuery
/**
* Subscribes to NIP-5A static-site manifests (kinds 15128/35128) while an nSite screen is open,
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class PicturesQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class PicturesFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class PodcastEpisodesQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class PodcastEpisodesFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class PodcastsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class PodcastsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class PollsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class PollsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class ProductsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class ProductsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class PublicChatsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class PublicChatsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class ShortsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class ShortsFilterAssembler(
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class SoftwareAppsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class SoftwareAppsFilterAssembler(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadEventLoaderSubAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadFilterSubAssembler
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -32,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@Stable
class ThreadQueryState(
val eventId: HexKey,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class ThreadFilterAssembler(
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoOutboxEventsFilterSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -29,10 +30,10 @@ import kotlinx.coroutines.CoroutineScope
// This allows multiple screen to be listening to tags, even the same tag
class VideoQueryState(
val account: Account,
override val account: Account,
val feedState: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
class VideoFilterAssembler(
client: INostrClient,
@@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class WorkoutsQueryState(
val account: Account,
override val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
) : AccountScopedQuery
@Stable
class WorkoutsFilterAssembler(
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -165,6 +166,7 @@ object ConcordSubscriptionPlanner {
lastReadFor: (channelIdHex: String) -> Long,
catchUpLimit: Int = 50,
previewLimit: Int = 10,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter> {
val authorsByChannel = LinkedHashMap<ConcordChannelId, MutableSet<String>>()
val relaysByChannel = HashMap<ConcordChannelId, Set<NormalizedRelayUrl>>()
@@ -180,6 +182,8 @@ object ConcordSubscriptionPlanner {
ExplainedFilter(
purpose = SubPurpose.COMMUNITY_CHATS,
purposeDetail = "concord community planes",
entityIds = listOf(entry.id),
accountPubKey = accountPubKey,
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
authors = authors.toList(),
// -1 so the last-read message (created_at == lastRead) is itself returned:
@@ -191,6 +195,8 @@ object ConcordSubscriptionPlanner {
ExplainedFilter(
purpose = SubPurpose.COMMUNITY_CHATS,
purposeDetail = "concord community planes",
entityIds = listOf(entry.id),
accountPubKey = accountPubKey,
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
authors = authors.toList(),
limit = previewLimit,
@@ -232,6 +238,7 @@ object ConcordSubscriptionPlanner {
fun controlIsolatedFilters(
entries: List<ConcordCommunityListEntry>,
since: SincePerRelayMap?,
accountPubKey: HexKey? = null,
stateOf: (ConcordCommunityListEntry) -> ConcordCommunityState?,
): List<RelayBasedFilter> {
val controlSubs = controlPlaneSubs(entries)
@@ -243,7 +250,7 @@ object ConcordSubscriptionPlanner {
otherSubs += channelPlaneSubs(entry, state)
}
return relayBasedFilters(controlSubs, since).orEmpty() + relayBasedFilters(otherSubs, since).orEmpty()
return relayBasedFilters(controlSubs, since, accountPubKey).orEmpty() + relayBasedFilters(otherSubs, since, accountPubKey).orEmpty()
}
/**
@@ -258,10 +265,18 @@ object ConcordSubscriptionPlanner {
fun relayBasedFilters(
subs: List<ConcordPlaneSub>,
since: SincePerRelayMap?,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter>? {
val authorsByRelay = HashMap<NormalizedRelayUrl, MutableSet<String>>()
// Which communities each relay is being asked about. Collapsing planes into one filter per
// relay is what makes the subscription screen unable to name them otherwise — the plane
// pubkeys are stream keys, not something a user can recognise.
val communitiesByRelay = HashMap<NormalizedRelayUrl, MutableSet<String>>()
for (sub in subs) {
for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex)
for (relay in sub.relays) {
authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex)
sub.channelId?.let { communitiesByRelay.getOrPut(relay) { HashSet() }.add(it.communityId) }
}
}
if (authorsByRelay.isEmpty()) return null
@@ -271,6 +286,9 @@ object ConcordSubscriptionPlanner {
filter =
ExplainedFilter(
purpose = SubPurpose.COMMUNITY_CHATS,
purposeDetail = "concord live planes",
entityIds = communitiesByRelay[relay]?.sorted(),
accountPubKey = accountPubKey,
// Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the
// live-only typing heartbeats a relay broadcasts but never stores.
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL),
@@ -85,7 +85,7 @@ private class CashuMintDirectorySubAssembler(
val mintAnnouncements =
ExplainedFilter(
purpose = SubPurpose.WALLET,
purpose = SubPurpose.MINT_DIRECTORY,
purposeDetail = "mint directory",
kinds = listOf(CashuMintEvent.KIND),
)
@@ -95,7 +95,7 @@ private class CashuMintDirectorySubAssembler(
// recommendations here.
val cashuRecommendations =
ExplainedFilter(
purpose = SubPurpose.WALLET,
purpose = SubPurpose.MINT_DIRECTORY,
purposeDetail = "mint directory",
kinds = listOf(MintRecommendationEvent.KIND),
tags = mapOf("k" to listOf(CashuMintEvent.KIND.toString())),
@@ -95,16 +95,31 @@ private class CashuWalletSubAssembler(
) : SingleSubEoseManager<CashuWalletQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun distinct(key: CashuWalletQueryState): Any = key.pubkey
/**
* One set of filters **per account**, never a merged one.
*
* [SingleSubEoseManager] hands over every distinct key, so with two wallets logged in this used
* to take `keys.first().pubkey` while pooling *both* accounts' relays — the second account's
* wallet was never subscribed, and its inbox relays were queried for the first account's
* nutzaps. Keeping each account's pubkey with its own relay sets is also what lets the
* subscription screen attribute these filters instead of piling them under "not attributed".
*/
override fun updateFilter(
keys: List<CashuWalletQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (keys.isEmpty()) return null
return keys.flatMap { filtersFor(it, since) }.ifEmpty { null }
}
val pubkey = keys.first().pubkey
val ownEventRelays = keys.flatMap { it.ownEventRelays }.toSet()
val inboxRelays = keys.flatMap { it.inboxRelays }.toSet()
if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return null
private fun filtersFor(
key: CashuWalletQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val pubkey = key.pubkey
val ownEventRelays = key.ownEventRelays
val inboxRelays = key.inboxRelays
if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return emptyList()
val ownedFilter =
ExplainedFilter(
@@ -123,13 +138,15 @@ private class CashuWalletSubAssembler(
MintRecommendationEvent.KIND,
),
authors = listOf(pubkey),
accountPubKey = pubkey,
)
val inboundNutzapsFilter =
ExplainedFilter(
purpose = SubPurpose.WALLET,
purpose = SubPurpose.NUTZAP_INBOX,
kinds = listOf(NutzapEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
accountPubKey = pubkey,
)
// Own NIP-60 events are read from the user's outbox; inbound nutzaps
@@ -40,6 +40,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
trustedAccounts: List<HexKey>,
relay: NormalizedRelayUrl,
since: Long?,
accountPubKey: HexKey? = null,
): RelayBasedFilter? {
if (targets.isEmpty() || trustedAccounts.isEmpty()) return null
return RelayBasedFilter(
@@ -47,6 +48,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
filter =
ExplainedFilter(
purpose = SubPurpose.PROFILE_METADATA,
accountPubKey = accountPubKey,
kinds = ContactCardKindList,
authors = trustedAccounts,
// kind:30382 addresses the target user in the d-tag
@@ -72,6 +74,8 @@ fun filterContactCardsByAuthorInTheRelay(
filter =
ExplainedFilter(
purpose = SubPurpose.PROFILE_METADATA,
// This variant fetches an account's OWN contact card, so the author is the owner.
accountPubKey = author,
kinds = ContactCardKindList,
authors = listOf(author),
limit = limit,
@@ -37,6 +37,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
data class MetadataQueryState(
val pubkeys: Set<HexKey>,
val indexRelays: Set<NormalizedRelayUrl>,
/** Which account is looking. Null when the caller has no account context (kept out of attribution). */
val accountPubKey: HexKey? = null,
)
/**
@@ -69,6 +71,11 @@ class MetadataFilterAssembler(
if (allPubkeys.isEmpty() || allRelays.isEmpty()) return null
// Attributed only when one account is looking. These pubkeys are whoever is rendered rather
// than anything an account owns, and several query states are batched into one filter, so with
// more than one asker there is no single honest owner.
val soleAccountPubKey = keys.mapNotNullTo(mutableSetOf()) { it.accountPubKey }.singleOrNull()
val pubkeyList = allPubkeys.toList()
// Create filter for metadata (Kind 0)
@@ -78,6 +85,7 @@ class MetadataFilterAssembler(
authors = pubkeyList,
limit = pubkeyList.size,
purpose = SubPurpose.PROFILE_METADATA,
accountPubKey = soleAccountPubKey,
)
// Apply since times per relay
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
@@ -66,12 +67,18 @@ class ExplainedFilter(
/** Free-form extra context for [SubPurpose.OTHER] or for narrowing a bucket while debugging. */
val purposeDetail: String? = null,
/**
* The thing this filter serves — a community, group, channel or mint id. Deliberately an **id,
* not a name**: names change, are not always loaded when the filter is built, and would pin a
* stale copy into a long-lived subscription. The UI resolves it against `LocalCache` at render
* time, so it always shows the current name and shows nothing gracefully when unknown.
* The things this filter serves — community, group, channel or mint ids.
*
* A **list**, because filters are routinely batched: relay-group state is fetched with one `#d`
* filter per host relay carrying every joined group on it, so a single filter can legitimately
* serve a dozen chats. Modelling one id would have forced either a wrong answer ("All") or a
* filter-per-chat, which is far more REQs than the relays want.
*
* Ids, not names: names change, are often not loaded when the filter is built, and would pin a
* stale copy into a long-lived subscription. The UI resolves them against `LocalCache` at render
* time, so it shows the current name and degrades to a short id when unknown.
*/
val entityId: HexKey? = null,
val entityIds: List<HexKey>? = null,
/**
* Which logged-in account asked for this. Several accounts are commonly active at once and they
* do not share relay sets, so "why is this relay connected" is only answerable per account —
@@ -93,7 +100,7 @@ class ExplainedFilter(
until: Long?,
limit: Int?,
search: String?,
) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityId, accountPubKey)
) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKey)
companion object {
/** Tags [filter] with a [purpose], preserving every protocol field. */
@@ -101,7 +108,7 @@ class ExplainedFilter(
filter: Filter,
purpose: SubPurpose,
detail: String? = null,
entityId: HexKey? = null,
entityIds: List<HexKey>? = null,
accountPubKey: HexKey? = null,
) = ExplainedFilter(
filter.ids,
@@ -115,7 +122,7 @@ class ExplainedFilter(
filter.search,
purpose,
detail,
entityId,
entityIds,
accountPubKey,
)
}
@@ -135,8 +142,14 @@ fun Collection<Filter>.purposes(): Set<SubPurpose> = mapNotNullTo(mutableSetOf()
* whose. Entries with no entity collapse to a single row for that purpose.
*/
fun Collection<Filter>.purposeEntities(): Set<PurposeEntity> =
mapNotNullTo(mutableSetOf()) { filter ->
(filter as? ExplainedFilter)?.let { PurposeEntity(it.purpose, it.entityId, it.accountPubKey, it.purposeDetail) }
flatMapTo(mutableSetOf()) { filter ->
val explained = filter as? ExplainedFilter ?: return@flatMapTo emptyList()
val ids = explained.entityIds
if (ids.isNullOrEmpty()) {
listOf(PurposeEntity(explained.purpose, null, explained.accountPubKey, explained.purposeDetail))
} else {
ids.map { PurposeEntity(explained.purpose, it, explained.accountPubKey, explained.purposeDetail) }
}
}
/** A single "this relay is doing X, for Y, on behalf of account Z" fact. Ids only; names resolve in the UI. */
@@ -146,3 +159,23 @@ data class PurposeEntity(
val accountPubKey: HexKey? = null,
val detail: String? = null,
)
/**
* Stamps [accountPubKey] onto every tagged filter that does not already name one.
*
* Applied once where a subscription manager knows its account, rather than threading a pubkey
* parameter through the ~200 filter builders below it. Filters that already name an account are left
* alone, so a builder with better knowledge always wins.
*/
fun List<RelayBasedFilter>.attributedTo(accountPubKey: HexKey): List<RelayBasedFilter> =
map { relayFilter ->
val filter = relayFilter.filter
if (filter is ExplainedFilter && filter.accountPubKey == null) {
RelayBasedFilter(
relay = relayFilter.relay,
filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, accountPubKey),
)
} else {
relayFilter
}
}