fix(home): route DVM listen subscription to DVM relays + wire pull-to-refresh

Two issues from the first pass:

1. Response routing. The kind-5300 request goes to the DVM's own
   inbox/used relays, but `filterHomePostsByDvmIds` was subscribing
   for kind 6300/7000 on the user's outbox/proxy relays. Most DVMs
   only publish their replies on their own relays, so the listen
   subscription would silently miss every response.

   Fix: `Account.requestDVMContentDiscovery` now exposes the relay
   set the request was sent to, the orchestrator stores it on the
   snapshot as `responseRelays`, and `FavoriteDvmTopNavPerRelayFilterSet`
   carries two distinct relay sets — `contentFetches` (user's outbox,
   for the actual notes) and `listenRelays` (DVM's relays, for the
   6300/7000 reply subscription). `filterHomePostsByDvmIds` issues
   each on the right relay.

2. Pull-to-refresh. Swiping down on Home only re-rendered the cached
   feed. When a `TopFilter.FavoriteDvm` is active it now also calls
   `orchestrator.refresh(addr)` so a fresh kind-5300 request is
   published to the DVM.
This commit is contained in:
Claude
2026-04-18 21:57:56 +00:00
parent 843da0b383
commit ee94dba570
9 changed files with 114 additions and 49 deletions
@@ -2357,7 +2357,7 @@ class Account(
suspend fun requestDVMContentDiscovery( suspend fun requestDVMContentDiscovery(
dvmPublicKey: User, dvmPublicKey: User,
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
) { ) {
val relays = nip65RelayList.inboxFlow.value.toSet() val relays = nip65RelayList.inboxFlow.value.toSet()
val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays)) val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
@@ -2367,7 +2367,7 @@ class Account(
?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex)) ?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
cache.justConsumeMyOwnEvent(request) cache.justConsumeMyOwnEvent(request)
onReady(request) onReady(request, relayList.toSet())
delay(100) delay(100)
client.publish(request, relayList) client.publish(request, relayList)
} }
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
@@ -44,12 +45,16 @@ import kotlinx.coroutines.sync.withLock
* Immutable snapshot of a favourite DVM's current request/response state. * Immutable snapshot of a favourite DVM's current request/response state.
* *
* - [requestId] is the id of the most recently published kind-5300 request. * - [requestId] is the id of the most recently published kind-5300 request.
* - [responseRelays] is the relay set the kind-5300 was sent to the same set on
* which the DVM will publish its 6300/7000 responses, so the home subscription
* manager must listen there (not on the user's own outbox).
* - [ids] and [addresses] are the note references returned by the latest kind-6300 response. * - [ids] and [addresses] are the note references returned by the latest kind-6300 response.
* - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, ). * - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, ).
* - [errorMessage] captures any client-side failure while publishing the request. * - [errorMessage] captures any client-side failure while publishing the request.
*/ */
data class FavoriteDvmSnapshot( data class FavoriteDvmSnapshot(
val requestId: HexKey? = null, val requestId: HexKey? = null,
val responseRelays: Set<NormalizedRelayUrl> = emptySet(),
val ids: Set<HexKey> = emptySet(), val ids: Set<HexKey> = emptySet(),
val addresses: Set<String> = emptySet(), val addresses: Set<String> = emptySet(),
val latestStatus: NIP90StatusEvent? = null, val latestStatus: NIP90StatusEvent? = null,
@@ -114,10 +119,11 @@ class FavoriteDvmOrchestrator(
val job = val job =
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
account.requestDVMContentDiscovery(user) { request -> account.requestDVMContentDiscovery(user) { request, relays ->
seed.update { seed.update {
it.copy( it.copy(
requestId = request.id, requestId = request.id,
responseRelays = relays,
ids = emptySet(), ids = emptySet(),
addresses = emptySet(), addresses = emptySet(),
latestStatus = null, latestStatus = null,
@@ -43,12 +43,13 @@ class FavoriteDvmFeedFlow(
private fun buildFilter( private fun buildFilter(
snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot,
relays: Set<NormalizedRelayUrl>, contentRelays: Set<NormalizedRelayUrl>,
) = FavoriteDvmTopNavFilter( ) = FavoriteDvmTopNavFilter(
dvmAddress = dvmAddress, dvmAddress = dvmAddress,
acceptedIds = snapshot.ids, acceptedIds = snapshot.ids,
acceptedAddresses = snapshot.addresses, acceptedAddresses = snapshot.addresses,
relayList = relays, contentRelays = contentRelays,
listenRelays = snapshot.responseRelays,
requestId = snapshot.requestId, requestId = snapshot.requestId,
) )
@@ -60,7 +61,7 @@ class FavoriteDvmFeedFlow(
override fun startValue(): FavoriteDvmTopNavFilter = override fun startValue(): FavoriteDvmTopNavFilter =
buildFilter( buildFilter(
snapshot = orchestrator.observe(dvmAddress).value, snapshot = orchestrator.observe(dvmAddress).value,
relays = resolveRelays(outboxRelays.value, proxyRelays.value), contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value),
) )
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) { override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
@@ -43,7 +43,8 @@ class FavoriteDvmTopNavFilter(
val dvmAddress: Address, val dvmAddress: Address,
val acceptedIds: Set<HexKey>, val acceptedIds: Set<HexKey>,
val acceptedAddresses: Set<String>, val acceptedAddresses: Set<String>,
val relayList: Set<NormalizedRelayUrl>, val contentRelays: Set<NormalizedRelayUrl>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestId: HexKey?, val requestId: HexKey?,
) : IFeedTopNavFilter { ) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey): Boolean = true override fun matchAuthor(pubkey: HexKey): Boolean = true
@@ -56,13 +57,14 @@ class FavoriteDvmTopNavFilter(
override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet =
FavoriteDvmTopNavPerRelayFilterSet( FavoriteDvmTopNavPerRelayFilterSet(
relayList.associateWith { contentFetches =
FavoriteDvmTopNavPerRelayFilter( contentRelays.associateWith {
dvmPubkey = dvmAddress.pubKeyHex, FavoriteDvmTopNavPerRelayFilter(
requestId = requestId, ids = acceptedIds,
ids = acceptedIds, addresses = acceptedAddresses,
addresses = acceptedAddresses, )
) },
}, listenRelays = listenRelays,
requestId = requestId,
) )
} }
@@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Immutable @Immutable
class FavoriteDvmTopNavPerRelayFilter( class FavoriteDvmTopNavPerRelayFilter(
val dvmPubkey: HexKey,
val requestId: HexKey?,
val ids: Set<HexKey>, val ids: Set<HexKey>,
val addresses: Set<String>, val addresses: Set<String>,
) : IFeedTopNavPerRelayFilter ) : IFeedTopNavPerRelayFilter
@@ -21,8 +21,19 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Two relay sets, two distinct subscriptions:
*
* - [contentFetches] for each user-configured content relay, the ids/addresses
* we want to pull (the actual notes the DVM curated).
* - [listenRelays] the DVM's own publish relays (where it will deliver future
* kind 6300 / 7000 events for this request).
*/
class FavoriteDvmTopNavPerRelayFilterSet( class FavoriteDvmTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, FavoriteDvmTopNavPerRelayFilter>, val contentFetches: Map<NormalizedRelayUrl, FavoriteDvmTopNavPerRelayFilter>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestId: HexKey?,
) : IFeedTopNavPerRelayFilterSet ) : IFeedTopNavPerRelayFilterSet
@@ -1809,8 +1809,8 @@ class AccountViewModel(
onReady: (event: Note) -> Unit, onReady: (event: Note) -> Unit,
) { ) {
launchSigner { launchSigner {
account.requestDVMContentDiscovery(dvmPublicKey) { account.requestDVMContentDiscovery(dvmPublicKey) { request, _ ->
onReady(LocalCache.getOrCreateNote(it.id)) onReady(LocalCache.getOrCreateNote(request.id))
} }
} }
} }
@@ -282,7 +282,20 @@ fun HomeFeeds(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
RefresheableBox(feedState, enablePullRefresh) { val activeFilter by accountViewModel.account.settings.defaultHomeFollowList
.collectAsStateWithLifecycle()
val activeDvm = activeFilter as? TopFilter.FavoriteDvm
val onRefresh: () -> Unit = {
feedState.invalidateData()
if (activeDvm != null) {
// Swiping down on Home should also re-issue the kind-5300 request so the
// DVM produces a fresh feed, not just re-render whatever's cached.
accountViewModel.refreshFavoriteDvm(activeDvm.address)
}
}
RefresheableHomeBox(onRefresh, enablePullRefresh) {
SaveableFeedContentState(feedState, scrollStateKey) { listState -> SaveableFeedContentState(feedState, scrollStateKey) { listState ->
RenderFeedContentState( RenderFeedContentState(
feedContentState = feedState, feedContentState = feedState,
@@ -291,12 +304,28 @@ fun HomeFeeds(
nav = nav, nav = nav,
routeForLastRead = routeForLastRead, routeForLastRead = routeForLastRead,
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) }, onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
onEmpty = { HomeFeedEmpty(feedState::invalidateData) }, onEmpty = { HomeFeedEmpty(onRefresh) },
) )
} }
} }
} }
@Composable
private fun RefresheableHomeBox(
onRefresh: () -> Unit,
enablePullRefresh: Boolean,
content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit,
) {
if (enablePullRefresh) {
RefresheableBox(onRefresh = onRefresh, content = content)
} else {
androidx.compose.foundation.layout.Box(
Modifier.fillMaxSize(),
content = content,
)
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun FeedLoaded( fun FeedLoaded(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -32,22 +33,40 @@ import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
/** /**
* Builds relay REQ filters for a favourite-DVM home feed. * Builds relay REQ filters for a favourite-DVM home feed.
* *
* Three filters are issued per relay: * Two distinct subscription kinds with two distinct relay sets:
* - fetch notes whose ids are in the DVM's latest kind-6300 response *
* - fetch addressable notes referenced by `a` tags in the DVM's response * - **Content fetch** for each of the user's outbox/proxy relays, request the
* - subscribe to the DVM's future kind 6300 / 7000 events so the filter snapshot * note IDs and addressable references the DVM curated. Notes typically live on
* keeps up to date while this filter is active * the user's normal relays, so this is where we fetch them.
*
* - **Response listen** for each relay the DVM advertised (where it received
* the kind-5300 request and will publish its 6300/7000 reply), subscribe to
* future kind 6300 / 7000 events tagged with the request id. The DVM almost
* never publishes responses on the user's outbox, so listening anywhere else
* would silently miss them.
*/ */
fun filterHomePostsByDvmIds( fun filterHomePostsByDvmIds(
set: FavoriteDvmTopNavPerRelayFilterSet, set: FavoriteDvmTopNavPerRelayFilterSet,
@Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?,
@Suppress("UNUSED_PARAMETER") defaultSince: Long?, @Suppress("UNUSED_PARAMETER") defaultSince: Long?,
): List<RelayBasedFilter> = ): List<RelayBasedFilter> {
set.set.flatMap { (relay, filter) -> val out = mutableListOf<RelayBasedFilter>()
buildFiltersFor(relay, filter)
set.contentFetches.forEach { (relay, filter) ->
out += contentFetchFilters(relay, filter)
} }
private fun buildFiltersFor( val requestId = set.requestId
if (requestId != null) {
set.listenRelays.forEach { relay ->
out += responseListenFilter(relay, requestId)
}
}
return out
}
private fun contentFetchFilters(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
filter: FavoriteDvmTopNavPerRelayFilter, filter: FavoriteDvmTopNavPerRelayFilter,
): List<RelayBasedFilter> { ): List<RelayBasedFilter> {
@@ -77,23 +96,22 @@ private fun buildFiltersFor(
) )
} }
val requestId = filter.requestId
if (requestId != null) {
out +=
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds =
listOf(
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
),
tags = mapOf("e" to listOf(requestId)),
limit = 10,
),
)
}
return out return out
} }
private fun responseListenFilter(
relay: NormalizedRelayUrl,
requestId: HexKey,
) = RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds =
listOf(
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
),
tags = mapOf("e" to listOf(requestId)),
limit = 10,
),
)