Merge pull request #2797 from mstrofnone/feat/nip66-stale-relay-hint
feat(notes): show stale-relay hint on replaceable events using NIP-66 cache
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+116
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user