From 83e79cf85702c29907223d1022b4b716cfe6daf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 02:15:16 +0000 Subject: [PATCH] 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 --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/relays/AllRelayListScreen.kt | 15 + .../relays/eventsync/EventSyncScreen.kt | 329 ++++++++++++++++++ .../relays/eventsync/EventSyncViewModel.kt | 283 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 23 ++ 6 files changed, 654 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 2bb646e98..0bc04f342 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { NIP47SetupScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + composableFromEnd { EventSyncScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b55b4dd06..9ea1f6b05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -124,6 +124,8 @@ sealed class Route { @Serializable object EditRelays : Route() + @Serializable object EventSync : Route() + @Serializable object EditMediaServers : Route() @Serializable object UpdateReactionType : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 508c8ae09..0d68925ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -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)) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt new file mode 100644 index 000000000..c8e290ada --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt @@ -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 -> "" + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncViewModel.kt new file mode 100644 index 000000000..f90e4894f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncViewModel.kt @@ -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.Idle) + val syncState: StateFlow = _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(1024) + val inboxSent = HashSet(1024) + val dmSent = HashSet(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(Channel.CONFLATED) + val subId = newSubId() + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onEvent(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done.trySend(Unit) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done.trySend(Unit) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + 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 create(modelClass: Class): T = EventSyncViewModel(account) as T + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 799135f7d..d2bf0265b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1748,4 +1748,27 @@ Select All %1$d%% uptime Namecoin Settings + + + Relay Sync + Relay Sync + 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. + Open Relay Sync… + What this does + 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. + Download all events you authored and send them to your outbox relays. + Download all events that mention you and send them to your inbox relays. + Download all direct messages addressed to you and send them to your DM relays. + ⚠ 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. + Start Sync + Start Anyway (mobile data) + Cancel + Phase 1 of 3 — Syncing your posts to outbox relays + Phase 2 of 3 — Syncing mentions to inbox relays + Phase 3 of 3 — Syncing direct messages to DM relays + Relay %1$d of %2$d + Events redistributed: %1$d + Sync complete + Redistributed %1$d events in %2$d seconds. + Sync error