refactor: move per-note event finder to commons on the new upstream model (Phase 3)

Move EventFinderFilterAssembler(+QueryState), the loaders (NoteEventLoader,
FilterMissingEvents, FilterMissingAddressables, AddressableAuthorRelayLoader) and
watchers (EventWatcher, FilterRepliesAndReactionsToNotes/Addresses) from
amethyst/reqCommand/event/ into commons/relayClient/event/, mirroring the Phase 2
user-finder move.

- EventFinderQueryState carries the narrow UserFinderAccount; DROPS AccountScopedQuery.
  Attribution stays inline via userFinderPubkeyHex; ExplainedFilter/SubPurpose tags
  (REFERENCED_EVENTS / ENGAGEMENT) preserved.
- cache: LocalCache -> ICacheProvider threaded into NoteEventLoaderSubAssembler + the
  FilterMissing* functions (which called the LocalCache singleton statically);
  getOrCreateUser is now nullable at these sites. Added ICacheProvider.checkGetOrCreateUser
  default; LocalCache.checkGetOrCreateUser now overrides it.
- SingleSubNoEoseCacheEoseManager moved to commons (account-agnostic accountPubKeyOf);
  its two amethyst callers keep attribution via a new amethyst subclass
  AccountScopedSingleSubNoEoseCacheEoseManager (ChannelLoader) or the plain commons base
  (NWCPaymentWatcher, which never attributed).

Android unchanged via EventFinderShims.kt (typealiases + AccountViewModel overload);
EventObservers.kt stays in amethyst. New commons EventFinderFilterAssemblerSubscription
(LocalEventFinder + (note) overload reusing LocalUserFinderAccount) for Desktop.
Updated the direct loader-function callers (search/hashtag/thread sub-assemblers + the
moved test) to import from commons and pass LocalCache.

Green: commons JVM + iOS purity, :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 983fc1aab2
commit 4d31cd7d25
21 changed files with 280 additions and 121 deletions
@@ -679,7 +679,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
override fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
fun load(keys: List<String>): List<User> = keys.mapNotNull(::checkGetOrCreateUser)
@@ -0,0 +1,46 @@
/*
* 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.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
/**
* Amethyst variant of [SingleSubNoEoseCacheEoseManager] that restores single-account
* attribution for [AccountScopedQuery] keys.
*
* The commons base is account-agnostic (attribution defaults to null) so it can live in
* commonMain. Query states that carry an [Account] (home feed, channels, notifications, …)
* subclass this so their single-account REQs still show up attributed in "Active Relay
* Subscriptions".
*
* 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.
*/
abstract class AccountScopedSingleSubNoEoseCacheEoseManager<T>(
client: INostrClient,
allKeys: () -> Set<T>,
invalidateAfterEose: Boolean = false,
) : SingleSubNoEoseCacheEoseManager<T>(client, allKeys, invalidateAfterEose) {
override fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
}
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.AccountScopedSingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class ChannelLoaderSubAssembler(
client: INostrClient,
allKeys: () -> Set<ChannelFinderQueryState>,
) : SingleSubNoEoseCacheEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
) : AccountScopedSingleSubNoEoseCacheEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(keys: List<ChannelFinderQueryState>): List<RelayBasedFilter> = filterMissingChannelsById(keys)
override fun distinct(key: ChannelFinderQueryState) = key.channel
@@ -21,30 +21,30 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Back-compat aliases: the per-note event finder moved to commons
* (`com.vitorpamplona.amethyst.commons.relayClient.event`). Existing Android call
* sites that reference these by their old names resolve here.
*/
typealias EventFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler
typealias EventFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
/**
* Android convenience overload: pulls the account + shared event-finder data source
* out of [accountViewModel] and delegates to the commons subscription. `Account`
* is-a `UserFinderAccount`, so no adaptation is needed.
*/
@Composable
fun EventFinderFilterAssemblerSubscription(
note: Note,
accountViewModel: AccountViewModel,
) = EventFinderFilterAssemblerSubscription(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@Composable
fun EventFinderFilterAssemblerSubscription(
note: Note,
account: Account,
dataSource: EventFinderFilterAssembler,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(note, account) {
EventFinderQueryState(note, account)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
) = EventFinderFilterAssemblerSubscription(
note,
accountViewModel.account,
accountViewModel.dataSources().eventFinder,
)
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
@@ -37,7 +37,7 @@ fun filterByAddress(
val list =
mapOfSet {
if (note.event == null) {
potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl ->
potentialRelaysToFindAddress(LocalCache, note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, note.address)
}
}
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -38,7 +38,7 @@ fun filterByEvent(
val list =
mapOfSet {
if (note !is AddressableNote && note.event == null) {
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
potentialRelaysToFindEvent(LocalCache, note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, note.idHex)
}
}
@@ -46,7 +46,7 @@ fun filterByEvent(
// loads threading that is event-based
note.replyTo?.forEach { parentNote ->
if (parentNote !is AddressableNote && note.event == null) {
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
potentialRelaysToFindEvent(LocalCache, note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, note.idHex)
}
}
@@ -20,12 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
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.reqCommand.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -93,7 +93,7 @@ fun filterHashtagLabels(
val target = LocalCache.getNoteIfExists(targetId)
if (target?.event == null) {
val targetNote = LocalCache.getOrCreateNote(targetId)
potentialRelaysToFindEvent(targetNote).ifEmpty { relays }.forEach { relayUrl ->
potentialRelaysToFindEvent(LocalCache, targetNote).ifEmpty { relays }.forEach { relayUrl ->
add(relayUrl, targetId)
}
}
@@ -21,11 +21,12 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies
import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
@@ -37,14 +38,14 @@ fun filterMissingEventsForThread(
val missingEvents =
mapOfSet {
if (threadInfo.root.event == null && threadInfo.root !is AddressableNote) {
potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl ->
potentialRelaysToFindEvent(LocalCache, threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl ->
add(relayUrl, threadInfo.root.idHex)
}
}
threadInfo.allNotes.forEach {
if (it !is AddressableNote && it.event == null) {
potentialRelaysToFindEvent(it).ifEmpty { defaultRelays }.forEach { relayUrl ->
potentialRelaysToFindEvent(LocalCache, it).ifEmpty { defaultRelays }.forEach { relayUrl ->
add(relayUrl, it.idHex)
}
}
@@ -59,14 +60,14 @@ fun filterMissingEventsForThread(
// note's aTag idHex into the hex-keyed event-hint index, which throws
// on the non-hex string and kills the whole filter build — leaving a
// thread opened on an uncached naddr permanently unfetched.
potentialRelaysToFindAddress(rootNote).ifEmpty { defaultRelays }.forEach { relayUrl ->
potentialRelaysToFindAddress(LocalCache, rootNote).ifEmpty { defaultRelays }.forEach { relayUrl ->
add(relayUrl, rootNote.address)
}
}
threadInfo.allNotes.forEach {
if (it is AddressableNote && it.event == null) {
potentialRelaysToFindAddress(it).ifEmpty { defaultRelays }.forEach { relayUrl ->
potentialRelaysToFindAddress(LocalCache, it).ifEmpty { defaultRelays }.forEach { relayUrl ->
add(relayUrl, it.address)
}
}
@@ -20,11 +20,12 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.AddressableAuthorRelayLoaderSubAssembler
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.quartz.nip01Core.core.Address
import io.mockk.every
import io.mockk.mockk
@@ -142,5 +142,15 @@ interface ICacheProvider {
*/
fun getOrCreateUser(pubkey: HexKey): User?
/**
* Gets or creates a User by public key hex, swallowing any failure.
* Used by the event-finder relay-hint scan, which touches many potentially
* malformed pubkeys and must never throw mid-scan.
*
* @param key The user's public key in hex format
* @return The User (existing or newly created), or null on failure
*/
fun checkGetOrCreateUser(key: HexKey): User? = runCatching { getOrCreateUser(key) }.getOrNull()
fun justConsumeMyOwnEvent(event: Event): Boolean
}
@@ -18,11 +18,9 @@
* 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.eoseManagers
package com.vitorpamplona.amethyst.commons.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
@@ -85,9 +83,10 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
/**
* 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.
* Account-agnostic in commons: front ends that want single-account attribution override this
* (see the amethyst `AccountScopedSingleSubNoEoseCacheEoseManager`, which reads
* `(key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex`). The default returns null,
* so pooled / cross-account subscriptions are filed as "not attributed".
*/
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
open fun accountPubKeyOf(key: Any?): String? = null
}
@@ -18,36 +18,35 @@
* 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.event
package com.vitorpamplona.amethyst.commons.relayClient.event
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Note
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.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
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.AddressableAuthorRelayLoaderSubAssembler
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.NoteEventLoaderSubAssembler
import com.vitorpamplona.amethyst.commons.relayClient.event.watchers.EventWatcherSubAssembler
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// This allows multiple screen to be listening to tags, even the same tag
@Stable
class EventFinderQueryState(
val note: Note,
override val account: Account,
) : AccountScopedQuery
val account: UserFinderAccount,
)
@Stable
class EventFinderFilterAssembler(
client: INostrClient,
cache: LocalCache,
cache: ICacheProvider,
userFinder: UserFinderFilterAssembler,
) : ComposeSubscriptionManager<EventFinderQueryState>() {
val group =
listOf(
NoteEventLoaderSubAssembler(client, ::allKeys),
NoteEventLoaderSubAssembler(client, cache, ::allKeys),
EventWatcherSubAssembler(client, ::allKeys),
AddressableAuthorRelayLoaderSubAssembler(cache, ::allKeys, userFinder),
)
@@ -0,0 +1,84 @@
/*
* 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.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
/**
* The shared per-note event data source (reactions / zaps / reposts / replies /
* OTS / references) for the current front end. Provided once near the composition
* root (Android via AppModules, Desktop via its subscriptions coordinator).
* Reading it without a provider is a programming error — the per-note observers
* must never be reachable from a composition that has no relay client (e.g. the
* Android `:napplet` sandbox process).
*
* The *account* half is reused from the user-finder: [LocalUserFinderAccount]
* already carries the narrow relay-hint seam the event loaders need.
*/
val LocalEventFinder =
staticCompositionLocalOf<EventFinderFilterAssembler> {
error("LocalEventFinder not provided")
}
/**
* Subscribes to relay updates for [note]'s interactions (reactions, zaps,
* reposts, replies, …) for as long as this composable is in composition,
* coalesced with every other on-screen note into batched REQs by [dataSource].
*
* Like the user-finder, because a `LazyColumn` composes only the visible window
* (+ a small prefetch buffer) this means "load interactions only for notes
* currently on screen" — [LifecycleAwareKeyDataSourceSubscription] unsubscribes
* ~30s after the row leaves composition or the app is backgrounded.
*/
@Composable
fun EventFinderFilterAssemblerSubscription(
note: Note,
account: UserFinderAccount,
dataSource: EventFinderFilterAssembler,
) {
// Different screens get their own query-state instance even when tracking
// the same note; the assembler dedups to one REQ per note.
val state =
remember(note, account) {
EventFinderQueryState(note, account)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
/**
* Convenience overload that reads the front end's [LocalEventFinder] and
* [LocalUserFinderAccount] from the composition.
*/
@Composable
fun EventFinderFilterAssemblerSubscription(note: Note) {
EventFinderFilterAssemblerSubscription(
note = note,
account = LocalUserFinderAccount.current,
dataSource = LocalEventFinder.current,
)
}
@@ -18,15 +18,15 @@
* 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.event.loaders
package com.vitorpamplona.amethyst.commons.relayClient.event.loaders
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.service.BundledUpdate
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
@@ -41,7 +41,7 @@ import kotlinx.coroutines.IO
* relay list arrives, [EventFinderFilterAssembler] is invalidated and can query the correct relay.
*/
class AddressableAuthorRelayLoaderSubAssembler(
val cache: LocalCache,
val cache: ICacheProvider,
val allKeys: () -> Set<EventFinderQueryState>,
val userFinder: UserFinderFilterAssembler,
) : IEoseManager {
@@ -69,7 +69,7 @@ class AddressableAuthorRelayLoaderSubAssembler(
val note = key.note
if (note is AddressableNote && note.event == null) {
val author = cache.getOrCreateUser(note.address.pubKeyHex)
if (author.authorRelayList() == null) {
if (author != null && author.authorRelayList() == null) {
needed.add(UserFinderQueryState(author, key.account))
}
}
@@ -18,33 +18,36 @@
* 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.event.loaders
package com.vitorpamplona.amethyst.commons.relayClient.event.loaders
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl> {
fun potentialRelaysToFindAddress(
cache: ICacheProvider,
note: AddressableNote,
): Set<NormalizedRelayUrl> {
val set = mutableSetOf<NormalizedRelayUrl>()
LocalCache.getOrCreateUser(note.address.pubKeyHex).outboxRelays()?.let {
cache.getOrCreateUser(note.address.pubKeyHex)?.outboxRelays()?.let {
set.addAll(it)
}
set.addAll(LocalCache.relayHints.hintsForAddress(note.idHex))
set.addAll(cache.relayHints.hintsForAddress(note.idHex))
LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
cache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
note.replyTo?.forEach { parentNote ->
set.addAll(parentNote.relays)
LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
cache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
parentNote.author?.inboxRelays()?.let { set.addAll(it) }
}
@@ -52,7 +55,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl>
note.replies.forEach { childNote ->
set.addAll(childNote.relays)
LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
cache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
childNote.author?.outboxRelays()?.let { set.addAll(it) }
}
@@ -72,13 +75,16 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl>
return set
}
fun filterMissingAddressables(keys: List<EventFinderQueryState>): List<RelayBasedFilter> {
fun filterMissingAddressables(
cache: ICacheProvider,
keys: List<EventFinderQueryState>,
): List<RelayBasedFilter> {
val addressesPerRelay =
mapOfSet {
keys.forEach { key ->
val default = key.account.followPlusAllMineWithSearch.flow.value
val default = key.account.followPlusAllMineWithSearchRelays()
if (key.note is AddressableNote && key.note.event == null) {
potentialRelaysToFindAddress(key.note).ifEmpty { default }.forEach { relayUrl ->
potentialRelaysToFindAddress(cache, key.note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, key.note.address)
}
}
@@ -86,7 +92,7 @@ fun filterMissingAddressables(keys: List<EventFinderQueryState>): List<RelayBase
// loads threading that is event-based
key.note.replyTo?.forEach { note ->
if (note is AddressableNote && note.event == null) {
potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl ->
potentialRelaysToFindAddress(cache, note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, note.address)
}
}
@@ -18,33 +18,36 @@
* 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.event.loaders
package com.vitorpamplona.amethyst.commons.relayClient.event.loaders
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
fun potentialRelaysToFindEvent(
cache: ICacheProvider,
note: Note,
): Set<NormalizedRelayUrl> {
val set = mutableSetOf<NormalizedRelayUrl>()
set.addAll(LocalCache.relayHints.hintsForEvent(note.idHex))
set.addAll(cache.relayHints.hintsForEvent(note.idHex))
note.author?.outboxRelays()?.let { set.addAll(it) }
LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
cache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
note.replyTo?.forEach { parentNote ->
set.addAll(parentNote.relays)
LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
cache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
parentNote.author?.inboxRelays()?.let { set.addAll(it) }
}
@@ -52,7 +55,7 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
note.replies.forEach { childNote ->
set.addAll(childNote.relays)
LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
cache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
childNote.author?.outboxRelays()?.let { set.addAll(it) }
}
@@ -81,7 +84,7 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
val noteEvent = parent.event
if (noteEvent is PubKeyHintProvider) {
noteEvent.linkedPubKeys().forEach { potentialAuthor ->
LocalCache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor ->
cache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor ->
potentialAuthor.outboxRelays()?.let { set.addAll(it) }
potentialAuthor.inboxRelays()?.let { set.addAll(it) }
}
@@ -98,18 +101,21 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
return set
}
fun filterMissingEvents(keys: List<EventFinderQueryState>): List<RelayBasedFilter> {
fun filterMissingEvents(
cache: ICacheProvider,
keys: List<EventFinderQueryState>,
): List<RelayBasedFilter> {
val eventsPerRelay =
mapOfSet {
keys.forEach { key ->
val default = key.account.followPlusAllMineWithSearch.flow.value
val default = key.account.followPlusAllMineWithSearchRelays()
if (key.note !is AddressableNote && key.note.event == null) {
potentialRelaysToFindEvent(key.note).ifEmpty { default }.forEach { relayUrl ->
potentialRelaysToFindEvent(cache, key.note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, key.note.idHex)
}
key.account.searchRelayList.flow.value.forEach { relayUrl ->
key.account.searchRelays().forEach { relayUrl ->
add(relayUrl, key.note.idHex)
}
}
@@ -117,7 +123,7 @@ fun filterMissingEvents(keys: List<EventFinderQueryState>): List<RelayBasedFilte
// loads threading that is event-based
key.note.replyTo?.forEach { note ->
if (note !is AddressableNote && note.event == null) {
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
potentialRelaysToFindEvent(cache, note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, note.idHex)
}
}
@@ -18,21 +18,28 @@
* 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.event.loaders
package com.vitorpamplona.amethyst.commons.relayClient.event.loaders
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
class NoteEventLoaderSubAssembler(
client: INostrClient,
val cache: ICacheProvider,
allKeys: () -> Set<EventFinderQueryState>,
) : SingleSubNoEoseCacheEoseManager<EventFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(keys: List<EventFinderQueryState>) =
listOfNotNull(
filterMissingEvents(keys),
filterMissingAddressables(keys),
filterMissingEvents(cache, keys),
filterMissingAddressables(cache, keys),
).flatten()
override fun distinct(key: EventFinderQueryState) = key.note
// Attribute to the account that owns this subscription, when a single account is watching.
// Deduped by pubkey hex, not by account identity, so two objects for the same logged-in user
// don't look like two accounts and suppress attribution.
override fun accountPubKeyOf(key: Any?): String? = (key as? EventFinderQueryState)?.account?.userFinderPubkeyHex
}
@@ -18,15 +18,15 @@
* 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.event.watchers
package com.vitorpamplona.amethyst.commons.relayClient.event.watchers
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.amethyst.service.relays.MutableTime
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
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
@@ -67,7 +67,7 @@ class EventWatcherSubAssembler(
// the same logged-in user would look like two accounts and suppress attribution entirely.
val soleAccountPubKey =
keys
.mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex }
.mapTo(mutableSetOf()) { it.account.userFinderPubkeyHex }
.singleOrNull()
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
@@ -18,12 +18,12 @@
* 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.event.watchers
package com.vitorpamplona.amethyst.commons.relayClient.event.watchers
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -20,12 +20,12 @@
*/
@file:Suppress("DEPRECATION")
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
package com.vitorpamplona.amethyst.commons.relayClient.event.watchers
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent