cleans up broadcasting tracker

This commit is contained in:
Vitor Pamplona
2026-02-09 20:28:35 -05:00
parent 89b058fff3
commit a70101f5e5
6 changed files with 57 additions and 204 deletions
@@ -141,7 +141,4 @@ data class BroadcastEvent(
data class BroadcastResult( data class BroadcastResult(
val broadcast: BroadcastEvent, val broadcast: BroadcastEvent,
val isSuccess: Boolean, val isSuccess: Boolean,
) { )
val successCount: Int get() = broadcast.successCount
val totalRelays: Int get() = broadcast.totalRelays
}
@@ -51,7 +51,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.ui.stringRes
/** /**
* Banner showing active broadcast progress. * Banner showing active broadcast progress.
@@ -102,7 +104,7 @@ private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
) { ) {
Icon( Icon(
imageVector = Icons.Default.Sync, imageVector = Icons.Default.Sync,
contentDescription = "Broadcasting", contentDescription = stringRes(R.string.broadcasting),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
@@ -113,7 +115,7 @@ private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
text = "Broadcasting ${broadcast.eventName} (kind ${broadcast.kind})", text = stringRes(R.string.broadcasting_name, broadcast.eventName),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -124,7 +126,7 @@ private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
text = "[${broadcast.results.size}/${broadcast.totalRelays}]", text = stringRes(R.string.share_of, broadcast.results.size, broadcast.totalRelays),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -160,7 +162,7 @@ private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) {
) { ) {
Icon( Icon(
imageVector = Icons.Default.Sync, imageVector = Icons.Default.Sync,
contentDescription = "Broadcasting", contentDescription = stringRes(R.string.broadcasting),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
@@ -171,13 +173,13 @@ private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
text = "Broadcasting ${broadcasts.size} events...", text = stringRes(R.string.broadcasting_number_events, broadcasts.size),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = "[$completedResponses/$totalRelays]", text = stringRes(R.string.share_of, completedResponses, totalRelays),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -70,42 +70,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.service.broadcast.RelayResult import com.vitorpamplona.amethyst.service.broadcast.RelayResult
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
private const val MAX_EXPANDED_SECTIONS = 2 private const val MAX_EXPANDED_SECTIONS = 2
/**
* Modal bottom sheet showing detailed relay results for broadcasts.
* Shows up to 2 expanded sections, with a summary for additional broadcasts.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BroadcastDetailsSheet(
broadcast: BroadcastEvent,
onDismiss: () -> Unit,
onRetryRelay: (NormalizedRelayUrl) -> Unit,
onRetryAllFailed: () -> Unit,
sheetState: SheetState =
rememberModalBottomSheetState(
skipPartiallyExpanded = true,
),
) {
MultiBroadcastDetailsSheet(
broadcasts = listOf(broadcast),
onDismiss = onDismiss,
onRetryRelay = { _, relay -> onRetryRelay(relay) },
onRetryAllFailed = { onRetryAllFailed() },
sheetState = sheetState,
)
}
/** /**
* Modal bottom sheet showing detailed relay results for multiple broadcasts. * Modal bottom sheet showing detailed relay results for multiple broadcasts.
*/ */
@@ -164,7 +142,7 @@ fun MultiBroadcastDetailsSheet(
) { ) {
// Header // Header
Text( Text(
text = if (broadcasts.size == 1) "Broadcast Results" else "Broadcasts (${broadcasts.size})", text = if (broadcasts.size == 1) stringRes(R.string.broadcast_results) else stringRes(R.string.broadcasts_number, broadcasts.size),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@@ -220,7 +198,7 @@ fun MultiBroadcastDetailsSheet(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Dismiss") Text(stringRes(R.string.dismiss))
} }
} }
} }
@@ -259,22 +237,17 @@ private fun BroadcastSection(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text(
text = "kind ${broadcast.kind}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
Text( Text(
text = "${broadcast.successCount}/${broadcast.totalRelays}", text = stringResource(R.string.share_of, broadcast.successCount, broadcast.totalRelays),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = statusColor(broadcast.status), color = statusColor(broadcast.status),
) )
Icon( Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand", contentDescription = if (isExpanded) stringRes(R.string.collapse) else stringRes(R.string.expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
@@ -315,7 +288,7 @@ private fun BroadcastSection(
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("Retry Failed (${broadcast.failedRelays.size})") Text(stringRes(R.string.retry_failed_number, broadcast.failedRelays.size))
} }
} }
} }
@@ -349,16 +322,16 @@ private fun OverflowSummary(broadcasts: List<BroadcastEvent>) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "+${broadcasts.size} more broadcasts", text = stringRes(R.string.more_broadcasts_number, broadcasts.size),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = text =
buildString { buildString {
append("$totalSuccess/$totalRelays relays") append(stringResource(R.string.share_of_relays, totalSuccess, totalRelays))
if (inProgressCount > 0) { if (inProgressCount > 0) {
append(" ($inProgressCount in progress)") append(stringResource(R.string.number_in_progress, inProgressCount))
} }
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -479,7 +452,7 @@ private fun RelayResultRow(
is RelayResult.Timeout -> { is RelayResult.Timeout -> {
Text( Text(
text = "Timeout", text = stringRes(R.string.timeout),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = warningColor, color = warningColor,
) )
@@ -487,7 +460,7 @@ private fun RelayResultRow(
is RelayResult.Retrying -> { is RelayResult.Retrying -> {
Text( Text(
text = "Retrying...", text = stringRes(R.string.retrying),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -498,14 +471,14 @@ private fun RelayResultRow(
} }
// Retry button for failed relays (not when already retrying) // Retry button for failed relays (not when already retrying)
if ((result is RelayResult.Error || result is RelayResult.Timeout) && result !is RelayResult.Retrying) { if (result is RelayResult.Error || result is RelayResult.Timeout) {
IconButton( IconButton(
onClick = onRetry, onClick = onRetry,
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Default.Refresh, imageVector = Icons.Default.Refresh,
contentDescription = "Retry", contentDescription = stringResource(R.string.retry),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
@@ -1,145 +0,0 @@
/*
* 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.broadcast
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import kotlinx.coroutines.flow.SharedFlow
/**
* Custom snackbar visuals for broadcast results.
*/
data class BroadcastSnackbarVisuals(
val broadcast: BroadcastEvent,
override val actionLabel: String? = "View",
override val duration: SnackbarDuration = SnackbarDuration.Short,
override val withDismissAction: Boolean = true,
) : SnackbarVisuals {
override val message: String
get() =
when (broadcast.status) {
BroadcastStatus.SUCCESS -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays"
BroadcastStatus.PARTIAL -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays"
BroadcastStatus.FAILED -> "${broadcast.eventName} failed - 0/${broadcast.totalRelays} relays"
BroadcastStatus.IN_PROGRESS -> "Broadcasting ${broadcast.eventName}..."
}
}
/**
* Snackbar host that listens to completed broadcasts and shows result notifications.
*/
@Composable
fun BroadcastSnackbarHost(
completedBroadcast: SharedFlow<BroadcastEvent>,
onViewDetails: (BroadcastEvent) -> Unit,
onRetry: (BroadcastEvent) -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(completedBroadcast) {
completedBroadcast.collect { broadcast ->
val visuals = BroadcastSnackbarVisuals(broadcast)
val result = snackbarHostState.showSnackbar(visuals)
when (result) {
SnackbarResult.ActionPerformed -> {
if (broadcast.status == BroadcastStatus.FAILED) {
onRetry(broadcast)
} else {
onViewDetails(broadcast)
}
}
SnackbarResult.Dismissed -> { /* No action */ }
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = modifier.padding(bottom = 8.dp),
) { snackbarData ->
BroadcastSnackbarContent(snackbarData)
}
}
@Composable
private fun BroadcastSnackbarContent(snackbarData: SnackbarData) {
val visuals = snackbarData.visuals
val broadcastVisuals = visuals as? BroadcastSnackbarVisuals
val containerColor =
when (broadcastVisuals?.broadcast?.status) {
BroadcastStatus.FAILED -> Color(0xFF7F1D1D)
// Dark red
BroadcastStatus.PARTIAL -> Color(0xFF78350F)
// Dark amber
else -> Color(0xFF1E3A5F) // Dark blue (default)
}
val actionLabel =
when (broadcastVisuals?.broadcast?.status) {
BroadcastStatus.FAILED -> "Retry"
else -> "View"
}
Snackbar(
action = {
snackbarData.visuals.actionLabel?.let {
TextButton(onClick = { snackbarData.performAction() }) {
Text(actionLabel)
}
}
},
dismissAction =
if (visuals.withDismissAction) {
{
TextButton(onClick = { snackbarData.dismiss() }) {
Text("Dismiss")
}
}
} else {
null
},
containerColor = containerColor,
) {
Text(visuals.message)
}
}
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -58,10 +59,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -80,7 +83,6 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
// Only show in COMPLETE UI mode // Only show in COMPLETE UI mode
if (!accountViewModel.settings.isCompleteUIMode()) return if (!accountViewModel.settings.isCompleteUIMode()) return
val scope = rememberCoroutineScope()
val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle() val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle()
// State for completed broadcast (with auto-dismiss) // State for completed broadcast (with auto-dismiss)
@@ -116,11 +118,13 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 56.dp), .navigationBarsPadding()
.padding(bottom = 50.dp),
) )
// Completed broadcast indicator (when no active broadcasts) // Completed broadcast indicator (when no active broadcasts)
if (activeBroadcasts.isEmpty()) { if (activeBroadcasts.isEmpty()) {
val scope = rememberCoroutineScope()
CompletedBroadcastIndicator( CompletedBroadcastIndicator(
broadcast = completedBroadcast, broadcast = completedBroadcast,
onTap = { broadcast -> onTap = { broadcast ->
@@ -142,13 +146,15 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 56.dp), .navigationBarsPadding()
.padding(bottom = 50.dp),
) )
} }
} }
// Details sheet - show all broadcasts when opened // Details sheet - show all broadcasts when opened
if (selectedBroadcast != null) { if (selectedBroadcast != null) {
val scope = rememberCoroutineScope()
// Combine active broadcasts with the selected/completed one // Combine active broadcasts with the selected/completed one
val allBroadcasts = val allBroadcasts =
(activeBroadcasts + listOfNotNull(completedBroadcast)) (activeBroadcasts + listOfNotNull(completedBroadcast))
@@ -238,7 +244,7 @@ private fun CompletedBroadcastIndicator(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
text = "${b.eventName} sent", text = stringRes(R.string.event_sent, b.eventName),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -247,7 +253,7 @@ private fun CompletedBroadcastIndicator(
) )
Text( Text(
text = "[${b.successCount}/${b.totalRelays}]", text = stringRes(R.string.share_of, b.successCount, b.totalRelays),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = iconTint, color = iconTint,
) )
@@ -255,7 +261,7 @@ private fun CompletedBroadcastIndicator(
if (b.failedRelays.isNotEmpty()) { if (b.failedRelays.isNotEmpty()) {
Text( Text(
text = "Tap to view details", text = stringRes(R.string.tap_to_view_details),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -270,7 +276,7 @@ private fun CompletedBroadcastIndicator(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Refresh, imageVector = Icons.Default.Refresh,
contentDescription = "Retry failed", contentDescription = stringRes(R.string.retry_failed),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
+20
View File
@@ -1475,4 +1475,24 @@
<string name="follow_set_delete">Delete List</string> <string name="follow_set_delete">Delete List</string>
<string name="follow_pack_delete">Delete Pack</string> <string name="follow_pack_delete">Delete Pack</string>
<string name="kinds">Kinds</string> <string name="kinds">Kinds</string>
<string name="retry_failed">Retry failed</string>
<string name="tap_to_view_details">Tap to view details</string>
<string name="event_sent">%1$s sent</string>
<string name="broadcast_results">Broadcast Results</string>
<string name="broadcasts_number">Broadcasts (%1$d)</string>
<string name="collapse">Collapse</string>
<string name="expand">Expand</string>
<string name="retry_failed_number">Retry Failed (%1$d)</string>
<string name="more_broadcasts_number">+%1$d more broadcasts</string>
<string name="number_in_progress">" (%1$d in progress)"</string>
<string name="share_of_relays">%1$d/%2$d relays</string>
<string name="timeout">Timeout</string>
<string name="retrying">Retrying…</string>
<string name="retry">Retry</string>
<string name="view">View</string>
<string name="dismiss">Dismiss</string>
<string name="share_of">%1$d/%2$d</string>
<string name="broadcasting">Broadcasting</string>
<string name="broadcasting_name">Broadcasting %1$s</string>
<string name="broadcasting_number_events">Broadcasting %1$d events...</string>
</resources> </resources>