From 4574005019d9c24c83d351dc8fb93ff981166161 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 01:46:58 +0000 Subject: [PATCH 1/2] feat: fetch author relay list when addressable event is missing When a kind-16 repost (or any event) has an a-tag pointing to an addressable event by an unknown author (no NIP-65 relay list loaded), potentialRelaysToFindAddress() returned an empty set and the event was never found. AddressableAuthorRelayLoaderSubAssembler mirrors UserOutboxFinderSubAssembler but is driven by EventFinderQueryState. It detects AddressableNote stubs whose author relay list is missing and emits kind-0/10002 filters to indexer/search relays. On EOSE it calls invalidateFilters(), causing NoteEventLoaderSubAssembler to re-run with the now-populated outbox relays and fetch the addressable event. --- .../RelaySubscriptionsCoordinator.kt | 2 +- .../event/EventFinderFilterAssembler.kt | 6 + ...ddressableAuthorRelayLoaderSubAssembler.kt | 145 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9a7c1b5342..3c76a2389a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -99,7 +99,7 @@ class RelaySubscriptionsCoordinator( // loaders of content that is not yet in the device. // they are active when looking at events, users, channels. val channelFinder = ChannelFinderFilterAssemblyGroup(client) - val eventFinder = EventFinderFilterAssembler(client) + val eventFinder = EventFinderFilterAssembler(client, cache, failureTracker) val userFinder = UserFinderFilterAssembler(client, cache, failureTracker) // active when searching or tagging users. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index 29a6767490..abafef5d79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -23,10 +23,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event import androidx.compose.runtime.Stable 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.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.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker // This allows multiple screen to be listening to tags, even the same tag @Stable @@ -38,11 +41,14 @@ class EventFinderQueryState( @Stable class EventFinderFilterAssembler( client: INostrClient, + cache: LocalCache, + failureTracker: RelayOfflineTracker, ) : ComposeSubscriptionManager() { val group = listOf( NoteEventLoaderSubAssembler(client, ::allKeys), EventWatcherSubAssembler(client, ::allKeys), + AddressableAuthorRelayLoaderSubAssembler(client, cache, failureTracker, ::allKeys), ) override fun invalidateFilters() = group.forEach { it.invalidateFilters() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt new file mode 100644 index 0000000000..31c33bc0cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders + +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * When an AddressableNote stub (event == null) references an author whose NIP-65 relay list has + * not been loaded yet, [potentialRelaysToFindAddress] returns an empty set and the event is never + * found. This assembler detects those cases and fetches kind 0 + kind 10002 for the missing + * authors from indexer/search relays. Once the relay list arrives, [invalidateFilters] re-triggers + * [NoteEventLoaderSubAssembler], which can now query the author's actual outbox relays. + */ +class AddressableAuthorRelayLoaderSubAssembler( + client: INostrClient, + val cache: LocalCache, + val failureTracker: RelayOfflineTracker, + allKeys: () -> Set, +) : BaseEoseManager(client, allKeys) { + val relayListKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND) + + var hasTried: EOSEAccountFast = EOSEAccountFast(200) + + val sub = + requestNewSubscription( + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + forFilters?.forEach { filter -> + filter.authors?.forEach { pubKey -> + cache.getUserIfExists(pubKey)?.let { user -> + hasTried.newEose(user, relay, TimeUtils.now()) + } + } + } + invalidateFilters() + } + }, + ) + + override fun updateSubscriptions(keys: Set) { + val newFilters = updateFilter(keys.toList())?.ifEmpty { null } + sub.updateFilters(newFilters?.groupByRelay()) + } + + fun updateFilter(keys: List): List? { + val unknownAuthors = mutableSetOf() + keys.forEach { key -> + val note = key.note + if (note is AddressableNote && note.event == null) { + val author = cache.getOrCreateUser(note.address.pubKeyHex) + if (author.authorRelayList() == null) { + unknownAuthors.add(author) + } + } + } + + if (unknownAuthors.isEmpty()) return null + + val accounts = keys.mapTo(mutableSetOf()) { it.account } + val connectedRelays = client.connectedRelaysFlow().value + + val perRelayKeys = + pickRelaysToLoadUsers( + unknownAuthors, + accounts, + connectedRelays, + failureTracker.cannotConnectRelays, + hasTried, + ) + + val activeFilters = + perRelayKeys.mapNotNull { (relay, pubkeys) -> + val sorted = pubkeys.sorted() + if (sorted.isNotEmpty()) { + RelayBasedFilter( + relay = relay, + filter = Filter(kinds = relayListKinds, authors = sorted), + ) + } else { + null + } + } + + val placedPubkeys = perRelayKeys.values.asSequence().flatten().toSet() + val abandonedPubkeys = + unknownAuthors.mapNotNullTo(mutableSetOf()) { user -> + user.pubkeyHex.takeIf { it !in placedPubkeys } + } + + if (abandonedPubkeys.isEmpty()) return activeFilters + + val sortedAbandoned = abandonedPubkeys.sorted() + val fallbackRelays = (DefaultIndexerRelayList + DefaultSearchRelayList) - failureTracker.cannotConnectRelays + val fallbackFilters = + fallbackRelays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = Filter(kinds = relayListKinds, authors = sortedAbandoned), + ) + } + + return activeFilters + fallbackFilters + } +} From 128eed4a25107eb7b9fc6bfff285c5451d3d5c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 13:34:15 +0000 Subject: [PATCH 2/2] refactor: reuse UserFinderFilterAssembler instead of duplicating relay-list logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the self-contained AddressableAuthorRelayLoaderSubAssembler (which duplicated UserOutboxFinderSubAssembler) with a thin bridge that injects UserFinderQueryState entries directly into the existing UserFinderFilterAssembler. When EventFinderFilterAssembler detects an AddressableNote stub whose author relay list is unknown, it subscribes that author to userFinder. UserOutboxFinderSubAssembler already handles the kind-0/10002 fetch and relay resolution — no logic is duplicated. Subscriptions are cleaned up when the note loads or the EventFinder key is removed. --- .../RelaySubscriptionsCoordinator.kt | 2 +- .../event/EventFinderFilterAssembler.kt | 6 +- ...ddressableAuthorRelayLoaderSubAssembler.kt | 139 +++++------------- 3 files changed, 38 insertions(+), 109 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 3c76a2389a..fb84051fe5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -99,8 +99,8 @@ class RelaySubscriptionsCoordinator( // loaders of content that is not yet in the device. // they are active when looking at events, users, channels. val channelFinder = ChannelFinderFilterAssemblyGroup(client) - val eventFinder = EventFinderFilterAssembler(client, cache, failureTracker) val userFinder = UserFinderFilterAssembler(client, cache, failureTracker) + val eventFinder = EventFinderFilterAssembler(client, cache, userFinder) // active when searching or tagging users. val search = SearchFilterAssembler(client, scope, cache) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index abafef5d79..3e00e65548 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -28,8 +28,8 @@ import com.vitorpamplona.amethyst.model.Note 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.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker // This allows multiple screen to be listening to tags, even the same tag @Stable @@ -42,13 +42,13 @@ class EventFinderQueryState( class EventFinderFilterAssembler( client: INostrClient, cache: LocalCache, - failureTracker: RelayOfflineTracker, + userFinder: UserFinderFilterAssembler, ) : ComposeSubscriptionManager() { val group = listOf( NoteEventLoaderSubAssembler(client, ::allKeys), EventWatcherSubAssembler(client, ::allKeys), - AddressableAuthorRelayLoaderSubAssembler(client, cache, failureTracker, ::allKeys), + AddressableAuthorRelayLoaderSubAssembler(cache, ::allKeys, userFinder), ) override fun invalidateFilters() = group.forEach { it.invalidateFilters() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt index 31c33bc0cb..5f81cb8d22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt @@ -12,134 +12,63 @@ * 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. + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders -import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList -import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList -import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState /** - * When an AddressableNote stub (event == null) references an author whose NIP-65 relay list has - * not been loaded yet, [potentialRelaysToFindAddress] returns an empty set and the event is never - * found. This assembler detects those cases and fetches kind 0 + kind 10002 for the missing - * authors from indexer/search relays. Once the relay list arrives, [invalidateFilters] re-triggers - * [NoteEventLoaderSubAssembler], which can now query the author's actual outbox relays. + * Bridges missing-addressable-note authors into [UserFinderFilterAssembler]. + * + * When an [AddressableNote] stub (event == null) references an author whose NIP-65 relay list + * has not been loaded yet, [potentialRelaysToFindAddress] returns an empty set and the event + * is never fetched. This manager detects those cases and injects a [UserFinderQueryState] for + * the author into the existing [UserFinderFilterAssembler], which already knows how to fetch + * kind-0 / kind-10002 and resolve outbox relays via [UserOutboxFinderSubAssembler]. Once the + * relay list arrives, [EventFinderFilterAssembler] is invalidated and can query the correct relay. */ class AddressableAuthorRelayLoaderSubAssembler( - client: INostrClient, val cache: LocalCache, - val failureTracker: RelayOfflineTracker, - allKeys: () -> Set, -) : BaseEoseManager(client, allKeys) { - val relayListKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND) + val allKeys: () -> Set, + val userFinder: UserFinderFilterAssembler, +) : IEoseManager { + private val activeSubscriptions = mutableSetOf() - var hasTried: EOSEAccountFast = EOSEAccountFast(200) + override fun invalidateFilters(ignoreIfDoing: Boolean) { + val needed = mutableSetOf() - val sub = - requestNewSubscription( - object : SubscriptionListener { - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - forFilters?.forEach { filter -> - filter.authors?.forEach { pubKey -> - cache.getUserIfExists(pubKey)?.let { user -> - hasTried.newEose(user, relay, TimeUtils.now()) - } - } - } - invalidateFilters() - } - }, - ) - - override fun updateSubscriptions(keys: Set) { - val newFilters = updateFilter(keys.toList())?.ifEmpty { null } - sub.updateFilters(newFilters?.groupByRelay()) - } - - fun updateFilter(keys: List): List? { - val unknownAuthors = mutableSetOf() - keys.forEach { key -> + allKeys().forEach { key -> val note = key.note if (note is AddressableNote && note.event == null) { val author = cache.getOrCreateUser(note.address.pubKeyHex) if (author.authorRelayList() == null) { - unknownAuthors.add(author) + needed.add(UserFinderQueryState(author, key.account)) } } } - if (unknownAuthors.isEmpty()) return null + val toAdd = needed - activeSubscriptions + val toRemove = activeSubscriptions - needed - val accounts = keys.mapTo(mutableSetOf()) { it.account } - val connectedRelays = client.connectedRelaysFlow().value + userFinder.subscribe(toAdd.toList()) + userFinder.unsubscribe(toRemove.toList()) - val perRelayKeys = - pickRelaysToLoadUsers( - unknownAuthors, - accounts, - connectedRelays, - failureTracker.cannotConnectRelays, - hasTried, - ) + activeSubscriptions.clear() + activeSubscriptions.addAll(needed) + } - val activeFilters = - perRelayKeys.mapNotNull { (relay, pubkeys) -> - val sorted = pubkeys.sorted() - if (sorted.isNotEmpty()) { - RelayBasedFilter( - relay = relay, - filter = Filter(kinds = relayListKinds, authors = sorted), - ) - } else { - null - } - } - - val placedPubkeys = perRelayKeys.values.asSequence().flatten().toSet() - val abandonedPubkeys = - unknownAuthors.mapNotNullTo(mutableSetOf()) { user -> - user.pubkeyHex.takeIf { it !in placedPubkeys } - } - - if (abandonedPubkeys.isEmpty()) return activeFilters - - val sortedAbandoned = abandonedPubkeys.sorted() - val fallbackRelays = (DefaultIndexerRelayList + DefaultSearchRelayList) - failureTracker.cannotConnectRelays - val fallbackFilters = - fallbackRelays.map { relay -> - RelayBasedFilter( - relay = relay, - filter = Filter(kinds = relayListKinds, authors = sortedAbandoned), - ) - } - - return activeFilters + fallbackFilters + override fun destroy() { + userFinder.unsubscribe(activeSubscriptions.toList()) + activeSubscriptions.clear() } }