From b7dd460734962f9ec43e631eb84cd91fbb56d99f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:26:12 +0000 Subject: [PATCH] feat: live relay activity UI for event sync screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewModel now emits a LiveSyncActivity StateFlow alongside the existing SyncState, carrying: - activeRelays: which relays are currently being queried - recentCompletions: last 100 completed relays with event counts - outboxTargets / inboxTargets / dmTargets: destination relay sets downloadFromPool gains onRelayStart/onRelayComplete(relay, eventsFound) callbacks; downloadFromRelay returns Int (total events across all pages). Tracking uses ConcurrentHashMap.newKeySet + synchronized ArrayDeque for thread safety across 50 concurrent workers. Screen adds three new live cards: ActiveRelaysCard — FlowRow of chips with a shared pulsing dot animation (one InfiniteTransition for all chips) DestinationRelaysCard — Outbox / Inbox / DMs sections, color-coded using primary / secondary / tertiary ActivityLogCard — fixed-height (260dp) inner-scrollable list; filled dot + count for active relays, muted for empty/unreachable ones; K/M formatting for counts String resources updated: event_sync_batch_of → event_sync_relays_progress, paused_body copy updated to relay language; 7 new strings added. https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX --- .../relays/eventsync/EventSyncScreen.kt | 385 ++++++++++++++++-- .../relays/eventsync/EventSyncViewModel.kt | 114 +++++- amethyst/src/main/res/values/strings.xml | 12 +- 3 files changed, 478 insertions(+), 33 deletions(-) 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 index 4477ac210..5bd3b4393 100644 --- 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 @@ -21,25 +21,40 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow 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.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -49,7 +64,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip 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 com.vitorpamplona.amethyst.R @@ -57,6 +75,7 @@ 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 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun EventSyncScreen( @@ -66,6 +85,7 @@ fun EventSyncScreen( val syncViewModel = accountViewModel.eventSyncViewModel val syncState by syncViewModel.syncState.collectAsStateWithLifecycle() + val liveActivity by syncViewModel.liveActivity.collectAsStateWithLifecycle() val isMobileOrMetered by accountViewModel.settings.isMobileOrMeteredConnection.collectAsStateWithLifecycle() var showMobileDataDialog by remember { mutableStateOf(false) } @@ -152,6 +172,27 @@ fun EventSyncScreen( } } + // ---- Live relay activity (shown during and after sync) ---- + val isRunning = syncState is EventSyncViewModel.SyncState.Running + + if (liveActivity.activeRelays.isNotEmpty()) { + ActiveRelaysCard( + activeRelays = liveActivity.activeRelays, + isRunning = isRunning, + ) + } + + if (liveActivity.outboxTargets.isNotEmpty() || + liveActivity.inboxTargets.isNotEmpty() || + liveActivity.dmTargets.isNotEmpty() + ) { + DestinationRelaysCard(activity = liveActivity) + } + + if (liveActivity.recentCompletions.isNotEmpty()) { + ActivityLogCard(completions = liveActivity.recentCompletions) + } + // ---- Action buttons ---- when (val state = syncState) { is EventSyncViewModel.SyncState.Idle, @@ -237,27 +278,9 @@ fun EventSyncScreen( } } -@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, - ) - } -} +// ------------------------------------------------------------------------- +// Progress / status cards +// ------------------------------------------------------------------------- @Composable private fun SyncProgressCard(state: EventSyncViewModel.SyncState.Running) { @@ -268,13 +291,19 @@ private fun SyncProgressCard(state: EventSyncViewModel.SyncState.Running) { ) { Column(modifier = Modifier.padding(16.dp)) { Text( - text = stringRes(R.string.event_sync_batch_of, state.relaysCompleted, state.totalRelays), + text = stringRes(R.string.event_sync_relays_progress, state.relaysCompleted, state.totalRelays), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(8.dp)) LinearProgressIndicator( - progress = { state.relaysCompleted.toFloat() / state.totalRelays }, + progress = { + if (state.totalRelays > 0) { + state.relaysCompleted.toFloat() / state.totalRelays + } else { + 0f + } + }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) @@ -375,3 +404,313 @@ private fun ErrorCard(message: String) { } } } + +// ------------------------------------------------------------------------- +// Live activity cards +// ------------------------------------------------------------------------- + +/** + * Shows all relays currently being queried, each with a pulsing dot indicator. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ActiveRelaysCard( + activeRelays: Set, + isRunning: Boolean, +) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val pulseAlpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.2f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 800), + repeatMode = RepeatMode.Reverse, + ), + label = "pulseAlpha", + ) + + 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 = stringRes(R.string.event_sync_reading_from, activeRelays.size), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(10.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + activeRelays.forEach { relay -> + ActiveRelayChip( + relay = relay, + pulseAlpha = if (isRunning) pulseAlpha else 1f, + ) + } + } + } + } +} + +@Composable +private fun ActiveRelayChip( + relay: NormalizedRelayUrl, + pulseAlpha: Float, +) { + SuggestionChip( + onClick = {}, + label = { + Text( + text = relay.displayHost(), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + icon = { + Box( + modifier = + Modifier + .size(8.dp) + .alpha(pulseAlpha) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + }, + colors = + SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), + ), + ) +} + +/** + * Shows where events are being sent: outbox, inbox, and DM relay lists. + */ +@Composable +private fun DestinationRelaysCard(activity: EventSyncViewModel.LiveSyncActivity) { + 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 = stringRes(R.string.event_sync_sending_to), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + if (activity.outboxTargets.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + DestinationSection( + label = stringRes(R.string.event_sync_outbox_relays), + relays = activity.outboxTargets, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (activity.inboxTargets.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(Modifier.height(10.dp)) + DestinationSection( + label = stringRes(R.string.event_sync_inbox_relays), + relays = activity.inboxTargets, + color = MaterialTheme.colorScheme.secondary, + ) + } + + if (activity.dmTargets.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(Modifier.height(10.dp)) + DestinationSection( + label = stringRes(R.string.event_sync_dm_relays), + relays = activity.dmTargets, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DestinationSection( + label: String, + relays: Set, + color: androidx.compose.ui.graphics.Color, +) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + relays.forEach { relay -> + SuggestionChip( + onClick = {}, + label = { + Text( + text = relay.displayHost(), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + colors = + SuggestionChipDefaults.suggestionChipColors( + containerColor = color.copy(alpha = 0.12f), + labelColor = color, + ), + ) + } + } +} + +/** + * Scrollable log of recently completed relays, newest at the top. + * Uses a fixed-height inner scroll area so it doesn't compete with the outer scroll. + */ +@Composable +private fun ActivityLogCard( + completions: List, +) { + 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 = stringRes(R.string.event_sync_activity_log, completions.size), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(10.dp)) + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(260.dp) + .verticalScroll(rememberScrollState()), + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + completions.forEachIndexed { index, info -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + ActivityLogRow(info = info) + } + } + } + } + } +} + +@Composable +private fun ActivityLogRow(info: EventSyncViewModel.LiveSyncActivity.CompletedRelayInfo) { + val hasEvents = info.eventsFound > 0 + val dotColor = + if (hasEvents) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + } + val textColor = + if (hasEvents) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(dotColor), + ) + Text( + text = info.relay.displayHost(), + style = MaterialTheme.typography.bodySmall, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = + if (hasEvents) { + stringRes(R.string.event_sync_events_found_log, formatCount(info.eventsFound)) + } else { + stringRes(R.string.event_sync_no_events) + }, + style = MaterialTheme.typography.bodySmall, + color = textColor, + ) + } +} + +// ------------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------------- + +@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, + ) + } +} + +/** Strips the WebSocket scheme and trailing slash for compact display. */ +private fun NormalizedRelayUrl.displayHost(): String = + url + .removePrefix("wss://") + .removePrefix("ws://") + .trimEnd('/') + +/** Formats a count with K/M suffix for large numbers. */ +private fun formatCount(n: Int): String = + when { + n >= 1_000_000 -> "${n / 1_000_000}M" + n >= 1_000 -> "${n / 1_000}K" + else -> n.toString() + } 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 index 39891277e..dd6c0449f 100644 --- 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 @@ -27,14 +27,13 @@ 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.CancellationException import kotlinx.coroutines.CoroutineScope 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.CancellationException -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope @@ -57,6 +56,9 @@ import java.util.concurrent.atomic.AtomicLong * Each relay is paginated individually: after EOSE the oldest [Event.createdAt] seen on that * relay becomes the next `until` cursor, repeating until the relay returns no new events. * + * Live activity is emitted via [liveActivity] so the UI can show which relays are currently + * being read, where events are being sent, and a running log of completed relays. + * * The sync is pausable: calling [cancel] transitions to [SyncState.Paused] so the user can * [resume] from the last completed relay index rather than starting over. * @@ -72,8 +74,15 @@ class EventSyncViewModel( /** How long (ms) to wait for a single relay to reply per page before giving up. */ const val RELAY_TIMEOUT_MS = 30_000L + + /** Maximum number of completed-relay entries kept in the activity log. */ + const val MAX_ACTIVITY_LOG = 100 } + // ------------------------------------------------------------------------- + // Public state + // ------------------------------------------------------------------------- + sealed class SyncState { object Idle : SyncState() @@ -100,13 +109,71 @@ class EventSyncViewModel( ) : SyncState() } + /** + * Per-relay activity snapshot emitted continuously while the sync runs. + * + * @param activeRelays Relays currently being queried. + * @param recentCompletions Last [MAX_ACTIVITY_LOG] relays that finished, newest first. + * @param outboxTargets Relays receiving events authored by the user. + * @param inboxTargets Relays receiving events that mention the user. + * @param dmTargets Relays receiving DMs addressed to the user. + */ + data class LiveSyncActivity( + val activeRelays: Set = emptySet(), + val recentCompletions: List = emptyList(), + val outboxTargets: Set = emptySet(), + val inboxTargets: Set = emptySet(), + val dmTargets: Set = emptySet(), + ) { + data class CompletedRelayInfo( + val relay: NormalizedRelayUrl, + /** Total events received across all pages. 0 = relay was empty or unreachable. */ + val eventsFound: Int, + ) + } + private val _syncState = MutableStateFlow(SyncState.Idle) val syncState: StateFlow = _syncState + private val _liveActivity = MutableStateFlow(LiveSyncActivity()) + val liveActivity: StateFlow = _liveActivity + + // ------------------------------------------------------------------------- + // Live activity tracking (written from worker threads) + // ------------------------------------------------------------------------- + + private val trackingActiveRelays = ConcurrentHashMap.newKeySet() + private val trackingCompletions = ArrayDeque() + private val trackingLock = Any() + + /** Destination relay sets, set at the start of each sync run. */ + @Volatile private var liveOutboxTargets: Set = emptySet() + @Volatile private var liveInboxTargets: Set = emptySet() + @Volatile private var liveDmTargets: Set = emptySet() + + private fun emitLiveSnapshot() { + val completions = synchronized(trackingLock) { trackingCompletions.toList() } + _liveActivity.value = + LiveSyncActivity( + activeRelays = trackingActiveRelays.toSet(), + recentCompletions = completions, + outboxTargets = liveOutboxTargets, + inboxTargets = liveInboxTargets, + dmTargets = liveDmTargets, + ) + } + + // ------------------------------------------------------------------------- + // Control functions + // ------------------------------------------------------------------------- + private var syncJob: Job? = null fun start() { if (_syncState.value is SyncState.Running) return + trackingActiveRelays.clear() + synchronized(trackingLock) { trackingCompletions.clear() } + _liveActivity.value = LiveSyncActivity() syncJob = scope.launch(Dispatchers.IO) { runSync(startRelayIndex = 0, initialEventsSent = 0) @@ -140,6 +207,10 @@ class EventSyncViewModel( } } + // ------------------------------------------------------------------------- + // Sync logic + // ------------------------------------------------------------------------- + private suspend fun runSync( startRelayIndex: Int, initialEventsSent: Int, @@ -165,6 +236,12 @@ class EventSyncViewModel( val inboxTargets = account.nip65RelayList.inboxFlow.value val dmTargets = account.dmRelays.flow.value + // Publish destination relays so the UI can show them before events arrive. + liveOutboxTargets = outboxTargets + liveInboxTargets = inboxTargets + liveDmTargets = dmTargets + emitLiveSnapshot() + val baseFilters = buildList { if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey))) @@ -223,8 +300,19 @@ class EventSyncViewModel( } } }, - onRelayComplete = { + onRelayStart = { relay -> + trackingActiveRelays.add(relay) + emitLiveSnapshot() + }, + onRelayComplete = { relay, eventsFound -> + trackingActiveRelays.remove(relay) + val info = LiveSyncActivity.CompletedRelayInfo(relay, eventsFound) + synchronized(trackingLock) { + trackingCompletions.addFirst(info) + while (trackingCompletions.size > MAX_ACTIVITY_LOG) trackingCompletions.removeLast() + } val completed = relaysCompleted.incrementAndGet() + emitLiveSnapshot() _syncState.value = SyncState.Running( relaysCompleted = completed, @@ -255,7 +343,8 @@ class EventSyncViewModel( relays: List, baseFilters: List, onEvent: (Event) -> Unit, - onRelayComplete: () -> Unit, + onRelayStart: (NormalizedRelayUrl) -> Unit, + onRelayComplete: (NormalizedRelayUrl, Int) -> Unit, ) { val semaphore = Semaphore(MAX_CONCURRENT_RELAYS) supervisorScope { @@ -264,8 +353,9 @@ class EventSyncViewModel( semaphore.acquire() launch { try { - downloadFromRelay(relay, baseFilters, onEvent) - onRelayComplete() + onRelayStart(relay) + val eventsFound = downloadFromRelay(relay, baseFilters, onEvent) + onRelayComplete(relay, eventsFound) } finally { semaphore.release() } @@ -281,13 +371,16 @@ class EventSyncViewModel( * [Event.createdAt] minus one becomes the next `until` and the query repeats. * Stops when EOSE arrives with no new events (relay exhausted) or the relay * cannot be reached. + * + * @return total number of events received across all pages. */ private suspend fun downloadFromRelay( relay: NormalizedRelayUrl, baseFilters: List, onEvent: (Event) -> Unit, - ) { + ): Int { var until: Long? = null + var totalEvents = 0 while (true) { coroutineContext.ensureActive() @@ -345,10 +438,15 @@ class EventSyncViewModel( account.client.close(subId) done.close() - if (pageCount.get() == 0) break // relay exhausted or unreachable + val count = pageCount.get() + if (count == 0) break // relay exhausted or unreachable + + totalEvents += count // Advance cursor: next page starts just before the oldest event seen. until = pageMinTs.get() - 1 } + + return totalEvents } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d7c2063ee..b5eaa0ae2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1767,11 +1767,19 @@ Resume Start Over Cancel - Batch %1$d of %2$d + Relays: %1$d / %2$d Events redistributed: %1$d Sync Paused - Completed batch %1$d of %2$d — %3$d events redistributed so far. Tap Resume to continue. + Completed %1$d of %2$d relays — %3$d events redistributed so far. Tap Resume to continue. Sync complete Redistributed %1$d events in %2$d seconds. Sync error + Reading (%1$d active) + Sending To + Outbox + Inbox + DMs + Activity (%1$d relays) + %1$s events + no events