Merge main: the golden's kind list stays machine-checked

Twenty commits, one conflict, in the file both sides had reason to touch.

Main pinned kind 11998 — the DVM heartbeat — into the golden test's KINDS
list as a `<not searchable>` row, so that a heartbeat quietly gaining
indexable content would show as a diff. This branch had already replaced
that hand-kept list with `SearchableKinds.ALL`, which holds only kinds
that are searchable, so the pinned row has nowhere to live and is
dropped.

Nothing is lost by that, which is the whole reason to resolve it this
way: `SearchableKindsTest` builds every kind from 0 to 65535 through
`EventFactory` and asserts the recorded set is exactly what came back
searchable. A heartbeat that started indexing text fails there, by
number, without anyone having remembered to list it — the same guarantee
the pinned row gave for one kind, for all of them. The KDoc says so, so
the next person doesn't re-add a row the list cannot hold.

Main's other change to that file is kept as-is: the golden comparison
normalizes CRLF, and `.gitattributes` forces LF on `*.golden`, so a
Windows checkout can't fail the suite on invisible line endings.

Everything else merged clean. The heartbeat kind is not searchable, so
`SearchableKinds`, `RenderableKinds` and the `kind:` vocabulary are
unaffected — verified by running their drift tests against merged main
rather than by reading the diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
This commit is contained in:
Claude
2026-09-10 17:36:39 +00:00
39 changed files with 2442 additions and 28 deletions
+4
View File
@@ -2,3 +2,7 @@
# binary so git never applies CRLF/text normalization or textual diff/merge, which
# would corrupt the compressed stream (important on Windows checkouts).
*.gz binary
# Golden test resources are compared byte-for-byte against generated strings:
# force LF everywhere so a Windows CRLF checkout cannot break the comparison.
*.golden text eol=lf
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
# DVM heartbeat liveness — only show DVMs with a fresh kind-11998 heartbeat
_Status: **implemented** (Android; desktop wiring deliberately out of scope — §8)._
## 0. The shape of the thing
Amethyst shows Data Vending Machines (DVMs) in three places: the Discover "Content" tab
(kind 31990 NIP-89 announcements advertising kind 5300), DVM feeds pinned to the top-nav
(`FavoriteAlgoFeedsOrchestrator`), and the per-DVM content-discovery screen. Today all of
these treat every announced DVM as alive, forever — a DVM that went down months ago still
renders as a usable feed.
DVM operators are now sending a **heartbeat event (kind 11998) every 300 seconds**. The
event is plain-text (`content = "Alive and kicking"`) with three tags:
- `status` — free-text status line (e.g. "My heart keeps beating like a hammer")
- `d` — the DVM's **NIP-89 DTAG**, tying the heartbeat to the announcement's address
- `expiration` — `createdAt + 300` (NIP-40), so relays drop the beat once the next one lands
Kind 11998 sits in the replaceable range (10000–19999), so relays keep only the latest beat
per author. There is no NIP for this yet — the shape above comes from the operator-side
builder and is treated as the wire contract.
The feature: **a DVM counts as alive only if its latest heartbeat is at most 900 seconds
old** (one missed 300s beat plus slack). Dead DVMs disappear from the Discover list; pinned
feeds and the detail surface show an offline state instead.
## 1. Decisions taken
1. **Approach: cache-backed heartbeats.** The heartbeat is a real event class stored through
the standard replaceable path in `LocalCache` (newest per address, standard invalidation).
Rejected alternatives: a side-state registry (duplicates invalidation plumbing) and
regular-note storage (no address matching, pollutes the notes index).
2. **Scope: all three surfaces** — Discover list (hide), pinned feeds (offline state, chip
stays), DVM detail screen (offline banner, requesting still allowed). The manage screen
(`FavoriteAlgoFeedsListScreen`) also gets the badge.
3. **Pinned chips stay when offline** — the user pinned them deliberately; they gray out
with an offline badge rather than vanishing, and tapping still opens the feed.
4. **Threshold: 900 seconds** (raised from the original 420 after field testing: beats arrive every 300s, and a 420s window tolerated barely one delivery hiccup, dropping live DVMs in oscillations). Exactly 900s old counts as fresh.
5. **Strict from cold start.** No grace period: the Discover list starts empty and fills
within ~1–2s as heartbeat REQs return (same behavior as the existing 31990 load).
## 2. Event model (quartz)
New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`:
- `class DvmHeartbeatEvent(...) : BaseAddressableEvent(...)`, `KIND = 11998` — the codebase
convention for 10xxx events with real `d` tags (e.g. `FollowListEvent`), so `dTag()` /
`address()` / `addressTag()` come from the base. The cache address is
`Address(11998, dvmPubkey, dTag)`, the exact mirror of the announcement's
`Address(31990, dvmPubkey, dTag)`.
- Accessors: `status()`, and `expiration()` via the existing NIP-40 extension.
- `MAX_AGE_SECONDS = 900` and `isFreshAt(now)` live in quartz too (commons imports them).
- Registered in `EventFactory` (kind → constructor) and allowlisted in
`EventFactoryKindRangeTest.knownDTagReaders`: the `d` tag keys the client-side address
while relay storage stays plain-replaceable per the kind range.
## 3. Cache consumption (LocalCache)
One routing line in `LocalCache.justConsumeInnerInner`: `is DvmHeartbeatEvent ->`
`consumeBaseReplaceable(event, relay, wasVerified)`. This yields newest-per-address
replacement, relay tracking, and `LocalCacheFlow` invalidation for free. Unlisted kinds fall
into the `else` branch and are rejected, so the routing line is mandatory.
Stale beats simply sit at their address until overwritten; the age check (§4) makes them
invisible. The cache pruner removes old entries on its own schedule.
## 4. Freshness core (amethyst)
Small helper file in `amethyst/.../model/` (the threshold constant itself lives in quartz):
- `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address
lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())`
- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 900`
- `@Composable fun rememberDvmHeartbeatFresh(address: Address, accountViewModel: AccountViewModel): State<Boolean>` —
as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 900s
old; an unresolved/absent beat counts as offline (`false`) on every surface. The returned
`State` identity is stable for the lifetime of the call site (one unconditional
`rememberUpdatedState`), so callers may capture it across recompositions. Composable-scoped
subscription (§5) + staleness re-check tick (§6), shared by every surface that renders
liveness.
## 5. Subscriptions
**Discover screen — all DVM heartbeats.** In
`commons/.../relayClient/discover/nip90DVMs/SubAssemblyHelper.kt`, `makeContentDVMsFilter`
unconditionally appends one filter for every top-filter variant:
`kinds = [11998], since = TimeUtils.now() - 900` — no authors, no tags, scoped to the same
relay set as the 31990 REQs. It deliberately ignores the 31990 `since`-cursor (heartbeats
are a rolling window, not a cursor stream — the cursor would miss re-opened tabs after the
beats expired). It rides the existing assembler lifecycle: subscribes on entering Discover,
closes on leaving.
**Per-surface — pinned chips, home banner, detail screen.** `rememberDvmHeartbeat` opens a
tiny composable-scoped subscription: `kinds = [11998], authors = [dvm pubkey], limit = 1,
since = now - 900`. The home top-bar chips live for the whole session, so they double as
the session-scoped watcher for pinned DVMs. Traffic is negligible (a few pinned DVMs ×
1 event / 5 min).
**Outbox fetcher (added after field testing).** The global REQ above only sees beats that
reach the *user's* discovery relays — but DVMs publish beats to their own write relays, and
relays don't gossip, so alive DVMs whose beats never overlap the user's relay set stayed
invisible (their detail screens proved the beats existed on the outbox). `DiscoveryDvmHeartbeatSubAssembler`
joins the discovery assembler group and, while Discover is composed, batches the cached
content-discovery announcements' authors per **DVM outbox relay** (`kinds = [11998],
authors = [those pubkeys], since = now - 900`, coverage-ranked and capped at 12 relays;
authors with unknown outboxes/hints rely on the global REQ as fallback). It re-issues when
the cached announcement set or the NIP-65 relay lists move.
The announcement source MUST be the **ungated cache scan**
(`LocalCache.cachedDvmAnnouncements` — every cached k=5300 announcement, newest first, capped
at 100), not the gated feed list. Sourcing from the gated list is a death spiral: a DVM
leaves the gated list the moment its beat ages out, the fetcher would stop covering it, and
no beat would ever arrive to bring it back — any transient staleness becomes a permanent
drop. The relay lookup unions the author's NIP-65 outbox with the cached relay hints for the
author (the same mix the event finder's `potentialRelaysToFindAddress` uses).
## 6. Invalidation — closing the two silent gaps
1. **A new heartbeat does not re-rank the list.** The additive feed path
(`FeedContentState.updateFeedWith`) re-filters only the *new* notes, and a heartbeat
note is never a list row — the affected 31990 card would not be re-evaluated. Fix: in
`AccountFeedContentStates.updateFeedsWith`, branch on
`newNotes.any { it.event is DvmHeartbeatEvent }` → `discoverDVMs.invalidateData()`
(full rebuild re-runs the freshness check on every announcement); otherwise the normal
additive path.
2. **Expiry produces no event.** A 60s timer collector in `AccountFeedContentStates`
(alongside the existing `scope.launch { flows.collect { … } }` observers) calls
`discoverDVMs.invalidateData()` every minute. The rebuild is a cheap scan (≤ a few
hundred 31990s) and `refreshSuspended()` no-ops when the list is unchanged. Composables
using `rememberDvmHeartbeatFresh` tick on a 30s cadence internally.
## 7. UI surfaces
1. **Discover "Content" tab** — `DiscoverNIP89FeedFilter.acceptApp` adds
`dvmHeartbeatOf(noteEvent)?.isFreshAt(now) == true`. No fresh beat → card hidden.
2. **Pinned top-nav chips** — chip stays; when the heartbeat is stale or absent the chip is
grayed out with a small offline dot appended to its label. Tapping still opens the feed.
3. **Pinned feed view** — new branch in `HomeAlgoFeedStatusBanner`: when the selected
pinned feed's heartbeat is stale, show an offline banner above the last known content,
shown *even when content exists* (the current banner only handles empty/error states).
Single-feed and all-feeds variants both covered.
4. **DVM detail screen** (`DvmContentDiscoveryScreen`) — same offline banner; requesting is
still allowed (informational, not a block).
5. **`FavoriteAlgoFeedsListScreen`** — offline badge per row so users can spot dead pins.
6. New English string resources (`dvm_offline`, `dvm_offline_banner`); translations flow
via Crowdin.
## 8. Edge cases (accepted limitations)
- Heartbeat without a `d` tag → cache address dTag `""` → matches nothing → DVM hidden
(strict; the wire contract always sends `d`).
- Device/DVM clock skew > 15 min → wrongly hidden (inherent to timestamp-based liveness).
- DVM beats that never reach the relays we query → shows offline (that is the feature).
- One keypair running multiple DVMs → relays keep only the latest beat per (kind, author);
per-d-tag cache slots help only across relays. Most DVMs use one key each.
- **Desktop app: out of scope this round.** The commons subscription helper is shared-ready
and the desktop relay assembler will pick up heartbeat REQs harmlessly (cache fills,
nothing renders), but all UI wiring is Android-only.
## 9. Testing
- **quartz**: parse/build `DvmHeartbeatEvent` — `dTag()` override, `statusTag()`,
`expiration()`, address assembly.
- **amethyst**: `DiscoverNIP89FeedFilter.acceptApp` matrix — no beat → reject; fresh beat →
accept; 421s-old beat → reject. The `updateFeedsWith` heartbeat branch triggers a full
rebuild. `isFreshAt` boundary (900s fresh, 901s stale).
- Verify with `./gradlew :quartz:test :amethyst:test`, then `./gradlew spotlessApply`.
@@ -0,0 +1,61 @@
/*
* 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.model
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/** The cache slot a DVM's heartbeat lives in: the announcement's own address, kind 11998. */
fun LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent? = getAddressableNoteIfExists(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))?.event as? DvmHeartbeatEvent
/**
* A DVM counts as alive only if its latest heartbeat is at most 900s old. The registry (not the
* WeakReference-held beat note) is the freshness source: beat notes have no strong holder on the
* Discover screen, and a GC sweep cleared them all at once, collapsing the list.
*/
fun LocalCache.hasFreshDvmHeartbeat(
appDef: AppDefinitionEvent,
now: Long = TimeUtils.now(),
): Boolean =
DvmHeartbeatRegistry
.latestAt(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))
?.let { it >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS } == true
/**
* Every cached content-discovery announcement, WITHOUT the freshness gate — this is the source the
* heartbeat outbox fetcher must use. Sourcing from the gated feed list would drop a DVM the moment
* its beat went stale, remove it from the fetch batch, and make the drop permanent (the fetcher
* could only ever help DVMs that were already visible). Applies the gate's other eligibility
* checks (a real content-discovery DVM, not a paid subscription app), newest first, capped.
*/
fun LocalCache.cachedDvmAnnouncements(limit: Int = 100): List<AppDefinitionEvent> =
addressables
.filterIntoSet(AppDefinitionEvent.KIND) { _, note ->
(note.event as? AppDefinitionEvent)?.let {
it.appMetaData()?.subscription != true && it.includeKind(NIP90ContentDiscoveryRequestEvent.KIND)
} == true
}.mapNotNull { it.event as? AppDefinitionEvent }
.sortedByDescending { it.createdAt }
.take(limit)
@@ -0,0 +1,58 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip01Core.core.Address
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.util.concurrent.ConcurrentHashMap
/**
* Strong, process-wide record of the latest heartbeat per DVM announcement address
* (`Address(11998, dvmPubKey, dTag) -> createdAt`).
*
* Beat Notes themselves live in `LocalCache.addressables`, a WeakReference store with no strong
* holder on the Discover screen — every GC sweep cleared them all at once and the freshness gate
* collapsed for every DVM simultaneously (the list emptied and rebuilt one beat at a time). This
* registry is the freshness source the gate and the liveness composables read: strong references,
* fed by every beat-arrival path (global REQ, outbox batches, per-surface fetches — all beat
* consumption routes through [record]).
*
* One entry per DVM address ever seen; timestamps only, so it stays tiny. `0` means "no beat".
*/
object DvmHeartbeatRegistry {
private val latestBeatCreatedAt = ConcurrentHashMap<Address, MutableStateFlow<Long>>()
private fun flowFor(address: Address): MutableStateFlow<Long> = latestBeatCreatedAt.getOrPut(address) { MutableStateFlow(0L) }
/** Observable latest-beat timestamp for this address; `0` means "no beat seen yet". */
fun flowForPublic(address: Address): StateFlow<Long> = flowFor(address)
/** Records a beat's createdAt; older beats never move the entry backwards. */
fun record(
address: Address,
createdAt: Long,
) = flowFor(address).update { current -> if (createdAt > current) createdAt else current }
/** The latest recorded beat's createdAt for this address, or null when no beat was ever seen. */
fun latestAt(address: Address): Long? = flowFor(address).value.takeIf { it > 0L }
}
@@ -381,6 +381,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent
@@ -3762,6 +3763,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
is CommunityDefinitionEvent,
is CommunityListEvent,
is ContactListEvent,
// DVM heartbeat (11998): stored per Address(11998, author, d) so liveness checks find the
// beat at the announcement's mirror address (amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md),
// AND recorded into the strong registry — beat notes are WeakReference-held with no strong
// holder on the Discover screen, so the gate must not depend on them surviving GC.
is DvmHeartbeatEvent,
->
consumeBaseReplaceable(event, relay, wasVerified).also {
DvmHeartbeatRegistry.record(event.address(), event.createdAt)
}
is EmojiPackEvent,
is EmojiPackSelectionEvent,
is EphemeralChatListEvent,
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.chess.ChessFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.communities.CommunityFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.communities.list.CommunitiesListFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.discover.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.DvmHeartbeatSources
import com.vitorpamplona.amethyst.commons.relayClient.emojipacks.BrowseEmojiSetsFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.followPacks.FollowPacksFilterAssembler
@@ -69,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.video.VideoFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.wallet.OnchainZapsFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.workouts.WorkoutsFilterAssembler
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.cachedDvmAnnouncements
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblyGroup
@@ -94,6 +96,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLi
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import kotlinx.coroutines.CoroutineScope
class RelaySubscriptionsCoordinator(
@@ -113,7 +118,25 @@ class RelaySubscriptionsCoordinator(
val home = HomeFilterAssembler(client)
val chatroomList = ChatroomListFilterAssembler(client)
val video = VideoFilterAssembler(client)
val discovery = DiscoveryFilterAssembler(client)
val discovery =
DiscoveryFilterAssembler(
client,
dvmHeartbeat =
DvmHeartbeatSources(
announcements = cache::cachedDvmAnnouncements,
outboxRelaysFor = { pubkey ->
buildSet {
cache.getUserIfExists(pubkey)?.outboxRelays()?.let { addAll(it) }
addAll(cache.relayHints.hintsForKey(pubkey))
}
},
changes =
listOf(
cache.observeNotes(Filter(kinds = listOf(AppDefinitionEvent.KIND))),
cache.observeNotes(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND))),
),
),
)
// loaders of content that is not yet in the device.
// they are active when looking at events, users, channels.
@@ -52,6 +52,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
@@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.dvm_offline
import com.vitorpamplona.amethyst.commons.resources.feed_filter_select_an_option
import com.vitorpamplona.amethyst.commons.resources.feed_filter_selected
import com.vitorpamplona.amethyst.commons.resources.lack_location_permissions
@@ -95,6 +97,7 @@ import com.vitorpamplona.amethyst.ui.screen.PeopleListName
import com.vitorpamplona.amethyst.ui.screen.RelayName
import com.vitorpamplona.amethyst.ui.screen.ResourceName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.rememberDvmHeartbeatFresh
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Font14SP
@@ -177,11 +180,29 @@ fun FeedFilterSpinner(
)
}
} else {
Text(
text = currentText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val favoriteAlgoFeedAddress = (selected?.name as? FavoriteAlgoFeedName)?.note?.address
if (favoriteAlgoFeedAddress != null) {
val heartbeatFresh by rememberDvmHeartbeatFresh(favoriteAlgoFeedAddress, accountViewModel)
val offlineLabel = stringRes(Res.string.dvm_offline)
Text(
text = if (heartbeatFresh) currentText else "$currentText \u2022",
color = if (heartbeatFresh) Color.Unspecified else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
if (heartbeatFresh) {
Modifier
} else {
Modifier.semantics { contentDescription = "$currentText, $offlineLabel" }
},
)
} else {
Text(
text = currentText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (filter is TopFilter.AroundMe) {
@@ -371,7 +392,30 @@ fun RenderOption(
val backed = option as NoteBackedName
val noteState by observeNote(backed.note, accountViewModel)
val name = remember(noteState) { option.name(context) }
Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
val appDefAddress = (option as? FavoriteAlgoFeedName)?.note?.address
val heartbeatFresh =
if (appDefAddress != null) {
rememberDvmHeartbeatFresh(appDefAddress, accountViewModel).value
} else {
true
}
val offlineLabel = stringRes(Res.string.dvm_offline)
Text(
text = if (heartbeatFresh) name else "$name \u2022",
fontSize = Font14SP,
color =
if (heartbeatFresh) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier =
if (heartbeatFresh) {
Modifier
} else {
Modifier.semantics { contentDescription = "$name, $offlineLabel" }
},
)
}
// Pure names: no relay subscription needed.
@@ -76,11 +76,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.dal.SoftwareAp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.dal.WorkoutFeedFilter
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class AccountFeedContentStates(
@@ -313,6 +316,15 @@ class AccountFeedContentStates(
notificationsEveryone.invalidateData()
}
}
// Heartbeat staleness produces no cache event (a beat just ages past 420s), so re-check
// the DVM discovery feed on a timer. refreshSuspended() no-ops when nothing changed.
scope.launch(Dispatchers.IO) {
while (isActive) {
delay(60_000)
discoverDVMs.invalidateData()
}
}
}
suspend fun init() {
@@ -335,7 +347,13 @@ class AccountFeedContentStates(
discoverMarketplace.updateFeedWith(newNotes)
discoverFollowSets.updateFeedWith(newNotes)
discoverReads.updateFeedWith(newNotes)
discoverDVMs.updateFeedWith(newNotes)
if (newNotes.any { it.event is DvmHeartbeatEvent }) {
// A heartbeat is never a feed row, so the additive path would graft nothing and never
// re-evaluate the announcement it just validated. Rebuild instead.
discoverDVMs.invalidateData()
} else {
discoverDVMs.updateFeedWith(newNotes)
}
discoverLive.updateFeedWith(newNotes)
discoverCommunities.updateFeedWith(newNotes)
discoverPublicChats.updateFeedWith(newNotes)
@@ -278,7 +278,18 @@ private fun DiscoverPages(
HorizontalPager(state = pagerState) { page ->
if (page >= 0 && page < feedTabs.size) {
val tab = feedTabs[page]
RefresheableBox(tab.feedState, true) {
RefresheableBox(
onRefresh = {
tab.feedState.invalidateData()
// The DVM tab's freshness gate lives or dies with beat delivery, and
// invalidateData only re-reads the cache — re-issue the discovery REQs
// (31990 + both heartbeat streams) with fresh rolling windows so refresh
// actually fetches.
if (tab.feedState == accountViewModel.feedStates.discoverDVMs) {
accountViewModel.dataSources().discovery.invalidateFilters()
}
},
) {
if (tab.useGridLayout) {
SaveableGridFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState ->
RenderDiscoverFeed(
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.hasFreshDvmHeartbeat
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@@ -93,7 +94,8 @@ open class DiscoverNIP89FeedFilter(
return noteEvent.appMetaData()?.subscription != true &&
filterParams.match(noteEvent, relays) &&
noteEvent.includeKind(targetKind) &&
noteEvent.createdAt > lastAnnounced
noteEvent.createdAt > lastAnnounced &&
LocalCache.hasFreshDvmHeartbeat(noteEvent)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> =
@@ -131,6 +131,9 @@ fun DvmContentDiscoveryScreen(
) {
val noteAuthor = appDefinition.author ?: return
val appDef = appDefinition.event as? AppDefinitionEvent
val heartbeatFresh = if (appDef != null) rememberDvmHeartbeatFresh(appDef.address(), accountViewModel) else null
var requestEventID by
remember(appDefinition) {
mutableStateOf<Note?>(null)
@@ -151,22 +154,18 @@ fun DvmContentDiscoveryScreen(
}
}
RefresheableBox(
onRefresh = onRefresh,
) {
val myRequestEventID = requestEventID
if (myRequestEventID != null) {
ObserverContentDiscoveryResponse(
appDefinition,
myRequestEventID,
onRefresh,
accountViewModel,
nav,
)
} else {
// TODO: Make a good splash screen with loading animation for this DVM.
// FeedDVM(appDefinition, null, accountViewModel, nav)
FeedEmptyWithStatus(appDefinition, stringRes(Res.string.dvm_requesting_job), accountViewModel, nav)
RefresheableBox(onRefresh = onRefresh) {
Box(modifier = Modifier.fillMaxSize()) {
val myRequestEventID = requestEventID
if (myRequestEventID != null) {
ObserverContentDiscoveryResponse(appDefinition, myRequestEventID, onRefresh, accountViewModel, nav)
} else {
// TODO: Make a good splash screen with loading animation for this DVM.
FeedEmptyWithStatus(appDefinition, stringRes(Res.string.dvm_requesting_job), accountViewModel, nav)
}
if (heartbeatFresh?.value == false) {
DvmOfflineBanner(modifier = Modifier.align(Alignment.TopCenter))
}
}
}
}
@@ -0,0 +1,126 @@
/*
* 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.ui.screen.loggedIn.dvms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.dvm_offline_banner
import com.vitorpamplona.amethyst.model.DvmHeartbeatRegistry
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
private const val HEARTBEAT_RECHECK_MILLIS = 30_000L
/**
* True while the DVM announced at [appDefinitionAddress] has a heartbeat (kind 11998) at most
* 420s old. Resolves the beat's cache note by its mirror address, opens a composable-scoped
* relay subscription (the event-finder assembler fetches the beat by kind/author/d while it is
* missing), and re-checks staleness on a timer — an expired beat produces no cache event.
*/
@Composable
fun rememberDvmHeartbeatFresh(
appDefinitionAddress: Address,
accountViewModel: AccountViewModel,
): State<Boolean> {
val heartbeatAddressTag =
remember(appDefinitionAddress) {
Address.assemble(DvmHeartbeatEvent.KIND, appDefinitionAddress.pubKeyHex, appDefinitionAddress.dTag)
}
var heartbeatNote by
remember(heartbeatAddressTag) {
mutableStateOf(accountViewModel.getNoteIfExists(heartbeatAddressTag))
}
LaunchedEffect(heartbeatAddressTag) {
if (heartbeatNote == null) {
heartbeatNote = accountViewModel.checkGetOrCreateNote(heartbeatAddressTag)
}
}
val observed =
heartbeatNote?.let { observeNoteAndMap(it, accountViewModel) { it.event as? DvmHeartbeatEvent } }
// The registry is the eviction-proof freshness source (beat notes are WeakReference-held);
// the observed note is the secondary source and also drives the outbox fetch.
val registryBeat by
DvmHeartbeatRegistry.flowForPublic(appDefinitionAddress).collectAsStateWithLifecycle()
var now by remember(heartbeatNote) { mutableLongStateOf(TimeUtils.now()) }
LaunchedEffect(heartbeatNote) {
while (isActive) {
delay(HEARTBEAT_RECHECK_MILLIS)
now = TimeUtils.now()
}
}
val beat = observed?.value
val bestBeat = maxOf(beat?.createdAt ?: 0L, registryBeat)
return rememberUpdatedState(bestBeat > 0L && bestBeat >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS)
}
/** Floating "DVM is offline" banner, mirroring the Home status banner's card style. */
@Composable
fun DvmOfflineBanner(modifier: Modifier = Modifier) {
Surface(
modifier = modifier.padding(horizontal = 12.dp, vertical = 6.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 4.dp,
shadowElevation = 4.dp,
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
Text(
text = stringRes(Res.string.dvm_offline_banner),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -52,6 +52,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -67,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.dvm_offline
import com.vitorpamplona.amethyst.commons.resources.favorite_dvms_add_more
import com.vitorpamplona.amethyst.commons.resources.favorite_dvms_empty_cta
import com.vitorpamplona.amethyst.commons.resources.favorite_dvms_empty_headline
@@ -84,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.rememberDvmHeartbeatFresh
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -282,12 +285,21 @@ private fun FavoriteAlgoFeedRow(
Column(
modifier = Modifier.weight(1f),
) {
val heartbeatFresh by rememberDvmHeartbeatFresh(feedNote.address, accountViewModel)
val displayName = card.name.ifBlank { feedNote.dTag() }
val offlineLabel = stringRes(Res.string.dvm_offline)
Text(
text = card.name.ifBlank { feedNote.dTag() },
text = if (heartbeatFresh) displayName else "$displayName \u2022",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyLarge,
modifier =
if (heartbeatFresh) {
Modifier
} else {
Modifier.semantics { contentDescription = "$displayName, $offlineLabel" }
},
)
card.description?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = StdVertSpacer)
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_payment_requ
import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_processing
import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_requesting
import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_requesting_all
import com.vitorpamplona.amethyst.commons.resources.dvm_offline_banner
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
@@ -55,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.rememberDvmHeartbeatFresh
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -87,6 +89,20 @@ private fun SingleAlgoFeedBanner(
.observe(favFeed.address)
.collectAsStateWithLifecycle()
val heartbeatFresh by rememberDvmHeartbeatFresh(favFeed.address, accountViewModel)
// The DVM is down (or its beats never reach us): the offline banner supersedes the
// requesting/error/payment banner, and shows even when last-known content is on screen.
if (!heartbeatFresh) {
BannerCard(modifier) {
BannerMessageRow(
message = stringRes(Res.string.dvm_offline_banner),
showSpinner = false,
)
}
return
}
// Hide the banner when the feed is already populated.
if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return
@@ -184,6 +200,23 @@ private fun AllFavoriteAlgoFeedsBanner(
if (addresses.isEmpty()) return
// Map every address to its freshness first (non-short-circuiting) so per-address
// composable call sites stay stable instead of appearing/disappearing with freshness.
val anyHeartbeatFresh =
addresses
.map { address ->
rememberDvmHeartbeatFresh(address, accountViewModel).value
}.any { it }
if (!anyHeartbeatFresh) {
BannerCard(modifier) {
BannerMessageRow(
message = stringRes(Res.string.dvm_offline_banner),
showSpinner = false,
)
}
return
}
// Observe each DVM's snapshot so we can decide whether to hide the banner
// based on the aggregate state. Hide it as soon as any DVM has produced a
// feed; only error out when every one of them has errored.
@@ -0,0 +1,182 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* `LocalCache` is a process-wide object and JUnit 4 runs methods in hash order, so every test
* uses its own pubkeys/dTags/ids (same discipline as ReportNamingIndexIngestionTest).
*/
class DvmHeartbeatTest {
private val appDefPubKey = "f1".repeat(32)
private fun appDef(
dTag: String,
pubKey: String = appDefPubKey,
) = AppDefinitionEvent(
id = "f2".repeat(32),
pubKey = pubKey,
createdAt = 1_760_000_000L,
tags = arrayOf(arrayOf("d", dTag), arrayOf("k", "5300")),
content = """{"name":"Test DVM"}""",
sig = "cc".repeat(64),
)
private fun beat(
dTag: String,
pubKey: String = appDefPubKey,
createdAt: Long,
id: String,
) = DvmHeartbeatEvent(
id = id,
pubKey = pubKey,
createdAt = createdAt,
tags =
arrayOf(
arrayOf("d", dTag),
arrayOf("status", "My heart keeps beating like a hammer"),
arrayOf("expiration", (createdAt + 300).toString()),
),
content = "Alive and kicking",
sig = "dd".repeat(64),
)
@Test
fun aConsumedHeartbeatLandsAtTheAnnouncementMirrorAddress() {
val app = appDef("dvm-one")
LocalCache.justConsume(beat("dvm-one", createdAt = 1_760_000_100L, id = "f3".repeat(32)), null, true)
val found = LocalCache.dvmHeartbeatOf(app)
assertTrue("heartbeat should be found via the announcement's address", found != null)
assertEquals(1_760_000_100L, found?.createdAt)
assertEquals(Address(11998, appDefPubKey, "dvm-one"), found?.address())
}
@Test
fun aFreshHeartbeatPassesTheGateAndAStaleOneDoesNot() {
val now = 1_760_000_000L
// Separate dTags: consumeBaseReplaceable only accepts NEWER beats per address, so a
// 901s-old beat could never supersede the 900s one within a single address slot.
val freshApp = appDef("dvm-two-fresh")
val staleApp = appDef("dvm-two-stale")
assertNull("no beat yet", LocalCache.dvmHeartbeatOf(freshApp))
LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 900, id = "f4".repeat(32)), null, true)
LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 901, id = "f5".repeat(32)), null, true)
assertTrue("exactly 900s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now))
assertFalse("901s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now))
}
@Test
fun theNewestBeatPerAddressWins() {
val now = 1_760_000_000L
val app = appDef("dvm-three")
LocalCache.justConsume(beat("dvm-three", createdAt = now - 600, id = "f6".repeat(32)), null, true)
LocalCache.justConsume(beat("dvm-three", createdAt = now - 60, id = "f7".repeat(32)), null, true)
assertEquals(now - 60, LocalCache.dvmHeartbeatOf(app)?.createdAt)
}
@Test
fun beatsAreKeyedByDTagSoDifferentDvmsDoNotCollide() {
val now = 1_760_000_000L
val appA = appDef("dvm-a")
val appB = appDef("dvm-b")
LocalCache.justConsume(beat("dvm-a", createdAt = now - 60, id = "f8".repeat(32)), null, true)
assertTrue(LocalCache.hasFreshDvmHeartbeat(appA, now))
assertFalse("no beat for dvm-b", LocalCache.hasFreshDvmHeartbeat(appB, now))
}
@Test
fun noHeartbeatMeansNoLiveness() {
assertFalse(LocalCache.hasFreshDvmHeartbeat(appDef("dvm-never"), TimeUtils.now()))
}
@Test
fun theGateSurvivesWeakCacheEvictionOfTheBeatNote() {
// Beat notes live in LocalCache's WeakReference store with no strong holder on the
// Discover screen — a GC sweep clears them all at once and the list collapses. The
// freshness gate must therefore read the strong registry, not the evictable note.
val app = appDef("dvm-registry")
DvmHeartbeatRegistry.record(Address(DvmHeartbeatEvent.KIND, appDefPubKey, "dvm-registry"), 1_760_000_000L - 100)
assertTrue("registry alone proves liveness", LocalCache.hasFreshDvmHeartbeat(app, 1_760_000_000L))
assertTrue("no entry for dvm-never", DvmHeartbeatRegistry.latestAt(Address(DvmHeartbeatEvent.KIND, appDefPubKey, "dvm-never")) == null)
}
@Test
fun theUngatedAnnouncementScanKeepsDvmsTheGateWouldHide() {
// The outbox fetcher must source announcements from the cache, NOT from the gated feed
// list: a DVM dropped for a stale beat must keep receiving outbox beats or it can never
// come back. Subscription apps and non-content-discovery apps stay excluded.
val alive = appDef("scan-dvm")
val subscriptionApp =
AppDefinitionEvent(
id = "c0".repeat(32),
pubKey = "ab".repeat(32),
createdAt = 1_760_000_500L,
tags = arrayOf(arrayOf("d", "subs"), arrayOf("k", "5300")),
content = """{"name":"Paid","subscription":true}""",
sig = "cc".repeat(64),
)
val nonDiscoveryApp =
AppDefinitionEvent(
id = "c1".repeat(32),
pubKey = "cb".repeat(32),
createdAt = 1_760_000_100L,
tags = arrayOf(arrayOf("d", "other"), arrayOf("k", "9999")),
content = """{"name":"Other"}""",
sig = "cc".repeat(64),
)
LocalCache.justConsume(appDef("dvm-x"), null, true)
LocalCache.justConsume(nonDiscoveryApp, null, true)
val consumed =
AppDefinitionEvent(
id = "c2".repeat(32),
pubKey = appDefPubKey,
createdAt = 1_760_000_000L,
tags = arrayOf(arrayOf("d", "subs2"), arrayOf("k", "5300")),
content = """{"name":"Paid2","subscription":true}""",
sig = "cc".repeat(64),
)
LocalCache.justConsume(consumed, null, true)
val scanned = LocalCache.cachedDvmAnnouncements()
assertTrue("dvm-x is not yet visible (no beat) but must still be sourced", scanned.any { it.dTag() == "dvm-x" })
assertFalse("subscription apps are not content-discovery DVMs", scanned.any { it.dTag() == "subs" })
assertFalse("k=9999 apps are not content-discovery DVMs", scanned.any { it.dTag() == "other" })
assertEquals("newest-first so the cap keeps the most relevant announcements", scanned.sortedByDescending { it.createdAt }, scanned)
assertTrue("capped", scanned.size <= 100)
}
}
@@ -2226,6 +2226,8 @@
<string name="dvm_home_status_payment_required">This feed algorithm requires payment</string>
<string name="dvm_home_status_error">The feed algorithm returned an error</string>
<string name="dvm_home_retry">Retry</string>
<string name="dvm_offline">Offline</string>
<string name="dvm_offline_banner">This feed algorithm has not sent a heartbeat recently and may be down</string>
<string name="temporary_account">Log off on device lock</string>
<string name="group_relay">Chat Relay</string>
<string name="share_image">Share image…</string>
@@ -35,4 +35,15 @@ interface IFeedTopNavPerRelayFilterSet {
* so it carries only the slice that applies to it.
*/
fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter?
/**
* The relays this selection's subscriptions should be issued on, regardless of whether the
* selection's per-kind filter dispatch produces a filter for each of them.
*
* Consumers that must stay alive across every selection — the DVM heartbeat REQ, whose beats
* keep the cached discovery list's liveness gate fresh — read this instead of deriving relays
* from the per-kind filters, which silently skip relays whose per-relay slice is empty and
* whole selections (the Relay variant) that dispatch no content filters at all.
*/
fun relays(): Set<NormalizedRelayUrl>
}
@@ -27,4 +27,6 @@ class AllFollowsTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, AllFollowsTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
override fun relays() = set.keys
}
@@ -27,4 +27,6 @@ class LocationTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, LocationTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
override fun relays() = set.keys
}
@@ -43,4 +43,6 @@ class FavoriteAlgoFeedTopNavPerRelayFilterSet(
// Only the content half is per-relay. [listenRelays] is where the DVMs answer, which is a
// delivery address rather than a scope, so a filter aimed there carries none.
override fun scopeFor(relay: NormalizedRelayUrl) = contentFetches[relay]
override fun relays() = contentFetches.keys
}
@@ -27,4 +27,6 @@ class GlobalTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, GlobalTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
override fun relays() = set.keys
}
@@ -27,4 +27,6 @@ class HashtagTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, HashtagTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
override fun relays() = set.keys
}
@@ -27,4 +27,6 @@ class AllCommunitiesTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, AllCommunitiesTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
override fun relays() = set.keys
}
@@ -27,4 +27,6 @@ class AuthorsTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, AuthorsTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
override fun relays() = set.keys
}
@@ -27,4 +27,6 @@ class SingleCommunityTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, SingleCommunityTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
override fun relays() = set.keys
}
@@ -27,4 +27,6 @@ class MutedAuthorsTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, MutedAuthorsTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
override fun relays() = set.keys
}
@@ -30,4 +30,6 @@ class RelayTopNavPerRelayFilterSet(
// The relay *is* the whole selection here, so there is nothing per-relay left to say — the
// filter's own relay already carries it.
override fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter? = null
override fun relays() = setOf(relayUrl)
}
@@ -26,4 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet {
override fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter? = null
override fun relays() = emptySet<NormalizedRelayUrl>()
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.relayClient.discover
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.DiscoveryDvmHeartbeatSubAssembler
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.DvmHeartbeatSources
import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
@@ -56,10 +58,12 @@ class DiscoveryQueryState(
class DiscoveryFilterAssembler(
client: INostrClient,
dvmHeartbeat: DvmHeartbeatSources,
) : TopNavFeedFilterAssembler<DiscoveryQueryState>({ keys ->
listOf(
DiscoveryLongFormClassifiedsAndDVMSubAssembler1(client, keys),
DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(client, keys),
DiscoveryPublicChatsAndCommunitiesSubAssembler3(client, keys),
DiscoveryDvmHeartbeatSubAssembler(client, keys, dvmHeartbeat),
)
})
@@ -0,0 +1,138 @@
/*
* 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.discover.nip90DVMs
import com.vitorpamplona.amethyst.commons.relayClient.discover.DiscoveryQueryState
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedSubAssembler
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/** How many DVM outbox relays the fetcher may open at once; coverage-ranked, so the top relays carry most authors. */
private const val MAX_OUTBOX_RELAYS = 12
/**
* Batches the announcement authors per DVM outbox relay so the freshness gate sees the beats the
* DVMs actually publish. DVMs send heartbeats to their own write relays, which need not overlap
* the user's discovery relays — the global heartbeat REQ (issued on the selection's relays) alone
* leaves alive DVMs invisible. Their beats exist on the outbox; this fetcher brings them into the
* same cache slots the gate reads.
*
* Authors with no known outbox are skipped, not blocked: the global REQ is their fallback.
* Relays are coverage-ranked and capped at [maxRelays] so browsing Discover cannot open dozens
* of sockets; deterministic tie-breaking keeps the chosen set stable across re-issues.
*/
fun dvmHeartbeatOutboxFilters(
announcements: List<AppDefinitionEvent>,
outboxRelaysFor: (HexKey) -> Collection<NormalizedRelayUrl>,
now: Long,
maxRelays: Int = MAX_OUTBOX_RELAYS,
): List<RelayBasedFilter> {
if (announcements.isEmpty()) return emptyList()
val authorsByRelay = mutableMapOf<NormalizedRelayUrl, MutableList<HexKey>>()
announcements.forEach { app ->
outboxRelaysFor(app.pubKey).forEach { relay ->
authorsByRelay.getOrPut(relay) { mutableListOf() }.add(app.pubKey)
}
}
if (authorsByRelay.isEmpty()) return emptyList()
val since = now - DvmHeartbeatEvent.MAX_AGE_SECONDS
return authorsByRelay
.entries
.sortedWith(
compareByDescending<MutableMap.MutableEntry<NormalizedRelayUrl, MutableList<HexKey>>> { it.value.size }
.thenBy { it.key.url },
).take(maxRelays)
.map { (relay, authors) ->
val covered = authors.distinct().sorted()
RelayBasedFilter(
relay = relay,
filter =
ExplainedFilter(
purpose = SubPurpose.DISCOVER_FEED,
kinds = listOf(DvmHeartbeatEvent.KIND),
authors = covered,
limit = covered.size,
since = since,
),
)
}
}
/**
* Cache-backed inputs the outbox fetcher needs, provided by the front end (the cache query and
* relay-hint surface are platform caches, not commons).
*
* [announcements] MUST be the ungated announcement set (every cached content-discovery DVM). The
* gated feed list would turn any transient staleness into a permanent drop: a DVM leaves the
* gated list the moment its beat ages out, the fetcher would stop covering it, and no beat would
* ever arrive to bring it back.
*
* [outboxRelaysFor] resolves where a DVM publishes its beats — NIP-65 outbox relays plus any
* relay hints for the author (the same mix the event finder uses).
*
* [changes] drive re-issues: when the cached announcement set or the outbox data moves, the
* batches are recomputed.
*/
class DvmHeartbeatSources(
val announcements: () -> List<AppDefinitionEvent>,
val outboxRelaysFor: (HexKey) -> Collection<NormalizedRelayUrl>,
val changes: List<Flow<*>>,
)
/**
* The discovery-side subscription that runs [dvmHeartbeatOutboxFilters] for the cached
* announcement set, alive while the Discover screen is composed (it joins the same assembler
* group and lifecycle as the other discovery sub-assemblers).
*
* No floor collectors ([floors] is empty) — the announcement set and outbox data ([changes]) are
* the drivers, not note timestamps.
*/
class DiscoveryDvmHeartbeatSubAssembler(
client: INostrClient,
allKeys: () -> Set<DiscoveryQueryState>,
private val sources: DvmHeartbeatSources,
) : TopNavFeedSubAssembler<DiscoveryQueryState>(client, allKeys) {
override fun updateFilter(
key: DiscoveryQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
dvmHeartbeatOutboxFilters(
sources.announcements(),
outboxRelaysFor = sources.outboxRelaysFor,
now = TimeUtils.now(),
)
override fun floors(key: DiscoveryQueryState): List<StateFlow<Long?>> = emptyList()
override fun extraInvalidators(key: DiscoveryQueryState): List<Flow<*>> = sources.changes
}
@@ -36,9 +36,13 @@ import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterC
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterContentDVMsByGeohash
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterContentDVMsByHashtag
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterContentDVMsGlobal
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
fun makeContentDVMsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
@@ -55,4 +59,34 @@ fun makeContentDVMsFilter(
is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}.let { contentDvmFilters ->
plusHeartbeatFilter(feedSettings, contentDvmFilters)
}.scopedTo(feedSettings)
/**
* The 31990 announcements say what a DVM advertises; kind-11998 heartbeats say whether it is
* still alive (amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md). Ask on the selection's own
* relays — NOT the 31990 filters' relays, which skip relays whose per-relay slice is empty and
* whole selections (the Relay variant) that dispatch no 31990 filters at all; there the cached
* DVM list would lose its beat stream and the staleness timer would drop every DVM within ~7
* minutes. Rolling window instead of the announcement cursor: beats expire (NIP-40) every 5
* minutes, so a stored `since` would miss beats on re-opened tabs.
*/
private fun plusHeartbeatFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
contentDvmFilters: List<RelayBasedFilter>,
): List<RelayBasedFilter> {
val relays = feedSettings.relays()
if (relays.isEmpty()) return contentDvmFilters
val heartbeatFilter =
ExplainedFilter(
purpose = SubPurpose.DISCOVER_FEED,
kinds = listOf(DvmHeartbeatEvent.KIND),
limit = 100,
since = TimeUtils.now() - DvmHeartbeatEvent.MAX_AGE_SECONDS,
)
return contentDvmFilters +
relays.map { relay ->
RelayBasedFilter(relay = relay, filter = heartbeatFilter)
}
}
@@ -0,0 +1,120 @@
/*
* 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.discover.nip90DVMs
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* DVMs publish heartbeats to their own outbox relays, which may not overlap the user's discovery
* relays at all — so the global heartbeat REQ alone leaves alive DVMs invisible (their beats never
* reach the cache the freshness gate reads). The outbox fetcher batches the announcement authors
* per DVM outbox relay so the gate sees the beats the DVMs actually publish.
*/
class DvmHeartbeatOutboxFiltersTest {
private val r1 = RelayUrlNormalizer.normalizeOrNull("wss://r1.example/")!!
private val r2 = RelayUrlNormalizer.normalizeOrNull("wss://r2.example/")!!
private val r3 = RelayUrlNormalizer.normalizeOrNull("wss://r3.example/")!!
private fun announcement(pubKey: HexKey) =
AppDefinitionEvent(
id = pubKey.take(16) + "a".repeat(48),
pubKey = pubKey,
createdAt = 1_760_000_000L,
tags = arrayOf(arrayOf("d", "dvm"), arrayOf("k", "5300")),
content = """{"name":"DVM"}""",
sig = "b".repeat(128),
)
private val authorA = "aa".repeat(32)
private val authorB = "bb".repeat(32)
private val authorC = "cc".repeat(32)
private val outboxes: (HexKey) -> Set<NormalizedRelayUrl> =
{
when (it) {
authorA -> setOf(r1, r2)
authorB -> setOf(r1, r3)
else -> emptySet()
}
}
@Test
fun batchesAnnouncementAuthorsPerOutboxRelay() {
val filters =
dvmHeartbeatOutboxFilters(
announcements = listOf(announcement(authorA), announcement(authorB), announcement(authorC)),
outboxRelaysFor = outboxes,
now = 1_760_000_420L,
)
assertEquals(3, filters.size, "r1 carries A+B, r2 carries A, r3 carries B; author C has no outbox")
val authorsOn = { relay: String -> filters.first { it.relay.url == relay }.filter.authors }
assertEquals(setOf(authorA, authorB), authorsOn(r1.url)?.toSet())
assertEquals(setOf(authorA), authorsOn(r2.url)?.toSet())
assertEquals(setOf(authorB), authorsOn(r3.url)?.toSet())
filters.forEach {
assertEquals(listOf(DvmHeartbeatEvent.KIND), it.filter.kinds)
assertEquals(1_759_999_520L, it.filter.since, "rolling window: now - MAX_AGE_SECONDS")
}
}
@Test
fun anAuthorWithNoKnownOutboxIsSkippedNotBlocked() {
val filters =
dvmHeartbeatOutboxFilters(
announcements = listOf(announcement(authorC)),
outboxRelaysFor = outboxes,
now = 1_760_000_420L,
)
assertTrue(filters.isEmpty(), "no outbox known — the global REQ is that DVM's fallback")
}
@Test
fun relayCapKeepsTheMostCoveringRelays() {
val filters =
dvmHeartbeatOutboxFilters(
announcements = listOf(announcement(authorA), announcement(authorB)),
outboxRelaysFor = outboxes,
now = 1_760_000_420L,
maxRelays = 2,
)
assertEquals(setOf(r1.url, r2.url), filters.map { it.relay.url }.toSet(), "r1 covers 2, r2 and r3 tie at 1 — tie broken deterministically")
}
@Test
fun noAnnouncementsMeansNoRequests() {
assertTrue(
dvmHeartbeatOutboxFilters(emptyList(), outboxRelaysFor = outboxes, now = 1_760_000_420L).isEmpty(),
)
}
}
@@ -0,0 +1,101 @@
/*
* 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.discover.nip90DVMs
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The DVM discovery list renders cached kind-31990 announcements on EVERY top-nav selection, and
* its freshness gate needs kind-11998 heartbeats to keep flowing. A selection whose per-relay set
* dispatches to no 31990 filter (the Relay variant; a relay whose author set is momentarily empty)
* must still issue the heartbeat REQ — otherwise cached beats age out and the 60s staleness timer
* drops every DVM within ~7 minutes (the "DVMs disappear after a while" bug).
*/
class MakeContentDVMsFilterTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example/")!!
private val otherRelay = RelayUrlNormalizer.normalizeOrNull("wss://other.example/")!!
@Test
fun relayVariantIssuesTheHeartbeatFilterEvenWithoutAnnouncementFilters() {
val filters = makeContentDVMsFilter(RelayTopNavPerRelayFilterSet(relay), null, null)
assertEquals(1, filters.size, "the Relay variant has no 31990 filter but must get the heartbeat REQ")
assertEquals(relay, filters.first().relay)
assertEquals(listOf(DvmHeartbeatEvent.KIND), (filters.first().filter as? ExplainedFilter)?.kinds)
}
@Test
fun globalVariantKeepsAnnouncementFiltersAndGainsTheHeartbeatFilter() {
val filters =
makeContentDVMsFilter(
GlobalTopNavPerRelayFilterSet(mapOf(relay to GlobalTopNavPerRelayFilter)),
null,
null,
)
val byKind =
filters
.groupBy { it.filter.kinds }
.mapValues { entry -> entry.value.map { it.relay } }
assertEquals(listOf(relay), byKind[listOf(AppDefinitionEvent.KIND)], "31990 REQ unchanged")
assertEquals(listOf(relay), byKind[listOf(DvmHeartbeatEvent.KIND)], "heartbeat REQ rides the same relay")
}
@Test
fun authorVariantRelayWithNoAuthorsStillGetsTheHeartbeatFilter() {
val filters =
makeContentDVMsFilter(
AuthorsTopNavPerRelayFilterSet(
mapOf(
relay to AuthorsTopNavPerRelayFilter(emptySet()),
otherRelay to AuthorsTopNavPerRelayFilter(setOf("a".repeat(64))),
),
),
null,
null,
)
val heartbeatRelays =
filters
.filter { it.filter.kinds == listOf(DvmHeartbeatEvent.KIND) }
.map { it.relay }
.sortedBy { it.url }
assertEquals(
listOf(otherRelay, relay).sortedBy { it.url },
heartbeatRelays,
"a relay whose author set is empty skips the 31990 REQ but must not skip the heartbeat REQ",
)
assertTrue(filters.any { it.filter.kinds == listOf(AppDefinitionEvent.KIND) && it.relay == otherRelay })
}
}
@@ -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.quartz.nip90Dvms.dvmHeartbeat
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* DVM heartbeat (kind 11998, experimental — no NIP yet): a beat a DVM publishes every 300s to
* prove it is alive. The operator contract is plain-text content with `d` (the DVM's NIP-89
* DTAG), `status` (free text) and `expiration` (createdAt + 300, NIP-40) tags.
*
* The kind sits in the replaceable range (10000–19999), so relays keep only the latest beat
* per author. The `d` tag participates in the client-side address so each announced DVM has
* its own cache slot: `Address(11998, dvmPubKey, dTag)` mirrors the announcement's
* `Address(31990, dvmPubKey, dTag)`.
*/
@Stable
@Immutable
class DvmHeartbeatEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun status(): String? = tags.firstOrNull { it.size > 1 && it[0] == STATUS_TAG }?.get(1)
fun expiration(): Long? = tags.expiration()
/** True while this beat still proves liveness at [now]. */
fun isFreshAt(now: Long = TimeUtils.now()): Boolean = createdAt >= now - MAX_AGE_SECONDS
companion object {
const val KIND = 11998
const val STATUS_TAG = "status"
const val CONTENT = "Alive and kicking"
/**
* A beat older than this no longer proves liveness. Beats arrive every 300s, so this
* window deliberately tolerates several missed deliveries (relay reconnects, REQ churn)
* before a DVM is dropped — hysteresis against transient delivery gaps, at the cost of a
* dead DVM lingering this long before disappearing.
*/
const val MAX_AGE_SECONDS = 900
fun build(
dTag: String,
status: String,
expiration: Long,
createdAt: Long = TimeUtils.now(),
): EventTemplate<DvmHeartbeatEvent> =
eventTemplate(KIND, CONTENT, createdAt) {
add(arrayOf("d", dTag))
add(arrayOf(STATUS_TAG, status))
add(arrayOf("expiration", expiration.toString()))
}
}
}
@@ -358,6 +358,7 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDi
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationRequestEvent
@@ -734,6 +735,7 @@ class EventFactory {
NutzapRedemptionEvent.KIND -> NutzapRedemptionEvent(id, pubKey, createdAt, tags, content, sig)
NostrConnectEvent.KIND -> NostrConnectEvent(id, pubKey, createdAt, tags, content, sig)
NIP90StatusEvent.KIND -> NIP90StatusEvent(id, pubKey, createdAt, tags, content, sig)
DvmHeartbeatEvent.KIND -> DvmHeartbeatEvent(id, pubKey, createdAt, tags, content, sig)
NIP90TextExtractionRequestEvent.KIND -> NIP90TextExtractionRequestEvent(id, pubKey, createdAt, tags, content, sig)
NIP90TextExtractionResponseEvent.KIND -> NIP90TextExtractionResponseEvent(id, pubKey, createdAt, tags, content, sig)
NIP90SummarizationRequestEvent.KIND -> NIP90SummarizationRequestEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,133 @@
/*
* 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.quartz.nip90Dvms.dvmHeartbeat
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DvmHeartbeatEventTest {
private val pubKey = "11".repeat(32)
private val dTag = "my-dvm"
private val beatTime = 1_760_000_000L
private fun heartbeat(createdAt: Long = beatTime) =
DvmHeartbeatEvent(
id = "00".repeat(32),
pubKey = pubKey,
createdAt = createdAt,
tags =
arrayOf(
arrayOf("d", dTag),
arrayOf("status", "My heart keeps beating like a hammer"),
arrayOf("expiration", (createdAt + 300).toString()),
),
content = "Alive and kicking",
sig = "22".repeat(64),
)
@Test
fun addressIncludesTheDTag() {
val event = heartbeat()
assertEquals(dTag, event.dTag())
assertEquals(Address(11998, pubKey, dTag), event.address())
assertEquals("11998:$pubKey:$dTag", event.addressTag())
}
@Test
fun missingDTagFallsBackToEmptyAddress() {
val event =
DvmHeartbeatEvent(
id = "00".repeat(32),
pubKey = pubKey,
createdAt = beatTime,
tags = emptyArray(),
content = "Alive and kicking",
sig = "22".repeat(64),
)
assertEquals("", event.dTag())
assertEquals(Address(11998, pubKey, ""), event.address())
}
@Test
fun readsStatusAndExpiration() {
val event = heartbeat()
assertEquals("My heart keeps beating like a hammer", event.status())
assertEquals(beatTime + 300, event.expiration())
}
@Test
fun statusIsOptional() {
val event = DvmHeartbeatEvent("00".repeat(32), pubKey, beatTime, arrayOf(arrayOf("d", dTag)), "", "22".repeat(64))
assertNull(event.status())
assertNull(event.expiration())
}
@Test
fun freshnessBoundary() {
val event = heartbeat()
assertTrue(event.isFreshAt(beatTime + 900))
assertFalse(event.isFreshAt(beatTime + 901))
}
@Test
fun buildWritesAllTags() {
val template =
DvmHeartbeatEvent.build(
dTag = dTag,
status = "My heart keeps beating like a hammer",
expiration = beatTime + 300,
createdAt = beatTime,
)
assertEquals(11998, template.kind)
assertEquals("Alive and kicking", template.content)
assertEquals(
listOf(
listOf("d", dTag),
listOf("status", "My heart keeps beating like a hammer"),
listOf("expiration", (beatTime + 300).toString()),
),
template.tags.map { it.toList() },
)
}
@Test
fun factoryBuildsDvmHeartbeatForKind11998() {
val event: Event =
EventFactory.create(
id = "00".repeat(32),
pubKey = pubKey,
createdAt = beatTime,
kind = DvmHeartbeatEvent.KIND,
tags = arrayOf(arrayOf("d", dTag)),
content = "",
sig = "22".repeat(64),
)
assertIs<DvmHeartbeatEvent>(event)
assertTrue(EventFactory.isKnownKind(DvmHeartbeatEvent.KIND), "kind 11998 should be a known kind")
}
}
@@ -73,6 +73,9 @@ class EventFactoryKindRangeTest {
10090,
10101,
10102,
// DVM heartbeat: the d tag is the DVM's NIP-89 DTAG and keys its client-side
// cache address (relay storage stays plain-replaceable per the kind range).
11998,
)
private val probeDTag = "probe-d-tag"
@@ -107,7 +107,9 @@ class IndexableContentGoldenTest {
"a kind's indexed text changed. If deliberate: rerun with -Dgolden=write, update " +
"references/searchable-kinds.md in the same commit, and schedule a reindex " +
"(IEventStore.reindexFullTextSearch) — existing databases keep their old text.",
golden.readText().trim(),
// Normalize CRLF: Windows checkouts (autocrlf) would otherwise fail this comparison
// on invisible line endings alone.
golden.readText().replace("\r\n", "\n").trim(),
actual.trim(),
)
}
@@ -160,6 +162,11 @@ class IndexableContentGoldenTest {
* This was a list of its own until it drifted: seventeen searchable kinds — every video
* kind among them — were absent and so were never pinned by any of the tests below, and
* one entry (31890) was a kind the factory does not build at all.
*
* It holds only *searchable* kinds now, so a kind pinned here as `<not searchable>` — 11998,
* the heartbeat, was one — drops out of the golden. Nothing is lost by that: pinning the
* row caught a kind that started being indexed, and `SearchableKindsTest` catches the same
* thing across the whole 16-bit space rather than for the kinds someone remembered to list.
*/
val KINDS = SearchableKinds.ALL
}