feat(notes): show stale-relay hint on replaceable events using NIP-66 cache

Surface a soft "this content's relay may be stale" UX cue on addressable
replaceable events (kind:30xxx) when every delivering relay's most recent
NIP-66 kind:30166 Relay Discovery monitor report cached locally is older
than 14 days (or has never been observed).

Read-only: uses only what's already in LocalCache, populated by the
existing RelayInfoNip66FilterSubAssembler. No new network fetches.

Implementation:
- New `StaleRelayHint` composable in `ui/note/elements/`, hooked into
  `NoteBody` after the zap-splits row. Skips quietly for non-addressable
  events, empty relay sets, or any relay still observed within 14 days.
- Pure `isStaleByLatestMonitorReports(latestPerRelay, now, threshold)`
  predicate — `null` (never monitored) and "older than cutoff" both count
  as stale, but a single fresh relay short-circuits the hint to off.
- Reactively tracks the note's relay set via
  `baseNote.flow().relays.stateFlow` so newly-observed relays update the
  hint without recomposition tricks.
- Latest monitor `created_at` per relay is read from `LocalCache` with
  the same `Filter(kinds=[30166], #d=[relay.url], limit=1)` shape used
  on the Relay Information screen.

Out of scope (per issue):
- Auto-refreshing or hiding stale content.
- Heuristics beyond the "all delivering relays stale" check.

Tests: 9 unit tests for the pure predicate covering empty/single/mixed
sets, null-handling, exact-cutoff boundary, and a custom threshold.

Closes #2762.
This commit is contained in:
m
2026-05-09 08:01:29 +10:00
parent fbadc61070
commit 594e1fb98b
4 changed files with 283 additions and 0 deletions
@@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.Reward
import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList
@@ -836,6 +837,7 @@ fun NoteBody(
Spacer(modifier = HalfDoubleVertSpacer)
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
}
StaleRelayHint(baseNote)
}
}
@@ -0,0 +1,163 @@
/*
* 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.note.elements
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
/**
* NIP-66 stale-relay hint for replaceable/addressable events.
*
* Soft, read-only UX cue: when an addressable event was delivered exclusively
* by relays whose latest NIP-66 `kind:30166` Relay Discovery monitor report is
* older than [StaleRelayThresholds.DEFAULT_THRESHOLD_SECS] (14 days), render
* an inline warning. The cache is populated by `RelayInfoNip66FilterSubAssembler`
* (visited via the Relay Information screen). No network is fetched here.
*
* Out of scope: auto-refreshing or hiding stale content. Heuristics beyond the
* "all delivering relays stale" check.
*/
object StaleRelayThresholds {
/** 14 days, per the issue's behaviour spec. Hardcoded for v1. */
const val DEFAULT_THRESHOLD_SECS: Long = 14L * TimeUtils.ONE_DAY
}
/**
* Pure helper: given the latest monitor `created_at` timestamp for each
* delivering relay (null when no kind:30166 has been observed for that relay),
* decide whether the event should be flagged as stale.
*
* Returns true iff the set is non-empty AND every entry is null OR older than
* `now - thresholdSecs`. The "all stale" rule keeps the hint quiet whenever at
* least one delivering relay still has a fresh monitor heartbeat.
*/
fun isStaleByLatestMonitorReports(
latestPerRelay: List<Long?>,
now: Long,
thresholdSecs: Long = StaleRelayThresholds.DEFAULT_THRESHOLD_SECS,
): Boolean {
if (latestPerRelay.isEmpty()) return false
val cutoff = now - thresholdSecs
return latestPerRelay.all { it == null || it < cutoff }
}
@Composable
fun StaleRelayHint(
baseNote: Note,
modifier: Modifier = Modifier,
) {
val event = baseNote.event ?: return
if (event !is AddressableEvent) return
// Reactively track the delivering relay set. Notes pick up extra relays as
// they are observed elsewhere; reuse the same flow `RelayListBox` uses so
// the hint converges on the latest set.
val noteRelays by baseNote
.flow()
.relays.stateFlow
.collectAsStateWithLifecycle()
val relays = noteRelays.note.relays
if (relays.isEmpty()) return
val combinedFlow: Flow<List<Long?>> =
remember(relays) {
if (relays.isEmpty()) {
flowOf(emptyList())
} else {
combine(relays.map { latestRelayDiscoveryCreatedAt(it) }) { values ->
values.toList()
}
}
}
val latestPerRelay by combinedFlow.collectAsState(initial = emptyList())
if (latestPerRelay.isEmpty()) return
val isStale = isStaleByLatestMonitorReports(latestPerRelay, TimeUtils.now())
if (!isStale) return
Row(
modifier = modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = stringRes(id = R.string.stale_relay_hint_icon_description),
tint = MaterialTheme.colorScheme.warningColor,
modifier = Modifier.size(14.dp),
)
Text(
text = stringRes(id = R.string.stale_relay_hint_label),
color = MaterialTheme.colorScheme.warningColor,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 4.dp),
)
}
}
/**
* Emits the most recent `kind:30166` `created_at` we have cached for [relay],
* or `null` while none has been observed. The underlying `LocalCache` filter
* subscription is shared with the Relay Information screen.
*/
private fun latestRelayDiscoveryCreatedAt(relay: NormalizedRelayUrl): Flow<Long?> {
val filter =
Filter(
kinds = listOf(RelayDiscoveryEvent.KIND),
tags = mapOf("d" to listOf(relay.url)),
limit = 1,
)
return LocalCache.observeEvents<RelayDiscoveryEvent>(filter).map { events ->
events.maxOfOrNull { it.createdAt }
}
}
+2
View File
@@ -168,6 +168,8 @@
<string name="pronouns">Pronouns</string>
<string name="ln_address">LN Address</string>
<string name="ln_url_outdated">LN URL (outdated)</string>
<string name="stale_relay_hint_label">Source relays may be stale</string>
<string name="stale_relay_hint_icon_description">Stale relay warning</string>
<string name="download_to_phone">Save to Phone</string>
<string name="save_to_gallery">Save to Gallery</string>
<string name="image_saved_to_the_gallery">Image saved to the phone\'s photo gallery</string>
@@ -0,0 +1,116 @@
/*
* 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.note.elements
import com.vitorpamplona.quartz.utils.TimeUtils
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class StaleRelayHintTest {
private val now = 1_730_000_000L
private val freshSecsAgo = TimeUtils.ONE_DAY.toLong() // 1 day ago — fresh
private val staleSecsAgo = StaleRelayThresholds.DEFAULT_THRESHOLD_SECS + TimeUtils.ONE_DAY // > 14d ago
@Test
fun `empty relay set is never stale`() {
assertFalse(isStaleByLatestMonitorReports(emptyList(), now))
}
@Test
fun `single fresh relay is not stale`() {
assertFalse(
isStaleByLatestMonitorReports(listOf(now - freshSecsAgo), now),
)
}
@Test
fun `single stale relay is stale`() {
assertTrue(
isStaleByLatestMonitorReports(listOf(now - staleSecsAgo), now),
)
}
@Test
fun `single never-monitored relay (null) is treated as stale`() {
assertTrue(
isStaleByLatestMonitorReports(listOf(null), now),
)
}
@Test
fun `mixed fresh-and-stale set is not stale (any-fresh wins)`() {
assertFalse(
isStaleByLatestMonitorReports(
listOf(now - staleSecsAgo, now - freshSecsAgo),
now,
),
)
}
@Test
fun `mixed null-and-fresh set is not stale (any-fresh wins)`() {
assertFalse(
isStaleByLatestMonitorReports(
listOf(null, now - freshSecsAgo),
now,
),
)
}
@Test
fun `all-stale-or-null set is stale`() {
assertTrue(
isStaleByLatestMonitorReports(
listOf(null, now - staleSecsAgo, now - staleSecsAgo - TimeUtils.ONE_DAY),
now,
),
)
}
@Test
fun `relay exactly at threshold cutoff is considered fresh`() {
// cutoff = now - thresholdSecs; helper uses strict `<`, so equal-to-cutoff is fresh
val cutoff = now - StaleRelayThresholds.DEFAULT_THRESHOLD_SECS
assertFalse(
isStaleByLatestMonitorReports(listOf(cutoff), now),
)
}
@Test
fun `custom threshold is honored`() {
// 1-hour threshold: 30 minutes ago is fresh, 2 hours ago is stale.
assertFalse(
isStaleByLatestMonitorReports(
listOf(now - 30 * 60),
now,
thresholdSecs = 60 * 60,
),
)
assertTrue(
isStaleByLatestMonitorReports(
listOf(now - 2 * 60 * 60),
now,
thresholdSecs = 60 * 60,
),
)
}
}