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/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..dfe5996c2d --- /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` 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** + +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" +``` 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..b67a5cd4bc --- /dev/null +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -0,0 +1,168 @@ +# DVM heartbeat liveness — only show DVMs with a fresh kind-11998 heartbeat + +_Status: **implemented** (Android; desktop wiring deliberately out of scope — §8)._ + +## 0. The shape of the thing + +Amethyst shows Data Vending Machines (DVMs) in three places: the Discover "Content" tab +(kind 31990 NIP-89 announcements advertising kind 5300), DVM feeds pinned to the top-nav +(`FavoriteAlgoFeedsOrchestrator`), and the per-DVM content-discovery screen. Today all of +these treat every announced DVM as alive, forever — a DVM that went down months ago still +renders as a usable feed. + +DVM operators are now sending a **heartbeat event (kind 11998) every 300 seconds**. The +event is plain-text (`content = "Alive and kicking"`) with three tags: + +- `status` — free-text status line (e.g. "My heart keeps beating like a hammer") +- `d` — the DVM's **NIP-89 DTAG**, tying the heartbeat to the announcement's address +- `expiration` — `createdAt + 300` (NIP-40), so relays drop the beat once the next one lands + +Kind 11998 sits in the replaceable range (10000–19999), so relays keep only the latest beat +per author. There is no NIP for this yet — the shape above comes from the operator-side +builder and is treated as the wire contract. + +The feature: **a DVM counts as alive only if its latest heartbeat is at most 900 seconds +old** (one missed 300s beat plus slack). Dead DVMs disappear from the Discover list; pinned +feeds and the detail surface show an offline state instead. + +## 1. Decisions taken + +1. **Approach: cache-backed heartbeats.** The heartbeat is a real event class stored through + the standard replaceable path in `LocalCache` (newest per address, standard invalidation). + Rejected alternatives: a side-state registry (duplicates invalidation plumbing) and + regular-note storage (no address matching, pollutes the notes index). +2. **Scope: all three surfaces** — Discover list (hide), pinned feeds (offline state, chip + stays), DVM detail screen (offline banner, requesting still allowed). The manage screen + (`FavoriteAlgoFeedsListScreen`) also gets the badge. +3. **Pinned chips stay when offline** — the user pinned them deliberately; they gray out + with an offline badge rather than vanishing, and tapping still opens the feed. +4. **Threshold: 900 seconds** (raised from the original 420 after field testing: beats arrive every 300s, and a 420s window tolerated barely one delivery hiccup, dropping live DVMs in oscillations). Exactly 900s old counts as fresh. +5. **Strict from cold start.** No grace period: the Discover list starts empty and fills + within ~1–2s as heartbeat REQs return (same behavior as the existing 31990 load). + +## 2. Event model (quartz) + +New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`: + +- `class DvmHeartbeatEvent(...) : BaseAddressableEvent(...)`, `KIND = 11998` — the codebase + convention for 10xxx events with real `d` tags (e.g. `FollowListEvent`), so `dTag()` / + `address()` / `addressTag()` come from the base. The cache address is + `Address(11998, dvmPubkey, dTag)`, the exact mirror of the announcement's + `Address(31990, dvmPubkey, dTag)`. +- Accessors: `status()`, and `expiration()` via the existing NIP-40 extension. +- `MAX_AGE_SECONDS = 900` and `isFreshAt(now)` live in quartz too (commons imports them). +- Registered in `EventFactory` (kind → constructor) and allowlisted in + `EventFactoryKindRangeTest.knownDTagReaders`: the `d` tag keys the client-side address + while relay storage stays plain-replaceable per the kind range. + +## 3. Cache consumption (LocalCache) + +One routing line in `LocalCache.justConsumeInnerInner`: `is DvmHeartbeatEvent ->` +`consumeBaseReplaceable(event, relay, wasVerified)`. This yields newest-per-address +replacement, relay tracking, and `LocalCacheFlow` invalidation for free. Unlisted kinds fall +into the `else` branch and are rejected, so the routing line is mandatory. + +Stale beats simply sit at their address until overwritten; the age check (§4) makes them +invisible. The cache pruner removes old entries on its own schedule. + +## 4. Freshness core (amethyst) + +Small helper file in `amethyst/.../model/` (the threshold constant itself lives in quartz): + +- `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address + lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())` +- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 900` +- `@Composable fun rememberDvmHeartbeatFresh(address: Address, accountViewModel: AccountViewModel): State` — + as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 900s + old; an unresolved/absent beat counts as offline (`false`) on every surface. The returned + `State` identity is stable for the lifetime of the call site (one unconditional + `rememberUpdatedState`), so callers may capture it across recompositions. Composable-scoped + subscription (§5) + staleness re-check tick (§6), shared by every surface that renders + liveness. + +## 5. Subscriptions + +**Discover screen — all DVM heartbeats.** In +`commons/.../relayClient/discover/nip90DVMs/SubAssemblyHelper.kt`, `makeContentDVMsFilter` +unconditionally appends one filter for every top-filter variant: +`kinds = [11998], since = TimeUtils.now() - 900` — no authors, no tags, scoped to the same +relay set as the 31990 REQs. It deliberately ignores the 31990 `since`-cursor (heartbeats +are a rolling window, not a cursor stream — the cursor would miss re-opened tabs after the +beats expired). It rides the existing assembler lifecycle: subscribes on entering Discover, +closes on leaving. + +**Per-surface — pinned chips, home banner, detail screen.** `rememberDvmHeartbeat` opens a +tiny composable-scoped subscription: `kinds = [11998], authors = [dvm pubkey], limit = 1, +since = now - 900`. The home top-bar chips live for the whole session, so they double as +the session-scoped watcher for pinned DVMs. Traffic is negligible (a few pinned DVMs × +1 event / 5 min). + +**Outbox fetcher (added after field testing).** The global REQ above only sees beats that +reach the *user's* discovery relays — but DVMs publish beats to their own write relays, and +relays don't gossip, so alive DVMs whose beats never overlap the user's relay set stayed +invisible (their detail screens proved the beats existed on the outbox). `DiscoveryDvmHeartbeatSubAssembler` +joins the discovery assembler group and, while Discover is composed, batches the cached +content-discovery announcements' authors per **DVM outbox relay** (`kinds = [11998], +authors = [those pubkeys], since = now - 900`, coverage-ranked and capped at 12 relays; +authors with unknown outboxes/hints rely on the global REQ as fallback). It re-issues when +the cached announcement set or the NIP-65 relay lists move. + +The announcement source MUST be the **ungated cache scan** +(`LocalCache.cachedDvmAnnouncements` — every cached k=5300 announcement, newest first, capped +at 100), not the gated feed list. Sourcing from the gated list is a death spiral: a DVM +leaves the gated list the moment its beat ages out, the fetcher would stop covering it, and +no beat would ever arrive to bring it back — any transient staleness becomes a permanent +drop. The relay lookup unions the author's NIP-65 outbox with the cached relay hints for the +author (the same mix the event finder's `potentialRelaysToFindAddress` uses). + +## 6. Invalidation — closing the two silent gaps + +1. **A new heartbeat does not re-rank the list.** The additive feed path + (`FeedContentState.updateFeedWith`) re-filters only the *new* notes, and a heartbeat + note is never a list row — the affected 31990 card would not be re-evaluated. Fix: in + `AccountFeedContentStates.updateFeedsWith`, branch on + `newNotes.any { it.event is DvmHeartbeatEvent }` → `discoverDVMs.invalidateData()` + (full rebuild re-runs the freshness check on every announcement); otherwise the normal + additive path. +2. **Expiry produces no event.** A 60s timer collector in `AccountFeedContentStates` + (alongside the existing `scope.launch { flows.collect { … } }` observers) calls + `discoverDVMs.invalidateData()` every minute. The rebuild is a cheap scan (≤ a few + hundred 31990s) and `refreshSuspended()` no-ops when the list is unchanged. Composables + using `rememberDvmHeartbeatFresh` tick on a 30s cadence internally. + +## 7. UI surfaces + +1. **Discover "Content" tab** — `DiscoverNIP89FeedFilter.acceptApp` adds + `dvmHeartbeatOf(noteEvent)?.isFreshAt(now) == true`. No fresh beat → card hidden. +2. **Pinned top-nav chips** — chip stays; when the heartbeat is stale or absent the chip is + grayed out with a small offline dot appended to its label. Tapping still opens the feed. +3. **Pinned feed view** — new branch in `HomeAlgoFeedStatusBanner`: when the selected + pinned feed's heartbeat is stale, show an offline banner above the last known content, + shown *even when content exists* (the current banner only handles empty/error states). + Single-feed and all-feeds variants both covered. +4. **DVM detail screen** (`DvmContentDiscoveryScreen`) — same offline banner; requesting is + still allowed (informational, not a block). +5. **`FavoriteAlgoFeedsListScreen`** — offline badge per row so users can spot dead pins. +6. New English string resources (`dvm_offline`, `dvm_offline_banner`); translations flow + via Crowdin. + +## 8. Edge cases (accepted limitations) + +- Heartbeat without a `d` tag → cache address dTag `""` → matches nothing → DVM hidden + (strict; the wire contract always sends `d`). +- Device/DVM clock skew > 15 min → wrongly hidden (inherent to timestamp-based liveness). +- DVM beats that never reach the relays we query → shows offline (that is the feature). +- One keypair running multiple DVMs → relays keep only the latest beat per (kind, author); + per-d-tag cache slots help only across relays. Most DVMs use one key each. +- **Desktop app: out of scope this round.** The commons subscription helper is shared-ready + and the desktop relay assembler will pick up heartbeat REQs harmlessly (cache fills, + nothing renders), but all UI wiring is Android-only. + +## 9. Testing + +- **quartz**: parse/build `DvmHeartbeatEvent` — `dTag()` override, `statusTag()`, + `expiration()`, address assembly. +- **amethyst**: `DiscoverNIP89FeedFilter.acceptApp` matrix — no beat → reject; fresh beat → + accept; 421s-old beat → reject. The `updateFeedsWith` heartbeat branch triggers a full + rebuild. `isFreshAt` boundary (900s fresh, 901s stale). +- Verify with `./gradlew :quartz:test :amethyst:test`, then `./gradlew spotlessApply`. 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..aafb366746 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** The cache slot a DVM's heartbeat lives in: the announcement's own address, kind 11998. */ +fun LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent? = getAddressableNoteIfExists(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))?.event as? DvmHeartbeatEvent + +/** + * A DVM counts as alive only if its latest heartbeat is at most 900s old. The registry (not the + * WeakReference-held beat note) is the freshness source: beat notes have no strong holder on the + * Discover screen, and a GC sweep cleared them all at once, collapsing the list. + */ +fun LocalCache.hasFreshDvmHeartbeat( + appDef: AppDefinitionEvent, + now: Long = TimeUtils.now(), +): Boolean = + DvmHeartbeatRegistry + .latestAt(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())) + ?.let { it >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS } == true + +/** + * Every cached content-discovery announcement, WITHOUT the freshness gate — this is the source the + * heartbeat outbox fetcher must use. Sourcing from the gated feed list would drop a DVM the moment + * its beat went stale, remove it from the fetch batch, and make the drop permanent (the fetcher + * could only ever help DVMs that were already visible). Applies the gate's other eligibility + * checks (a real content-discovery DVM, not a paid subscription app), newest first, capped. + */ +fun LocalCache.cachedDvmAnnouncements(limit: Int = 100): List = + 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/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 a1e17c07bf..fba4ed1416 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,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is CommunityDefinitionEvent, is CommunityListEvent, is ContactListEvent, + // DVM heartbeat (11998): stored per Address(11998, author, d) so liveness checks find the + // beat at the announcement's mirror address (amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md), + // AND recorded into the strong registry — beat notes are WeakReference-held with no strong + // holder on the Discover screen, so the gate must not depend on them surviving GC. + is DvmHeartbeatEvent, + -> + consumeBaseReplaceable(event, relay, wasVerified).also { + DvmHeartbeatRegistry.record(event.address(), event.createdAt) + } is EmojiPackEvent, is EmojiPackSelectionEvent, is EphemeralChatListEvent, 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..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) + 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/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..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 @@ -95,6 +97,7 @@ import com.vitorpamplona.amethyst.ui.screen.PeopleListName import com.vitorpamplona.amethyst.ui.screen.RelayName import com.vitorpamplona.amethyst.ui.screen.ResourceName import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.rememberDvmHeartbeatFresh import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Font14SP @@ -177,11 +180,29 @@ fun FeedFilterSpinner( ) } } else { - Text( - text = currentText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + val favoriteAlgoFeedAddress = (selected?.name as? FavoriteAlgoFeedName)?.note?.address + if (favoriteAlgoFeedAddress != null) { + val heartbeatFresh by rememberDvmHeartbeatFresh(favoriteAlgoFeedAddress, accountViewModel) + val offlineLabel = stringRes(Res.string.dvm_offline) + Text( + text = if (heartbeatFresh) currentText else "$currentText \u2022", + color = if (heartbeatFresh) Color.Unspecified else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + if (heartbeatFresh) { + Modifier + } else { + Modifier.semantics { contentDescription = "$currentText, $offlineLabel" } + }, + ) + } else { + Text( + text = currentText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } if (filter is TopFilter.AroundMe) { @@ -371,7 +392,30 @@ fun RenderOption( val backed = option as NoteBackedName val noteState by observeNote(backed.note, accountViewModel) val name = remember(noteState) { option.name(context) } - Text(text = name, fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) + val appDefAddress = (option as? FavoriteAlgoFeedName)?.note?.address + val heartbeatFresh = + if (appDefAddress != null) { + rememberDvmHeartbeatFresh(appDefAddress, accountViewModel).value + } else { + true + } + val offlineLabel = stringRes(Res.string.dvm_offline) + Text( + text = if (heartbeatFresh) name else "$name \u2022", + fontSize = Font14SP, + color = + if (heartbeatFresh) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = + if (heartbeatFresh) { + Modifier + } else { + Modifier.semantics { contentDescription = "$name, $offlineLabel" } + }, + ) } // Pure names: no relay subscription needed. 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/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( 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 = 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..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 @@ -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,18 @@ fun DvmContentDiscoveryScreen( } } - RefresheableBox( - onRefresh = onRefresh, - ) { - val myRequestEventID = requestEventID - if (myRequestEventID != null) { - ObserverContentDiscoveryResponse( - appDefinition, - myRequestEventID, - onRefresh, - accountViewModel, - nav, - ) - } else { - // TODO: Make a good splash screen with loading animation for this DVM. - // FeedDVM(appDefinition, null, accountViewModel, nav) - FeedEmptyWithStatus(appDefinition, stringRes(Res.string.dvm_requesting_job), accountViewModel, nav) + RefresheableBox(onRefresh = onRefresh) { + Box(modifier = Modifier.fillMaxSize()) { + val myRequestEventID = requestEventID + if (myRequestEventID != null) { + ObserverContentDiscoveryResponse(appDefinition, myRequestEventID, onRefresh, accountViewModel, nav) + } else { + // TODO: Make a good splash screen with loading animation for this DVM. + FeedEmptyWithStatus(appDefinition, stringRes(Res.string.dvm_requesting_job), accountViewModel, nav) + } + if (heartbeatFresh?.value == false) { + DvmOfflineBanner(modifier = Modifier.align(Alignment.TopCenter)) + } } } } 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..97f28efee2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.dvm_offline_banner +import com.vitorpamplona.amethyst.model.DvmHeartbeatRegistry +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +private const val HEARTBEAT_RECHECK_MILLIS = 30_000L + +/** + * True while the DVM announced at [appDefinitionAddress] has a heartbeat (kind 11998) at most + * 420s old. Resolves the beat's cache note by its mirror address, opens a composable-scoped + * relay subscription (the event-finder assembler fetches the beat by kind/author/d while it is + * missing), and re-checks staleness on a timer — an expired beat produces no cache event. + */ +@Composable +fun rememberDvmHeartbeatFresh( + appDefinitionAddress: Address, + accountViewModel: AccountViewModel, +): State { + val heartbeatAddressTag = + remember(appDefinitionAddress) { + Address.assemble(DvmHeartbeatEvent.KIND, appDefinitionAddress.pubKeyHex, appDefinitionAddress.dTag) + } + + var heartbeatNote by + remember(heartbeatAddressTag) { + mutableStateOf(accountViewModel.getNoteIfExists(heartbeatAddressTag)) + } + LaunchedEffect(heartbeatAddressTag) { + if (heartbeatNote == null) { + heartbeatNote = accountViewModel.checkGetOrCreateNote(heartbeatAddressTag) + } + } + + val observed = + heartbeatNote?.let { observeNoteAndMap(it, accountViewModel) { it.event as? DvmHeartbeatEvent } } + + // The registry is the eviction-proof freshness source (beat notes are WeakReference-held); + // the observed note is the secondary source and also drives the outbox fetch. + val registryBeat by + DvmHeartbeatRegistry.flowForPublic(appDefinitionAddress).collectAsStateWithLifecycle() + + var now by remember(heartbeatNote) { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(heartbeatNote) { + while (isActive) { + delay(HEARTBEAT_RECHECK_MILLIS) + now = TimeUtils.now() + } + } + + val beat = observed?.value + val bestBeat = maxOf(beat?.createdAt ?: 0L, registryBeat) + return rememberUpdatedState(bestBeat > 0L && bestBeat >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS) +} + +/** Floating "DVM is offline" banner, mirroring the Home status banner's card style. */ +@Composable +fun DvmOfflineBanner(modifier: Modifier = Modifier) { + Surface( + modifier = modifier.padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Text( + text = stringRes(Res.string.dvm_offline_banner), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} 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..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 @@ -84,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.rememberDvmHeartbeatFresh import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -282,12 +285,21 @@ private fun FavoriteAlgoFeedRow( Column( modifier = Modifier.weight(1f), ) { + val heartbeatFresh by rememberDvmHeartbeatFresh(feedNote.address, accountViewModel) + val displayName = card.name.ifBlank { feedNote.dTag() } + val offlineLabel = stringRes(Res.string.dvm_offline) Text( - text = card.name.ifBlank { feedNote.dTag() }, + text = if (heartbeatFresh) displayName else "$displayName \u2022", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge, + modifier = + if (heartbeatFresh) { + Modifier + } else { + Modifier.semantics { contentDescription = "$displayName, $offlineLabel" } + }, ) card.description?.takeIf { it.isNotBlank() }?.let { Spacer(modifier = StdVertSpacer) 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..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 @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_payment_requ import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_processing import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_requesting import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_requesting_all +import com.vitorpamplona.amethyst.commons.resources.dvm_offline_banner import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap @@ -55,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.rememberDvmHeartbeatFresh import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -87,6 +89,20 @@ private fun SingleAlgoFeedBanner( .observe(favFeed.address) .collectAsStateWithLifecycle() + val heartbeatFresh by rememberDvmHeartbeatFresh(favFeed.address, accountViewModel) + + // The DVM is down (or its beats never reach us): the offline banner supersedes the + // requesting/error/payment banner, and shows even when last-known content is on screen. + if (!heartbeatFresh) { + BannerCard(modifier) { + BannerMessageRow( + message = stringRes(Res.string.dvm_offline_banner), + showSpinner = false, + ) + } + return + } + // Hide the banner when the feed is already populated. if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return @@ -184,6 +200,23 @@ private fun AllFavoriteAlgoFeedsBanner( if (addresses.isEmpty()) return + // Map every address to its freshness first (non-short-circuiting) so per-address + // composable call sites stay stable instead of appearing/disappearing with freshness. + val anyHeartbeatFresh = + addresses + .map { address -> + rememberDvmHeartbeatFresh(address, accountViewModel).value + }.any { it } + if (!anyHeartbeatFresh) { + BannerCard(modifier) { + BannerMessageRow( + message = stringRes(Res.string.dvm_offline_banner), + showSpinner = false, + ) + } + return + } + // Observe each DVM's snapshot so we can decide whether to hide the banner // based on the aggregate state. Hide it as soon as any DVM has produced a // feed; only error out when every one of them has errored. 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..87e693af34 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `LocalCache` is a process-wide object and JUnit 4 runs methods in hash order, so every test + * uses its own pubkeys/dTags/ids (same discipline as ReportNamingIndexIngestionTest). + */ +class DvmHeartbeatTest { + private val appDefPubKey = "f1".repeat(32) + + private fun appDef( + dTag: String, + pubKey: String = appDefPubKey, + ) = AppDefinitionEvent( + id = "f2".repeat(32), + pubKey = pubKey, + createdAt = 1_760_000_000L, + tags = arrayOf(arrayOf("d", dTag), arrayOf("k", "5300")), + content = """{"name":"Test DVM"}""", + sig = "cc".repeat(64), + ) + + private fun beat( + dTag: String, + pubKey: String = appDefPubKey, + createdAt: Long, + id: String, + ) = DvmHeartbeatEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("d", dTag), + arrayOf("status", "My heart keeps beating like a hammer"), + arrayOf("expiration", (createdAt + 300).toString()), + ), + content = "Alive and kicking", + sig = "dd".repeat(64), + ) + + @Test + fun aConsumedHeartbeatLandsAtTheAnnouncementMirrorAddress() { + val app = appDef("dvm-one") + LocalCache.justConsume(beat("dvm-one", createdAt = 1_760_000_100L, id = "f3".repeat(32)), null, true) + + val found = LocalCache.dvmHeartbeatOf(app) + assertTrue("heartbeat should be found via the announcement's address", found != null) + assertEquals(1_760_000_100L, found?.createdAt) + assertEquals(Address(11998, appDefPubKey, "dvm-one"), found?.address()) + } + + @Test + fun aFreshHeartbeatPassesTheGateAndAStaleOneDoesNot() { + val now = 1_760_000_000L + // Separate dTags: consumeBaseReplaceable only accepts NEWER beats per address, so a + // 901s-old beat could never supersede the 900s one within a single address slot. + val freshApp = appDef("dvm-two-fresh") + val staleApp = appDef("dvm-two-stale") + assertNull("no beat yet", LocalCache.dvmHeartbeatOf(freshApp)) + + LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 900, id = "f4".repeat(32)), null, true) + LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 901, id = "f5".repeat(32)), null, true) + + assertTrue("exactly 900s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now)) + assertFalse("901s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now)) + } + + @Test + fun theNewestBeatPerAddressWins() { + val now = 1_760_000_000L + val app = appDef("dvm-three") + LocalCache.justConsume(beat("dvm-three", createdAt = now - 600, id = "f6".repeat(32)), null, true) + LocalCache.justConsume(beat("dvm-three", createdAt = now - 60, id = "f7".repeat(32)), null, true) + + assertEquals(now - 60, LocalCache.dvmHeartbeatOf(app)?.createdAt) + } + + @Test + fun beatsAreKeyedByDTagSoDifferentDvmsDoNotCollide() { + val now = 1_760_000_000L + val appA = appDef("dvm-a") + val appB = appDef("dvm-b") + LocalCache.justConsume(beat("dvm-a", createdAt = now - 60, id = "f8".repeat(32)), null, true) + + assertTrue(LocalCache.hasFreshDvmHeartbeat(appA, now)) + assertFalse("no beat for dvm-b", LocalCache.hasFreshDvmHeartbeat(appB, now)) + } + + @Test + fun noHeartbeatMeansNoLiveness() { + assertFalse(LocalCache.hasFreshDvmHeartbeat(appDef("dvm-never"), TimeUtils.now())) + } + + @Test + fun theGateSurvivesWeakCacheEvictionOfTheBeatNote() { + // Beat notes live in LocalCache's WeakReference store with no strong holder on the + // Discover screen — a GC sweep clears them all at once and the list collapses. The + // freshness gate must therefore read the strong registry, not the evictable note. + val app = appDef("dvm-registry") + DvmHeartbeatRegistry.record(Address(DvmHeartbeatEvent.KIND, appDefPubKey, "dvm-registry"), 1_760_000_000L - 100) + + assertTrue("registry alone proves liveness", LocalCache.hasFreshDvmHeartbeat(app, 1_760_000_000L)) + assertTrue("no entry for dvm-never", DvmHeartbeatRegistry.latestAt(Address(DvmHeartbeatEvent.KIND, appDefPubKey, "dvm-never")) == null) + } + + @Test + fun theUngatedAnnouncementScanKeepsDvmsTheGateWouldHide() { + // The outbox fetcher must source announcements from the cache, NOT from the gated feed + // list: a DVM dropped for a stale beat must keep receiving outbox beats or it can never + // come back. Subscription apps and non-content-discovery apps stay excluded. + val alive = appDef("scan-dvm") + val subscriptionApp = + AppDefinitionEvent( + id = "c0".repeat(32), + pubKey = "ab".repeat(32), + createdAt = 1_760_000_500L, + tags = arrayOf(arrayOf("d", "subs"), arrayOf("k", "5300")), + content = """{"name":"Paid","subscription":true}""", + sig = "cc".repeat(64), + ) + val nonDiscoveryApp = + AppDefinitionEvent( + id = "c1".repeat(32), + pubKey = "cb".repeat(32), + createdAt = 1_760_000_100L, + tags = arrayOf(arrayOf("d", "other"), arrayOf("k", "9999")), + content = """{"name":"Other"}""", + sig = "cc".repeat(64), + ) + + LocalCache.justConsume(appDef("dvm-x"), null, true) + LocalCache.justConsume(nonDiscoveryApp, null, true) + val consumed = + AppDefinitionEvent( + id = "c2".repeat(32), + pubKey = appDefPubKey, + createdAt = 1_760_000_000L, + tags = arrayOf(arrayOf("d", "subs2"), arrayOf("k", "5300")), + content = """{"name":"Paid2","subscription":true}""", + sig = "cc".repeat(64), + ) + LocalCache.justConsume(consumed, null, true) + + val scanned = LocalCache.cachedDvmAnnouncements() + + assertTrue("dvm-x is not yet visible (no beat) but must still be sourced", scanned.any { it.dTag() == "dvm-x" }) + assertFalse("subscription apps are not content-discovery DVMs", scanned.any { it.dTag() == "subs" }) + assertFalse("k=9999 apps are not content-discovery DVMs", scanned.any { it.dTag() == "other" }) + assertEquals("newest-first so the cap keeps the most relevant announcements", scanned.sortedByDescending { it.createdAt }, scanned) + assertTrue("capped", scanned.size <= 100) + } +} 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/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/DiscoveryFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/DiscoveryFilterAssembler.kt index 959d71ce03..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 @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.relayClient.discover import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter +import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.DiscoveryDvmHeartbeatSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.DvmHeartbeatSources import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState @@ -56,10 +58,12 @@ class DiscoveryQueryState( class DiscoveryFilterAssembler( client: INostrClient, + dvmHeartbeat: DvmHeartbeatSources, ) : TopNavFeedFilterAssembler({ keys -> listOf( DiscoveryLongFormClassifiedsAndDVMSubAssembler1(client, keys), DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(client, keys), DiscoveryPublicChatsAndCommunitiesSubAssembler3(client, 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 new file mode 100644 index 0000000000..3141c9ca1b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxSubAssembler.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs + +import com.vitorpamplona.amethyst.commons.relayClient.discover.DiscoveryQueryState +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedSubAssembler +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** How many DVM outbox relays the fetcher may open at once; coverage-ranked, so the top relays carry most authors. */ +private const val MAX_OUTBOX_RELAYS = 12 + +/** + * Batches the announcement authors per DVM outbox relay so the freshness gate sees the beats the + * DVMs actually publish. DVMs send heartbeats to their own write relays, which need not overlap + * the user's discovery relays — the global heartbeat REQ (issued on the selection's relays) alone + * leaves alive DVMs invisible. Their beats exist on the outbox; this fetcher brings them into the + * same cache slots the gate reads. + * + * Authors with no known outbox are skipped, not blocked: the global REQ is their fallback. + * Relays are coverage-ranked and capped at [maxRelays] so browsing Discover cannot open dozens + * of sockets; deterministic tie-breaking keeps the chosen set stable across re-issues. + */ +fun dvmHeartbeatOutboxFilters( + announcements: List, + 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, + ), + ) + } +} + +/** + * Cache-backed inputs the outbox fetcher needs, provided by the front end (the cache query and + * relay-hint surface are platform caches, not commons). + * + * [announcements] MUST be the ungated announcement set (every cached content-discovery DVM). The + * gated feed list would turn any transient staleness into a permanent drop: a DVM leaves the + * gated list the moment its beat ages out, the fetcher would stop covering it, and no beat would + * ever arrive to bring it back. + * + * [outboxRelaysFor] resolves where a DVM publishes its beats — NIP-65 outbox relays plus any + * relay hints for the author (the same mix the event finder uses). + * + * [changes] drive re-issues: when the cached announcement set or the outbox data moves, the + * batches are recomputed. + */ +class DvmHeartbeatSources( + val announcements: () -> List, + 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, + allKeys: () -> Set, + private val sources: DvmHeartbeatSources, +) : TopNavFeedSubAssembler(client, allKeys) { + override fun updateFilter( + key: DiscoveryQueryState, + since: SincePerRelayMap?, + ): List = + dvmHeartbeatOutboxFilters( + sources.announcements(), + outboxRelaysFor = sources.outboxRelaysFor, + now = TimeUtils.now(), + ) + + override fun floors(key: DiscoveryQueryState): List> = emptyList() + + override fun extraInvalidators(key: DiscoveryQueryState): List> = sources.changes +} 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..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 @@ -36,9 +36,13 @@ import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterC import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterContentDVMsByGeohash import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterContentDVMsByHashtag import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterContentDVMsGlobal +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent +import com.vitorpamplona.quartz.utils.TimeUtils fun makeContentDVMsFilter( feedSettings: IFeedTopNavPerRelayFilterSet, @@ -55,4 +59,34 @@ fun makeContentDVMsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince) else -> emptyList() + }.let { contentDvmFilters -> + plusHeartbeatFilter(feedSettings, contentDvmFilters) }.scopedTo(feedSettings) + +/** + * The 31990 announcements say what a DVM advertises; kind-11998 heartbeats say whether it is + * still alive (amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md). Ask on the selection's own + * relays — NOT the 31990 filters' relays, which skip relays whose per-relay slice is empty and + * whole selections (the Relay variant) that dispatch no 31990 filters at all; there the cached + * DVM list would lose its beat stream and the staleness timer would drop every DVM within ~7 + * minutes. Rolling window instead of the announcement cursor: beats expire (NIP-40) every 5 + * minutes, so a stored `since` would miss beats on re-opened tabs. + */ +private fun plusHeartbeatFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + contentDvmFilters: List, +): List { + val relays = feedSettings.relays() + if (relays.isEmpty()) return contentDvmFilters + val heartbeatFilter = + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, + kinds = listOf(DvmHeartbeatEvent.KIND), + limit = 100, + since = TimeUtils.now() - DvmHeartbeatEvent.MAX_AGE_SECONDS, + ) + return contentDvmFilters + + relays.map { relay -> + RelayBasedFilter(relay = relay, filter = heartbeatFilter) + } +} 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..b31176262e --- /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_759_999_520L, it.filter.since, "rolling window: now - MAX_AGE_SECONDS") + } + } + + @Test + fun anAuthorWithNoKnownOutboxIsSkippedNotBlocked() { + val filters = + dvmHeartbeatOutboxFilters( + announcements = listOf(announcement(authorC)), + outboxRelaysFor = outboxes, + now = 1_760_000_420L, + ) + + assertTrue(filters.isEmpty(), "no outbox known — the global REQ is that DVM's fallback") + } + + @Test + fun relayCapKeepsTheMostCoveringRelays() { + val filters = + dvmHeartbeatOutboxFilters( + announcements = listOf(announcement(authorA), announcement(authorB)), + outboxRelaysFor = outboxes, + now = 1_760_000_420L, + maxRelays = 2, + ) + + assertEquals(setOf(r1.url, r2.url), filters.map { it.relay.url }.toSet(), "r1 covers 2, r2 and r3 tie at 1 — tie broken deterministically") + } + + @Test + fun noAnnouncementsMeansNoRequests() { + assertTrue( + dvmHeartbeatOutboxFilters(emptyList(), outboxRelaysFor = outboxes, now = 1_760_000_420L).isEmpty(), + ) + } +} 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 }) + } +} 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..cad008ee96 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * DVM heartbeat (kind 11998, experimental — no NIP yet): a beat a DVM publishes every 300s to + * prove it is alive. The operator contract is plain-text content with `d` (the DVM's NIP-89 + * DTAG), `status` (free text) and `expiration` (createdAt + 300, NIP-40) tags. + * + * The kind sits in the replaceable range (10000–19999), so relays keep only the latest beat + * per author. The `d` tag participates in the client-side address so each announced DVM has + * its own cache slot: `Address(11998, dvmPubKey, dTag)` mirrors the announcement's + * `Address(31990, dvmPubKey, dTag)`. + */ +@Stable +@Immutable +class DvmHeartbeatEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun status(): String? = tags.firstOrNull { it.size > 1 && it[0] == STATUS_TAG }?.get(1) + + fun expiration(): Long? = tags.expiration() + + /** True while this beat still proves liveness at [now]. */ + fun isFreshAt(now: Long = TimeUtils.now()): Boolean = createdAt >= now - MAX_AGE_SECONDS + + companion object { + const val KIND = 11998 + const val STATUS_TAG = "status" + const val CONTENT = "Alive and kicking" + + /** + * A beat older than this no longer proves liveness. Beats arrive every 300s, so this + * window deliberately tolerates several missed deliveries (relay reconnects, REQ churn) + * before a DVM is dropped — hysteresis against transient delivery gaps, at the cost of a + * dead DVM lingering this long before disappearing. + */ + const val MAX_AGE_SECONDS = 900 + + fun build( + dTag: String, + status: String, + expiration: Long, + createdAt: Long = TimeUtils.now(), + ): EventTemplate = + 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..23c558c868 --- /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 + 900)) + assertFalse(event.isFreshAt(beatTime + 901)) + } + + @Test + fun buildWritesAllTags() { + val template = + DvmHeartbeatEvent.build( + dTag = dTag, + status = "My heart keeps beating like a hammer", + expiration = beatTime + 300, + createdAt = beatTime, + ) + assertEquals(11998, template.kind) + assertEquals("Alive and kicking", template.content) + assertEquals( + listOf( + listOf("d", dTag), + listOf("status", "My heart keeps beating like a hammer"), + listOf("expiration", (beatTime + 300).toString()), + ), + template.tags.map { it.toList() }, + ) + } + + @Test + fun factoryBuildsDvmHeartbeatForKind11998() { + val event: Event = + EventFactory.create( + id = "00".repeat(32), + pubKey = pubKey, + createdAt = beatTime, + kind = DvmHeartbeatEvent.KIND, + tags = arrayOf(arrayOf("d", dTag)), + content = "", + sig = "22".repeat(64), + ) + assertIs(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" 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