mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge main: the golden's kind list stays machine-checked
Twenty commits, one conflict, in the file both sides had reason to touch. Main pinned kind 11998 — the DVM heartbeat — into the golden test's KINDS list as a `<not searchable>` row, so that a heartbeat quietly gaining indexable content would show as a diff. This branch had already replaced that hand-kept list with `SearchableKinds.ALL`, which holds only kinds that are searchable, so the pinned row has nowhere to live and is dropped. Nothing is lost by that, which is the whole reason to resolve it this way: `SearchableKindsTest` builds every kind from 0 to 65535 through `EventFactory` and asserts the recorded set is exactly what came back searchable. A heartbeat that started indexing text fails there, by number, without anyone having remembered to list it — the same guarantee the pinned row gave for one kind, for all of them. The KDoc says so, so the next person doesn't re-add a row the list cannot hold. Main's other change to that file is kept as-is: the golden comparison normalizes CRLF, and `.gitattributes` forces LF on `*.golden`, so a Windows checkout can't fail the suite on invisible line endings. Everything else merged clean. The heartbeat kind is not searchable, so `SearchableKinds`, `RenderableKinds` and the `kind:` vocabulary are unaffected — verified by running their drift tests against merged main rather than by reading the diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
This commit is contained in:
@@ -2,3 +2,7 @@
|
||||
# binary so git never applies CRLF/text normalization or textual diff/merge, which
|
||||
# would corrupt the compressed stream (important on Windows checkouts).
|
||||
*.gz binary
|
||||
|
||||
# Golden test resources are compared byte-for-byte against generated strings:
|
||||
# force LF everywhere so a Windows CRLF checkout cannot break the comparison.
|
||||
*.golden text eol=lf
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
# DVM heartbeat liveness — only show DVMs with a fresh kind-11998 heartbeat
|
||||
|
||||
_Status: **implemented** (Android; desktop wiring deliberately out of scope — §8)._
|
||||
|
||||
## 0. The shape of the thing
|
||||
|
||||
Amethyst shows Data Vending Machines (DVMs) in three places: the Discover "Content" tab
|
||||
(kind 31990 NIP-89 announcements advertising kind 5300), DVM feeds pinned to the top-nav
|
||||
(`FavoriteAlgoFeedsOrchestrator`), and the per-DVM content-discovery screen. Today all of
|
||||
these treat every announced DVM as alive, forever — a DVM that went down months ago still
|
||||
renders as a usable feed.
|
||||
|
||||
DVM operators are now sending a **heartbeat event (kind 11998) every 300 seconds**. The
|
||||
event is plain-text (`content = "Alive and kicking"`) with three tags:
|
||||
|
||||
- `status` — free-text status line (e.g. "My heart keeps beating like a hammer")
|
||||
- `d` — the DVM's **NIP-89 DTAG**, tying the heartbeat to the announcement's address
|
||||
- `expiration` — `createdAt + 300` (NIP-40), so relays drop the beat once the next one lands
|
||||
|
||||
Kind 11998 sits in the replaceable range (10000–19999), so relays keep only the latest beat
|
||||
per author. There is no NIP for this yet — the shape above comes from the operator-side
|
||||
builder and is treated as the wire contract.
|
||||
|
||||
The feature: **a DVM counts as alive only if its latest heartbeat is at most 900 seconds
|
||||
old** (one missed 300s beat plus slack). Dead DVMs disappear from the Discover list; pinned
|
||||
feeds and the detail surface show an offline state instead.
|
||||
|
||||
## 1. Decisions taken
|
||||
|
||||
1. **Approach: cache-backed heartbeats.** The heartbeat is a real event class stored through
|
||||
the standard replaceable path in `LocalCache` (newest per address, standard invalidation).
|
||||
Rejected alternatives: a side-state registry (duplicates invalidation plumbing) and
|
||||
regular-note storage (no address matching, pollutes the notes index).
|
||||
2. **Scope: all three surfaces** — Discover list (hide), pinned feeds (offline state, chip
|
||||
stays), DVM detail screen (offline banner, requesting still allowed). The manage screen
|
||||
(`FavoriteAlgoFeedsListScreen`) also gets the badge.
|
||||
3. **Pinned chips stay when offline** — the user pinned them deliberately; they gray out
|
||||
with an offline badge rather than vanishing, and tapping still opens the feed.
|
||||
4. **Threshold: 900 seconds** (raised from the original 420 after field testing: beats arrive every 300s, and a 420s window tolerated barely one delivery hiccup, dropping live DVMs in oscillations). Exactly 900s old counts as fresh.
|
||||
5. **Strict from cold start.** No grace period: the Discover list starts empty and fills
|
||||
within ~1–2s as heartbeat REQs return (same behavior as the existing 31990 load).
|
||||
|
||||
## 2. Event model (quartz)
|
||||
|
||||
New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`:
|
||||
|
||||
- `class DvmHeartbeatEvent(...) : BaseAddressableEvent(...)`, `KIND = 11998` — the codebase
|
||||
convention for 10xxx events with real `d` tags (e.g. `FollowListEvent`), so `dTag()` /
|
||||
`address()` / `addressTag()` come from the base. The cache address is
|
||||
`Address(11998, dvmPubkey, dTag)`, the exact mirror of the announcement's
|
||||
`Address(31990, dvmPubkey, dTag)`.
|
||||
- Accessors: `status()`, and `expiration()` via the existing NIP-40 extension.
|
||||
- `MAX_AGE_SECONDS = 900` and `isFreshAt(now)` live in quartz too (commons imports them).
|
||||
- Registered in `EventFactory` (kind → constructor) and allowlisted in
|
||||
`EventFactoryKindRangeTest.knownDTagReaders`: the `d` tag keys the client-side address
|
||||
while relay storage stays plain-replaceable per the kind range.
|
||||
|
||||
## 3. Cache consumption (LocalCache)
|
||||
|
||||
One routing line in `LocalCache.justConsumeInnerInner`: `is DvmHeartbeatEvent ->`
|
||||
`consumeBaseReplaceable(event, relay, wasVerified)`. This yields newest-per-address
|
||||
replacement, relay tracking, and `LocalCacheFlow` invalidation for free. Unlisted kinds fall
|
||||
into the `else` branch and are rejected, so the routing line is mandatory.
|
||||
|
||||
Stale beats simply sit at their address until overwritten; the age check (§4) makes them
|
||||
invisible. The cache pruner removes old entries on its own schedule.
|
||||
|
||||
## 4. Freshness core (amethyst)
|
||||
|
||||
Small helper file in `amethyst/.../model/` (the threshold constant itself lives in quartz):
|
||||
|
||||
- `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address
|
||||
lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())`
|
||||
- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 900`
|
||||
- `@Composable fun rememberDvmHeartbeatFresh(address: Address, accountViewModel: AccountViewModel): State<Boolean>` —
|
||||
as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 900s
|
||||
old; an unresolved/absent beat counts as offline (`false`) on every surface. The returned
|
||||
`State` identity is stable for the lifetime of the call site (one unconditional
|
||||
`rememberUpdatedState`), so callers may capture it across recompositions. Composable-scoped
|
||||
subscription (§5) + staleness re-check tick (§6), shared by every surface that renders
|
||||
liveness.
|
||||
|
||||
## 5. Subscriptions
|
||||
|
||||
**Discover screen — all DVM heartbeats.** In
|
||||
`commons/.../relayClient/discover/nip90DVMs/SubAssemblyHelper.kt`, `makeContentDVMsFilter`
|
||||
unconditionally appends one filter for every top-filter variant:
|
||||
`kinds = [11998], since = TimeUtils.now() - 900` — no authors, no tags, scoped to the same
|
||||
relay set as the 31990 REQs. It deliberately ignores the 31990 `since`-cursor (heartbeats
|
||||
are a rolling window, not a cursor stream — the cursor would miss re-opened tabs after the
|
||||
beats expired). It rides the existing assembler lifecycle: subscribes on entering Discover,
|
||||
closes on leaving.
|
||||
|
||||
**Per-surface — pinned chips, home banner, detail screen.** `rememberDvmHeartbeat` opens a
|
||||
tiny composable-scoped subscription: `kinds = [11998], authors = [dvm pubkey], limit = 1,
|
||||
since = now - 900`. The home top-bar chips live for the whole session, so they double as
|
||||
the session-scoped watcher for pinned DVMs. Traffic is negligible (a few pinned DVMs ×
|
||||
1 event / 5 min).
|
||||
|
||||
**Outbox fetcher (added after field testing).** The global REQ above only sees beats that
|
||||
reach the *user's* discovery relays — but DVMs publish beats to their own write relays, and
|
||||
relays don't gossip, so alive DVMs whose beats never overlap the user's relay set stayed
|
||||
invisible (their detail screens proved the beats existed on the outbox). `DiscoveryDvmHeartbeatSubAssembler`
|
||||
joins the discovery assembler group and, while Discover is composed, batches the cached
|
||||
content-discovery announcements' authors per **DVM outbox relay** (`kinds = [11998],
|
||||
authors = [those pubkeys], since = now - 900`, coverage-ranked and capped at 12 relays;
|
||||
authors with unknown outboxes/hints rely on the global REQ as fallback). It re-issues when
|
||||
the cached announcement set or the NIP-65 relay lists move.
|
||||
|
||||
The announcement source MUST be the **ungated cache scan**
|
||||
(`LocalCache.cachedDvmAnnouncements` — every cached k=5300 announcement, newest first, capped
|
||||
at 100), not the gated feed list. Sourcing from the gated list is a death spiral: a DVM
|
||||
leaves the gated list the moment its beat ages out, the fetcher would stop covering it, and
|
||||
no beat would ever arrive to bring it back — any transient staleness becomes a permanent
|
||||
drop. The relay lookup unions the author's NIP-65 outbox with the cached relay hints for the
|
||||
author (the same mix the event finder's `potentialRelaysToFindAddress` uses).
|
||||
|
||||
## 6. Invalidation — closing the two silent gaps
|
||||
|
||||
1. **A new heartbeat does not re-rank the list.** The additive feed path
|
||||
(`FeedContentState.updateFeedWith`) re-filters only the *new* notes, and a heartbeat
|
||||
note is never a list row — the affected 31990 card would not be re-evaluated. Fix: in
|
||||
`AccountFeedContentStates.updateFeedsWith`, branch on
|
||||
`newNotes.any { it.event is DvmHeartbeatEvent }` → `discoverDVMs.invalidateData()`
|
||||
(full rebuild re-runs the freshness check on every announcement); otherwise the normal
|
||||
additive path.
|
||||
2. **Expiry produces no event.** A 60s timer collector in `AccountFeedContentStates`
|
||||
(alongside the existing `scope.launch { flows.collect { … } }` observers) calls
|
||||
`discoverDVMs.invalidateData()` every minute. The rebuild is a cheap scan (≤ a few
|
||||
hundred 31990s) and `refreshSuspended()` no-ops when the list is unchanged. Composables
|
||||
using `rememberDvmHeartbeatFresh` tick on a 30s cadence internally.
|
||||
|
||||
## 7. UI surfaces
|
||||
|
||||
1. **Discover "Content" tab** — `DiscoverNIP89FeedFilter.acceptApp` adds
|
||||
`dvmHeartbeatOf(noteEvent)?.isFreshAt(now) == true`. No fresh beat → card hidden.
|
||||
2. **Pinned top-nav chips** — chip stays; when the heartbeat is stale or absent the chip is
|
||||
grayed out with a small offline dot appended to its label. Tapping still opens the feed.
|
||||
3. **Pinned feed view** — new branch in `HomeAlgoFeedStatusBanner`: when the selected
|
||||
pinned feed's heartbeat is stale, show an offline banner above the last known content,
|
||||
shown *even when content exists* (the current banner only handles empty/error states).
|
||||
Single-feed and all-feeds variants both covered.
|
||||
4. **DVM detail screen** (`DvmContentDiscoveryScreen`) — same offline banner; requesting is
|
||||
still allowed (informational, not a block).
|
||||
5. **`FavoriteAlgoFeedsListScreen`** — offline badge per row so users can spot dead pins.
|
||||
6. New English string resources (`dvm_offline`, `dvm_offline_banner`); translations flow
|
||||
via Crowdin.
|
||||
|
||||
## 8. Edge cases (accepted limitations)
|
||||
|
||||
- Heartbeat without a `d` tag → cache address dTag `""` → matches nothing → DVM hidden
|
||||
(strict; the wire contract always sends `d`).
|
||||
- Device/DVM clock skew > 15 min → wrongly hidden (inherent to timestamp-based liveness).
|
||||
- DVM beats that never reach the relays we query → shows offline (that is the feature).
|
||||
- One keypair running multiple DVMs → relays keep only the latest beat per (kind, author);
|
||||
per-d-tag cache slots help only across relays. Most DVMs use one key each.
|
||||
- **Desktop app: out of scope this round.** The commons subscription helper is shared-ready
|
||||
and the desktop relay assembler will pick up heartbeat REQs harmlessly (cache fills,
|
||||
nothing renders), but all UI wiring is Android-only.
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- **quartz**: parse/build `DvmHeartbeatEvent` — `dTag()` override, `statusTag()`,
|
||||
`expiration()`, address assembly.
|
||||
- **amethyst**: `DiscoverNIP89FeedFilter.acceptApp` matrix — no beat → reject; fresh beat →
|
||||
accept; 421s-old beat → reject. The `updateFeedsWith` heartbeat branch triggers a full
|
||||
rebuild. `isFreshAt` boundary (900s fresh, 901s stale).
|
||||
- Verify with `./gradlew :quartz:test :amethyst:test`, then `./gradlew spotlessApply`.
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/** The cache slot a DVM's heartbeat lives in: the announcement's own address, kind 11998. */
|
||||
fun LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent? = getAddressableNoteIfExists(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))?.event as? DvmHeartbeatEvent
|
||||
|
||||
/**
|
||||
* A DVM counts as alive only if its latest heartbeat is at most 900s old. The registry (not the
|
||||
* WeakReference-held beat note) is the freshness source: beat notes have no strong holder on the
|
||||
* Discover screen, and a GC sweep cleared them all at once, collapsing the list.
|
||||
*/
|
||||
fun LocalCache.hasFreshDvmHeartbeat(
|
||||
appDef: AppDefinitionEvent,
|
||||
now: Long = TimeUtils.now(),
|
||||
): Boolean =
|
||||
DvmHeartbeatRegistry
|
||||
.latestAt(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))
|
||||
?.let { it >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS } == true
|
||||
|
||||
/**
|
||||
* Every cached content-discovery announcement, WITHOUT the freshness gate — this is the source the
|
||||
* heartbeat outbox fetcher must use. Sourcing from the gated feed list would drop a DVM the moment
|
||||
* its beat went stale, remove it from the fetch batch, and make the drop permanent (the fetcher
|
||||
* could only ever help DVMs that were already visible). Applies the gate's other eligibility
|
||||
* checks (a real content-discovery DVM, not a paid subscription app), newest first, capped.
|
||||
*/
|
||||
fun LocalCache.cachedDvmAnnouncements(limit: Int = 100): List<AppDefinitionEvent> =
|
||||
addressables
|
||||
.filterIntoSet(AppDefinitionEvent.KIND) { _, note ->
|
||||
(note.event as? AppDefinitionEvent)?.let {
|
||||
it.appMetaData()?.subscription != true && it.includeKind(NIP90ContentDiscoveryRequestEvent.KIND)
|
||||
} == true
|
||||
}.mapNotNull { it.event as? AppDefinitionEvent }
|
||||
.sortedByDescending { it.createdAt }
|
||||
.take(limit)
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Strong, process-wide record of the latest heartbeat per DVM announcement address
|
||||
* (`Address(11998, dvmPubKey, dTag) -> createdAt`).
|
||||
*
|
||||
* Beat Notes themselves live in `LocalCache.addressables`, a WeakReference store with no strong
|
||||
* holder on the Discover screen — every GC sweep cleared them all at once and the freshness gate
|
||||
* collapsed for every DVM simultaneously (the list emptied and rebuilt one beat at a time). This
|
||||
* registry is the freshness source the gate and the liveness composables read: strong references,
|
||||
* fed by every beat-arrival path (global REQ, outbox batches, per-surface fetches — all beat
|
||||
* consumption routes through [record]).
|
||||
*
|
||||
* One entry per DVM address ever seen; timestamps only, so it stays tiny. `0` means "no beat".
|
||||
*/
|
||||
object DvmHeartbeatRegistry {
|
||||
private val latestBeatCreatedAt = ConcurrentHashMap<Address, MutableStateFlow<Long>>()
|
||||
|
||||
private fun flowFor(address: Address): MutableStateFlow<Long> = latestBeatCreatedAt.getOrPut(address) { MutableStateFlow(0L) }
|
||||
|
||||
/** Observable latest-beat timestamp for this address; `0` means "no beat seen yet". */
|
||||
fun flowForPublic(address: Address): StateFlow<Long> = flowFor(address)
|
||||
|
||||
/** Records a beat's createdAt; older beats never move the entry backwards. */
|
||||
fun record(
|
||||
address: Address,
|
||||
createdAt: Long,
|
||||
) = flowFor(address).update { current -> if (createdAt > current) createdAt else current }
|
||||
|
||||
/** The latest recorded beat's createdAt for this address, or null when no beat was ever seen. */
|
||||
fun latestAt(address: Address): Long? = flowFor(address).value.takeIf { it > 0L }
|
||||
}
|
||||
@@ -381,6 +381,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent
|
||||
@@ -3762,6 +3763,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
is CommunityDefinitionEvent,
|
||||
is CommunityListEvent,
|
||||
is ContactListEvent,
|
||||
// DVM heartbeat (11998): stored per Address(11998, author, d) so liveness checks find the
|
||||
// beat at the announcement's mirror address (amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md),
|
||||
// AND recorded into the strong registry — beat notes are WeakReference-held with no strong
|
||||
// holder on the Discover screen, so the gate must not depend on them surviving GC.
|
||||
is DvmHeartbeatEvent,
|
||||
->
|
||||
consumeBaseReplaceable(event, relay, wasVerified).also {
|
||||
DvmHeartbeatRegistry.record(event.address(), event.createdAt)
|
||||
}
|
||||
is EmojiPackEvent,
|
||||
is EmojiPackSelectionEvent,
|
||||
is EphemeralChatListEvent,
|
||||
|
||||
+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"
|
||||
|
||||
+8
-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(),
|
||||
)
|
||||
}
|
||||
@@ -160,6 +162,11 @@ class IndexableContentGoldenTest {
|
||||
* This was a list of its own until it drifted: seventeen searchable kinds — every video
|
||||
* kind among them — were absent and so were never pinned by any of the tests below, and
|
||||
* one entry (31890) was a kind the factory does not build at all.
|
||||
*
|
||||
* It holds only *searchable* kinds now, so a kind pinned here as `<not searchable>` — 11998,
|
||||
* the heartbeat, was one — drops out of the golden. Nothing is lost by that: pinning the
|
||||
* row caught a kind that started being indexed, and `SearchableKindsTest` catches the same
|
||||
* thing across the whole 16-bit space rather than for the kinds someone remembered to list.
|
||||
*/
|
||||
val KINDS = SearchableKinds.ALL
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user