feat: add EventSync screen for relay event synchronization

- Add EventSyncScreen composable with UI for event sync configuration
- Add EventSyncViewModel for managing sync state and business logic
- Add route and navigation entry for EventSync screen
- Add entry point in AllRelayListScreen
- Add string resources for EventSync UI

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
This commit is contained in:
Claude
2026-03-13 02:15:16 +00:00
parent 548ce0a69d
commit 83e79cf857
6 changed files with 654 additions and 0 deletions
@@ -108,6 +108,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
@@ -194,6 +195,7 @@ fun AppNavigation(
composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
composableFromEnd<Route.EventSync> { EventSyncScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
@@ -124,6 +124,8 @@ sealed class Route {
@Serializable object EditRelays : Route()
@Serializable object EventSync : Route()
@Serializable object EditMediaServers : Route()
@Serializable object UpdateReactionType : Route()
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.model.DefaultDMRelayList
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelayListViewModel
@@ -346,6 +347,20 @@ fun MappedAllRelayListView(
)
}
renderConnectedItems(connectedRelays, connectedViewModel, accountViewModel, newNav)
item {
SettingsCategory(
R.string.event_sync_section,
R.string.event_sync_section_explainer,
SettingsCategorySpacingModifier,
)
OutlinedButton(
onClick = { newNav.nav(Route.EventSync) },
modifier = Modifier.padding(top = 8.dp),
) {
Text(stringRes(R.string.event_sync_open_button))
}
}
}
}
}
@@ -0,0 +1,329 @@
/*
* 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.relays.eventsync
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun EventSyncScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val syncViewModel: EventSyncViewModel =
viewModel(factory = EventSyncViewModel.Factory(accountViewModel.account))
val syncState by syncViewModel.syncState.collectAsStateWithLifecycle()
val isMobileOrMetered by accountViewModel.settings.isMobileOrMeteredConnection.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopBarWithBackButton(
caption = stringRes(R.string.event_sync_title),
popBack = {
syncViewModel.cancel()
nav.popBack()
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Spacer(Modifier.height(8.dp))
// ---- Explanation card ----
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.event_sync_what_happens_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringRes(R.string.event_sync_what_happens_body),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
StepRow(number = "1", text = stringRes(R.string.event_sync_step1))
Spacer(Modifier.height(4.dp))
StepRow(number = "2", text = stringRes(R.string.event_sync_step2))
Spacer(Modifier.height(4.dp))
StepRow(number = "3", text = stringRes(R.string.event_sync_step3))
}
}
// ---- WiFi warning ----
if (isMobileOrMetered) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Text(
text = stringRes(R.string.event_sync_wifi_warning),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp),
)
}
}
// ---- Progress / Status area ----
when (val state = syncState) {
is EventSyncViewModel.SyncState.Idle -> {
// No progress shown — just the start button below
}
is EventSyncViewModel.SyncState.Running -> {
SyncProgressCard(state = state)
}
is EventSyncViewModel.SyncState.Done -> {
DoneCard(state = state)
}
is EventSyncViewModel.SyncState.Error -> {
ErrorCard(message = state.message)
}
}
// ---- Action buttons ----
when (syncState) {
is EventSyncViewModel.SyncState.Idle,
is EventSyncViewModel.SyncState.Done,
is EventSyncViewModel.SyncState.Error,
-> {
Button(
onClick = { syncViewModel.start() },
modifier = Modifier.fillMaxWidth(),
enabled = !isMobileOrMetered || syncState !is EventSyncViewModel.SyncState.Idle,
) {
Text(stringRes(R.string.event_sync_start))
}
if (isMobileOrMetered && syncState is EventSyncViewModel.SyncState.Idle) {
OutlinedButton(
onClick = { syncViewModel.start() },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.event_sync_start_anyway))
}
}
}
is EventSyncViewModel.SyncState.Running -> {
OutlinedButton(
onClick = { syncViewModel.cancel() },
modifier = Modifier.fillMaxWidth(),
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringRes(R.string.event_sync_cancel))
}
}
}
Spacer(Modifier.height(16.dp))
}
}
}
@Composable
private fun StepRow(
number: String,
text: String,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = number + ".",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
private fun SyncProgressCard(state: EventSyncViewModel.SyncState.Running) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = phaseLabel(state.phase),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringRes(R.string.event_sync_relay_of, state.relayIndex, state.totalRelays),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
text = state.currentRelay,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
// Overall progress across all phases and relays
val overallProgress =
((state.phase - 1).toFloat() / state.phaseTotal +
(state.relayIndex.toFloat() / state.totalRelays) / state.phaseTotal)
.coerceIn(0f, 1f)
LinearProgressIndicator(
progress = { overallProgress },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringRes(R.string.event_sync_events_sent, state.eventsSent),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun DoneCard(state: EventSyncViewModel.SyncState.Done) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.event_sync_done_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(Modifier.height(4.dp))
Text(
text =
stringRes(
R.string.event_sync_done_body,
state.totalEventsSent,
(state.durationMs / 1000).toInt(),
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
@Composable
private fun ErrorCard(message: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.event_sync_error_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(4.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
@Composable
private fun phaseLabel(phase: Int): String =
when (phase) {
1 -> stringRes(R.string.event_sync_phase1)
2 -> stringRes(R.string.event_sync_phase2)
3 -> stringRes(R.string.event_sync_phase3)
else -> ""
}
@@ -0,0 +1,283 @@
/*
* 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.relays.eventsync
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* Syncs the user's events across all known relays:
* 1. Downloads all events authored by the user and sends them to their outbox relays.
* 2. Downloads all events that p-tag the user and sends them to their inbox relays.
* 3. Downloads kind-4 and kind-1059 events that p-tag the user and sends them to DM relays.
*
* The procedure iterates over every relay URL in LocalCache.relayHints.relayDB.
* Events are deduplicated per destination to avoid redundant sends.
*/
class EventSyncViewModel(
val account: Account,
) : ViewModel() {
sealed class SyncState {
object Idle : SyncState()
data class Running(
val phase: Int,
val phaseTotal: Int,
val currentRelay: String,
val relayIndex: Int,
val totalRelays: Int,
val eventsSent: Int,
) : SyncState()
data class Done(
val totalEventsSent: Int,
val durationMs: Long,
) : SyncState()
data class Error(
val message: String,
) : SyncState()
}
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
val syncState: StateFlow<SyncState> = _syncState
private var syncJob: Job? = null
fun start() {
if (_syncState.value is SyncState.Running) return
syncJob =
viewModelScope.launch(Dispatchers.IO) {
runSync()
}
}
fun cancel() {
syncJob?.cancel()
_syncState.value = SyncState.Idle
}
private suspend fun runSync() {
val startTime = System.currentTimeMillis()
val myPubKey = account.signer.pubKey
val allRelays = account.cache.relayHints.relayDB.keys().toList()
if (allRelays.isEmpty()) {
_syncState.value = SyncState.Error("No known relays found. Browse some content first to discover relays.")
return
}
val outboxTargets = account.outboxRelays.flow.value
val inboxTargets = account.nip65RelayList.inboxFlow.value
val dmTargets = account.dmRelays.flow.value
// Deduplication sets — one per destination category
val outboxSent = HashSet<String>(1024)
val inboxSent = HashSet<String>(1024)
val dmSent = HashSet<String>(1024)
var totalSent = 0
val totalPhases = 3
try {
// -------------------------
// Phase 1: Author's events → Outbox relays
// -------------------------
if (outboxTargets.isNotEmpty()) {
allRelays.forEachIndexed { index, relay ->
if (!isActive) return
_syncState.value =
SyncState.Running(
phase = 1,
phaseTotal = totalPhases,
currentRelay = relay.url,
relayIndex = index + 1,
totalRelays = allRelays.size,
eventsSent = totalSent,
)
downloadFromRelay(
relay = relay,
filter = Filter(authors = listOf(myPubKey)),
) { event ->
if (outboxSent.add(event.id)) {
account.client.send(event, outboxTargets)
totalSent++
}
}
}
}
// -------------------------
// Phase 2: P-tagged (non-DM) events → Inbox relays
// -------------------------
if (inboxTargets.isNotEmpty()) {
allRelays.forEachIndexed { index, relay ->
if (!isActive) return
_syncState.value =
SyncState.Running(
phase = 2,
phaseTotal = totalPhases,
currentRelay = relay.url,
relayIndex = index + 1,
totalRelays = allRelays.size,
eventsSent = totalSent,
)
downloadFromRelay(
relay = relay,
filter = Filter(tags = mapOf("p" to listOf(myPubKey))),
) { event ->
// Skip DM kinds — handled in phase 3
if (event.kind != 4 && event.kind != 1059) {
if (inboxSent.add(event.id)) {
account.client.send(event, inboxTargets)
totalSent++
}
}
}
}
}
// -------------------------
// Phase 3: DM events (kind 4 & 1059) → DM relays
// -------------------------
if (dmTargets.isNotEmpty()) {
allRelays.forEachIndexed { index, relay ->
if (!isActive) return
_syncState.value =
SyncState.Running(
phase = 3,
phaseTotal = totalPhases,
currentRelay = relay.url,
relayIndex = index + 1,
totalRelays = allRelays.size,
eventsSent = totalSent,
)
downloadFromRelay(
relay = relay,
filter = Filter(kinds = listOf(4, 1059), tags = mapOf("p" to listOf(myPubKey))),
) { event ->
if (dmSent.add(event.id)) {
account.client.send(event, dmTargets)
totalSent++
}
}
}
}
_syncState.value =
SyncState.Done(
totalEventsSent = totalSent,
durationMs = System.currentTimeMillis() - startTime,
)
} catch (e: Exception) {
if (isActive) {
_syncState.value = SyncState.Error(e.message ?: "Unknown error")
}
}
}
/**
* Opens a REQ subscription on [relay] with [filter], delivers every event to [onEvent],
* and suspends until the relay sends EOSE (or 90 seconds pass, whichever comes first).
*/
private suspend fun downloadFromRelay(
relay: NormalizedRelayUrl,
filter: Filter,
onEvent: (Event) -> Unit,
) {
val done = Channel<Unit>(Channel.CONFLATED)
val subId = newSubId()
val listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onEvent(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
done.trySend(Unit)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
done.trySend(Unit)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
done.trySend(Unit)
}
}
account.client.openReqSubscription(subId, mapOf(relay to listOf(filter)), listener)
withTimeoutOrNull(90_000L) {
done.receive()
}
account.client.close(subId)
done.close()
}
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = EventSyncViewModel(account) as T
}
}
+23
View File
@@ -1748,4 +1748,27 @@
<string name="select_all">Select All</string>
<string name="uptime">%1$d%% uptime</string>
<string name="namecoin_settings">Namecoin Settings</string>
<!-- Event Sync Screen -->
<string name="event_sync_title">Relay Sync</string>
<string name="event_sync_section">Relay Sync</string>
<string name="event_sync_section_explainer">Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data.</string>
<string name="event_sync_open_button">Open Relay Sync…</string>
<string name="event_sync_what_happens_title">What this does</string>
<string name="event_sync_what_happens_body">This tool scans every relay your app has seen and redistributes your events to the correct destinations. It may transfer a large amount of data, so it is best run on Wi-Fi.</string>
<string name="event_sync_step1">Download all events you authored and send them to your outbox relays.</string>
<string name="event_sync_step2">Download all events that mention you and send them to your inbox relays.</string>
<string name="event_sync_step3">Download all direct messages addressed to you and send them to your DM relays.</string>
<string name="event_sync_wifi_warning">⚠ You appear to be on a metered or mobile connection. This operation can transfer a very large amount of data. Connect to Wi-Fi before starting.</string>
<string name="event_sync_start">Start Sync</string>
<string name="event_sync_start_anyway">Start Anyway (mobile data)</string>
<string name="event_sync_cancel">Cancel</string>
<string name="event_sync_phase1">Phase 1 of 3 — Syncing your posts to outbox relays</string>
<string name="event_sync_phase2">Phase 2 of 3 — Syncing mentions to inbox relays</string>
<string name="event_sync_phase3">Phase 3 of 3 — Syncing direct messages to DM relays</string>
<string name="event_sync_relay_of">Relay %1$d of %2$d</string>
<string name="event_sync_events_sent">Events redistributed: %1$d</string>
<string name="event_sync_done_title">Sync complete</string>
<string name="event_sync_done_body">Redistributed %1$d events in %2$d seconds.</string>
<string name="event_sync_error_title">Sync error</string>
</resources>