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:
+134
@@ -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
|
||||
}
|
||||
+271
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+277
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user