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(
dvmPublicKey: User,
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
) {
val relays = nip65RelayList.inboxFlow.value.toSet()
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))
cache.justConsumeMyOwnEvent(request)
onReady(request)
onReady(request, relayList.toSet())
delay(100)
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.HexKey
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.status.NIP90StatusEvent
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.
*
* - [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.
* - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, ).
* - [errorMessage] captures any client-side failure while publishing the request.
*/
data class FavoriteDvmSnapshot(
val requestId: HexKey? = null,
val responseRelays: Set<NormalizedRelayUrl> = emptySet(),
val ids: Set<HexKey> = emptySet(),
val addresses: Set<String> = emptySet(),
val latestStatus: NIP90StatusEvent? = null,
@@ -114,10 +119,11 @@ class FavoriteDvmOrchestrator(
val job =
scope.launch(Dispatchers.IO) {
try {
account.requestDVMContentDiscovery(user) { request ->
account.requestDVMContentDiscovery(user) { request, relays ->
seed.update {
it.copy(
requestId = request.id,
responseRelays = relays,
ids = emptySet(),
addresses = emptySet(),
latestStatus = null,
@@ -43,12 +43,13 @@ class FavoriteDvmFeedFlow(
private fun buildFilter(
snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot,
relays: Set<NormalizedRelayUrl>,
contentRelays: Set<NormalizedRelayUrl>,
) = FavoriteDvmTopNavFilter(
dvmAddress = dvmAddress,
acceptedIds = snapshot.ids,
acceptedAddresses = snapshot.addresses,
relayList = relays,
contentRelays = contentRelays,
listenRelays = snapshot.responseRelays,
requestId = snapshot.requestId,
)
@@ -60,7 +61,7 @@ class FavoriteDvmFeedFlow(
override fun startValue(): FavoriteDvmTopNavFilter =
buildFilter(
snapshot = orchestrator.observe(dvmAddress).value,
relays = resolveRelays(outboxRelays.value, proxyRelays.value),
contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value),
)
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
@@ -43,7 +43,8 @@ class FavoriteDvmTopNavFilter(
val dvmAddress: Address,
val acceptedIds: Set<HexKey>,
val acceptedAddresses: Set<String>,
val relayList: Set<NormalizedRelayUrl>,
val contentRelays: Set<NormalizedRelayUrl>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestId: HexKey?,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey): Boolean = true
@@ -56,13 +57,14 @@ class FavoriteDvmTopNavFilter(
override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet =
FavoriteDvmTopNavPerRelayFilterSet(
relayList.associateWith {
FavoriteDvmTopNavPerRelayFilter(
dvmPubkey = dvmAddress.pubKeyHex,
requestId = requestId,
ids = acceptedIds,
addresses = acceptedAddresses,
)
},
contentFetches =
contentRelays.associateWith {
FavoriteDvmTopNavPerRelayFilter(
ids = acceptedIds,
addresses = acceptedAddresses,
)
},
listenRelays = listenRelays,
requestId = requestId,
)
}
@@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Immutable
class FavoriteDvmTopNavPerRelayFilter(
val dvmPubkey: HexKey,
val requestId: HexKey?,
val ids: Set<HexKey>,
val addresses: Set<String>,
) : IFeedTopNavPerRelayFilter
@@ -21,8 +21,19 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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(
val set: Map<NormalizedRelayUrl, FavoriteDvmTopNavPerRelayFilter>,
val contentFetches: Map<NormalizedRelayUrl, FavoriteDvmTopNavPerRelayFilter>,
val listenRelays: Set<NormalizedRelayUrl>,
val requestId: HexKey?,
) : IFeedTopNavPerRelayFilterSet
@@ -1809,8 +1809,8 @@ class AccountViewModel(
onReady: (event: Note) -> Unit,
) {
launchSigner {
account.requestDVMContentDiscovery(dvmPublicKey) {
onReady(LocalCache.getOrCreateNote(it.id))
account.requestDVMContentDiscovery(dvmPublicKey) { request, _ ->
onReady(LocalCache.getOrCreateNote(request.id))
}
}
}
@@ -282,7 +282,20 @@ fun HomeFeeds(
accountViewModel: AccountViewModel,
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 ->
RenderFeedContentState(
feedContentState = feedState,
@@ -291,12 +304,28 @@ fun HomeFeeds(
nav = nav,
routeForLastRead = routeForLastRead,
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)
@Composable
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.FavoriteDvmTopNavPerRelayFilterSet
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.filters.Filter
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.
*
* Three filters are issued per relay:
* - 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
* - subscribe to the DVM's future kind 6300 / 7000 events so the filter snapshot
* keeps up to date while this filter is active
* Two distinct subscription kinds with two distinct relay sets:
*
* - **Content fetch** for each of the user's outbox/proxy relays, request the
* note IDs and addressable references the DVM curated. Notes typically live on
* 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(
set: FavoriteDvmTopNavPerRelayFilterSet,
@Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?,
@Suppress("UNUSED_PARAMETER") defaultSince: Long?,
): List<RelayBasedFilter> =
set.set.flatMap { (relay, filter) ->
buildFiltersFor(relay, filter)
): List<RelayBasedFilter> {
val out = mutableListOf<RelayBasedFilter>()
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,
filter: FavoriteDvmTopNavPerRelayFilter,
): 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
}
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,
),
)