feat(broadcast): Add transparent event broadcasting feedback (#1682)

Phase 1 - Core infrastructure:
- BroadcastModels: BroadcastEvent, RelayResult, BroadcastStatus data classes
- BroadcastTracker: State management with live progress updates
- BroadcastBanner: Global progress indicator (animated)
- BroadcastSnackbar: Result notifications with actions
- BroadcastDetailsSheet: Relay status detail bottom sheet

Refs: #1682

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-01-23 06:50:25 +02:00
parent 8bbeb9aadb
commit 3ac8ba669b
5 changed files with 1025 additions and 0 deletions
@@ -0,0 +1,134 @@
/**
* 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.service.broadcast
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Result of a relay's response to an event publish.
*/
@Immutable
sealed class RelayResult {
/** Relay accepted the event (OK message with success=true) */
data object Success : RelayResult()
/** Relay rejected the event (OK message with success=false) */
data class Error(
val code: String,
val message: String?,
) : RelayResult()
/** Relay did not respond within timeout */
data object Timeout : RelayResult()
/** Waiting for relay response */
data object Pending : RelayResult()
}
/**
* Overall status of a broadcast operation.
*/
enum class BroadcastStatus {
/** Currently waiting for relay responses */
IN_PROGRESS,
/** All relays accepted the event */
SUCCESS,
/** Some relays accepted, some failed */
PARTIAL,
/** No relays accepted the event */
FAILED,
}
/**
* Tracks a single event broadcast to multiple relays.
*/
@Immutable
data class BroadcastEvent(
val id: String,
val eventId: HexKey,
val eventName: String,
val kind: Int,
val targetRelays: List<NormalizedRelayUrl>,
val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(),
val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS,
val startedAt: Long = System.currentTimeMillis(),
) {
/** Number of relays that accepted the event */
val successCount: Int
get() = results.count { it.value is RelayResult.Success }
/** Number of relays that rejected or timed out */
val failureCount: Int
get() = results.count { it.value is RelayResult.Error || it.value is RelayResult.Timeout }
/** Number of relays still pending response */
val pendingCount: Int
get() = targetRelays.size - results.size
/** Total number of target relays */
val totalRelays: Int
get() = targetRelays.size
/** Progress as a fraction (0.0 to 1.0) */
val progress: Float
get() = if (totalRelays == 0) 0f else results.size.toFloat() / totalRelays
/** Whether all relays have responded */
val isComplete: Boolean
get() = results.size >= targetRelays.size
/** List of relays that failed (for retry) */
val failedRelays: List<NormalizedRelayUrl>
get() = results.filter { it.value is RelayResult.Error || it.value is RelayResult.Timeout }.keys.toList()
/** Creates a copy with an updated relay result */
fun withResult(
relay: NormalizedRelayUrl,
result: RelayResult,
): BroadcastEvent {
val newResults = results + (relay to result)
val newStatus =
when {
newResults.size < targetRelays.size -> BroadcastStatus.IN_PROGRESS
newResults.all { it.value is RelayResult.Success } -> BroadcastStatus.SUCCESS
newResults.none { it.value is RelayResult.Success } -> BroadcastStatus.FAILED
else -> BroadcastStatus.PARTIAL
}
return copy(results = newResults, status = newStatus)
}
}
/**
* Result of a broadcast operation, returned after all relays respond or timeout.
*/
@Immutable
data class BroadcastResult(
val broadcast: BroadcastEvent,
val isSuccess: Boolean,
) {
val successCount: Int get() = broadcast.successCount
val totalRelays: Int get() = broadcast.totalRelays
}
@@ -0,0 +1,271 @@
/**
* 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.service.broadcast
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
/**
* Tracks event broadcasts to relays with live progress updates.
*
* Provides:
* - Real-time progress as relays respond
* - Detailed per-relay success/error information
* - Retry functionality for failed relays
*/
class BroadcastTracker {
companion object {
private const val TAG = "BroadcastTracker"
private const val TIMEOUT_SECONDS = 15L
}
private val _activeBroadcasts = MutableStateFlow<List<BroadcastEvent>>(emptyList())
val activeBroadcasts: StateFlow<List<BroadcastEvent>> = _activeBroadcasts.asStateFlow()
private val _completedBroadcast = MutableSharedFlow<BroadcastEvent>(extraBufferCapacity = 10)
val completedBroadcast: SharedFlow<BroadcastEvent> = _completedBroadcast.asSharedFlow()
/**
* Tracks an event broadcast to relays with live progress updates.
*
* @param event The Nostr event to broadcast
* @param eventName Human-readable name (e.g., "Boost", "Reaction")
* @param relays Target relays to send to
* @param client The Nostr client for sending
* @return BroadcastResult with final status
*/
@OptIn(DelicateCoroutinesApi::class)
suspend fun trackBroadcast(
event: Event,
eventName: String,
relays: Set<NormalizedRelayUrl>,
client: INostrClient,
): BroadcastResult {
val trackingId = UUID.randomUUID().toString()
val broadcast =
BroadcastEvent(
id = trackingId,
eventId = event.id,
eventName = eventName,
kind = event.kind,
targetRelays = relays.toList(),
)
// Add to active broadcasts
_activeBroadcasts.update { it + broadcast }
Log.d(TAG, "Starting broadcast $trackingId: $eventName (kind ${event.kind}) to ${relays.size} relays")
val resultChannel = Channel<RelayResponse>(UNLIMITED)
val subscription =
object : IRelayClientListener {
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
if (relay.url in relays) {
resultChannel.trySend(
RelayResponse(
relay = relay.url,
result = RelayResult.Error("CONNECTION_ERROR", errorMessage),
),
)
Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage")
}
}
override fun onDisconnected(relay: IRelayClient) {
if (relay.url in relays) {
resultChannel.trySend(
RelayResponse(
relay = relay.url,
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"),
),
)
Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}")
}
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
super.onIncomingMessage(relay, msgStr, msg)
when (msg) {
is OkMessage -> {
if (msg.eventId == event.id) {
val result =
if (msg.success) {
RelayResult.Success
} else {
val (code, message) = parseOkError(msg.message)
RelayResult.Error(code, message)
}
resultChannel.trySend(RelayResponse(relay.url, result))
Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}")
}
}
}
}
}
client.subscribe(subscription)
val finalBroadcast =
coroutineScope {
val resultCollector =
async {
val receivedRelays = mutableSetOf<NormalizedRelayUrl>()
var currentBroadcast = broadcast
withTimeoutOrNull(TIMEOUT_SECONDS * 1000) {
while (receivedRelays.size < relays.size) {
val response = resultChannel.receive()
// Skip if already received (don't override success)
if (response.relay in receivedRelays) continue
receivedRelays.add(response.relay)
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
// Update active broadcasts with new progress
_activeBroadcasts.update { list ->
list.map { if (it.id == trackingId) currentBroadcast else it }
}
}
}
// Mark remaining relays as timeout
relays.filter { it !in receivedRelays }.forEach { relay ->
currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout)
}
currentBroadcast
}
// Send after setting up listener
client.send(event, relays)
resultCollector.await()
}
client.unsubscribe(subscription)
resultChannel.close()
// Remove from active, emit to completed
_activeBroadcasts.update { list -> list.filter { it.id != trackingId } }
_completedBroadcast.emit(finalBroadcast)
Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
return BroadcastResult(
broadcast = finalBroadcast,
isSuccess = finalBroadcast.successCount > 0,
)
}
/**
* Retries sending an event to failed relays.
*
* @param originalBroadcast The original broadcast with failures
* @param event The original event to resend
* @param client The Nostr client
* @param specificRelay Optional specific relay to retry (null = all failed)
*/
suspend fun retry(
originalBroadcast: BroadcastEvent,
event: Event,
client: INostrClient,
specificRelay: NormalizedRelayUrl? = null,
): BroadcastResult {
val relaysToRetry =
if (specificRelay != null) {
setOf(specificRelay)
} else {
originalBroadcast.failedRelays.toSet()
}
if (relaysToRetry.isEmpty()) {
return BroadcastResult(originalBroadcast, originalBroadcast.successCount > 0)
}
return trackBroadcast(
event = event,
eventName = "${originalBroadcast.eventName} (retry)",
relays = relaysToRetry,
client = client,
)
}
/**
* Gets details for a specific broadcast by tracking ID.
*/
fun getActiveBroadcast(trackingId: String): BroadcastEvent? = _activeBroadcasts.value.find { it.id == trackingId }
/**
* Clears all active broadcasts (e.g., on logout).
*/
fun clear() {
_activeBroadcasts.update { emptyList() }
}
/**
* Parses NIP-20 OK error message into code and description.
* Format: "prefix: message" or just "message"
*/
private fun parseOkError(message: String): Pair<String, String?> {
val parts = message.split(":", limit = 2)
return if (parts.size == 2) {
parts[0].trim().uppercase() to parts[1].trim()
} else {
"ERROR" to message.takeIf { it.isNotBlank() }
}
}
private data class RelayResponse(
val relay: NormalizedRelayUrl,
val result: RelayResult,
)
}
@@ -0,0 +1,203 @@
/**
* 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.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
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.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.material.icons.Icons
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
/**
* Banner showing active broadcast progress.
* Displayed above bottom navigation when events are being sent to relays.
*/
@Composable
fun BroadcastBanner(
broadcasts: List<BroadcastEvent>,
onTap: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = broadcasts.isNotEmpty(),
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
modifier = modifier,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 2.dp,
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onTap),
) {
Column(
modifier =
Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.animateContentSize(),
) {
if (broadcasts.size == 1) {
SingleBroadcastContent(broadcasts.first())
} else {
MultipleBroadcastsContent(broadcasts)
}
}
}
}
}
@Composable
private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Default.Sync,
contentDescription = "Broadcasting",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Broadcasting ${broadcast.eventName} (kind ${broadcast.kind})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
text = "[${broadcast.results.size}/${broadcast.totalRelays}]",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(Modifier.height(4.dp))
val animatedProgress by animateFloatAsState(
targetValue = broadcast.progress,
animationSpec = tween(300),
label = "progress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
@Composable
private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) {
val totalRelays = broadcasts.sumOf { it.totalRelays }
val completedResponses = broadcasts.sumOf { it.results.size }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Default.Sync,
contentDescription = "Broadcasting",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Broadcasting ${broadcasts.size} events...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "[$completedResponses/$totalRelays]",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(Modifier.height(4.dp))
val progress = if (totalRelays > 0) completedResponses.toFloat() / totalRelays else 0f
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(300),
label = "progress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
@@ -0,0 +1,277 @@
/**
* 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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* Modal bottom sheet showing detailed relay results for a broadcast.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BroadcastDetailsSheet(
broadcast: BroadcastEvent,
onDismiss: () -> Unit,
onRetryRelay: (NormalizedRelayUrl) -> Unit,
onRetryAllFailed: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp),
) {
// Header
Text(
text = "Broadcast Results",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(8.dp))
// Summary
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StatusIcon(broadcast.status)
Text(
text = "${broadcast.eventName} (kind ${broadcast.kind})",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.weight(1f))
Text(
text = "${broadcast.successCount}/${broadcast.totalRelays} relays",
style = MaterialTheme.typography.labelLarge,
color =
when (broadcast.status) {
BroadcastStatus.SUCCESS -> Color(0xFF22C55E)
BroadcastStatus.PARTIAL -> Color(0xFFF59E0B)
BroadcastStatus.FAILED -> Color(0xFFEF4444)
BroadcastStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
},
)
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// Relay list
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f, fill = false),
) {
items(broadcast.targetRelays) { relay ->
val result = broadcast.results[relay] ?: RelayResult.Pending
RelayResultRow(
relay = relay,
result = result,
onRetry = { onRetryRelay(relay) },
)
}
}
Spacer(Modifier.height(16.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
) {
Text("Dismiss")
}
if (broadcast.failedRelays.isNotEmpty()) {
Button(
onClick = onRetryAllFailed,
modifier = Modifier.weight(1f),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text("Retry Failed (${broadcast.failedRelays.size})")
}
}
}
}
}
}
@Composable
private fun StatusIcon(status: BroadcastStatus) {
val (icon, tint) =
when (status) {
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to Color(0xFF22C55E)
BroadcastStatus.PARTIAL -> Icons.Default.Error to Color(0xFFF59E0B)
BroadcastStatus.FAILED -> Icons.Default.Error to Color(0xFFEF4444)
BroadcastStatus.IN_PROGRESS -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.primary
}
Icon(
imageVector = icon,
contentDescription = status.name,
tint = tint,
modifier = Modifier.size(24.dp),
)
}
@Composable
private fun RelayResultRow(
relay: NormalizedRelayUrl,
result: RelayResult,
onRetry: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Status icon
val (icon, tint) =
when (result) {
is RelayResult.Success -> Icons.Default.CheckCircle to Color(0xFF22C55E)
is RelayResult.Error -> Icons.Default.Error to Color(0xFFEF4444)
is RelayResult.Timeout -> Icons.Default.HourglassEmpty to Color(0xFFF59E0B)
is RelayResult.Pending -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant
}
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(12.dp))
// Relay URL
Column(modifier = Modifier.weight(1f)) {
Text(
text = relay.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Error message if present
when (result) {
is RelayResult.Error -> {
Text(
text = "[${result.code}]${result.message?.let { " $it" } ?: ""}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = Color(0xFFEF4444),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
is RelayResult.Timeout -> {
Text(
text = "Timeout",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFF59E0B),
)
}
else -> {}
}
}
// Retry button for failed relays
if (result is RelayResult.Error || result is RelayResult.Timeout) {
IconButton(
onClick = onRetry,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Retry",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
}
@@ -0,0 +1,140 @@
/**
* 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)
}
}