diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt new file mode 100644 index 000000000..5a5980a02 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt @@ -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, + val results: Map = 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 + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt new file mode 100644 index 000000000..2a71fea2b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt @@ -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>(emptyList()) + val activeBroadcasts: StateFlow> = _activeBroadcasts.asStateFlow() + + private val _completedBroadcast = MutableSharedFlow(extraBufferCapacity = 10) + val completedBroadcast: SharedFlow = _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, + 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(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() + 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 { + 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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt new file mode 100644 index 000000000..0db0e2628 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -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, + 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) { + 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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt new file mode 100644 index 000000000..3cbdee92f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt @@ -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), + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastSnackbar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastSnackbar.kt new file mode 100644 index 000000000..df71a6a30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastSnackbar.kt @@ -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, + 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) + } +}