mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4069 from believethehype/main
Introducing DVM heartbeats
This commit is contained in:
@@ -2,3 +2,7 @@
|
||||
# binary so git never applies CRLF/text normalization or textual diff/merge, which
|
||||
# would corrupt the compressed stream (important on Windows checkouts).
|
||||
*.gz binary
|
||||
|
||||
# Golden test resources are compared byte-for-byte against generated strings:
|
||||
# force LF everywhere so a Windows CRLF checkout cannot break the comparison.
|
||||
*.golden text eol=lf
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
# DVM heartbeat liveness — only show DVMs with a fresh kind-11998 heartbeat
|
||||
|
||||
_Status: **implemented** (Android; desktop wiring deliberately out of scope — §8)._
|
||||
|
||||
## 0. The shape of the thing
|
||||
|
||||
Amethyst shows Data Vending Machines (DVMs) in three places: the Discover "Content" tab
|
||||
(kind 31990 NIP-89 announcements advertising kind 5300), DVM feeds pinned to the top-nav
|
||||
(`FavoriteAlgoFeedsOrchestrator`), and the per-DVM content-discovery screen. Today all of
|
||||
these treat every announced DVM as alive, forever — a DVM that went down months ago still
|
||||
renders as a usable feed.
|
||||
|
||||
DVM operators are now sending a **heartbeat event (kind 11998) every 300 seconds**. The
|
||||
event is plain-text (`content = "Alive and kicking"`) with three tags:
|
||||
|
||||
- `status` — free-text status line (e.g. "My heart keeps beating like a hammer")
|
||||
- `d` — the DVM's **NIP-89 DTAG**, tying the heartbeat to the announcement's address
|
||||
- `expiration` — `createdAt + 300` (NIP-40), so relays drop the beat once the next one lands
|
||||
|
||||
Kind 11998 sits in the replaceable range (10000–19999), so relays keep only the latest beat
|
||||
per author. There is no NIP for this yet — the shape above comes from the operator-side
|
||||
builder and is treated as the wire contract.
|
||||
|
||||
The feature: **a DVM counts as alive only if its latest heartbeat is at most 900 seconds
|
||||
old** (one missed 300s beat plus slack). Dead DVMs disappear from the Discover list; pinned
|
||||
feeds and the detail surface show an offline state instead.
|
||||
|
||||
## 1. Decisions taken
|
||||
|
||||
1. **Approach: cache-backed heartbeats.** The heartbeat is a real event class stored through
|
||||
the standard replaceable path in `LocalCache` (newest per address, standard invalidation).
|
||||
Rejected alternatives: a side-state registry (duplicates invalidation plumbing) and
|
||||
regular-note storage (no address matching, pollutes the notes index).
|
||||
2. **Scope: all three surfaces** — Discover list (hide), pinned feeds (offline state, chip
|
||||
stays), DVM detail screen (offline banner, requesting still allowed). The manage screen
|
||||
(`FavoriteAlgoFeedsListScreen`) also gets the badge.
|
||||
3. **Pinned chips stay when offline** — the user pinned them deliberately; they gray out
|
||||
with an offline badge rather than vanishing, and tapping still opens the feed.
|
||||
4. **Threshold: 900 seconds** (raised from the original 420 after field testing: beats arrive every 300s, and a 420s window tolerated barely one delivery hiccup, dropping live DVMs in oscillations). Exactly 900s old counts as fresh.
|
||||
5. **Strict from cold start.** No grace period: the Discover list starts empty and fills
|
||||
within ~1–2s as heartbeat REQs return (same behavior as the existing 31990 load).
|
||||
|
||||
## 2. Event model (quartz)
|
||||
|
||||
New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`:
|
||||
|
||||
- `class DvmHeartbeatEvent(...) : BaseAddressableEvent(...)`, `KIND = 11998` — the codebase
|
||||
convention for 10xxx events with real `d` tags (e.g. `FollowListEvent`), so `dTag()` /
|
||||
`address()` / `addressTag()` come from the base. The cache address is
|
||||
`Address(11998, dvmPubkey, dTag)`, the exact mirror of the announcement's
|
||||
`Address(31990, dvmPubkey, dTag)`.
|
||||
- Accessors: `status()`, and `expiration()` via the existing NIP-40 extension.
|
||||
- `MAX_AGE_SECONDS = 900` and `isFreshAt(now)` live in quartz too (commons imports them).
|
||||
- Registered in `EventFactory` (kind → constructor) and allowlisted in
|
||||
`EventFactoryKindRangeTest.knownDTagReaders`: the `d` tag keys the client-side address
|
||||
while relay storage stays plain-replaceable per the kind range.
|
||||
|
||||
## 3. Cache consumption (LocalCache)
|
||||
|
||||
One routing line in `LocalCache.justConsumeInnerInner`: `is DvmHeartbeatEvent ->`
|
||||
`consumeBaseReplaceable(event, relay, wasVerified)`. This yields newest-per-address
|
||||
replacement, relay tracking, and `LocalCacheFlow` invalidation for free. Unlisted kinds fall
|
||||
into the `else` branch and are rejected, so the routing line is mandatory.
|
||||
|
||||
Stale beats simply sit at their address until overwritten; the age check (§4) makes them
|
||||
invisible. The cache pruner removes old entries on its own schedule.
|
||||
|
||||
## 4. Freshness core (amethyst)
|
||||
|
||||
Small helper file in `amethyst/.../model/` (the threshold constant itself lives in quartz):
|
||||
|
||||
- `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address
|
||||
lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())`
|
||||
- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 900`
|
||||
- `@Composable fun rememberDvmHeartbeatFresh(address: Address, accountViewModel: AccountViewModel): State<Boolean>` —
|
||||
as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 900s
|
||||
old; an unresolved/absent beat counts as offline (`false`) on every surface. The returned
|
||||
`State` identity is stable for the lifetime of the call site (one unconditional
|
||||
`rememberUpdatedState`), so callers may capture it across recompositions. Composable-scoped
|
||||
subscription (§5) + staleness re-check tick (§6), shared by every surface that renders
|
||||
liveness.
|
||||
|
||||
## 5. Subscriptions
|
||||
|
||||
**Discover screen — all DVM heartbeats.** In
|
||||
`commons/.../relayClient/discover/nip90DVMs/SubAssemblyHelper.kt`, `makeContentDVMsFilter`
|
||||
unconditionally appends one filter for every top-filter variant:
|
||||
`kinds = [11998], since = TimeUtils.now() - 900` — no authors, no tags, scoped to the same
|
||||
relay set as the 31990 REQs. It deliberately ignores the 31990 `since`-cursor (heartbeats
|
||||
are a rolling window, not a cursor stream — the cursor would miss re-opened tabs after the
|
||||
beats expired). It rides the existing assembler lifecycle: subscribes on entering Discover,
|
||||
closes on leaving.
|
||||
|
||||
**Per-surface — pinned chips, home banner, detail screen.** `rememberDvmHeartbeat` opens a
|
||||
tiny composable-scoped subscription: `kinds = [11998], authors = [dvm pubkey], limit = 1,
|
||||
since = now - 900`. The home top-bar chips live for the whole session, so they double as
|
||||
the session-scoped watcher for pinned DVMs. Traffic is negligible (a few pinned DVMs ×
|
||||
1 event / 5 min).
|
||||
|
||||
**Outbox fetcher (added after field testing).** The global REQ above only sees beats that
|
||||
reach the *user's* discovery relays — but DVMs publish beats to their own write relays, and
|
||||
relays don't gossip, so alive DVMs whose beats never overlap the user's relay set stayed
|
||||
invisible (their detail screens proved the beats existed on the outbox). `DiscoveryDvmHeartbeatSubAssembler`
|
||||
joins the discovery assembler group and, while Discover is composed, batches the cached
|
||||
content-discovery announcements' authors per **DVM outbox relay** (`kinds = [11998],
|
||||
authors = [those pubkeys], since = now - 900`, coverage-ranked and capped at 12 relays;
|
||||
authors with unknown outboxes/hints rely on the global REQ as fallback). It re-issues when
|
||||
the cached announcement set or the NIP-65 relay lists move.
|
||||
|
||||
The announcement source MUST be the **ungated cache scan**
|
||||
(`LocalCache.cachedDvmAnnouncements` — every cached k=5300 announcement, newest first, capped
|
||||
at 100), not the gated feed list. Sourcing from the gated list is a death spiral: a DVM
|
||||
leaves the gated list the moment its beat ages out, the fetcher would stop covering it, and
|
||||
no beat would ever arrive to bring it back — any transient staleness becomes a permanent
|
||||
drop. The relay lookup unions the author's NIP-65 outbox with the cached relay hints for the
|
||||
author (the same mix the event finder's `potentialRelaysToFindAddress` uses).
|
||||
|
||||
## 6. Invalidation — closing the two silent gaps
|
||||
|
||||
1. **A new heartbeat does not re-rank the list.** The additive feed path
|
||||
(`FeedContentState.updateFeedWith`) re-filters only the *new* notes, and a heartbeat
|
||||
note is never a list row — the affected 31990 card would not be re-evaluated. Fix: in
|
||||
`AccountFeedContentStates.updateFeedsWith`, branch on
|
||||
`newNotes.any { it.event is DvmHeartbeatEvent }` → `discoverDVMs.invalidateData()`
|
||||
(full rebuild re-runs the freshness check on every announcement); otherwise the normal
|
||||
additive path.
|
||||
2. **Expiry produces no event.** A 60s timer collector in `AccountFeedContentStates`
|
||||
(alongside the existing `scope.launch { flows.collect { … } }` observers) calls
|
||||
`discoverDVMs.invalidateData()` every minute. The rebuild is a cheap scan (≤ a few
|
||||
hundred 31990s) and `refreshSuspended()` no-ops when the list is unchanged. Composables
|
||||
using `rememberDvmHeartbeatFresh` tick on a 30s cadence internally.
|
||||
|
||||
## 7. UI surfaces
|
||||
|
||||
1. **Discover "Content" tab** — `DiscoverNIP89FeedFilter.acceptApp` adds
|
||||
`dvmHeartbeatOf(noteEvent)?.isFreshAt(now) == true`. No fresh beat → card hidden.
|
||||
2. **Pinned top-nav chips** — chip stays; when the heartbeat is stale or absent the chip is
|
||||
grayed out with a small offline dot appended to its label. Tapping still opens the feed.
|
||||
3. **Pinned feed view** — new branch in `HomeAlgoFeedStatusBanner`: when the selected
|
||||
pinned feed's heartbeat is stale, show an offline banner above the last known content,
|
||||
shown *even when content exists* (the current banner only handles empty/error states).
|
||||
Single-feed and all-feeds variants both covered.
|
||||
4. **DVM detail screen** (`DvmContentDiscoveryScreen`) — same offline banner; requesting is
|
||||
still allowed (informational, not a block).
|
||||
5. **`FavoriteAlgoFeedsListScreen`** — offline badge per row so users can spot dead pins.
|
||||
6. New English string resources (`dvm_offline`, `dvm_offline_banner`); translations flow
|
||||
via Crowdin.
|
||||
|
||||
## 8. Edge cases (accepted limitations)
|
||||
|
||||
- Heartbeat without a `d` tag → cache address dTag `""` → matches nothing → DVM hidden
|
||||
(strict; the wire contract always sends `d`).
|
||||
- Device/DVM clock skew > 15 min → wrongly hidden (inherent to timestamp-based liveness).
|
||||
- DVM beats that never reach the relays we query → shows offline (that is the feature).
|
||||
- One keypair running multiple DVMs → relays keep only the latest beat per (kind, author);
|
||||
per-d-tag cache slots help only across relays. Most DVMs use one key each.
|
||||
- **Desktop app: out of scope this round.** The commons subscription helper is shared-ready
|
||||
and the desktop relay assembler will pick up heartbeat REQs harmlessly (cache fills,
|
||||
nothing renders), but all UI wiring is Android-only.
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- **quartz**: parse/build `DvmHeartbeatEvent` — `dTag()` override, `statusTag()`,
|
||||
`expiration()`, address assembly.
|
||||
- **amethyst**: `DiscoverNIP89FeedFilter.acceptApp` matrix — no beat → reject; fresh beat →
|
||||
accept; 421s-old beat → reject. The `updateFeedsWith` heartbeat branch triggers a full
|
||||
rebuild. `isFreshAt` boundary (900s fresh, 901s stale).
|
||||
- Verify with `./gradlew :quartz:test :amethyst:test`, then `./gradlew spotlessApply`.
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/** The cache slot a DVM's heartbeat lives in: the announcement's own address, kind 11998. */
|
||||
fun LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent? = getAddressableNoteIfExists(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))?.event as? DvmHeartbeatEvent
|
||||
|
||||
/**
|
||||
* A DVM counts as alive only if its latest heartbeat is at most 900s old. The registry (not the
|
||||
* WeakReference-held beat note) is the freshness source: beat notes have no strong holder on the
|
||||
* Discover screen, and a GC sweep cleared them all at once, collapsing the list.
|
||||
*/
|
||||
fun LocalCache.hasFreshDvmHeartbeat(
|
||||
appDef: AppDefinitionEvent,
|
||||
now: Long = TimeUtils.now(),
|
||||
): Boolean =
|
||||
DvmHeartbeatRegistry
|
||||
.latestAt(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))
|
||||
?.let { it >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS } == true
|
||||
|
||||
/**
|
||||
* Every cached content-discovery announcement, WITHOUT the freshness gate — this is the source the
|
||||
* heartbeat outbox fetcher must use. Sourcing from the gated feed list would drop a DVM the moment
|
||||
* its beat went stale, remove it from the fetch batch, and make the drop permanent (the fetcher
|
||||
* could only ever help DVMs that were already visible). Applies the gate's other eligibility
|
||||
* checks (a real content-discovery DVM, not a paid subscription app), newest first, capped.
|
||||
*/
|
||||
fun LocalCache.cachedDvmAnnouncements(limit: Int = 100): List<AppDefinitionEvent> =
|
||||
addressables
|
||||
.filterIntoSet(AppDefinitionEvent.KIND) { _, note ->
|
||||
(note.event as? AppDefinitionEvent)?.let {
|
||||
it.appMetaData()?.subscription != true && it.includeKind(NIP90ContentDiscoveryRequestEvent.KIND)
|
||||
} == true
|
||||
}.mapNotNull { it.event as? AppDefinitionEvent }
|
||||
.sortedByDescending { it.createdAt }
|
||||
.take(limit)
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Strong, process-wide record of the latest heartbeat per DVM announcement address
|
||||
* (`Address(11998, dvmPubKey, dTag) -> createdAt`).
|
||||
*
|
||||
* Beat Notes themselves live in `LocalCache.addressables`, a WeakReference store with no strong
|
||||
* holder on the Discover screen — every GC sweep cleared them all at once and the freshness gate
|
||||
* collapsed for every DVM simultaneously (the list emptied and rebuilt one beat at a time). This
|
||||
* registry is the freshness source the gate and the liveness composables read: strong references,
|
||||
* fed by every beat-arrival path (global REQ, outbox batches, per-surface fetches — all beat
|
||||
* consumption routes through [record]).
|
||||
*
|
||||
* One entry per DVM address ever seen; timestamps only, so it stays tiny. `0` means "no beat".
|
||||
*/
|
||||
object DvmHeartbeatRegistry {
|
||||
private val latestBeatCreatedAt = ConcurrentHashMap<Address, MutableStateFlow<Long>>()
|
||||
|
||||
private fun flowFor(address: Address): MutableStateFlow<Long> = latestBeatCreatedAt.getOrPut(address) { MutableStateFlow(0L) }
|
||||
|
||||
/** Observable latest-beat timestamp for this address; `0` means "no beat seen yet". */
|
||||
fun flowForPublic(address: Address): StateFlow<Long> = flowFor(address)
|
||||
|
||||
/** Records a beat's createdAt; older beats never move the entry backwards. */
|
||||
fun record(
|
||||
address: Address,
|
||||
createdAt: Long,
|
||||
) = flowFor(address).update { current -> if (createdAt > current) createdAt else current }
|
||||
|
||||
/** The latest recorded beat's createdAt for this address, or null when no beat was ever seen. */
|
||||
fun latestAt(address: Address): Long? = flowFor(address).value.takeIf { it > 0L }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+24
-1
@@ -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.
|
||||
|
||||
+50
-6
@@ -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.
|
||||
|
||||
+19
-1
@@ -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)
|
||||
|
||||
+12
-1
@@ -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(
|
||||
|
||||
+3
-1
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
import com.vitorpamplona.amethyst.model.hasFreshDvmHeartbeat
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
@@ -93,7 +94,8 @@ open class DiscoverNIP89FeedFilter(
|
||||
return noteEvent.appMetaData()?.subscription != true &&
|
||||
filterParams.match(noteEvent, relays) &&
|
||||
noteEvent.includeKind(targetKind) &&
|
||||
noteEvent.createdAt > lastAnnounced
|
||||
noteEvent.createdAt > lastAnnounced &&
|
||||
LocalCache.hasFreshDvmHeartbeat(noteEvent)
|
||||
}
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> =
|
||||
|
||||
+15
-16
@@ -131,6 +131,9 @@ fun DvmContentDiscoveryScreen(
|
||||
) {
|
||||
val noteAuthor = appDefinition.author ?: return
|
||||
|
||||
val appDef = appDefinition.event as? AppDefinitionEvent
|
||||
val heartbeatFresh = if (appDef != null) rememberDvmHeartbeatFresh(appDef.address(), accountViewModel) else null
|
||||
|
||||
var requestEventID by
|
||||
remember(appDefinition) {
|
||||
mutableStateOf<Note?>(null)
|
||||
@@ -151,22 +154,18 @@ fun DvmContentDiscoveryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
RefresheableBox(
|
||||
onRefresh = onRefresh,
|
||||
) {
|
||||
val myRequestEventID = requestEventID
|
||||
if (myRequestEventID != null) {
|
||||
ObserverContentDiscoveryResponse(
|
||||
appDefinition,
|
||||
myRequestEventID,
|
||||
onRefresh,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else {
|
||||
// TODO: Make a good splash screen with loading animation for this DVM.
|
||||
// FeedDVM(appDefinition, null, accountViewModel, nav)
|
||||
FeedEmptyWithStatus(appDefinition, stringRes(Res.string.dvm_requesting_job), accountViewModel, nav)
|
||||
RefresheableBox(onRefresh = onRefresh) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val myRequestEventID = requestEventID
|
||||
if (myRequestEventID != null) {
|
||||
ObserverContentDiscoveryResponse(appDefinition, myRequestEventID, onRefresh, accountViewModel, nav)
|
||||
} else {
|
||||
// TODO: Make a good splash screen with loading animation for this DVM.
|
||||
FeedEmptyWithStatus(appDefinition, stringRes(Res.string.dvm_requesting_job), accountViewModel, nav)
|
||||
}
|
||||
if (heartbeatFresh?.value == false) {
|
||||
DvmOfflineBanner(modifier = Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.dvm_offline_banner
|
||||
import com.vitorpamplona.amethyst.model.DvmHeartbeatRegistry
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
private const val HEARTBEAT_RECHECK_MILLIS = 30_000L
|
||||
|
||||
/**
|
||||
* True while the DVM announced at [appDefinitionAddress] has a heartbeat (kind 11998) at most
|
||||
* 420s old. Resolves the beat's cache note by its mirror address, opens a composable-scoped
|
||||
* relay subscription (the event-finder assembler fetches the beat by kind/author/d while it is
|
||||
* missing), and re-checks staleness on a timer — an expired beat produces no cache event.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDvmHeartbeatFresh(
|
||||
appDefinitionAddress: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<Boolean> {
|
||||
val heartbeatAddressTag =
|
||||
remember(appDefinitionAddress) {
|
||||
Address.assemble(DvmHeartbeatEvent.KIND, appDefinitionAddress.pubKeyHex, appDefinitionAddress.dTag)
|
||||
}
|
||||
|
||||
var heartbeatNote by
|
||||
remember(heartbeatAddressTag) {
|
||||
mutableStateOf(accountViewModel.getNoteIfExists(heartbeatAddressTag))
|
||||
}
|
||||
LaunchedEffect(heartbeatAddressTag) {
|
||||
if (heartbeatNote == null) {
|
||||
heartbeatNote = accountViewModel.checkGetOrCreateNote(heartbeatAddressTag)
|
||||
}
|
||||
}
|
||||
|
||||
val observed =
|
||||
heartbeatNote?.let { observeNoteAndMap(it, accountViewModel) { it.event as? DvmHeartbeatEvent } }
|
||||
|
||||
// The registry is the eviction-proof freshness source (beat notes are WeakReference-held);
|
||||
// the observed note is the secondary source and also drives the outbox fetch.
|
||||
val registryBeat by
|
||||
DvmHeartbeatRegistry.flowForPublic(appDefinitionAddress).collectAsStateWithLifecycle()
|
||||
|
||||
var now by remember(heartbeatNote) { mutableLongStateOf(TimeUtils.now()) }
|
||||
LaunchedEffect(heartbeatNote) {
|
||||
while (isActive) {
|
||||
delay(HEARTBEAT_RECHECK_MILLIS)
|
||||
now = TimeUtils.now()
|
||||
}
|
||||
}
|
||||
|
||||
val beat = observed?.value
|
||||
val bestBeat = maxOf(beat?.createdAt ?: 0L, registryBeat)
|
||||
return rememberUpdatedState(bestBeat > 0L && bestBeat >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS)
|
||||
}
|
||||
|
||||
/** Floating "DVM is offline" banner, mirroring the Home status banner's card style. */
|
||||
@Composable
|
||||
fun DvmOfflineBanner(modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(Res.string.dvm_offline_banner),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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)
|
||||
|
||||
+33
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_payment_requ
|
||||
import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_processing
|
||||
import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_requesting
|
||||
import com.vitorpamplona.amethyst.commons.resources.dvm_home_status_requesting_all
|
||||
import com.vitorpamplona.amethyst.commons.resources.dvm_offline_banner
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
@@ -55,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.rememberDvmHeartbeatFresh
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
@@ -87,6 +89,20 @@ private fun SingleAlgoFeedBanner(
|
||||
.observe(favFeed.address)
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val heartbeatFresh by rememberDvmHeartbeatFresh(favFeed.address, accountViewModel)
|
||||
|
||||
// The DVM is down (or its beats never reach us): the offline banner supersedes the
|
||||
// requesting/error/payment banner, and shows even when last-known content is on screen.
|
||||
if (!heartbeatFresh) {
|
||||
BannerCard(modifier) {
|
||||
BannerMessageRow(
|
||||
message = stringRes(Res.string.dvm_offline_banner),
|
||||
showSpinner = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Hide the banner when the feed is already populated.
|
||||
if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return
|
||||
|
||||
@@ -184,6 +200,23 @@ private fun AllFavoriteAlgoFeedsBanner(
|
||||
|
||||
if (addresses.isEmpty()) return
|
||||
|
||||
// Map every address to its freshness first (non-short-circuiting) so per-address
|
||||
// composable call sites stay stable instead of appearing/disappearing with freshness.
|
||||
val anyHeartbeatFresh =
|
||||
addresses
|
||||
.map { address ->
|
||||
rememberDvmHeartbeatFresh(address, accountViewModel).value
|
||||
}.any { it }
|
||||
if (!anyHeartbeatFresh) {
|
||||
BannerCard(modifier) {
|
||||
BannerMessageRow(
|
||||
message = stringRes(Res.string.dvm_offline_banner),
|
||||
showSpinner = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Observe each DVM's snapshot so we can decide whether to hide the banner
|
||||
// based on the aggregate state. Hide it as soon as any DVM has produced a
|
||||
// feed; only error out when every one of them has errored.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* `LocalCache` is a process-wide object and JUnit 4 runs methods in hash order, so every test
|
||||
* uses its own pubkeys/dTags/ids (same discipline as ReportNamingIndexIngestionTest).
|
||||
*/
|
||||
class DvmHeartbeatTest {
|
||||
private val appDefPubKey = "f1".repeat(32)
|
||||
|
||||
private fun appDef(
|
||||
dTag: String,
|
||||
pubKey: String = appDefPubKey,
|
||||
) = AppDefinitionEvent(
|
||||
id = "f2".repeat(32),
|
||||
pubKey = pubKey,
|
||||
createdAt = 1_760_000_000L,
|
||||
tags = arrayOf(arrayOf("d", dTag), arrayOf("k", "5300")),
|
||||
content = """{"name":"Test DVM"}""",
|
||||
sig = "cc".repeat(64),
|
||||
)
|
||||
|
||||
private fun beat(
|
||||
dTag: String,
|
||||
pubKey: String = appDefPubKey,
|
||||
createdAt: Long,
|
||||
id: String,
|
||||
) = DvmHeartbeatEvent(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", dTag),
|
||||
arrayOf("status", "My heart keeps beating like a hammer"),
|
||||
arrayOf("expiration", (createdAt + 300).toString()),
|
||||
),
|
||||
content = "Alive and kicking",
|
||||
sig = "dd".repeat(64),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun aConsumedHeartbeatLandsAtTheAnnouncementMirrorAddress() {
|
||||
val app = appDef("dvm-one")
|
||||
LocalCache.justConsume(beat("dvm-one", createdAt = 1_760_000_100L, id = "f3".repeat(32)), null, true)
|
||||
|
||||
val found = LocalCache.dvmHeartbeatOf(app)
|
||||
assertTrue("heartbeat should be found via the announcement's address", found != null)
|
||||
assertEquals(1_760_000_100L, found?.createdAt)
|
||||
assertEquals(Address(11998, appDefPubKey, "dvm-one"), found?.address())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFreshHeartbeatPassesTheGateAndAStaleOneDoesNot() {
|
||||
val now = 1_760_000_000L
|
||||
// Separate dTags: consumeBaseReplaceable only accepts NEWER beats per address, so a
|
||||
// 901s-old beat could never supersede the 900s one within a single address slot.
|
||||
val freshApp = appDef("dvm-two-fresh")
|
||||
val staleApp = appDef("dvm-two-stale")
|
||||
assertNull("no beat yet", LocalCache.dvmHeartbeatOf(freshApp))
|
||||
|
||||
LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 900, id = "f4".repeat(32)), null, true)
|
||||
LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 901, id = "f5".repeat(32)), null, true)
|
||||
|
||||
assertTrue("exactly 900s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now))
|
||||
assertFalse("901s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNewestBeatPerAddressWins() {
|
||||
val now = 1_760_000_000L
|
||||
val app = appDef("dvm-three")
|
||||
LocalCache.justConsume(beat("dvm-three", createdAt = now - 600, id = "f6".repeat(32)), null, true)
|
||||
LocalCache.justConsume(beat("dvm-three", createdAt = now - 60, id = "f7".repeat(32)), null, true)
|
||||
|
||||
assertEquals(now - 60, LocalCache.dvmHeartbeatOf(app)?.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun beatsAreKeyedByDTagSoDifferentDvmsDoNotCollide() {
|
||||
val now = 1_760_000_000L
|
||||
val appA = appDef("dvm-a")
|
||||
val appB = appDef("dvm-b")
|
||||
LocalCache.justConsume(beat("dvm-a", createdAt = now - 60, id = "f8".repeat(32)), null, true)
|
||||
|
||||
assertTrue(LocalCache.hasFreshDvmHeartbeat(appA, now))
|
||||
assertFalse("no beat for dvm-b", LocalCache.hasFreshDvmHeartbeat(appB, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noHeartbeatMeansNoLiveness() {
|
||||
assertFalse(LocalCache.hasFreshDvmHeartbeat(appDef("dvm-never"), TimeUtils.now()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theGateSurvivesWeakCacheEvictionOfTheBeatNote() {
|
||||
// Beat notes live in LocalCache's WeakReference store with no strong holder on the
|
||||
// Discover screen — a GC sweep clears them all at once and the list collapses. The
|
||||
// freshness gate must therefore read the strong registry, not the evictable note.
|
||||
val app = appDef("dvm-registry")
|
||||
DvmHeartbeatRegistry.record(Address(DvmHeartbeatEvent.KIND, appDefPubKey, "dvm-registry"), 1_760_000_000L - 100)
|
||||
|
||||
assertTrue("registry alone proves liveness", LocalCache.hasFreshDvmHeartbeat(app, 1_760_000_000L))
|
||||
assertTrue("no entry for dvm-never", DvmHeartbeatRegistry.latestAt(Address(DvmHeartbeatEvent.KIND, appDefPubKey, "dvm-never")) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theUngatedAnnouncementScanKeepsDvmsTheGateWouldHide() {
|
||||
// The outbox fetcher must source announcements from the cache, NOT from the gated feed
|
||||
// list: a DVM dropped for a stale beat must keep receiving outbox beats or it can never
|
||||
// come back. Subscription apps and non-content-discovery apps stay excluded.
|
||||
val alive = appDef("scan-dvm")
|
||||
val subscriptionApp =
|
||||
AppDefinitionEvent(
|
||||
id = "c0".repeat(32),
|
||||
pubKey = "ab".repeat(32),
|
||||
createdAt = 1_760_000_500L,
|
||||
tags = arrayOf(arrayOf("d", "subs"), arrayOf("k", "5300")),
|
||||
content = """{"name":"Paid","subscription":true}""",
|
||||
sig = "cc".repeat(64),
|
||||
)
|
||||
val nonDiscoveryApp =
|
||||
AppDefinitionEvent(
|
||||
id = "c1".repeat(32),
|
||||
pubKey = "cb".repeat(32),
|
||||
createdAt = 1_760_000_100L,
|
||||
tags = arrayOf(arrayOf("d", "other"), arrayOf("k", "9999")),
|
||||
content = """{"name":"Other"}""",
|
||||
sig = "cc".repeat(64),
|
||||
)
|
||||
|
||||
LocalCache.justConsume(appDef("dvm-x"), null, true)
|
||||
LocalCache.justConsume(nonDiscoveryApp, null, true)
|
||||
val consumed =
|
||||
AppDefinitionEvent(
|
||||
id = "c2".repeat(32),
|
||||
pubKey = appDefPubKey,
|
||||
createdAt = 1_760_000_000L,
|
||||
tags = arrayOf(arrayOf("d", "subs2"), arrayOf("k", "5300")),
|
||||
content = """{"name":"Paid2","subscription":true}""",
|
||||
sig = "cc".repeat(64),
|
||||
)
|
||||
LocalCache.justConsume(consumed, null, true)
|
||||
|
||||
val scanned = LocalCache.cachedDvmAnnouncements()
|
||||
|
||||
assertTrue("dvm-x is not yet visible (no beat) but must still be sourced", scanned.any { it.dTag() == "dvm-x" })
|
||||
assertFalse("subscription apps are not content-discovery DVMs", scanned.any { it.dTag() == "subs" })
|
||||
assertFalse("k=9999 apps are not content-discovery DVMs", scanned.any { it.dTag() == "other" })
|
||||
assertEquals("newest-first so the cap keeps the most relevant announcements", scanned.sortedByDescending { it.createdAt }, scanned)
|
||||
assertTrue("capped", scanned.size <= 100)
|
||||
}
|
||||
}
|
||||
@@ -2226,6 +2226,8 @@
|
||||
<string name="dvm_home_status_payment_required">This feed algorithm requires payment</string>
|
||||
<string name="dvm_home_status_error">The feed algorithm returned an error</string>
|
||||
<string name="dvm_home_retry">Retry</string>
|
||||
<string name="dvm_offline">Offline</string>
|
||||
<string name="dvm_offline_banner">This feed algorithm has not sent a heartbeat recently and may be down</string>
|
||||
<string name="temporary_account">Log off on device lock</string>
|
||||
<string name="group_relay">Chat Relay</string>
|
||||
<string name="share_image">Share image…</string>
|
||||
|
||||
+11
@@ -35,4 +35,15 @@ interface IFeedTopNavPerRelayFilterSet {
|
||||
* so it carries only the slice that applies to it.
|
||||
*/
|
||||
fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter?
|
||||
|
||||
/**
|
||||
* The relays this selection's subscriptions should be issued on, regardless of whether the
|
||||
* selection's per-kind filter dispatch produces a filter for each of them.
|
||||
*
|
||||
* Consumers that must stay alive across every selection — the DVM heartbeat REQ, whose beats
|
||||
* keep the cached discovery list's liveness gate fresh — read this instead of deriving relays
|
||||
* from the per-kind filters, which silently skip relays whose per-relay slice is empty and
|
||||
* whole selections (the Relay variant) that dispatch no content filters at all.
|
||||
*/
|
||||
fun relays(): Set<NormalizedRelayUrl>
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ class AllFollowsTopNavPerRelayFilterSet(
|
||||
val set: Map<NormalizedRelayUrl, AllFollowsTopNavPerRelayFilter>,
|
||||
) : IFeedTopNavPerRelayFilterSet {
|
||||
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
|
||||
|
||||
override fun relays() = set.keys
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ class LocationTopNavPerRelayFilterSet(
|
||||
val set: Map<NormalizedRelayUrl, LocationTopNavPerRelayFilter>,
|
||||
) : IFeedTopNavPerRelayFilterSet {
|
||||
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
|
||||
|
||||
override fun relays() = set.keys
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ class GlobalTopNavPerRelayFilterSet(
|
||||
val set: Map<NormalizedRelayUrl, GlobalTopNavPerRelayFilter>,
|
||||
) : IFeedTopNavPerRelayFilterSet {
|
||||
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
|
||||
|
||||
override fun relays() = set.keys
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ class HashtagTopNavPerRelayFilterSet(
|
||||
val set: Map<NormalizedRelayUrl, HashtagTopNavPerRelayFilter>,
|
||||
) : IFeedTopNavPerRelayFilterSet {
|
||||
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
|
||||
|
||||
override fun relays() = set.keys
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ class AllCommunitiesTopNavPerRelayFilterSet(
|
||||
val set: Map<NormalizedRelayUrl, AllCommunitiesTopNavPerRelayFilter>,
|
||||
) : IFeedTopNavPerRelayFilterSet {
|
||||
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
|
||||
|
||||
override fun relays() = set.keys
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ class AuthorsTopNavPerRelayFilterSet(
|
||||
val set: Map<NormalizedRelayUrl, AuthorsTopNavPerRelayFilter>,
|
||||
) : IFeedTopNavPerRelayFilterSet {
|
||||
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
|
||||
|
||||
override fun relays() = set.keys
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ class SingleCommunityTopNavPerRelayFilterSet(
|
||||
val set: Map<NormalizedRelayUrl, SingleCommunityTopNavPerRelayFilter>,
|
||||
) : IFeedTopNavPerRelayFilterSet {
|
||||
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
|
||||
|
||||
override fun relays() = set.keys
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ class MutedAuthorsTopNavPerRelayFilterSet(
|
||||
val set: Map<NormalizedRelayUrl, MutedAuthorsTopNavPerRelayFilter>,
|
||||
) : IFeedTopNavPerRelayFilterSet {
|
||||
override fun scopeFor(relay: NormalizedRelayUrl) = set[relay]
|
||||
|
||||
override fun relays() = set.keys
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -26,4 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet {
|
||||
override fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter? = null
|
||||
|
||||
override fun relays() = emptySet<NormalizedRelayUrl>()
|
||||
}
|
||||
|
||||
+4
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.relayClient.discover
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.DiscoveryDvmHeartbeatSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.DvmHeartbeatSources
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedQueryState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
@@ -56,10 +58,12 @@ class DiscoveryQueryState(
|
||||
|
||||
class DiscoveryFilterAssembler(
|
||||
client: INostrClient,
|
||||
dvmHeartbeat: DvmHeartbeatSources,
|
||||
) : TopNavFeedFilterAssembler<DiscoveryQueryState>({ keys ->
|
||||
listOf(
|
||||
DiscoveryLongFormClassifiedsAndDVMSubAssembler1(client, keys),
|
||||
DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(client, keys),
|
||||
DiscoveryPublicChatsAndCommunitiesSubAssembler3(client, keys),
|
||||
DiscoveryDvmHeartbeatSubAssembler(client, keys, dvmHeartbeat),
|
||||
)
|
||||
})
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.discover.DiscoveryQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.topNavFeeds.TopNavFeedSubAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** How many DVM outbox relays the fetcher may open at once; coverage-ranked, so the top relays carry most authors. */
|
||||
private const val MAX_OUTBOX_RELAYS = 12
|
||||
|
||||
/**
|
||||
* Batches the announcement authors per DVM outbox relay so the freshness gate sees the beats the
|
||||
* DVMs actually publish. DVMs send heartbeats to their own write relays, which need not overlap
|
||||
* the user's discovery relays — the global heartbeat REQ (issued on the selection's relays) alone
|
||||
* leaves alive DVMs invisible. Their beats exist on the outbox; this fetcher brings them into the
|
||||
* same cache slots the gate reads.
|
||||
*
|
||||
* Authors with no known outbox are skipped, not blocked: the global REQ is their fallback.
|
||||
* Relays are coverage-ranked and capped at [maxRelays] so browsing Discover cannot open dozens
|
||||
* of sockets; deterministic tie-breaking keeps the chosen set stable across re-issues.
|
||||
*/
|
||||
fun dvmHeartbeatOutboxFilters(
|
||||
announcements: List<AppDefinitionEvent>,
|
||||
outboxRelaysFor: (HexKey) -> Collection<NormalizedRelayUrl>,
|
||||
now: Long,
|
||||
maxRelays: Int = MAX_OUTBOX_RELAYS,
|
||||
): List<RelayBasedFilter> {
|
||||
if (announcements.isEmpty()) return emptyList()
|
||||
|
||||
val authorsByRelay = mutableMapOf<NormalizedRelayUrl, MutableList<HexKey>>()
|
||||
announcements.forEach { app ->
|
||||
outboxRelaysFor(app.pubKey).forEach { relay ->
|
||||
authorsByRelay.getOrPut(relay) { mutableListOf() }.add(app.pubKey)
|
||||
}
|
||||
}
|
||||
if (authorsByRelay.isEmpty()) return emptyList()
|
||||
|
||||
val since = now - DvmHeartbeatEvent.MAX_AGE_SECONDS
|
||||
return authorsByRelay
|
||||
.entries
|
||||
.sortedWith(
|
||||
compareByDescending<MutableMap.MutableEntry<NormalizedRelayUrl, MutableList<HexKey>>> { it.value.size }
|
||||
.thenBy { it.key.url },
|
||||
).take(maxRelays)
|
||||
.map { (relay, authors) ->
|
||||
val covered = authors.distinct().sorted()
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.DISCOVER_FEED,
|
||||
kinds = listOf(DvmHeartbeatEvent.KIND),
|
||||
authors = covered,
|
||||
limit = covered.size,
|
||||
since = since,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-backed inputs the outbox fetcher needs, provided by the front end (the cache query and
|
||||
* relay-hint surface are platform caches, not commons).
|
||||
*
|
||||
* [announcements] MUST be the ungated announcement set (every cached content-discovery DVM). The
|
||||
* gated feed list would turn any transient staleness into a permanent drop: a DVM leaves the
|
||||
* gated list the moment its beat ages out, the fetcher would stop covering it, and no beat would
|
||||
* ever arrive to bring it back.
|
||||
*
|
||||
* [outboxRelaysFor] resolves where a DVM publishes its beats — NIP-65 outbox relays plus any
|
||||
* relay hints for the author (the same mix the event finder uses).
|
||||
*
|
||||
* [changes] drive re-issues: when the cached announcement set or the outbox data moves, the
|
||||
* batches are recomputed.
|
||||
*/
|
||||
class DvmHeartbeatSources(
|
||||
val announcements: () -> List<AppDefinitionEvent>,
|
||||
val outboxRelaysFor: (HexKey) -> Collection<NormalizedRelayUrl>,
|
||||
val changes: List<Flow<*>>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The discovery-side subscription that runs [dvmHeartbeatOutboxFilters] for the cached
|
||||
* announcement set, alive while the Discover screen is composed (it joins the same assembler
|
||||
* group and lifecycle as the other discovery sub-assemblers).
|
||||
*
|
||||
* No floor collectors ([floors] is empty) — the announcement set and outbox data ([changes]) are
|
||||
* the drivers, not note timestamps.
|
||||
*/
|
||||
class DiscoveryDvmHeartbeatSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<DiscoveryQueryState>,
|
||||
private val sources: DvmHeartbeatSources,
|
||||
) : TopNavFeedSubAssembler<DiscoveryQueryState>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: DiscoveryQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
dvmHeartbeatOutboxFilters(
|
||||
sources.announcements(),
|
||||
outboxRelaysFor = sources.outboxRelaysFor,
|
||||
now = TimeUtils.now(),
|
||||
)
|
||||
|
||||
override fun floors(key: DiscoveryQueryState): List<StateFlow<Long?>> = emptyList()
|
||||
|
||||
override fun extraInvalidators(key: DiscoveryQueryState): List<Flow<*>> = sources.changes
|
||||
}
|
||||
+34
@@ -36,9 +36,13 @@ import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterC
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterContentDVMsByGeohash
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterContentDVMsByHashtag
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs.filterContentDVMsGlobal
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
fun makeContentDVMsFilter(
|
||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||
@@ -55,4 +59,34 @@ fun makeContentDVMsFilter(
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince)
|
||||
else -> emptyList()
|
||||
}.let { contentDvmFilters ->
|
||||
plusHeartbeatFilter(feedSettings, contentDvmFilters)
|
||||
}.scopedTo(feedSettings)
|
||||
|
||||
/**
|
||||
* The 31990 announcements say what a DVM advertises; kind-11998 heartbeats say whether it is
|
||||
* still alive (amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md). Ask on the selection's own
|
||||
* relays — NOT the 31990 filters' relays, which skip relays whose per-relay slice is empty and
|
||||
* whole selections (the Relay variant) that dispatch no 31990 filters at all; there the cached
|
||||
* DVM list would lose its beat stream and the staleness timer would drop every DVM within ~7
|
||||
* minutes. Rolling window instead of the announcement cursor: beats expire (NIP-40) every 5
|
||||
* minutes, so a stored `since` would miss beats on re-opened tabs.
|
||||
*/
|
||||
private fun plusHeartbeatFilter(
|
||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||
contentDvmFilters: List<RelayBasedFilter>,
|
||||
): List<RelayBasedFilter> {
|
||||
val relays = feedSettings.relays()
|
||||
if (relays.isEmpty()) return contentDvmFilters
|
||||
val heartbeatFilter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.DISCOVER_FEED,
|
||||
kinds = listOf(DvmHeartbeatEvent.KIND),
|
||||
limit = 100,
|
||||
since = TimeUtils.now() - DvmHeartbeatEvent.MAX_AGE_SECONDS,
|
||||
)
|
||||
return contentDvmFilters +
|
||||
relays.map { relay ->
|
||||
RelayBasedFilter(relay = relay, filter = heartbeatFilter)
|
||||
}
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.discover.nip90DVMs
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* DVMs publish heartbeats to their own outbox relays, which may not overlap the user's discovery
|
||||
* relays at all — so the global heartbeat REQ alone leaves alive DVMs invisible (their beats never
|
||||
* reach the cache the freshness gate reads). The outbox fetcher batches the announcement authors
|
||||
* per DVM outbox relay so the gate sees the beats the DVMs actually publish.
|
||||
*/
|
||||
class DvmHeartbeatOutboxFiltersTest {
|
||||
private val r1 = RelayUrlNormalizer.normalizeOrNull("wss://r1.example/")!!
|
||||
private val r2 = RelayUrlNormalizer.normalizeOrNull("wss://r2.example/")!!
|
||||
private val r3 = RelayUrlNormalizer.normalizeOrNull("wss://r3.example/")!!
|
||||
|
||||
private fun announcement(pubKey: HexKey) =
|
||||
AppDefinitionEvent(
|
||||
id = pubKey.take(16) + "a".repeat(48),
|
||||
pubKey = pubKey,
|
||||
createdAt = 1_760_000_000L,
|
||||
tags = arrayOf(arrayOf("d", "dvm"), arrayOf("k", "5300")),
|
||||
content = """{"name":"DVM"}""",
|
||||
sig = "b".repeat(128),
|
||||
)
|
||||
|
||||
private val authorA = "aa".repeat(32)
|
||||
private val authorB = "bb".repeat(32)
|
||||
private val authorC = "cc".repeat(32)
|
||||
|
||||
private val outboxes: (HexKey) -> Set<NormalizedRelayUrl> =
|
||||
{
|
||||
when (it) {
|
||||
authorA -> setOf(r1, r2)
|
||||
authorB -> setOf(r1, r3)
|
||||
else -> emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun batchesAnnouncementAuthorsPerOutboxRelay() {
|
||||
val filters =
|
||||
dvmHeartbeatOutboxFilters(
|
||||
announcements = listOf(announcement(authorA), announcement(authorB), announcement(authorC)),
|
||||
outboxRelaysFor = outboxes,
|
||||
now = 1_760_000_420L,
|
||||
)
|
||||
|
||||
assertEquals(3, filters.size, "r1 carries A+B, r2 carries A, r3 carries B; author C has no outbox")
|
||||
|
||||
val authorsOn = { relay: String -> filters.first { it.relay.url == relay }.filter.authors }
|
||||
|
||||
assertEquals(setOf(authorA, authorB), authorsOn(r1.url)?.toSet())
|
||||
assertEquals(setOf(authorA), authorsOn(r2.url)?.toSet())
|
||||
assertEquals(setOf(authorB), authorsOn(r3.url)?.toSet())
|
||||
|
||||
filters.forEach {
|
||||
assertEquals(listOf(DvmHeartbeatEvent.KIND), it.filter.kinds)
|
||||
assertEquals(1_759_999_520L, it.filter.since, "rolling window: now - MAX_AGE_SECONDS")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAuthorWithNoKnownOutboxIsSkippedNotBlocked() {
|
||||
val filters =
|
||||
dvmHeartbeatOutboxFilters(
|
||||
announcements = listOf(announcement(authorC)),
|
||||
outboxRelaysFor = outboxes,
|
||||
now = 1_760_000_420L,
|
||||
)
|
||||
|
||||
assertTrue(filters.isEmpty(), "no outbox known — the global REQ is that DVM's fallback")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayCapKeepsTheMostCoveringRelays() {
|
||||
val filters =
|
||||
dvmHeartbeatOutboxFilters(
|
||||
announcements = listOf(announcement(authorA), announcement(authorB)),
|
||||
outboxRelaysFor = outboxes,
|
||||
now = 1_760_000_420L,
|
||||
maxRelays = 2,
|
||||
)
|
||||
|
||||
assertEquals(setOf(r1.url, r2.url), filters.map { it.relay.url }.toSet(), "r1 covers 2, r2 and r3 tie at 1 — tie broken deterministically")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noAnnouncementsMeansNoRequests() {
|
||||
assertTrue(
|
||||
dvmHeartbeatOutboxFilters(emptyList(), outboxRelaysFor = outboxes, now = 1_760_000_420L).isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+101
@@ -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 })
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* DVM heartbeat (kind 11998, experimental — no NIP yet): a beat a DVM publishes every 300s to
|
||||
* prove it is alive. The operator contract is plain-text content with `d` (the DVM's NIP-89
|
||||
* DTAG), `status` (free text) and `expiration` (createdAt + 300, NIP-40) tags.
|
||||
*
|
||||
* The kind sits in the replaceable range (10000–19999), so relays keep only the latest beat
|
||||
* per author. The `d` tag participates in the client-side address so each announced DVM has
|
||||
* its own cache slot: `Address(11998, dvmPubKey, dTag)` mirrors the announcement's
|
||||
* `Address(31990, dvmPubKey, dTag)`.
|
||||
*/
|
||||
@Stable
|
||||
@Immutable
|
||||
class DvmHeartbeatEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun status(): String? = tags.firstOrNull { it.size > 1 && it[0] == STATUS_TAG }?.get(1)
|
||||
|
||||
fun expiration(): Long? = tags.expiration()
|
||||
|
||||
/** True while this beat still proves liveness at [now]. */
|
||||
fun isFreshAt(now: Long = TimeUtils.now()): Boolean = createdAt >= now - MAX_AGE_SECONDS
|
||||
|
||||
companion object {
|
||||
const val KIND = 11998
|
||||
const val STATUS_TAG = "status"
|
||||
const val CONTENT = "Alive and kicking"
|
||||
|
||||
/**
|
||||
* A beat older than this no longer proves liveness. Beats arrive every 300s, so this
|
||||
* window deliberately tolerates several missed deliveries (relay reconnects, REQ churn)
|
||||
* before a DVM is dropped — hysteresis against transient delivery gaps, at the cost of a
|
||||
* dead DVM lingering this long before disappearing.
|
||||
*/
|
||||
const val MAX_AGE_SECONDS = 900
|
||||
|
||||
fun build(
|
||||
dTag: String,
|
||||
status: String,
|
||||
expiration: Long,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): EventTemplate<DvmHeartbeatEvent> =
|
||||
eventTemplate(KIND, CONTENT, createdAt) {
|
||||
add(arrayOf("d", dTag))
|
||||
add(arrayOf(STATUS_TAG, status))
|
||||
add(arrayOf("expiration", expiration.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +358,7 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDi
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationRequestEvent
|
||||
@@ -734,6 +735,7 @@ class EventFactory {
|
||||
NutzapRedemptionEvent.KIND -> NutzapRedemptionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
NostrConnectEvent.KIND -> NostrConnectEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
NIP90StatusEvent.KIND -> NIP90StatusEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
DvmHeartbeatEvent.KIND -> DvmHeartbeatEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
NIP90TextExtractionRequestEvent.KIND -> NIP90TextExtractionRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
NIP90TextExtractionResponseEvent.KIND -> NIP90TextExtractionResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
NIP90SummarizationRequestEvent.KIND -> NIP90SummarizationRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DvmHeartbeatEventTest {
|
||||
private val pubKey = "11".repeat(32)
|
||||
private val dTag = "my-dvm"
|
||||
private val beatTime = 1_760_000_000L
|
||||
|
||||
private fun heartbeat(createdAt: Long = beatTime) =
|
||||
DvmHeartbeatEvent(
|
||||
id = "00".repeat(32),
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", dTag),
|
||||
arrayOf("status", "My heart keeps beating like a hammer"),
|
||||
arrayOf("expiration", (createdAt + 300).toString()),
|
||||
),
|
||||
content = "Alive and kicking",
|
||||
sig = "22".repeat(64),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun addressIncludesTheDTag() {
|
||||
val event = heartbeat()
|
||||
assertEquals(dTag, event.dTag())
|
||||
assertEquals(Address(11998, pubKey, dTag), event.address())
|
||||
assertEquals("11998:$pubKey:$dTag", event.addressTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingDTagFallsBackToEmptyAddress() {
|
||||
val event =
|
||||
DvmHeartbeatEvent(
|
||||
id = "00".repeat(32),
|
||||
pubKey = pubKey,
|
||||
createdAt = beatTime,
|
||||
tags = emptyArray(),
|
||||
content = "Alive and kicking",
|
||||
sig = "22".repeat(64),
|
||||
)
|
||||
assertEquals("", event.dTag())
|
||||
assertEquals(Address(11998, pubKey, ""), event.address())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsStatusAndExpiration() {
|
||||
val event = heartbeat()
|
||||
assertEquals("My heart keeps beating like a hammer", event.status())
|
||||
assertEquals(beatTime + 300, event.expiration())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusIsOptional() {
|
||||
val event = DvmHeartbeatEvent("00".repeat(32), pubKey, beatTime, arrayOf(arrayOf("d", dTag)), "", "22".repeat(64))
|
||||
assertNull(event.status())
|
||||
assertNull(event.expiration())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshnessBoundary() {
|
||||
val event = heartbeat()
|
||||
assertTrue(event.isFreshAt(beatTime + 900))
|
||||
assertFalse(event.isFreshAt(beatTime + 901))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildWritesAllTags() {
|
||||
val template =
|
||||
DvmHeartbeatEvent.build(
|
||||
dTag = dTag,
|
||||
status = "My heart keeps beating like a hammer",
|
||||
expiration = beatTime + 300,
|
||||
createdAt = beatTime,
|
||||
)
|
||||
assertEquals(11998, template.kind)
|
||||
assertEquals("Alive and kicking", template.content)
|
||||
assertEquals(
|
||||
listOf(
|
||||
listOf("d", dTag),
|
||||
listOf("status", "My heart keeps beating like a hammer"),
|
||||
listOf("expiration", (beatTime + 300).toString()),
|
||||
),
|
||||
template.tags.map { it.toList() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun factoryBuildsDvmHeartbeatForKind11998() {
|
||||
val event: Event =
|
||||
EventFactory.create(
|
||||
id = "00".repeat(32),
|
||||
pubKey = pubKey,
|
||||
createdAt = beatTime,
|
||||
kind = DvmHeartbeatEvent.KIND,
|
||||
tags = arrayOf(arrayOf("d", dTag)),
|
||||
content = "",
|
||||
sig = "22".repeat(64),
|
||||
)
|
||||
assertIs<DvmHeartbeatEvent>(event)
|
||||
assertTrue(EventFactory.isKnownKind(DvmHeartbeatEvent.KIND), "kind 11998 should be a known kind")
|
||||
}
|
||||
}
|
||||
+3
@@ -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"
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
10100
|
||||
10154 The Title\nThe Description
|
||||
11871 The content body.
|
||||
11998 <not searchable>
|
||||
12473 The Alt
|
||||
15128 The Title\nThe Description
|
||||
15129 The Title\nThe Description
|
||||
|
||||
Reference in New Issue
Block a user