From 4e1443e2e08ee8503d260a7a96e43fac1eef839c Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:45:01 +0200 Subject: [PATCH 01/19] docs: add DVM heartbeat liveness design plan (kind 11998) --- .../2026-09-10-dvm-heartbeat-liveness.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md new file mode 100644 index 0000000000..eb88312619 --- /dev/null +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -0,0 +1,144 @@ +# DVM heartbeat liveness — only show DVMs with a fresh kind-11998 heartbeat + +_Status: **design approved, not implemented**._ + +## 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 420 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: 420 seconds**, a named constant. Exactly 420s 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/heartbeat/DvmHeartbeatEvent.kt`: + +- `class DvmHeartbeatEvent(...) : BaseReplaceableEvent(...)`, `KIND = 11998` +- **Overrides `dTag()`** to read the event's actual `d` tag — `BaseReplaceableEvent.dTag()` + returns a fixed `""`, so without the override the cache address could not match the + announcement. With it, the cache address is `Address(11998, dvmPubkey, dTag)`, the exact + mirror of the announcement's `Address(31990, dvmPubkey, dTag)`. +- Accessors: `statusTag()`, and `expiration()` via the existing NIP-40 extension. +- Registered in `EventFactory` (kind → constructor). + +## 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/.../dvms/`: + +- `const val DVM_HEARTBEAT_MAX_AGE_SECONDS = 420` +- `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address + lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())` +- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 420` +- `@Composable fun rememberDvmHeartbeat(address: Address): State` — + 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() - 420` — 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 - 420`. 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). + +## 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 `rememberDvmHeartbeat` 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 > 7 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 (420s fresh, 421s stale). +- Verify with `./gradlew :quartz:test :amethyst:test`, then `./gradlew spotlessApply`. From 967cae7f153ec3c50ecb07ea91bc7dce274640a8 Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:14:41 +0200 Subject: [PATCH 02/19] docs: add DVM heartbeat liveness implementation plan --- .../2026-09-10-dvm-heartbeat-liveness-plan.md | 1002 +++++++++++++++++ 1 file changed, 1002 insertions(+) create mode 100644 amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md new file mode 100644 index 0000000000..a395a5ca72 --- /dev/null +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md @@ -0,0 +1,1002 @@ +# DVM Heartbeat Liveness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Only show DVMs (NIP-89 kind 31990 announcements) that have a fresh kind-11998 heartbeat — at most 420 seconds old — in the Discover list, with offline states on pinned feeds, the detail screen, and the manage screen. + +**Architecture:** A new quartz event class `DvmHeartbeatEvent` (kind 11998, `BaseAddressableEvent` — the codebase convention for 10xxx events with real `d` tags) is stored replaceably in `LocalCache.addressables` at `Address(11998, dvmPubkey, dTag)`, mirroring the announcement's address. One shared freshness lookup gates the Discover feed; a composable helper + one extra filter in the existing Discover subscription assembler feed it. Staleness transitions are silent, so both the feed and the UI re-check on timers. + +**Tech Stack:** Kotlin, KMP (quartz → commons → amethyst), Compose, kotlinx.coroutines, kotlinx.serialization not needed. Spec: `amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md`. + +## Global Constraints + +- Heartbeat max age: **420 seconds** (`DvmHeartbeatEvent.MAX_AGE_SECONDS`), defined once in quartz; exactly 420s old counts as fresh. +- Kind: **11998** — no other kind number anywhere. +- The wire contract (operator-side builder, no NIP yet): content `"Alive and kicking"` (plain text), tags `d` (= the NIP-89 DTAG), `status` (free text), `expiration` (= createdAt + 300, NIP-40). +- `commons` must not depend on `amethyst` — the constant and `isFreshAt` live in quartz; the cache lookup helper lives in amethyst. +- No new Gradle dependencies. No new icons (the offline marker is a text bullet `"\u2022"`, so **no font regeneration is needed**). +- New user-facing strings: English only in `commons/src/commonMain/composeResources/values/strings.xml` (translations flow via Crowdin). +- Run `./gradlew spotlessApply` before every commit; conventional commits (`feat:`, `test:`, `docs:`); never `--no-verify`. +- `LocalCache` is a process-wide object shared by all JVM tests — every test must use unique ids/pubkeys/dTags. + +--- + +### Task 1: Quartz event class `DvmHeartbeatEvent` (kind 11998) + +**Files:** +- Create: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt` +- Modify: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt` (import ~line 357–424 block, dispatch ~line 736–774 block) +- Modify: `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt:52-76` (`knownDTagReaders`) +- Modify: `amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md` (§2 wording) +- Test: `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt` + +**Interfaces:** +- Consumes: `BaseAddressableEvent`, `eventTemplate` (quartz `nip01Core`), `TagArray.expiration()` (NIP-40). +- Produces (used by every later task): + - `DvmHeartbeatEvent.KIND = 11998` + - `DvmHeartbeatEvent.MAX_AGE_SECONDS = 420` + - `DvmHeartbeatEvent.CONTENT = "Alive and kicking"` + - `fun status(): String?`, `fun expiration(): Long?`, `fun isFreshAt(now: Long = TimeUtils.now()): Boolean` + - `fun build(dTag: String, status: String, expiration: Long, createdAt: Long): EventTemplate` — documents the wire contract; the client never signs heartbeats. + +- [ ] **Step 1: Write the failing test** + +Create `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt`: + +```kotlin +/* + * Copyright (c) 2026 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.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 + 420)) + assertFalse(event.isFreshAt(beatTime + 421)) + } + + @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( + arrayOf( + arrayOf("d", dTag), + arrayOf("status", "My heart keeps beating like a hammer"), + arrayOf("expiration", (beatTime + 300).toString()), + ), + template.tags, + ) + } + + @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(event) + assertTrue(EventFactory.isKnownKind(DvmHeartbeatEvent.KIND), "kind 11998 should be a known kind") + } +} +``` + +Note: import `Event` (`com.vitorpamplona.quartz.nip01Core.core.Event`) if the compiler needs the explicit type annotation to resolve `assertIs`; keep the import list tight. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEventTest"` +Expected: COMPILATION ERROR ("unresolved reference: dvmHeartbeat" — the class does not exist yet). + +- [ ] **Step 3: Write the event class** + +Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`: + +```kotlin +/* + * Copyright (c) 2026 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.NostrSigner +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>, + 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 (one missed 300s beat + slack). */ + const val MAX_AGE_SECONDS = 420 + + fun build( + dTag: String, + status: String, + expiration: Long, + createdAt: Long = TimeUtils.now(), + ): EventTemplate = + eventTemplate(KIND, CONTENT, createdAt) { + add(arrayOf("d", dTag)) + add(arrayOf(STATUS_TAG, status)) + add(arrayOf("expiration", expiration.toString())) + } + } +} +``` + +Imports (after the package): `androidx.compose.runtime.Immutable`, `androidx.compose.runtime.Stable`, `com.vitorpamplona.quartz.nip01Core.core.EventTemplate`, `com.vitorpamplona.quartz.nip01Core.core.HexKey`, `com.vitorpamplona.quartz.nip01Core.signers.eventTemplate`, `com.vitorpamplona.quartz.nip40Expiration.expiration`, `com.vitorpamplona.quartz.utils.TimeUtils`. Signatures use `Array>` directly (the repo style — see `BlossomServersEvent.createTagArray`). + +- [ ] **Step 4: Register the kind in EventFactory** + +In `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt`: + +1. Add the import in the NIP-90 import block (alphabetical, near line 358): +```kotlin +import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent +``` +2. Add a dispatch branch inside the `when (kind)` near the other NIP-90 kinds (next to line 736 `NIP90StatusEvent.KIND -> ...`): +```kotlin +DvmHeartbeatEvent.KIND -> DvmHeartbeatEvent(id, pubKey, createdAt, tags, content, sig) +``` + +- [ ] **Step 5: Add 11998 to the kind-range guard allowlist** + +In `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt`, add `11998` to `knownDTagReaders` (lines 52–76). This is mandatory in the same change as Step 4 — the `plainReplaceableKindsIgnoreStrayDTags` test probes every registered 10000–19999 kind with a stray `d` tag and fails for any class that reads it. + +```kotlin +private val knownDTagReaders = + setOf( + 10004, 10005, 10006, 10007, 10009, 10012, 10013, 10015, 10017, 10018, + 10020, 10023, 10040, 10054, 10081, 10086, 10087, 10088, 10089, 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, + ) +``` + +- [ ] **Step 6: Update the design doc §2** + +In `amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md`, replace the §2 bullet that says the class "extends `BaseReplaceableEvent`" / "Overrides `dTag()`" with the as-built wording: extends `BaseAddressableEvent` (the codebase convention for 10xxx events with real `d` tags, e.g. `FollowListEvent`), so `dTag()`/`address()`/`addressTag()` come from the base; note `MAX_AGE_SECONDS`, `isFreshAt`, and the `knownDTagReaders` entry live in quartz too (commons imports them). + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEventTest" --tests "com.vitorpamplona.quartz.utils.EventFactoryKindRangeTest"` +Expected: PASS (both classes). + +- [ ] **Step 8: Format and commit** + +```bash +./gradlew spotlessApply +git add quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt \ + quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt \ + quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt \ + quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt \ + amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +git commit -m "feat: add DvmHeartbeatEvent (kind 11998) for DVM liveness" +``` + +--- + +### Task 2: Cache routing + freshness lookup in amethyst + +**Files:** +- Create: `amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt` +- Modify: `amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt:3744` (the replaceable-consume group) +- Test: `amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt` + +**Interfaces:** +- Consumes: `DvmHeartbeatEvent` (Task 1: `KIND`, `isFreshAt`, `MAX_AGE_SECONDS`). +- Produces (used by Tasks 3–5): + - `fun LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` + - `fun LocalCache.hasFreshDvmHeartbeat(appDef: AppDefinitionEvent, now: Long = TimeUtils.now()): Boolean` +- Behavior: consuming a kind-11998 event stores it in `LocalCache.addressables` keyed by `Address(11998, author, dTag)`; the newest per address wins; `hasFreshDvmHeartbeat` is the single gate every consumer uses. + +- [ ] **Step 1: Write the failing tests** + +Create `amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt`: + +```kotlin +/* + * Copyright (c) 2026 Vitor Pamplona + * (standard MIT license header — copy from ReportNamingIndexIngestionTest.kt) + */ +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 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 = "aa".repeat(32) + + private fun appDef( + dTag: String, + pubKey: String = appDefPubKey, + ) = AppDefinitionEvent( + id = "b0".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.consume(beat("dvm-one", createdAt = 1_760_000_100L, id = "e0".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 + // 421s-old beat could never supersede the 420s one within a single address slot. + val freshApp = appDef("dvm-two-fresh") + val staleApp = appDef("dvm-two-stale") + assertNull(LocalCache.dvmHeartbeatOf(freshApp), "no beat yet") + + LocalCache.consume(beat("dvm-two-fresh", createdAt = now - 420, id = "e1".repeat(32)), null, true) + LocalCache.consume(beat("dvm-two-stale", createdAt = now - 421, id = "e2".repeat(32)), null, true) + + assertTrue(LocalCache.hasFreshDvmHeartbeat(freshApp, now), "exactly 420s old counts as fresh") + assertFalse(LocalCache.hasFreshDvmHeartbeat(staleApp, now), "421s old is stale") + } + + @Test + fun theNewestBeatPerAddressWins() { + val now = 1_760_000_000L + val app = appDef("dvm-three") + LocalCache.consume(beat("dvm-three", createdAt = now - 600, id = "e3".repeat(32)), null, true) + LocalCache.consume(beat("dvm-three", createdAt = now - 60, id = "e4".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.consume(beat("dvm-a", createdAt = now - 60, id = "e5".repeat(32)), null, true) + + assertTrue(LocalCache.hasFreshDvmHeartbeat(appA, now)) + assertFalse(LocalCache.hasFreshDvmHeartbeat(appB, now), "no beat for dvm-b") + } + + @Test + fun noHeartbeatMeansNoLiveness() { + assertFalse(LocalCache.hasFreshDvmHeartbeat(appDef("dvm-never"), TimeUtils.now())) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `./gradlew :amethyst:testPlayDebugUnitTest --tests "com.vitorpamplona.amethyst.model.DvmHeartbeatTest"` +Expected: COMPILATION ERROR (`dvmHeartbeatOf`/`hasFreshDvmHeartbeat` unresolved). + +- [ ] **Step 3: Add the freshness helper** + +Create `amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt`: + +```kotlin +/* + * Copyright (c) 2026 Vitor Pamplona + * (standard MIT license header — copy from a sibling file in this package) + */ +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 + +/** 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 420s old. */ +fun LocalCache.hasFreshDvmHeartbeat( + appDef: AppDefinitionEvent, + now: Long = TimeUtils.now(), +): Boolean = dvmHeartbeatOf(appDef)?.isFreshAt(now) == true +``` + +- [ ] **Step 4: Route the kind through the replaceable consume path** + +In `amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt`, inside the big replaceable group in `justConsumeInnerInner` (the branch ending `-> consumeBaseReplaceable(event, relay, wasVerified)` at line 3832), insert after `is ContactListEvent,` (line 3744): + +```kotlin +// 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). +is DvmHeartbeatEvent, +``` + +and add the import `com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent` in the quartz import block. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `./gradlew :amethyst:testPlayDebugUnitTest --tests "com.vitorpamplona.amethyst.model.DvmHeartbeatTest"` +Expected: PASS (5 tests). + +- [ ] **Step 6: Format and commit** + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt \ + amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt \ + amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt +git commit -m "feat: store DVM heartbeats in LocalCache and expose the freshness gate" +``` + +--- + +### Task 3: Gate the Discover list + make staleness recompute + +**Files:** +- Modify: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt:88-97` +- Modify: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt` (two spots: `updateFeedsWith` ~line 338, `init` block ~line 315) + +**Interfaces:** +- Consumes: `LocalCache.hasFreshDvmHeartbeat` (Task 2), `DvmHeartbeatEvent` (Task 1). +- Produces: the Discover "Content" list only contains DVMs with a fresh beat; a new beat or the 60s timer triggers a full rebuild of `discoverDVMs` (the additive path can never re-evaluate a 31990 that a heartbeat just validated, and expiry emits no event). + +No unit tests here: `DiscoverNIP89FeedFilter` and `AccountFeedContentStates` both require a full `Account` (no test constructs one — verified). The gate logic itself is already covered by Task 2's tests; this task is wiring. + +- [ ] **Step 1: Gate `acceptApp` on heartbeat freshness** + +In `DiscoverNIP89FeedFilter.kt`, change `acceptApp` (lines 88–97) to: + +```kotlin +open fun acceptApp( + noteEvent: AppDefinitionEvent, + relays: List, +): Boolean { + val filterParams = buildFilterParams(account) + return noteEvent.appMetaData()?.subscription != true && + filterParams.match(noteEvent, relays) && + noteEvent.includeKind(targetKind) && + noteEvent.createdAt > lastAnnounced && + LocalCache.hasFreshDvmHeartbeat(noteEvent) +} +``` + +Add import: `com.vitorpamplona.amethyst.model.hasFreshDvmHeartbeat`. (`LocalCache` is already imported in this file.) + +- [ ] **Step 2: Full-rebuild branch when heartbeats arrive** + +In `AccountFeedContentStates.kt`, `updateFeedsWith` (line 338), replace the single line `discoverDVMs.updateFeedWith(newNotes)` with: + +```kotlin +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) +} +``` + +Add import: `com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent`. + +- [ ] **Step 3: 60s staleness timer** + +In `AccountFeedContentStates.kt`'s `init` block, after the `account.hiddenUsers.flow.collect { ... }` launcher (ends line 315), add: + +```kotlin +// 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() + } +} +``` + +Add imports: `kotlinx.coroutines.delay`, `kotlinx.coroutines.isActive` (both usually present already — check before adding). + +- [ ] **Step 4: Build and format** + +Run: `./gradlew :amethyst:compilePlayDebugKotlin` — Expected: BUILD SUCCESSFUL. + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt \ + amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +git commit -m "feat: hide DVMs without a fresh heartbeat from the Discover list" +``` + +--- + +### Task 4: Discover heartbeat REQ + shared UI helper + strings + +**Files:** +- Modify: `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt:43-58` +- Create: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt` +- Modify: `commons/src/commonMain/composeResources/values/strings.xml` (~line 2228, after `dvm_home_retry`) + +**Interfaces:** +- Consumes: `DvmHeartbeatEvent.KIND`/`MAX_AGE_SECONDS` (Task 1 — commons reads quartz, never amethyst). +- Produces (used by Task 5): + - `fun makeContentDVMsFilter(...)` now also emits one kind-11998 REQ per discovery relay. + - `@Composable fun rememberDvmHeartbeatFresh(appDefinitionAddress: Address, accountViewModel: AccountViewModel): State` + - `@Composable fun DvmOfflineBanner(modifier: Modifier = Modifier)` + - `Res.string.dvm_offline`, `Res.string.dvm_offline_banner` + +- [ ] **Step 1: Append the heartbeat filter to the DVM discover assembly** + +In `SubAssemblyHelper.kt`, change `makeContentDVMsFilter` to: + +```kotlin +fun makeContentDVMsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long?, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterContentDVMsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterContentDVMsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterContentDVMsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterContentDVMsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterContentDVMsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince) + else -> emptyList() + } + .plusHeartbeatFilter() + .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 same relays the + * announcements were asked on, with a 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 List.plusHeartbeatFilter(): List { + if (isEmpty()) return this + val heartbeatFilter = + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, + kinds = listOf(DvmHeartbeatEvent.KIND), + limit = 100, + since = TimeUtils.now() - DvmHeartbeatEvent.MAX_AGE_SECONDS, + ) + return this + + map { it.relay }.distinct().map { relay -> + RelayBasedFilter(relay = relay, filter = heartbeatFilter) + } +} +``` + +Add imports: `com.vitorpamplona.quartz.nip01Core.relay.filters.Filter` is NOT needed (ExplainedFilter carries kinds); add `com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent`, `com.vitorpamplona.quartz.utils.TimeUtils`, and `com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter` / `...subscriptions.SubPurpose` if not already imported. (`RelayBasedFilter` is already imported.) + +- [ ] **Step 2: Add the strings** + +In `commons/src/commonMain/composeResources/values/strings.xml`, after `Retry` (line ~2228): + +```xml +Offline +This feed algorithm has not sent a heartbeat recently and may be down +``` + +- [ ] **Step 3: Create the shared observation helper + offline banner** + +Create `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt`: + +```kotlin +/* + * Copyright (c) 2026 Vitor Pamplona + * (standard MIT license header — copy from DvmContentDiscoveryScreen.kt) + */ +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 com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +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 { + 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 resolved = heartbeatNote ?: return remember(heartbeatAddressTag) { mutableStateOf(false) } + + val heartbeat by observeNoteAndMap(resolved, accountViewModel) { it.event as? DvmHeartbeatEvent } + + var now by remember(resolved) { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(resolved) { + while (isActive) { + delay(HEARTBEAT_RECHECK_MILLIS) + now = TimeUtils.now() + } + } + + val beat = heartbeat + return rememberUpdatedState(beat != null && beat.isFreshAt(now)) +} + +/** 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, + ) + } + } +} +``` + +Verify `accountViewModel.getNoteIfExists(...)` / `checkGetOrCreateNote(...)` exist on `AccountViewModel` (they are the exact two calls `LoadNote` makes in `amethyst/.../ui/components/RichTextViewer.kt:875-891`; if they are `AccountViewModel` extension functions rather than members, import them from the same file `LoadNote` imports them from). + +- [ ] **Step 4: Build and format** + +Run: `./gradlew :commons:compileKotlinJvm :amethyst:compilePlayDebugKotlin` — Expected: BUILD SUCCESSFUL. + +```bash +./gradlew spotlessApply +git add commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt \ + commons/src/commonMain/composeResources/values/strings.xml \ + amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt +git commit -m "feat: subscribe to DVM heartbeats from the Discover screen and add liveness helper" +``` + +--- + +### Task 5: Wire the four surfaces + +**Files:** +- Modify: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt` (collapsed text ~lines 179–185; `RenderOption` lines 370–375) +- Modify: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt` (`SingleAlgoFeedBanner` lines 80–91, `AllFavoriteAlgoFeedsBanner` lines 178–199) +- Modify: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt` (inner screen, lines 126–172) +- Modify: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt` (`FavoriteAlgoFeedRow`, lines 235–311) + +**Interfaces:** +- Consumes: `rememberDvmHeartbeatFresh`, `DvmOfflineBanner`, `Res.string.dvm_offline` (Task 4). + +- [ ] **Step 1: Pinned chips — collapsed spinner text** + +In `FeedFilterSpinner.kt`, replace the plain `Text` in the non-Geohash branch (lines 179–185) with: + +```kotlin +} else { + val favoriteAlgoFeedAddress = (selected?.name as? FavoriteAlgoFeedName)?.note?.address + if (favoriteAlgoFeedAddress != null) { + val heartbeatFresh by rememberDvmHeartbeatFresh(favoriteAlgoFeedAddress, accountViewModel) + Text( + text = if (heartbeatFresh) currentText else "$currentText \u2022", + color = if (heartbeatFresh) Color.Unspecified else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = currentText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} +``` + +Add imports: `androidx.compose.ui.graphics.Color`, `com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.rememberDvmHeartbeatFresh`, `com.vitorpamplona.amethyst.ui.screen.TopNavFilterState.FavoriteAlgoFeedName` (check its actual package — `TopNavFilterState.kt` line 586). + +- [ ] **Step 2: Pinned chips — dialog option rows** + +In `FeedFilterSpinner.kt`, change the `is PeopleListName, is CommunityName, is FavoriteAlgoFeedName ->` branch of `RenderOption` (lines 370–375) to: + +```kotlin +is PeopleListName, is CommunityName, is FavoriteAlgoFeedName -> { + val backed = option as NoteBackedName + val noteState by observeNote(backed.note, accountViewModel) + val name = remember(noteState) { option.name(context) } + val appDefAddress = (option as? FavoriteAlgoFeedName)?.note?.address + val heartbeatFresh = + if (appDefAddress != null) { + rememberDvmHeartbeatFresh(appDefAddress, accountViewModel).value + } else { + true + } + Text( + text = if (heartbeatFresh) name else "$name \u2022", + fontSize = Font14SP, + color = + if (heartbeatFresh) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) +} +``` + +- [ ] **Step 3: Home status banner — offline supersedes** + +In `AlgoFeedStatusBanner.kt`: + +a) `SingleAlgoFeedBanner` (lines 80–91) — observe freshness and short-circuit before the existing early return: + +```kotlin +@Composable +private fun SingleAlgoFeedBanner( + favFeed: TopFilter.FavoriteAlgoFeed, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val snapshot by accountViewModel.account.favoriteAlgoFeedsOrchestrator + .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 + + // ... rest of the existing body unchanged ... +} +``` + +b) `AllFavoriteAlgoFeedsBanner` (lines 178–199) — after the `if (addresses.isEmpty()) return` and before the snapshots loop, insert: + +```kotlin +val anyHeartbeatFresh = + addresses.any { address -> + rememberDvmHeartbeatFresh(address, accountViewModel).value + } +if (!anyHeartbeatFresh) { + BannerCard(modifier) { + BannerMessageRow( + message = stringRes(Res.string.dvm_offline_banner), + showSpinner = false, + ) + } + return +} +``` + +(When at least one pinned DVM is alive, the existing requesting/error logic governs — the dead ones simply contribute nothing new.) + +Add imports to `AlgoFeedStatusBanner.kt`: `com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.rememberDvmHeartbeatFresh`, and `androidx.compose.runtime.getValue` (already present). + +- [ ] **Step 4: Detail screen — offline banner** + +In `DvmContentDiscoveryScreen.kt`, inner `DvmContentDiscoveryScreen(appDefinition: Note, ...)` (lines 126–172). After `val noteAuthor = ...` (line 128), add: + +```kotlin +val appDef = appDefinition.event as? AppDefinitionEvent +val heartbeatFresh = if (appDef != null) rememberDvmHeartbeatFresh(appDef.address(), accountViewModel) else null +``` + +and wrap the body of `RefresheableBox` in a `Box` so the banner floats over the loading/content view: + +```kotlin +RefresheableBox(onRefresh = onRefresh) { + Box(modifier = Modifier.fillMaxSize()) { + val myRequestEventID = requestEventID + if (myRequestEventID != null) { + ObserverContentDiscoveryResponse(appDefinition, myRequestEventID, onRefresh, accountViewModel, nav) + } else { + FeedEmptyWithStatus(appDefinition, stringRes(Res.string.dvm_requesting_job), accountViewModel, nav) + } + if (heartbeatFresh?.value == false) { + DvmOfflineBanner(modifier = Modifier.align(Alignment.TopCenter)) + } + } +} +``` + +Notes: `heartbeatFresh?.value == false` deliberately shows the banner only when a note object exists and is stale — an unresolved heartbeat note (surfaces that never subscribed) shows nothing rather than a false "offline". `AppDefinitionEvent` is already imported in this file; add `androidx.compose.foundation.layout.Box`, `androidx.compose.foundation.layout.fillMaxSize`, `androidx.compose.ui.Alignment`, `com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmOfflineBanner` (same package — no import needed) and `rememberDvmHeartbeatFresh` (same package — no import needed). + +- [ ] **Step 5: Manage screen rows** + +In `FavoriteAlgoFeedsListScreen.kt`'s `FavoriteAlgoFeedRow` (row body ~lines 282–302), inside the `Column(Modifier.weight(1f))`, change the name `Text` (lines 285–291) to: + +```kotlin +val heartbeatFresh by rememberDvmHeartbeatFresh(feedNote.address, accountViewModel) +val displayName = card.name.ifBlank { feedNote.dTag() } +Text( + text = if (heartbeatFresh) displayName else "$displayName \u2022", + fontWeight = FontWeight.Bold, + // keep the existing fontSize / maxLines / overflow parameters from the original Text +) +``` + +(preserve whatever style/color parameters the original `Text` already carries — only the `text` argument changes; add the `rememberDvmHeartbeatFresh` import). + +- [ ] **Step 6: Build and format** + +Run: `./gradlew :amethyst:compilePlayDebugKotlin` — Expected: BUILD SUCCESSFUL. + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt \ + amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt \ + amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt \ + amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt +git commit -m "feat: show DVM liveness (offline dot, offline banners) on pinned feeds and detail screens" +``` + +--- + +### Task 6: Full verification + doc status + +- [ ] **Step 1: Run the affected test suites** + +```bash +./gradlew :quartz:jvmTest :amethyst:testPlayDebugUnitTest +``` +Expected: BUILD SUCCESSFUL (all suites green, including the new `DvmHeartbeatEventTest` and `DvmHeartbeatTest`). + +- [ ] **Step 2: Build the app** + +Run: `./gradlew :amethyst:assemblePlayDebug` — Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Update the design doc status** + +In `amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md`, change the status line to `_Status: **implemented** (Android; desktop wiring deliberately out of scope — §8)._` + +- [ ] **Step 4: Commit** + +```bash +./gradlew spotlessApply +git add amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +git commit -m "docs: mark DVM heartbeat liveness as implemented" +``` From cd14c15188e2c907ab7f1006162e3083a7634b1a Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:03:32 +0200 Subject: [PATCH 03/19] feat: add DvmHeartbeatEvent (kind 11998) for DVM liveness --- .../2026-09-10-dvm-heartbeat-liveness.md | 19 +-- .../dvmHeartbeat/DvmHeartbeatEvent.kt | 79 +++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../dvmHeartbeat/DvmHeartbeatEventTest.kt | 133 ++++++++++++++++++ .../quartz/utils/EventFactoryKindRangeTest.kt | 3 + 5 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md index eb88312619..84737ba6fc 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -42,15 +42,18 @@ feeds and the detail surface show an offline state instead. ## 2. Event model (quartz) -New `quartz/.../nip90Dvms/heartbeat/DvmHeartbeatEvent.kt`: +New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`: -- `class DvmHeartbeatEvent(...) : BaseReplaceableEvent(...)`, `KIND = 11998` -- **Overrides `dTag()`** to read the event's actual `d` tag — `BaseReplaceableEvent.dTag()` - returns a fixed `""`, so without the override the cache address could not match the - announcement. With it, the cache address is `Address(11998, dvmPubkey, dTag)`, the exact - mirror of the announcement's `Address(31990, dvmPubkey, dTag)`. -- Accessors: `statusTag()`, and `expiration()` via the existing NIP-40 extension. -- Registered in `EventFactory` (kind → constructor). +- `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 = 420` 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt new file mode 100644 index 0000000000..66336a46f8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt @@ -0,0 +1,79 @@ +/* + * 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>, + 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 (one missed 300s beat + slack). */ + const val MAX_AGE_SECONDS = 420 + + fun build( + dTag: String, + status: String, + expiration: Long, + createdAt: Long = TimeUtils.now(), + ): EventTemplate = + eventTemplate(KIND, CONTENT, createdAt) { + add(arrayOf("d", dTag)) + add(arrayOf(STATUS_TAG, status)) + add(arrayOf("expiration", expiration.toString())) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index f0c8b03122..f99ff9628b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt new file mode 100644 index 0000000000..147ce7691e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt @@ -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 + 420)) + assertFalse(event.isFreshAt(beatTime + 421)) + } + + @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(event) + assertTrue(EventFactory.isKnownKind(DvmHeartbeatEvent.KIND), "kind 11998 should be a known kind") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt index f6dd98567b..8a06584d9b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt @@ -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" From 0e774730649eceedf99995e0e6eee0ab98aecc46 Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:09:03 +0200 Subject: [PATCH 04/19] feat: store DVM heartbeats in LocalCache and expose the freshness gate --- .../amethyst/model/DvmHeartbeat.kt | 35 +++++ .../amethyst/model/LocalCache.kt | 4 + .../amethyst/model/DvmHeartbeatTest.kt | 123 ++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt new file mode 100644 index 0000000000..aa45fbecd0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt @@ -0,0 +1,35 @@ +/* + * 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 + +/** 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 420s old. */ +fun LocalCache.hasFreshDvmHeartbeat( + appDef: AppDefinitionEvent, + now: Long = TimeUtils.now(), +): Boolean = dvmHeartbeatOf(appDef)?.isFreshAt(now) == true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index a1e17c07bf..82e0d1d813 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -380,6 +380,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 @@ -3742,6 +3743,9 @@ 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). + is DvmHeartbeatEvent, is EmojiPackEvent, is EmojiPackSelectionEvent, is EphemeralChatListEvent, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt new file mode 100644 index 0000000000..84f45923cb --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -0,0 +1,123 @@ +/* + * 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 = "aa".repeat(32) + + private fun appDef( + dTag: String, + pubKey: String = appDefPubKey, + ) = AppDefinitionEvent( + id = "b0".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 = "e0".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 + // 421s-old beat could never supersede the 420s 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 - 420, id = "e1".repeat(32)), null, true) + LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 421, id = "e2".repeat(32)), null, true) + + assertTrue("exactly 420s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now)) + assertFalse("421s 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 = "e3".repeat(32)), null, true) + LocalCache.justConsume(beat("dvm-three", createdAt = now - 60, id = "e4".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 = "e5".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())) + } +} From dac0033dcdee90eb76cb9d1e8eae385e1fe9ded7 Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:16:50 +0200 Subject: [PATCH 05/19] fix: use collision-free 64-char hex ids in DvmHeartbeatTest --- .../amethyst/model/DvmHeartbeatTest.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt index 84f45923cb..84bdb112e1 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -35,13 +35,13 @@ import org.junit.Test * uses its own pubkeys/dTags/ids (same discipline as ReportNamingIndexIngestionTest). */ class DvmHeartbeatTest { - private val appDefPubKey = "aa".repeat(32) + private val appDefPubKey = "f1".repeat(32) private fun appDef( dTag: String, pubKey: String = appDefPubKey, ) = AppDefinitionEvent( - id = "b0".repeat(32), + id = "f2".repeat(32), pubKey = pubKey, createdAt = 1_760_000_000L, tags = arrayOf(arrayOf("d", dTag), arrayOf("k", "5300")), @@ -71,7 +71,7 @@ class DvmHeartbeatTest { @Test fun aConsumedHeartbeatLandsAtTheAnnouncementMirrorAddress() { val app = appDef("dvm-one") - LocalCache.justConsume(beat("dvm-one", createdAt = 1_760_000_100L, id = "e0".repeat(32)), null, true) + 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) @@ -88,8 +88,8 @@ class DvmHeartbeatTest { val staleApp = appDef("dvm-two-stale") assertNull("no beat yet", LocalCache.dvmHeartbeatOf(freshApp)) - LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 420, id = "e1".repeat(32)), null, true) - LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 421, id = "e2".repeat(32)), null, true) + LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 420, id = "f4".repeat(32)), null, true) + LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 421, id = "f5".repeat(32)), null, true) assertTrue("exactly 420s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now)) assertFalse("421s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now)) @@ -99,8 +99,8 @@ class DvmHeartbeatTest { fun theNewestBeatPerAddressWins() { val now = 1_760_000_000L val app = appDef("dvm-three") - LocalCache.justConsume(beat("dvm-three", createdAt = now - 600, id = "e3".repeat(32)), null, true) - LocalCache.justConsume(beat("dvm-three", createdAt = now - 60, id = "e4".repeat(32)), null, true) + 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) } @@ -110,7 +110,7 @@ class DvmHeartbeatTest { 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 = "e5".repeat(32)), null, true) + 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)) From 2f67d97cc6e2399401b20abaeafc1fb8b5588e12 Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:20:16 +0200 Subject: [PATCH 06/19] feat: hide DVMs without a fresh heartbeat from the Discover list --- .../loggedIn/AccountFeedContentStates.kt | 20 ++++++++++++++++++- .../nip90DVMs/DiscoverNIP89FeedFilter.kt | 4 +++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index da5142695f..29253b62fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt index 5302732039..e5df683ea7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt @@ -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): Set = From fc98d1536491dab39ec48e6510923f36bae7e951 Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:26:03 +0200 Subject: [PATCH 07/19] feat: subscribe to DVM heartbeats from the Discover screen and add liveness helper --- .../loggedIn/dvms/DvmHeartbeatObservation.kt | 119 ++++++++++++++++++ .../composeResources/values/strings.xml | 2 + .../discover/nip90DVMs/SubAssemblyHelper.kt | 28 ++++- 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt new file mode 100644 index 0000000000..fcd4790796 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt @@ -0,0 +1,119 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.dvm_offline_banner +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 { + 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 resolved = heartbeatNote ?: return remember(heartbeatAddressTag) { mutableStateOf(false) } + + val heartbeat by observeNoteAndMap(resolved, accountViewModel) { it.event as? DvmHeartbeatEvent } + + var now by remember(resolved) { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(resolved) { + while (isActive) { + delay(HEARTBEAT_RECHECK_MILLIS) + now = TimeUtils.now() + } + } + + val beat = heartbeat + return rememberUpdatedState(beat != null && beat.isFreshAt(now)) +} + +/** 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, + ) + } + } +} diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 51d8d6a11d..3ae89b22f8 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -2226,6 +2226,8 @@ This feed algorithm requires payment The feed algorithm returned an error Retry + Offline + This feed algorithm has not sent a heartbeat recently and may be down Log off on device lock Chat Relay Share image… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt index ba96a3b35d..22afa6ffad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt @@ -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,26 @@ fun makeContentDVMsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - }.scopedTo(feedSettings) + }.plusHeartbeatFilter() + .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 same relays the + * announcements were asked on, with a 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 List.plusHeartbeatFilter(): List { + if (isEmpty()) return this + val heartbeatFilter = + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, + kinds = listOf(DvmHeartbeatEvent.KIND), + limit = 100, + since = TimeUtils.now() - DvmHeartbeatEvent.MAX_AGE_SECONDS, + ) + return this + + map { it.relay }.distinct().map { relay -> + RelayBasedFilter(relay = relay, filter = heartbeatFilter) + } +} From fed8f67ee1e779f179a44e8523bcf50976efaf3b Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:31:02 +0200 Subject: [PATCH 08/19] feat: show DVM liveness (offline dot, offline banners) on pinned feeds and detail screens --- .../navigation/topbars/FeedFilterSpinner.kt | 40 ++++++++++++++++--- .../dvms/DvmContentDiscoveryScreen.kt | 30 +++++++------- .../favorites/FavoriteAlgoFeedsListScreen.kt | 5 ++- .../loggedIn/home/AlgoFeedStatusBanner.kt | 30 ++++++++++++++ 4 files changed, 82 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 8960b7a9c7..0913899df1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -95,6 +95,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 +178,22 @@ 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) + Text( + text = if (heartbeatFresh) currentText else "$currentText \u2022", + color = if (heartbeatFresh) Color.Unspecified else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = currentText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } if (filter is TopFilter.AroundMe) { @@ -371,7 +383,23 @@ 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 + } + Text( + text = if (heartbeatFresh) name else "$name \u2022", + fontSize = Font14SP, + color = + if (heartbeatFresh) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) } // Pure names: no relay subscription needed. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index fcc8835053..09c2292c2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -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(null) @@ -151,22 +154,17 @@ 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 { + FeedEmptyWithStatus(appDefinition, stringRes(Res.string.dvm_requesting_job), accountViewModel, nav) + } + if (heartbeatFresh?.value == false) { + DvmOfflineBanner(modifier = Modifier.align(Alignment.TopCenter)) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt index 6c1c9a3ed2..4cbbe46792 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt @@ -84,6 +84,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,8 +283,10 @@ private fun FavoriteAlgoFeedRow( Column( modifier = Modifier.weight(1f), ) { + val heartbeatFresh by rememberDvmHeartbeatFresh(feedNote.address, accountViewModel) + val displayName = card.name.ifBlank { feedNote.dTag() } Text( - text = card.name.ifBlank { feedNote.dTag() }, + text = if (heartbeatFresh) displayName else "$displayName \u2022", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt index e07ed48105..82f9107925 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt @@ -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,20 @@ private fun AllFavoriteAlgoFeedsBanner( if (addresses.isEmpty()) return + val anyHeartbeatFresh = + addresses.any { address -> + rememberDvmHeartbeatFresh(address, accountViewModel).value + } + 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. From eda92fa06be0d2ba30d00a2a8a9a8a6bee4f1119 Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:33:23 +0200 Subject: [PATCH 09/19] fix: restore DVM splash-screen TODO comment --- .../ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 09c2292c2c..6210bc89bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -160,6 +160,7 @@ fun DvmContentDiscoveryScreen( 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) { From 97172c1bb3942ca0bb39043f5d6e5016f5e5ea05 Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:35:36 +0200 Subject: [PATCH 10/19] docs: mark DVM heartbeat liveness as implemented --- amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md index 84737ba6fc..4ab76d7fab 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -1,6 +1,6 @@ # DVM heartbeat liveness — only show DVMs with a fresh kind-11998 heartbeat -_Status: **design approved, not implemented**._ +_Status: **implemented** (Android; desktop wiring deliberately out of scope — §8)._ ## 0. The shape of the thing From ad1d4ccf8b00724d67961352a5c7ff8d385d981a Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:52:19 +0200 Subject: [PATCH 11/19] fix: stabilize DVM heartbeat freshness state and label the offline dot --- .../ui/navigation/topbars/FeedFilterSpinner.kt | 16 ++++++++++++++++ .../loggedIn/dvms/DvmHeartbeatObservation.kt | 11 +++++------ .../favorites/FavoriteAlgoFeedsListScreen.kt | 9 +++++++++ .../screen/loggedIn/home/AlgoFeedStatusBanner.kt | 9 ++++++--- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 0913899df1..8a0dc7cef7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -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 @@ -181,11 +183,18 @@ fun FeedFilterSpinner( 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( @@ -390,6 +399,7 @@ fun RenderOption( } else { true } + val offlineLabel = stringRes(Res.string.dvm_offline) Text( text = if (heartbeatFresh) name else "$name \u2022", fontSize = Font14SP, @@ -399,6 +409,12 @@ fun RenderOption( } else { MaterialTheme.colorScheme.onSurfaceVariant }, + modifier = + if (heartbeatFresh) { + Modifier + } else { + Modifier.semantics { contentDescription = "$name, $offlineLabel" } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt index fcd4790796..db80c0a3b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt @@ -78,19 +78,18 @@ fun rememberDvmHeartbeatFresh( } } - val resolved = heartbeatNote ?: return remember(heartbeatAddressTag) { mutableStateOf(false) } + val observed = + heartbeatNote?.let { observeNoteAndMap(it, accountViewModel) { it.event as? DvmHeartbeatEvent } } - val heartbeat by observeNoteAndMap(resolved, accountViewModel) { it.event as? DvmHeartbeatEvent } - - var now by remember(resolved) { mutableLongStateOf(TimeUtils.now()) } - LaunchedEffect(resolved) { + var now by remember(heartbeatNote) { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(heartbeatNote) { while (isActive) { delay(HEARTBEAT_RECHECK_MILLIS) now = TimeUtils.now() } } - val beat = heartbeat + val beat = observed?.value return rememberUpdatedState(beat != null && beat.isFreshAt(now)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt index 4cbbe46792..f043cc8483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt @@ -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 @@ -285,12 +287,19 @@ private fun FavoriteAlgoFeedRow( ) { val heartbeatFresh by rememberDvmHeartbeatFresh(feedNote.address, accountViewModel) val displayName = card.name.ifBlank { feedNote.dTag() } + val offlineLabel = stringRes(Res.string.dvm_offline) Text( 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt index 82f9107925..90f0fc5344 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt @@ -200,10 +200,13 @@ 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.any { address -> - rememberDvmHeartbeatFresh(address, accountViewModel).value - } + addresses + .map { address -> + rememberDvmHeartbeatFresh(address, accountViewModel).value + }.any { it } if (!anyHeartbeatFresh) { BannerCard(modifier) { BannerMessageRow( From e9ae377e1b543a1a74072003f6e7a6be0b854e05 Mon Sep 17 00:00:00 2001 From: Believethehype <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:52:23 +0200 Subject: [PATCH 12/19] docs: record uniform-strict offline semantics for DVM liveness --- .../plans/2026-09-10-dvm-heartbeat-liveness-plan.md | 2 +- amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md index a395a5ca72..dfe5996c2d 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md @@ -943,7 +943,7 @@ RefresheableBox(onRefresh = onRefresh) { } ``` -Notes: `heartbeatFresh?.value == false` deliberately shows the banner only when a note object exists and is stale — an unresolved heartbeat note (surfaces that never subscribed) shows nothing rather than a false "offline". `AppDefinitionEvent` is already imported in this file; add `androidx.compose.foundation.layout.Box`, `androidx.compose.foundation.layout.fillMaxSize`, `androidx.compose.ui.Alignment`, `com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmOfflineBanner` (same package — no import needed) and `rememberDvmHeartbeatFresh` (same package — no import needed). +Notes: `heartbeatFresh?.value == false` exists only because `appDef` itself may be null. Per human ruling (uniform-strict), unresolved heartbeat beats count as offline on every surface — chips, home banners, and the detail screen all show the offline indicator once the beat is unresolved/stale, rather than showing nothing. `AppDefinitionEvent` is already imported in this file; add `androidx.compose.foundation.layout.Box`, `androidx.compose.foundation.layout.fillMaxSize`, `androidx.compose.ui.Alignment`, `com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmOfflineBanner` (same package — no import needed) and `rememberDvmHeartbeatFresh` (same package — no import needed). - [ ] **Step 5: Manage screen rows** diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md index 4ab76d7fab..e291daa14e 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -73,9 +73,13 @@ Small helper file in `amethyst/.../dvms/`: - `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())` - `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 420` -- `@Composable fun rememberDvmHeartbeat(address: Address): State` — - composable-scoped subscription (§5) + staleness re-check tick (§6), shared by every - surface that renders liveness. +- `@Composable fun rememberDvmHeartbeatFresh(address: Address, accountViewModel: AccountViewModel): State` — + as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 420s + 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 @@ -107,7 +111,7 @@ the session-scoped watcher for pinned DVMs. Traffic is negligible (a few pinned (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 `rememberDvmHeartbeat` tick on a 30s cadence internally. + using `rememberDvmHeartbeatFresh` tick on a 30s cadence internally. ## 7. UI surfaces From 6d5e847f733fc0432fc54e5b57c9d5b1e86f26aa Mon Sep 17 00:00:00 2001 From: "Dr. Tobias Baur" <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:08:10 +0200 Subject: [PATCH 13/19] fix: keep the DVM heartbeat REQ alive on every Discover selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a Relay (or favorite-algo-feed) chip in the Discover menu, or a relay whose per-relay slice was momentarily empty, dispatched no kind-31990 filters — and the heartbeat REQ rode along, so beats stopped arriving while the Content tab kept rendering cached announcements. The 60s staleness timer then dropped every DVM as its cached beat aged past 420s. The heartbeat filter now rides the selection's own relays (new IFeedTopNavPerRelayFilterSet.relays()) instead of the 31990 dispatch result. --- .../IFeedTopNavPerRelayFilterSet.kt | 11 ++ .../AllFollowsTopNavPerRelayFilterSet.kt | 2 + .../LocationTopNavPerRelayFilterSet.kt | 2 + ...FavoriteAlgoFeedTopNavPerRelayFilterSet.kt | 2 + .../global/GlobalTopNavPerRelayFilterSet.kt | 2 + .../hashtag/HashtagTopNavPerRelayFilterSet.kt | 2 + .../AllCommunitiesTopNavPerRelayFilterSet.kt | 2 + .../author/AuthorsTopNavPerRelayFilterSet.kt | 2 + .../SingleCommunityTopNavPerRelayFilterSet.kt | 2 + .../MutedAuthorsTopNavPerRelayFilterSet.kt | 2 + .../relay/RelayTopNavPerRelayFilterSet.kt | 2 + .../unknown/UnknownTopNavPerRelayFilterSet.kt | 2 + .../discover/nip90DVMs/SubAssemblyHelper.kt | 26 +++-- .../nip90DVMs/MakeContentDVMsFilterTest.kt | 101 ++++++++++++++++++ 14 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/MakeContentDVMsFilterTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt index 4addcf75f7..fc0e4f323b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt @@ -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 } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt index 1e550dbdda..069626dae4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt @@ -27,4 +27,6 @@ class AllFollowsTopNavPerRelayFilterSet( val set: Map, ) : IFeedTopNavPerRelayFilterSet { override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] + + override fun relays() = set.keys } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt index 00f3ed6995..fc76f32d9a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt @@ -27,4 +27,6 @@ class LocationTopNavPerRelayFilterSet( val set: Map, ) : IFeedTopNavPerRelayFilterSet { override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] + + override fun relays() = set.keys } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt index 130ff04adc..d5c883cd95 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt @@ -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 } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt index 128d9f8386..561231969d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt @@ -27,4 +27,6 @@ class GlobalTopNavPerRelayFilterSet( val set: Map, ) : IFeedTopNavPerRelayFilterSet { override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] + + override fun relays() = set.keys } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt index 8ae9a41aec..3abc4d9888 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt @@ -27,4 +27,6 @@ class HashtagTopNavPerRelayFilterSet( val set: Map, ) : IFeedTopNavPerRelayFilterSet { override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] + + override fun relays() = set.keys } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt index b7d9406a3c..d5983552ab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt @@ -27,4 +27,6 @@ class AllCommunitiesTopNavPerRelayFilterSet( val set: Map, ) : IFeedTopNavPerRelayFilterSet { override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] + + override fun relays() = set.keys } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt index cffff74dc5..f83c2b9f38 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt @@ -27,4 +27,6 @@ class AuthorsTopNavPerRelayFilterSet( val set: Map, ) : IFeedTopNavPerRelayFilterSet { override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] + + override fun relays() = set.keys } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt index d0178373bd..0698c86cda 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt @@ -27,4 +27,6 @@ class SingleCommunityTopNavPerRelayFilterSet( val set: Map, ) : IFeedTopNavPerRelayFilterSet { override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] + + override fun relays() = set.keys } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt index 942adce733..42730c7390 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt @@ -27,4 +27,6 @@ class MutedAuthorsTopNavPerRelayFilterSet( val set: Map, ) : IFeedTopNavPerRelayFilterSet { override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] + + override fun relays() = set.keys } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt index bf57ace838..9d7fab9f60 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt @@ -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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt index 612c5da309..1b7406e153 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt @@ -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() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt index 22afa6ffad..f3b6cad290 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/SubAssemblyHelper.kt @@ -59,17 +59,25 @@ fun makeContentDVMsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - }.plusHeartbeatFilter() - .scopedTo(feedSettings) + }.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 same relays the - * announcements were asked on, with a 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. + * 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 List.plusHeartbeatFilter(): List { - if (isEmpty()) return this +private fun plusHeartbeatFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + contentDvmFilters: List, +): List { + val relays = feedSettings.relays() + if (relays.isEmpty()) return contentDvmFilters val heartbeatFilter = ExplainedFilter( purpose = SubPurpose.DISCOVER_FEED, @@ -77,8 +85,8 @@ private fun List.plusHeartbeatFilter(): List limit = 100, since = TimeUtils.now() - DvmHeartbeatEvent.MAX_AGE_SECONDS, ) - return this + - map { it.relay }.distinct().map { relay -> + return contentDvmFilters + + relays.map { relay -> RelayBasedFilter(relay = relay, filter = heartbeatFilter) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/MakeContentDVMsFilterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/MakeContentDVMsFilterTest.kt new file mode 100644 index 0000000000..74ccefcbfb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/MakeContentDVMsFilterTest.kt @@ -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 }) + } +} From 6a02d787b6969465570a6ac48535266ae3d54fcc Mon Sep 17 00:00:00 2001 From: "Dr. Tobias Baur" <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:28:16 +0200 Subject: [PATCH 14/19] fix: fetch DVM heartbeats from the DVMs' outbox relays Alive DVMs whose heartbeats never reach the user's discovery relays stayed hidden from the Discover list: the freshness gate only saw beats delivered by the global heartbeat REQ on the selection's relays, while the beats existed on each DVM's own outbox (their detail screens proved it). DiscoveryDvmHeartbeatSubAssembler joins the discovery assembler group and batches the DVM list's announcement authors per DVM outbox relay (coverage-ranked, capped at 12 relays; unknown outboxes fall back to the global REQ). Re-issues on DVM-list membership changes via the inner feed flow, since FeedState.Loaded reuses its wrapper. --- .../2026-09-10-dvm-heartbeat-liveness.md | 11 ++ .../RelaySubscriptionsCoordinator.kt | 2 +- .../discover/DiscoveryFilterAssembler.kt | 4 + .../DvmHeartbeatOutboxSubAssembler.kt | 140 ++++++++++++++++++ .../DvmHeartbeatOutboxFiltersTest.kt | 120 +++++++++++++++ 5 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxSubAssembler.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md index e291daa14e..f87a10312e 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -98,6 +98,17 @@ since = now - 420`. The home top-bar chips live for the whole session, so they d 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 DVM list's +announcement authors per **DVM outbox relay** (`kinds = [11998], authors = [those pubkeys], +since = now - 420`, coverage-ranked and capped at 12 relays; authors with unknown outboxes +rely on the global REQ as fallback). It re-issues when the DVM list's membership changes and +unwraps `FeedState.Loaded` to the inner feed flow (the wrapper is reused, so only the inner +flow emits real list changes). + ## 6. Invalidation — closing the two silent gaps 1. **A new heartbeat does not re-rank the list.** The additive feed path diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 72706304ec..6811ee2dc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -113,7 +113,7 @@ class RelaySubscriptionsCoordinator( val home = HomeFilterAssembler(client) val chatroomList = ChatroomListFilterAssembler(client) val video = VideoFilterAssembler(client) - val discovery = DiscoveryFilterAssembler(client) + val discovery = DiscoveryFilterAssembler(client, cache) // loaders of content that is not yet in the device. // they are active when looking at events, users, channels. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt index 959d71ce03..f0a8928715 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.commons.relayClient.discover import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider 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.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, + cache: ICacheProvider, ) : TopNavFeedFilterAssembler({ keys -> listOf( DiscoveryLongFormClassifiedsAndDVMSubAssembler1(client, keys), DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(client, keys), DiscoveryPublicChatsAndCommunitiesSubAssembler3(client, keys), + DiscoveryDvmHeartbeatSubAssembler(client, cache, keys), ) }) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxSubAssembler.kt new file mode 100644 index 0000000000..71db6ff521 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxSubAssembler.kt @@ -0,0 +1,140 @@ +/* + * 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.cache.ICacheProvider +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.amethyst.commons.ui.feeds.FeedState +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +/** 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, + outboxRelaysFor: (HexKey) -> Collection, + now: Long, + maxRelays: Int = MAX_OUTBOX_RELAYS, +): List { + if (announcements.isEmpty()) return emptyList() + + val authorsByRelay = mutableMapOf>() + 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>> { 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, + ), + ) + } +} + +/** + * The discovery-side subscription that runs [dvmHeartbeatOutboxFilters] for the DVM list's current + * announcement set, alive while the Discover screen is composed (it joins the same assembler group + * and lifecycle as the other discovery sub-assemblers). + * + * Re-issues when the DVM list's membership changes: [FeedState.Loaded] reuses its wrapper, so the + * invalidator unwraps to the inner feed flow, which emits on every real list change. No floor + * collectors ([floors] is empty) — the announcement set is the driver, not note timestamps. + */ +class DiscoveryDvmHeartbeatSubAssembler( + client: INostrClient, + private val cache: ICacheProvider, + allKeys: () -> Set, +) : TopNavFeedSubAssembler(client, allKeys) { + override fun updateFilter( + key: DiscoveryQueryState, + since: SincePerRelayMap?, + ): List { + val announcements = + (key.dvms.feedContent.value as? FeedState.Loaded) + ?.feed + ?.value + ?.list + ?.mapNotNull { it.event as? AppDefinitionEvent } + .orEmpty() + + return dvmHeartbeatOutboxFilters( + announcements, + outboxRelaysFor = { pubkey -> + cache.getUserIfExists(pubkey)?.outboxRelays().orEmpty() + }, + now = TimeUtils.now(), + ) + } + + override fun floors(key: DiscoveryQueryState): List> = emptyList() + + @OptIn(ExperimentalCoroutinesApi::class) + override fun extraInvalidators(key: DiscoveryQueryState): List> = + listOf( + key.dvms.feedContent.flatMapLatest { state -> + (state as? FeedState.Loaded)?.feed ?: flowOf(null) + }, + ) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt new file mode 100644 index 0000000000..816bdd5576 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt @@ -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 = + { + 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_760_000_000L, 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(), + ) + } +} From cd67c56909aaf767a779e5e52f1ac2c9f48377ba Mon Sep 17 00:00:00 2001 From: "Dr. Tobias Baur" <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:09:57 +0200 Subject: [PATCH 15/19] fix: source the heartbeat outbox fetcher from the ungated cache scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outbox fetcher read its announcement set from the gated DVM feed list — a death spiral: a DVM dropped for a stale beat left the fetch batch, its beats were never fetched again, and the drop became permanent. The fetcher could only ever help DVMs that were already visible. The assembler now takes cache-backed sources from the front end: LocalCache.cachedDvmAnnouncements (every cached k=5300 announcement, newest-first, capped at 100 — the gate's other eligibility checks, WITHOUT the freshness gate), a per-author relay lookup unioning the NIP-65 outbox with the cached relay hints (same mix as the event finder), and 31990/NIP-65 observation flows as re-issue drivers. --- .../2026-09-10-dvm-heartbeat-liveness.md | 19 ++++-- .../amethyst/model/DvmHeartbeat.kt | 19 ++++++ .../RelaySubscriptionsCoordinator.kt | 25 ++++++- .../amethyst/model/DvmHeartbeatTest.kt | 47 +++++++++++++ .../discover/DiscoveryFilterAssembler.kt | 6 +- .../DvmHeartbeatOutboxSubAssembler.kt | 66 +++++++++---------- 6 files changed, 138 insertions(+), 44 deletions(-) diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md index f87a10312e..ac884e97a8 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -102,12 +102,19 @@ the session-scoped watcher for pinned DVMs. Traffic is negligible (a few pinned 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 DVM list's -announcement authors per **DVM outbox relay** (`kinds = [11998], authors = [those pubkeys], -since = now - 420`, coverage-ranked and capped at 12 relays; authors with unknown outboxes -rely on the global REQ as fallback). It re-issues when the DVM list's membership changes and -unwraps `FeedState.Loaded` to the inner feed flow (the wrapper is reused, so only the inner -flow emits real list changes). +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 - 420`, 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt index aa45fbecd0..7a4fc9e8b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt @@ -20,8 +20,10 @@ */ 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 @@ -33,3 +35,20 @@ fun LocalCache.hasFreshDvmHeartbeat( appDef: AppDefinitionEvent, now: Long = TimeUtils.now(), ): Boolean = dvmHeartbeatOf(appDef)?.isFreshAt(now) == 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 = + 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 6811ee2dc4..18e9bf5518 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, cache) + 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. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt index 84bdb112e1..fa77c0f5f2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -120,4 +120,51 @@ class DvmHeartbeatTest { fun noHeartbeatMeansNoLiveness() { assertFalse(LocalCache.hasFreshDvmHeartbeat(appDef("dvm-never"), TimeUtils.now())) } + + @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) + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt index f0a8928715..fefc949992 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.commons.relayClient.discover import com.vitorpamplona.amethyst.commons.model.IAccount -import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider 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 @@ -58,12 +58,12 @@ class DiscoveryQueryState( class DiscoveryFilterAssembler( client: INostrClient, - cache: ICacheProvider, + dvmHeartbeat: DvmHeartbeatSources, ) : TopNavFeedFilterAssembler({ keys -> listOf( DiscoveryLongFormClassifiedsAndDVMSubAssembler1(client, keys), DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(client, keys), DiscoveryPublicChatsAndCommunitiesSubAssembler3(client, keys), - DiscoveryDvmHeartbeatSubAssembler(client, cache, keys), + DiscoveryDvmHeartbeatSubAssembler(client, keys, dvmHeartbeat), ) }) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxSubAssembler.kt index 71db6ff521..3141c9ca1b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxSubAssembler.kt @@ -20,13 +20,11 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs -import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider 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.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -34,11 +32,8 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf /** 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 @@ -94,47 +89,50 @@ fun dvmHeartbeatOutboxFilters( } /** - * The discovery-side subscription that runs [dvmHeartbeatOutboxFilters] for the DVM list's current - * announcement set, alive while the Discover screen is composed (it joins the same assembler group - * and lifecycle as the other discovery sub-assemblers). + * Cache-backed inputs the outbox fetcher needs, provided by the front end (the cache query and + * relay-hint surface are platform caches, not commons). * - * Re-issues when the DVM list's membership changes: [FeedState.Loaded] reuses its wrapper, so the - * invalidator unwraps to the inner feed flow, which emits on every real list change. No floor - * collectors ([floors] is empty) — the announcement set is the driver, not note timestamps. + * [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, + val outboxRelaysFor: (HexKey) -> Collection, + val changes: List>, +) + +/** + * 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, - private val cache: ICacheProvider, allKeys: () -> Set, + private val sources: DvmHeartbeatSources, ) : TopNavFeedSubAssembler(client, allKeys) { override fun updateFilter( key: DiscoveryQueryState, since: SincePerRelayMap?, - ): List { - val announcements = - (key.dvms.feedContent.value as? FeedState.Loaded) - ?.feed - ?.value - ?.list - ?.mapNotNull { it.event as? AppDefinitionEvent } - .orEmpty() - - return dvmHeartbeatOutboxFilters( - announcements, - outboxRelaysFor = { pubkey -> - cache.getUserIfExists(pubkey)?.outboxRelays().orEmpty() - }, + ): List = + dvmHeartbeatOutboxFilters( + sources.announcements(), + outboxRelaysFor = sources.outboxRelaysFor, now = TimeUtils.now(), ) - } override fun floors(key: DiscoveryQueryState): List> = emptyList() - @OptIn(ExperimentalCoroutinesApi::class) - override fun extraInvalidators(key: DiscoveryQueryState): List> = - listOf( - key.dvms.feedContent.flatMapLatest { state -> - (state as? FeedState.Loaded)?.feed ?: flowOf(null) - }, - ) + override fun extraInvalidators(key: DiscoveryQueryState): List> = sources.changes } From 5a4002e256177ed74d960fc79e89dbdab909be4b Mon Sep 17 00:00:00 2001 From: "Dr. Tobias Baur" <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:28:25 +0200 Subject: [PATCH 16/19] fix: widen the DVM heartbeat drop window to 900s (hysteresis) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beats arrive every 300s, so the old 420s window tolerated barely one delivery hiccup — live DVMs dropped in oscillations and flickered back on the next beat. The gate now keeps a DVM visible through several missed deliveries (relay reconnects, REQ churn) before dropping it; a dead DVM still disappears after 15 minutes. Single constant, all surfaces consistent. --- .../2026-09-10-dvm-heartbeat-liveness.md | 23 +++++++++---------- .../amethyst/model/DvmHeartbeatTest.kt | 10 ++++---- .../DvmHeartbeatOutboxFiltersTest.kt | 2 +- .../dvmHeartbeat/DvmHeartbeatEvent.kt | 9 ++++++-- .../dvmHeartbeat/DvmHeartbeatEventTest.kt | 4 ++-- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md index ac884e97a8..b67a5cd4bc 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -21,7 +21,7 @@ Kind 11998 sits in the replaceable range (10000–19999), so relays keep only th 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 420 seconds +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. @@ -36,7 +36,7 @@ feeds and the detail surface show an offline state instead. (`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: 420 seconds**, a named constant. Exactly 420s old counts as fresh. +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). @@ -50,7 +50,7 @@ New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`: `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 = 420` and `isFreshAt(now)` live in quartz too (commons imports them). +- `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. @@ -67,14 +67,13 @@ invisible. The cache pruner removes old entries on its own schedule. ## 4. Freshness core (amethyst) -Small helper file in `amethyst/.../dvms/`: +Small helper file in `amethyst/.../model/` (the threshold constant itself lives in quartz): -- `const val DVM_HEARTBEAT_MAX_AGE_SECONDS = 420` - `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())` -- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 420` +- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 900` - `@Composable fun rememberDvmHeartbeatFresh(address: Address, accountViewModel: AccountViewModel): State` — - as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 420s + 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 @@ -86,7 +85,7 @@ Small helper file in `amethyst/.../dvms/`: **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() - 420` — no authors, no tags, scoped to the same +`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, @@ -94,7 +93,7 @@ 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 - 420`. The home top-bar chips live for the whole session, so they double as +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). @@ -104,7 +103,7 @@ relays don't gossip, so alive DVMs whose beats never overlap the user's relay se 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 - 420`, coverage-ranked and capped at 12 relays; +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. @@ -151,7 +150,7 @@ author (the same mix the event finder's `potentialRelaysToFindAddress` uses). - Heartbeat without a `d` tag → cache address dTag `""` → matches nothing → DVM hidden (strict; the wire contract always sends `d`). -- Device/DVM clock skew > 7 min → wrongly hidden (inherent to timestamp-based liveness). +- 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. @@ -165,5 +164,5 @@ author (the same mix the event finder's `potentialRelaysToFindAddress` uses). `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 (420s fresh, 421s stale). + rebuild. `isFreshAt` boundary (900s fresh, 901s stale). - Verify with `./gradlew :quartz:test :amethyst:test`, then `./gradlew spotlessApply`. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt index fa77c0f5f2..886fe55cda 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -83,16 +83,16 @@ class DvmHeartbeatTest { fun aFreshHeartbeatPassesTheGateAndAStaleOneDoesNot() { val now = 1_760_000_000L // Separate dTags: consumeBaseReplaceable only accepts NEWER beats per address, so a - // 421s-old beat could never supersede the 420s one within a single address slot. + // 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 - 420, id = "f4".repeat(32)), null, true) - LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 421, id = "f5".repeat(32)), null, true) + 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 420s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now)) - assertFalse("421s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now)) + assertTrue("exactly 900s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now)) + assertFalse("901s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now)) } @Test diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt index 816bdd5576..b31176262e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt @@ -82,7 +82,7 @@ class DvmHeartbeatOutboxFiltersTest { filters.forEach { assertEquals(listOf(DvmHeartbeatEvent.KIND), it.filter.kinds) - assertEquals(1_760_000_000L, it.filter.since, "rolling window: now - MAX_AGE_SECONDS") + assertEquals(1_759_999_520L, it.filter.since, "rolling window: now - MAX_AGE_SECONDS") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt index 66336a46f8..cad008ee96 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt @@ -61,8 +61,13 @@ class DvmHeartbeatEvent( const val STATUS_TAG = "status" const val CONTENT = "Alive and kicking" - /** A beat older than this no longer proves liveness (one missed 300s beat + slack). */ - const val MAX_AGE_SECONDS = 420 + /** + * 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, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt index 147ce7691e..23c558c868 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt @@ -90,8 +90,8 @@ class DvmHeartbeatEventTest { @Test fun freshnessBoundary() { val event = heartbeat() - assertTrue(event.isFreshAt(beatTime + 420)) - assertFalse(event.isFreshAt(beatTime + 421)) + assertTrue(event.isFreshAt(beatTime + 900)) + assertFalse(event.isFreshAt(beatTime + 901)) } @Test From 9db245f6097145d79328b47f01f784c66822de33 Mon Sep 17 00:00:00 2001 From: "Dr. Tobias Baur" <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:39:55 +0200 Subject: [PATCH 17/19] fix: make Discover's pull-to-refresh re-issue the discovery REQs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull-to-refresh only re-read the cache (invalidateData), so when cached beats were stale it showed the loss instead of fixing it. The DVM tab's refresh now also invalidates the discovery assembler, re-issuing the 31990 REQ and both heartbeat streams (global + outbox batches) with fresh rolling windows — beats repopulate in seconds instead of whenever the open REQs happen to deliver. --- .../ui/screen/loggedIn/discover/DiscoverScreen.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index 833e843078..a5a4106e7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -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( From f8040e5962d182865bc0e80debbe0d1d0a89dc5a Mon Sep 17 00:00:00 2001 From: "Dr. Tobias Baur" <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:49:55 +0200 Subject: [PATCH 18/19] =?UTF-8?q?fix:=20record=20DVM=20heartbeats=20in=20a?= =?UTF-8?q?=20strong=20registry=20=E2=80=94=20beat=20notes=20are=20WeakRef?= =?UTF-8?q?erence-held?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beat notes live in LocalCache.addressables (WeakReference values) with no strong holder on the Discover screen: every GC sweep cleared them all at once, the freshness gate failed for every DVM simultaneously, and the list collapsed and rebuilt one beat at a time (worst on refresh — an allocation spike). The detail screen survived only because its own composable held the note. DvmHeartbeatRegistry (Address -> latest createdAt, strong, one entry per DVM) is now the freshness source: recorded on every beat consumption, read by the gate and the liveness composables. Stale entries are inert; the map is bounded by distinct DVM addresses. --- .../amethyst/model/DvmHeartbeat.kt | 11 +++- .../amethyst/model/DvmHeartbeatRegistry.kt | 58 +++++++++++++++++++ .../amethyst/model/LocalCache.kt | 8 ++- .../loggedIn/dvms/DvmHeartbeatObservation.kt | 10 +++- .../amethyst/model/DvmHeartbeatTest.kt | 12 ++++ 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt index 7a4fc9e8b4..aafb366746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt @@ -30,11 +30,18 @@ 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 420s old. */ +/** + * 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 = dvmHeartbeatOf(appDef)?.isFreshAt(now) == true +): 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt new file mode 100644 index 0000000000..5f7ec90245 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt @@ -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>() + + private fun flowFor(address: Address): MutableStateFlow = latestBeatCreatedAt.getOrPut(address) { MutableStateFlow(0L) } + + /** Observable latest-beat timestamp for this address; `0` means "no beat seen yet". */ + fun flowForPublic(address: Address): StateFlow = 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 } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 82e0d1d813..fba4ed1416 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3744,8 +3744,14 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { 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). + // 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt index db80c0a3b7..97f28efee2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt @@ -39,8 +39,10 @@ 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 @@ -81,6 +83,11 @@ fun rememberDvmHeartbeatFresh( 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) { @@ -90,7 +97,8 @@ fun rememberDvmHeartbeatFresh( } val beat = observed?.value - return rememberUpdatedState(beat != null && beat.isFreshAt(now)) + 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. */ diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt index 886fe55cda..87e693af34 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -121,6 +121,18 @@ class DvmHeartbeatTest { 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 From e19fce0ed24ca5b8126884652b8f8bf83035b8c4 Mon Sep 17 00:00:00 2001 From: "Dr. Tobias Baur" <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:40:06 +0200 Subject: [PATCH 19/19] fix: pin kind 11998 in the indexable-content golden and make it CRLF-proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The golden test's KINDS list was missing the new heartbeat kind, and the comparison read the golden raw — on Windows CI (autocrlf checkout) every internal CRLF counted as a diff, failing the suite with a full-table ComparisonFailure. Adds 11998 as a pinned row (a heartbeat is a machine signal: no indexable content, per the NIP-50 eligibility policy), normalizes CRLF in the comparison, and forces LF for *.golden in .gitattributes so checkouts can never break it. No reindex needed: nothing is indexed for this kind. --- .gitattributes | 4 ++++ .../quartz/nip50Search/IndexableContentGoldenTest.kt | 5 ++++- quartz/src/jvmTest/resources/indexable-content.golden | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 1fbeadf800..59568dc78d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt index 0fc7a7697f..e68af7c54b 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt @@ -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(), ) } @@ -211,6 +213,7 @@ class IndexableContentGoldenTest { 10100, 10154, 11871, + 11998, 12473, 15128, 15129, diff --git a/quartz/src/jvmTest/resources/indexable-content.golden b/quartz/src/jvmTest/resources/indexable-content.golden index 161a2acdf6..bc38d78371 100644 --- a/quartz/src/jvmTest/resources/indexable-content.golden +++ b/quartz/src/jvmTest/resources/indexable-content.golden @@ -52,6 +52,7 @@ 10100 10154 The Title\nThe Description 11871 The content body. +11998 12473 The Alt 15128 The Title\nThe Description 15129 The Title\nThe Description