Adds a RelaySync filter by since/until dates.

This commit is contained in:
Vitor Pamplona
2026-03-22 11:18:28 -04:00
parent b6b4856fe4
commit 40c45fb3a1
4 changed files with 245 additions and 19 deletions
@@ -23,9 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -38,6 +42,7 @@ import org.junit.runner.RunWith
class EventSyncTest {
companion object {
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient =
@@ -70,4 +75,38 @@ class EventSyncTest {
sync.runSync()
}
@Test
fun testFiatjafSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = { listOf(fiatjaf) },
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
val newClient = NostrClient(socketBuilder, appScope)
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
val signer = NostrSignerInternal(KeyPair())
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { authTemplate ->
listOf(signer.sign(authTemplate))
},
)
newClient
},
scope = appScope,
)
sync.runSync()
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_ACTIVITY_LOG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_CONCURRENT_RELAYS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.RELAY_TIMEOUT_MS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.LiveSyncActivity.SourceRelayInfo
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -106,14 +107,18 @@ class EventSync(
val eventsSent: MutableStateFlow<Int>,
val eventsReceived: MutableStateFlow<Int>,
val eventsAccepted: MutableStateFlow<Int>,
val sinceFilter: Long? = null,
val untilFilter: Long? = null,
) : SyncState() {
constructor(relaysCompleted: Int, totalRelays: Int, eventsSent: Int, eventsReceived: Int, eventsAccepted: Int) :
constructor(relaysCompleted: Int, totalRelays: Int, eventsSent: Int, eventsReceived: Int, eventsAccepted: Int, sinceFilter: Long? = null, untilFilter: Long? = null) :
this(
relaysCompleted = MutableStateFlow(relaysCompleted),
totalRelays = MutableStateFlow(totalRelays),
eventsSent = MutableStateFlow(eventsSent),
eventsReceived = MutableStateFlow(eventsReceived),
eventsAccepted = MutableStateFlow(eventsAccepted),
sinceFilter = sinceFilter,
untilFilter = untilFilter,
)
}
@@ -122,10 +127,14 @@ class EventSync(
val totalEventsSent: Int,
val totalEventsAccepted: Int,
val durationMs: Long,
val sinceFilter: Long? = null,
val untilFilter: Long? = null,
) : SyncState()
data class Error(
val message: String,
val sinceFilter: Long? = null,
val untilFilter: Long? = null,
) : SyncState()
}
@@ -282,14 +291,20 @@ class EventSync(
// Control functions
// -------------------------------------------------------------------------
/** User-selected date range for the sync filter (epoch seconds). */
val sinceSecs = MutableStateFlow<Long?>(null)
val untilSecs = MutableStateFlow<Long?>(null)
private var syncJob: Job? = null
fun start() {
if (_syncState.value is SyncState.Running) return
_liveActivity.value = LiveSyncActivity()
val capturedSince = sinceSecs.value
val capturedUntil = untilSecs.value
syncJob =
scope.launch(Dispatchers.IO) {
runSync()
runSync(capturedSince, capturedUntil)
}
}
@@ -300,7 +315,10 @@ class EventSync(
// -------------------------------------------------------------------------
// Sync logic
// -------------------------------------------------------------------------
suspend fun runSync() {
suspend fun runSync(
filterSince: Long? = null,
filterUntil: Long? = null,
) {
val startTime = System.currentTimeMillis()
_liveActivity.value = LiveSyncActivity()
@@ -311,7 +329,7 @@ class EventSync(
if (relaysToProcess.isEmpty()) {
_syncState.value =
SyncState.Error("No known relays found. Browse some content first to discover relays.")
SyncState.Error("No known relays found. Browse some content first to discover relays.", filterSince, filterUntil)
return
}
@@ -323,14 +341,14 @@ class EventSync(
val defaultFilters =
buildList {
if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey)))
if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey), since = filterSince, until = filterUntil))
if (inboxTargets.isNotEmpty() || dmTargets.isNotEmpty()) {
add(Filter(tags = mapOf("p" to listOf(myPubKey))))
add(Filter(tags = mapOf("p" to listOf(myPubKey)), since = filterSince, until = filterUntil))
}
}
if (defaultFilters.isEmpty()) {
_syncState.value = SyncState.Error("No outbox, inbox, or DM relays configured.")
_syncState.value = SyncState.Error("No outbox, inbox, or DM relays configured.", filterSince, filterUntil)
return
}
@@ -342,9 +360,9 @@ class EventSync(
defaultFilters
} else {
buildList {
if (it !in outboxTargets) add(Filter(authors = listOf(myPubKey)))
if (it !in outboxTargets) add(Filter(authors = listOf(myPubKey), since = filterSince, until = filterUntil))
if (it !in inboxTargets && it !in dmTargets) {
add(Filter(tags = mapOf("p" to listOf(myPubKey))))
add(Filter(tags = mapOf("p" to listOf(myPubKey)), since = filterSince, until = filterUntil))
}
}
}
@@ -373,6 +391,8 @@ class EventSync(
eventsSent = 0,
eventsReceived = 0,
eventsAccepted = 0,
sinceFilter = filterSince,
untilFilter = filterUntil,
)
val okListener =
@@ -563,6 +583,8 @@ class EventSync(
totalEventsSent = runningState.eventsSent.value,
totalEventsAccepted = runningState.eventsAccepted.value,
durationMs = System.currentTimeMillis() - startTime,
sinceFilter = filterSince,
untilFilter = filterUntil,
)
} catch (e: Exception) {
_syncState.value =
@@ -571,10 +593,12 @@ class EventSync(
totalEventsSent = runningState.eventsSent.value,
totalEventsAccepted = runningState.eventsAccepted.value,
durationMs = System.currentTimeMillis() - startTime,
sinceFilter = filterSince,
untilFilter = filterUntil,
)
if (e is CancellationException) throw e
_syncState.value = SyncState.Error(e.message ?: "Unknown error")
_syncState.value = SyncState.Error(e.message ?: "Unknown error", filterSince, filterUntil)
} finally {
client.unsubscribe(okListener)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -43,10 +44,12 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RangeSlider
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -54,6 +57,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -70,6 +75,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun EventSyncScreen(
@@ -94,6 +102,8 @@ fun EventSyncScreen(
syncState = syncState,
liveActivity = liveActivity,
isMobileOrMetered = isMobileOrMetered,
onSinceChanged = { syncViewModel.sinceSecs.value = it },
onUntilChanged = { syncViewModel.untilSecs.value = it },
onStart = syncViewModel::start,
onCancel = syncViewModel::cancel,
)
@@ -106,6 +116,8 @@ fun EventScreenBody(
syncState: EventSync.SyncState,
liveActivity: EventSync.LiveSyncActivity,
isMobileOrMetered: Boolean = false,
onSinceChanged: (Long?) -> Unit = {},
onUntilChanged: (Long?) -> Unit = {},
onStart: () -> Unit = {},
onCancel: () -> Unit = {},
) {
@@ -119,10 +131,10 @@ fun EventScreenBody(
item {
// ---- Progress / Status area ----
when (syncState) {
is EventSync.SyncState.Idle -> ExplanationCard(isMobileOrMetered, onStart)
is EventSync.SyncState.Idle -> ExplanationCard(isMobileOrMetered, onSinceChanged, onUntilChanged, onStart)
is EventSync.SyncState.Running -> SyncProgressCard(state = syncState, onCancel)
is EventSync.SyncState.Done -> DoneCard(state = syncState, isMobileOrMetered, onStart)
is EventSync.SyncState.Error -> ErrorCard(syncState.message, isMobileOrMetered, onStart)
is EventSync.SyncState.Done -> DoneCard(state = syncState, isMobileOrMetered, onSinceChanged, onUntilChanged, onStart)
is EventSync.SyncState.Error -> ErrorCard(syncState, isMobileOrMetered, onSinceChanged, onUntilChanged, onStart)
}
}
@@ -231,7 +243,9 @@ private fun StartSyncButton(
@Composable
private fun ExplanationCard(
isMobileOrMetered: Boolean,
onStart: () -> Unit,
onSinceChanged: (Long?) -> Unit = {},
onUntilChanged: (Long?) -> Unit = {},
onStart: () -> Unit = {},
) {
// ---- Explanation card ----
Card(
@@ -256,6 +270,7 @@ private fun ExplanationCard(
Spacer(Modifier.height(4.dp))
StepRow(number = "3", text = stringRes(R.string.event_sync_step3))
Spacer(Modifier.height(10.dp))
// ---- WiFi warning ----
if (isMobileOrMetered) {
Card(
@@ -274,6 +289,12 @@ private fun ExplanationCard(
}
Spacer(Modifier.height(10.dp))
}
DateRangeFilterCard(
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
onSinceChanged = onSinceChanged,
onUntilChanged = onUntilChanged,
)
Spacer(Modifier.height(14.dp))
StartSyncButton(isMobileOrMetered = isMobileOrMetered, onStart)
}
}
@@ -352,6 +373,8 @@ private fun RelayStatement(state: EventSync.SyncState.Running) {
private fun DoneCard(
state: EventSync.SyncState.Done,
isMobileOrMetered: Boolean = false,
onSinceChanged: (Long?) -> Unit = {},
onUntilChanged: (Long?) -> Unit = {},
onStart: () -> Unit = { },
) {
Card(
@@ -387,6 +410,16 @@ private fun DoneCard(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
)
Spacer(Modifier.height(14.dp))
DateRangeFilterCard(
backgroundColor = MaterialTheme.colorScheme.primaryContainer,
onSinceStartValue = state.sinceFilter,
onUntilStartValue = state.untilFilter,
onSinceChanged = onSinceChanged,
onUntilChanged = onUntilChanged,
)
Spacer(Modifier.height(10.dp))
StartSyncButton(isMobileOrMetered, onStart)
}
@@ -395,8 +428,10 @@ private fun DoneCard(
@Composable
private fun ErrorCard(
message: String,
state: EventSync.SyncState.Error,
isMobileOrMetered: Boolean = false,
onSinceChanged: (Long?) -> Unit = {},
onUntilChanged: (Long?) -> Unit = {},
onStart: () -> Unit = {},
) {
Card(
@@ -415,10 +450,19 @@ private fun ErrorCard(
)
Spacer(Modifier.height(4.dp))
Text(
text = message,
text = state.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(14.dp))
DateRangeFilterCard(
backgroundColor = MaterialTheme.colorScheme.errorContainer,
onSinceStartValue = state.sinceFilter,
onUntilStartValue = state.untilFilter,
onSinceChanged = onSinceChanged,
onUntilChanged = onUntilChanged,
)
Spacer(Modifier.height(10.dp))
StartSyncButton(isMobileOrMetered, onStart)
}
@@ -660,6 +704,117 @@ private fun ActivityLogRow(info: EventSync.LiveSyncActivity.SourceRelayInfo) {
}
}
/** Oldest possible "since" — roughly 2020-01-01 in epoch seconds. */
private const val EPOCH_2023 = 1_672_549_201L
@Composable
private fun DateRangeFilterCard(
backgroundColor: Color,
onSinceStartValue: Long? = null,
onUntilStartValue: Long? = null,
onSinceChanged: (Long?) -> Unit = {},
onUntilChanged: (Long?) -> Unit = {},
) {
val now = remember { System.currentTimeMillis() / 1000 }
// Slider range: 0f = EPOCH_2020, 1f = now
val range = (now - EPOCH_2023).toFloat()
// Initialise from lastSyncTimestamp when available, otherwise full range
var sliderValues by remember(onSinceStartValue, onUntilStartValue) {
val initialSince =
if (onSinceStartValue != null) {
((onSinceStartValue - EPOCH_2023) / range).coerceIn(0f, 1f)
} else {
0f
}
val finalSince =
if (onUntilStartValue != null) {
((onUntilStartValue - EPOCH_2023) / range).coerceIn(0f, 1f)
} else {
1f
}
mutableStateOf(initialSince..finalSince)
}
// Derive actual timestamps
val sinceEpoch = (EPOCH_2023 + (sliderValues.start * range).toLong())
val untilEpoch = (EPOCH_2023 + (sliderValues.endInclusive * range).toLong())
// Fire callbacks
val effectiveSince = if (sliderValues.start <= 0.001f) null else sinceEpoch
val effectiveUntil = if (sliderValues.endInclusive >= 0.999f) null else untilEpoch
// Notify parent on each change
LaunchedEffect(effectiveSince) { onSinceChanged(effectiveSince) }
LaunchedEffect(effectiveUntil) { onUntilChanged(effectiveUntil) }
Card(
modifier =
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = backgroundColor),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.event_sync_date_filter_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(12.dp))
// Labels for current selection
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text =
if (effectiveSince == null) {
stringRes(R.string.event_sync_date_filter_all_time)
} else {
stringRes(R.string.event_sync_date_filter_since) + " " + formatEpochDate(sinceEpoch)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text =
if (effectiveUntil == null) {
stringRes(R.string.event_sync_date_filter_now)
} else {
stringRes(R.string.event_sync_date_filter_until) + " " + formatEpochDate(untilEpoch)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
Box(
// block left drawer and back gestures when sliding
Modifier.pointerInput(Unit) {
detectDragGestures { change, _ ->
change.consume()
}
},
) {
RangeSlider(
value = sliderValues,
onValueChange = { sliderValues = it },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
private fun formatEpochDate(epochSecs: Long): String {
val sdf = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
return sdf.format(Date(epochSecs * 1000))
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@@ -747,7 +902,7 @@ private val previewActivity =
@Preview
fun IdleCardWifiPreview() {
ThemeComparisonColumn {
ExplanationCard(false, {})
ExplanationCard(false)
}
}
@@ -755,7 +910,7 @@ fun IdleCardWifiPreview() {
@Preview
fun IdleCardMobilePreview() {
ThemeComparisonColumn {
ExplanationCard(true, {})
ExplanationCard(true)
}
}
@@ -797,7 +952,7 @@ fun DoneCardPreview() {
@Preview
fun ErrorCardPreview() {
ThemeComparisonColumn {
ErrorCard(message = "No outbox, inbox, or DM relays configured.")
ErrorCard(state = EventSync.SyncState.Error("No outbox, inbox, or DM relays configured."))
}
}
+8
View File
@@ -1912,4 +1912,12 @@
<string name="attestor_proficiency_for_kinds">Proficient in verifying kinds: %1$s</string>
<string name="attestation_attests_to">Attests to</string>
<string name="attestation_requests_attestation_to">Requests attestation to</string>
<string name="event_sync_date_filter_title">Date Range</string>
<string name="event_sync_date_filter_since">Since</string>
<string name="event_sync_date_filter_until">Until</string>
<string name="event_sync_date_filter_now">Now</string>
<string name="event_sync_date_filter_all_time">All time</string>
<string name="event_sync_date_filter_last_sync">Last sync: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Since Last Sync</string>
</resources>