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 index 931726d90..1424ded75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.broadcast import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -34,8 +34,7 @@ sealed class RelayResult { /** Relay rejected the event (OK message with success=false) */ data class Error( - val code: String, - val message: String?, + val message: String, ) : RelayResult() /** Relay did not respond within timeout */ @@ -71,13 +70,11 @@ enum class BroadcastStatus { @Immutable data class BroadcastEvent( val id: String, - val eventId: HexKey, - val eventName: String, - val kind: Int, + val event: Event, val targetRelays: List, + val startedAt: Long = System.currentTimeMillis(), 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 @@ -133,12 +130,3 @@ data class BroadcastEvent( 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, -) 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 index 78d47aae3..1c3d62232 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt @@ -28,16 +28,16 @@ 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.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList 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 @@ -54,51 +54,38 @@ import java.util.UUID class BroadcastTracker { companion object { private const val TAG = "BroadcastTracker" - private const val TIMEOUT_SECONDS = 15L - const val COMPLETED_DISPLAY_DURATION_MS = 10_000L // SnackbarDuration.Long equivalent + const val TIMEOUT_SECONDS = 10L } - private val _activeBroadcasts = MutableStateFlow>(emptyList()) - val activeBroadcasts: StateFlow> = _activeBroadcasts.asStateFlow() - - private val _completedBroadcast = MutableSharedFlow(extraBufferCapacity = 10) - val completedBroadcast: SharedFlow = _completedBroadcast.asSharedFlow() - - // Event cache for retries - maps tracking ID to original Event - private val eventCache = mutableMapOf() + private val _activeBroadcasts = MutableStateFlow>(persistentListOf()) + val activeBroadcasts: StateFlow> = _activeBroadcasts.asStateFlow() /** * 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, + event = event, targetRelays = relays.toList(), ) // Add to active broadcasts and cache event for retries - _activeBroadcasts.update { it + broadcast } - eventCache[trackingId] = event + _activeBroadcasts.update { (it + broadcast).toImmutableList() } - Log.d(TAG, "Starting broadcast $trackingId: $eventName (kind ${event.kind}) to ${relays.size} relays") + Log.d(TAG, "Starting broadcast $trackingId (kind ${event.kind}) to ${relays.size} relays") val resultChannel = Channel(UNLIMITED) @@ -112,7 +99,7 @@ class BroadcastTracker { resultChannel.trySend( RelayResponse( relay = relay.url, - result = RelayResult.Error("CONNECTION_ERROR", errorMessage), + result = RelayResult.Error(errorMessage), ), ) Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage") @@ -124,7 +111,7 @@ class BroadcastTracker { resultChannel.trySend( RelayResponse( relay = relay.url, - result = RelayResult.Error("DISCONNECTED", "Relay disconnected"), + result = RelayResult.Error("Relay disconnected before completion"), ), ) Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}") @@ -145,8 +132,7 @@ class BroadcastTracker { if (msg.success) { RelayResult.Success } else { - val (code, message) = parseOkError(msg.message) - RelayResult.Error(code, message) + RelayResult.Error(msg.message) } resultChannel.trySend(RelayResponse(relay.url, result)) Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}") @@ -177,7 +163,7 @@ class BroadcastTracker { // Update active broadcasts with new progress _activeBroadcasts.update { list -> - list.map { if (it.id == trackingId) currentBroadcast else it } + list.map { if (it.id == trackingId) currentBroadcast else it }.toImmutableList() } } } @@ -200,15 +186,11 @@ class BroadcastTracker { resultChannel.close() // Remove from active, emit to completed - _activeBroadcasts.update { list -> list.filter { it.id != trackingId } } - _completedBroadcast.emit(finalBroadcast) + _activeBroadcasts.update { list -> + list.map { if (it.id == trackingId) finalBroadcast else it }.toImmutableList() + } Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") - - return BroadcastResult( - broadcast = finalBroadcast, - isSuccess = finalBroadcast.successCount > 0, - ) } /** @@ -220,17 +202,18 @@ class BroadcastTracker { relays: Set, ) { _activeBroadcasts.update { list -> - list.map { broadcast -> - if (broadcast.id == broadcastId) { - var updated = broadcast - relays.forEach { relay -> - updated = updated.withResult(relay, RelayResult.Retrying) + list + .map { broadcast -> + if (broadcast.id == broadcastId) { + var updated = broadcast + relays.forEach { relay -> + updated = updated.withResult(relay, RelayResult.Retrying) + } + updated.copy(status = BroadcastStatus.IN_PROGRESS) + } else { + broadcast } - updated.copy(status = BroadcastStatus.IN_PROGRESS) - } else { - broadcast - } - } + }.toImmutableList() } } @@ -248,8 +231,8 @@ class BroadcastTracker { broadcast: BroadcastEvent, client: INostrClient, specificRelay: NormalizedRelayUrl? = null, - ): BroadcastEvent? { - val event = eventCache[broadcast.id] ?: return null + ): BroadcastEvent { + val event = broadcast.event val relaysToRetry = if (specificRelay != null) { @@ -272,7 +255,7 @@ class BroadcastTracker { relaysToRetry.forEach { relay -> updated = updated.withResult(relay, RelayResult.Retrying) } - list + updated.copy(status = BroadcastStatus.IN_PROGRESS) + (list + updated.copy(status = BroadcastStatus.IN_PROGRESS)).toImmutableList() } } @@ -289,7 +272,7 @@ class BroadcastTracker { resultChannel.trySend( RelayResponse( relay = relay.url, - result = RelayResult.Error("CONNECTION_ERROR", errorMessage), + result = RelayResult.Error(errorMessage), ), ) Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage") @@ -301,7 +284,7 @@ class BroadcastTracker { resultChannel.trySend( RelayResponse( relay = relay.url, - result = RelayResult.Error("DISCONNECTED", "Relay disconnected"), + result = RelayResult.Error("Relay disconnected before completion"), ), ) Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}") @@ -322,8 +305,7 @@ class BroadcastTracker { if (msg.success) { RelayResult.Success } else { - val (code, message) = parseOkError(msg.message) - RelayResult.Error(code, message) + RelayResult.Error(msg.message) } resultChannel.trySend(RelayResponse(relay.url, result)) Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}") @@ -353,7 +335,7 @@ class BroadcastTracker { currentBroadcast = currentBroadcast.withResult(response.relay, response.result) _activeBroadcasts.update { list -> - list.map { if (it.id == broadcast.id) currentBroadcast else it } + list.map { if (it.id == broadcast.id) currentBroadcast else it }.toImmutableList() } } } @@ -395,12 +377,7 @@ class BroadcastTracker { // Update in active broadcasts _activeBroadcasts.update { list -> - list.map { if (it.id == broadcast.id) finalBroadcast else it } - } - - // Emit to completed if all done - if (finalBroadcast.status != BroadcastStatus.IN_PROGRESS) { - _completedBroadcast.emit(finalBroadcast) + list.map { if (it.id == broadcast.id) finalBroadcast else it }.toImmutableList() } Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") @@ -408,43 +385,11 @@ class BroadcastTracker { return finalBroadcast } - /** - * Gets details for a specific broadcast by tracking ID. - */ - fun getActiveBroadcast(trackingId: String): BroadcastEvent? = _activeBroadcasts.value.find { it.id == trackingId } - - /** - * Gets the cached event for a broadcast (for retries). - */ - fun getCachedEvent(trackingId: String): Event? = eventCache[trackingId] - - /** - * Removes a broadcast from cache (call after COMPLETED_DISPLAY_DURATION_MS expires). - */ - fun expireBroadcast(trackingId: String) { - eventCache.remove(trackingId) - Log.d(TAG, "Expired broadcast $trackingId from cache") - } - /** * Clears all active broadcasts and cache (e.g., on logout). */ fun clear() { - _activeBroadcasts.update { emptyList() } - eventCache.clear() - } - - /** - * 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() } - } + _activeBroadcasts.update { persistentListOf() } } private data class RelayResponse( 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 index b2f1773ec..29508f69c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -39,8 +39,12 @@ 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.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Sync import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -49,11 +53,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent +import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus +import com.vitorpamplona.amethyst.service.broadcast.RelayResult import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID /** * Banner showing active broadcast progress. @@ -61,8 +82,10 @@ import com.vitorpamplona.amethyst.ui.stringRes */ @Composable fun BroadcastBanner( - broadcasts: List, - onTap: () -> Unit, + broadcasts: ImmutableList, + onTap: () -> Unit = {}, + onRetryAll: () -> Unit = {}, + onDismiss: () -> Unit = {}, modifier: Modifier = Modifier, ) { AnimatedVisibility( @@ -85,10 +108,20 @@ fun BroadcastBanner( .padding(horizontal = 16.dp, vertical = 8.dp) .animateContentSize(), ) { - if (broadcasts.size == 1) { - SingleBroadcastContent(broadcasts.first()) + val isAllFinished = broadcasts.all { it.status != BroadcastStatus.IN_PROGRESS } + + if (isAllFinished) { + if (broadcasts.size == 1) { + CompletedBroadcastContent(broadcasts.first(), onRetryAll, onDismiss) + } else { + MultipleCompletedBroadcastContent(broadcasts, onRetryAll, onDismiss) + } } else { - MultipleBroadcastsContent(broadcasts) + if (broadcasts.size == 1) { + SingleBroadcastContent(broadcasts.first()) + } else { + MultipleBroadcastsContent(broadcasts) + } } } } @@ -115,7 +148,7 @@ private fun SingleBroadcastContent(broadcast: BroadcastEvent) { modifier = Modifier.fillMaxWidth(), ) { Text( - text = stringRes(R.string.broadcasting_name, broadcast.eventName), + text = stringRes(R.string.broadcasting_name, broadcast.event.toKindName()), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -151,7 +184,7 @@ private fun SingleBroadcastContent(broadcast: BroadcastEvent) { } @Composable -private fun MultipleBroadcastsContent(broadcasts: List) { +private fun MultipleBroadcastsContent(broadcasts: ImmutableList) { val totalRelays = broadcasts.sumOf { it.totalRelays } val completedResponses = broadcasts.sumOf { it.results.size } @@ -203,3 +236,464 @@ private fun MultipleBroadcastsContent(broadcasts: List) { } } } + +@Composable +fun CompletedBroadcastContent( + broadcast: BroadcastEvent, + onRetryAll: () -> Unit, + onDismiss: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + // Status colors (for icon tint only) + val successColor = Color(0xFF22C55E) + val warningColor = Color(0xFFF59E0B) + + val (statusIcon, iconTint) = + when (broadcast.status) { + BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor + BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor + BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error + BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary + } + + // Small status icon (like BroadcastBanner) + Icon( + imageVector = statusIcon, + contentDescription = broadcast.status.name, + tint = iconTint, + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringRes(R.string.event_sent, broadcast.event.toKindName()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + if (broadcast.failedRelays.isNotEmpty()) { + Text( + text = stringRes(R.string.tap_to_view_details), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Text( + text = stringRes(R.string.share_of, broadcast.successCount, broadcast.totalRelays), + style = MaterialTheme.typography.labelMedium, + color = iconTint, + ) + + // Retry button for failures + if (broadcast.failedRelays.isNotEmpty()) { + IconButton( + onClick = onRetryAll, + modifier = Modifier.size(22.dp), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringRes(R.string.retry_failed), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + + // Dismiss X + Text( + text = "×", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .clickable(onClick = onDismiss) + .padding(start = 2.dp), + ) + } +} + +@Composable +fun MultipleCompletedBroadcastContent( + broadcasts: ImmutableList, + onRetryAll: () -> Unit, + onDismiss: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + // Status colors (for icon tint only) + val successColor = Color(0xFF22C55E) + val warningColor = Color(0xFFF59E0B) + + val totalRelayCount = broadcasts.sumOf { it.totalRelays } + val failedRelayCount = broadcasts.sumOf { it.failedRelays.size } + val allSuccess = broadcasts.all { it.status == BroadcastStatus.SUCCESS } + val allFailed = broadcasts.all { it.status == BroadcastStatus.FAILED } + + val (statusIcon, iconTint) = + if (allSuccess) { + Icons.Default.CheckCircle to successColor + } else if (allFailed) { + Icons.Default.Error to MaterialTheme.colorScheme.error + } else { + Icons.Default.Error to warningColor + } + + // Small status icon (like BroadcastBanner) + Icon( + imageVector = statusIcon, + contentDescription = + if (allSuccess) { + stringRes(R.string.bradcasting_result_success) + } else if (allFailed) { + stringRes(R.string.bradcasting_result_failure) + } else { + stringRes(R.string.bradcasting_result_partial) + }, + tint = iconTint, + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringRes(R.string.sent_number_events, broadcasts.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + if (failedRelayCount > 0) { + Text( + text = stringRes(R.string.tap_to_view_details), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Text( + text = stringRes(R.string.share_of, (totalRelayCount - failedRelayCount), totalRelayCount), + style = MaterialTheme.typography.labelMedium, + color = iconTint, + ) + + // Retry button for failures + if (failedRelayCount > 0) { + IconButton( + onClick = onRetryAll, + modifier = Modifier.size(22.dp), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringRes(R.string.retry_failed), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + + // Dismiss X + Text( + text = "×", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .clickable(onClick = onDismiss) + .padding(start = 2.dp), + ) + } +} + +@Composable +fun Event.toKindName(): String = + when (this) { + is ReactionEvent -> stringRes(R.string.reaction) + is RepostEvent -> stringRes(R.string.boost) + is GenericRepostEvent -> stringRes(R.string.boost) + is VoiceEvent -> stringRes(R.string.voice_post) + is VoiceReplyEvent -> stringRes(R.string.voice_reply) + is BookmarkListEvent -> stringRes(R.string.bookmarks) + else -> stringRes(R.string.post) + } + +@Preview +@Composable +fun BroadcastBannerSingleEventPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonColumn { + Column { + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + ), + ), + ) + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = mapOf(Constants.mom to RelayResult.Success), + ), + ), + ) + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + ), + ), + ) + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.SUCCESS, + ), + ), + ) + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Error("code"), + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.PARTIAL, + ), + ), + ) + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Error("code"), + Constants.mom to RelayResult.Error("code"), + Constants.nos to RelayResult.Error("code"), + ), + status = BroadcastStatus.FAILED, + ), + ), + ) + } + } +} + +@Preview +@Composable +fun BroadcastBannerDoubleEventPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonColumn { + Column { + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + ), + ), + ) + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = mapOf(Constants.mom to RelayResult.Success), + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = mapOf(Constants.mom to RelayResult.Success), + ), + ), + ) + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = + mapOf( + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + ), + ), + ) + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.SUCCESS, + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = + mapOf( + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.SUCCESS, + ), + ), + ) + + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.SUCCESS, + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = + mapOf( + Constants.mom to RelayResult.Error("code"), + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.PARTIAL, + ), + ), + ) + + BroadcastBanner( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Error("code"), + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.FAILED, + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = + mapOf( + Constants.mom to RelayResult.Timeout, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.FAILED, + ), + ), + ) + } + } +} 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 index f6c9c193c..34082f631 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt @@ -31,6 +31,7 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -73,14 +74,22 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.service.broadcast.RelayResult import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID private const val MAX_EXPANDED_SECTIONS = 2 @@ -90,14 +99,36 @@ private const val MAX_EXPANDED_SECTIONS = 2 @OptIn(ExperimentalMaterial3Api::class) @Composable fun MultiBroadcastDetailsSheet( - broadcasts: List, - onDismiss: () -> Unit, - onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit, - onRetryAllFailed: (BroadcastEvent) -> Unit, + broadcasts: ImmutableList, + onDismiss: () -> Unit = {}, + onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit = { _, _ -> }, + onRetryAllFailed: (BroadcastEvent) -> Unit = {}, sheetState: SheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, ), +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + MultiBroadcastDetailsSheetContent( + broadcasts = broadcasts, + onDismiss = onDismiss, + onRetryRelay = onRetryRelay, + onRetryAllFailed = onRetryAllFailed, + ) + } +} + +@Composable +fun MultiBroadcastDetailsSheetContent( + broadcasts: ImmutableList, + onDismiss: () -> Unit = {}, + onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit = { _, _ -> }, + onRetryAllFailed: (BroadcastEvent) -> Unit = {}, ) { // Synced rotation animation for all pending/retrying icons val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation") @@ -113,93 +144,68 @@ fun MultiBroadcastDetailsSheet( ) // Track which sections are expanded (by broadcast ID) - var expandedSections by remember { mutableStateOf(setOf()) } - - // Sort: in-progress first, then by start time - val sortedBroadcasts = - broadcasts.sortedWith( - compareBy { it.status != BroadcastStatus.IN_PROGRESS } - .thenByDescending { it.startedAt }, - ) - - // First 2 get shown expanded by default (unless completed) - val visibleBroadcasts = sortedBroadcasts.take(MAX_EXPANDED_SECTIONS) - val overflowBroadcasts = sortedBroadcasts.drop(MAX_EXPANDED_SECTIONS) - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp) - .verticalScroll(rememberScrollState()), - ) { - // Header - Text( - text = if (broadcasts.size == 1) stringRes(R.string.broadcast_results) else stringRes(R.string.broadcasts_number, broadcasts.size), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + var expandedSections by remember { + // Sort: in-progress first, then by start time + val sortedBroadcasts = + broadcasts.sortedWith( + compareBy { it.startedAt }, ) - Spacer(Modifier.height(16.dp)) + // First 2 get shown expanded by default (unless completed) + val visibleBroadcasts = sortedBroadcasts.take(MAX_EXPANDED_SECTIONS) - // Visible broadcast sections - visibleBroadcasts.forEachIndexed { index, broadcast -> - val isExpanded = - broadcast.id in expandedSections || - (broadcast.status == BroadcastStatus.IN_PROGRESS && broadcast.id !in expandedSections) + mutableStateOf(visibleBroadcasts.map { it.id }.toSet()) + } - // Auto-expand in-progress, auto-collapse completed (unless manually expanded) - val showExpanded = - if (broadcast.status == BroadcastStatus.IN_PROGRESS) { - true - } else { - broadcast.id in expandedSections - } + Column( + verticalArrangement = spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()), + ) { + // Header + Text( + text = if (broadcasts.size == 1) stringRes(R.string.broadcast_results) else stringRes(R.string.broadcasts_number, broadcasts.size), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) - BroadcastSection( - broadcast = broadcast, - isExpanded = showExpanded, - onToggleExpand = { - expandedSections = - if (broadcast.id in expandedSections) { - expandedSections - broadcast.id - } else { - expandedSections + broadcast.id - } - }, - onRetryRelay = { relay -> onRetryRelay(broadcast, relay) }, - onRetryAllFailed = { onRetryAllFailed(broadcast) }, - rotationAngle = rotationAngle, - ) + Spacer(Modifier.height(8.dp)) - if (index < visibleBroadcasts.size - 1 || overflowBroadcasts.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - } + // Visible broadcast sections + broadcasts.forEachIndexed { index, broadcast -> + BroadcastSection( + broadcast = broadcast, + isExpanded = broadcast.id in expandedSections, + onToggleExpand = { + expandedSections = + if (broadcast.id in expandedSections) { + expandedSections - broadcast.id + } else { + expandedSections + broadcast.id + } + }, + onRetryRelay = { relay -> onRetryRelay(broadcast, relay) }, + onRetryAllFailed = { onRetryAllFailed(broadcast) }, + rotationAngle = rotationAngle, + ) + + if (index < broadcasts.lastIndex) { + HorizontalDivider() } + } - // Overflow summary - if (overflowBroadcasts.isNotEmpty()) { - OverflowSummary(overflowBroadcasts) - } + Spacer(Modifier.height(8.dp)) - Spacer(Modifier.height(16.dp)) - - // Dismiss button - OutlinedButton( - onClick = onDismiss, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringRes(R.string.dismiss)) - } + // Dismiss button + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.dismiss)) } } } @@ -231,7 +237,7 @@ private fun BroadcastSection( Column(modifier = Modifier.weight(1f)) { Text( - text = broadcast.eventName, + text = broadcast.event.toKindName(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -296,52 +302,6 @@ private fun BroadcastSection( } } -@Composable -private fun OverflowSummary(broadcasts: List) { - val totalSuccess = broadcasts.sumOf { it.successCount } - val totalRelays = broadcasts.sumOf { it.totalRelays } - val inProgressCount = broadcasts.count { it.status == BroadcastStatus.IN_PROGRESS } - - Surface( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - shape = MaterialTheme.shapes.small, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(12.dp), - ) { - Icon( - imageVector = Icons.Default.HourglassEmpty, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - - Spacer(Modifier.width(8.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringRes(R.string.more_broadcasts_number, broadcasts.size), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = - buildString { - append(stringResource(R.string.share_of_relays, totalSuccess, totalRelays)) - if (inProgressCount > 0) { - append(stringResource(R.string.number_in_progress, inProgressCount)) - } - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - @Composable private fun statusColor(status: BroadcastStatus): Color = when (status) { @@ -404,7 +364,7 @@ private fun RelayResultRow( when (result) { is RelayResult.Success -> Triple(Icons.Default.CheckCircle, successColor, false) is RelayResult.Error -> Triple(Icons.Default.Error, errorColor, false) - is RelayResult.Timeout -> Triple(Icons.Default.HourglassEmpty, warningColor, false) + is RelayResult.Timeout -> Triple(Icons.Default.Error, warningColor, false) is RelayResult.Pending -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.onSurfaceVariant, true) is RelayResult.Retrying -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true) } @@ -441,7 +401,7 @@ private fun RelayResultRow( when (result) { is RelayResult.Error -> { Text( - text = "[${result.code}]${result.message?.let { " $it" } ?: ""}", + text = result.message, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = errorColor, @@ -486,3 +446,393 @@ private fun RelayResultRow( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerSingleEventSheetInProgressStartPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerSingleEventSheetInProgressOneSuccessPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = mapOf(Constants.mom to RelayResult.Success), + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerSingleEventSheetSuccessPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.SUCCESS, + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerSingleEventSheetPartialPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Error("code"), + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.PARTIAL, + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerSingleEventSheetErrorsPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Error("code"), + Constants.mom to RelayResult.Error("code"), + Constants.nos to RelayResult.Error("code"), + ), + status = BroadcastStatus.FAILED, + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerDoubleEventSheetInProgressStartPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerDoubleEventSheetInProgressMiddlePreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = mapOf(Constants.mom to RelayResult.Success), + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = mapOf(Constants.mom to RelayResult.Success), + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerDoubleEventSheetInProgressEndPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = + mapOf( + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerDoubleEventSheetDoneAllGoodPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.SUCCESS, + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = + mapOf( + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.SUCCESS, + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerDoubleEventSheetDoneOneGoodPreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Success, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.SUCCESS, + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = + mapOf( + Constants.mom to RelayResult.Error("code"), + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.PARTIAL, + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = "spec:width=1500px,height=2340px,dpi=440") +@Composable +fun BroadcastBannerDoubleEventSheetDonePreview() { + val repost = + RepostEvent( + id = "", + pubKey = "", + createdAt = TimeUtils.now(), + tags = emptyArray(), + content = "", + sig = "", + ) + ThemeComparisonRow { + MultiBroadcastDetailsSheetContent( + persistentListOf( + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos), + results = + mapOf( + Constants.antiprimal to RelayResult.Success, + Constants.mom to RelayResult.Error("code"), + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.PARTIAL, + ), + BroadcastEvent( + id = UUID.randomUUID().toString(), + event = repost, + targetRelays = listOf(Constants.mom, Constants.nos), + results = + mapOf( + Constants.mom to RelayResult.Timeout, + Constants.nos to RelayResult.Success, + ), + status = BroadcastStatus.PARTIAL, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt index 8792ebbcf..45a1b709e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt @@ -20,54 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.broadcast -import androidx.compose.animation.AnimatedVisibility -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.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.Refresh import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent -import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay -import kotlinx.coroutines.launch /** * Displays broadcast progress UI components: @@ -85,86 +59,30 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) { val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle() - // State for completed broadcast (with auto-dismiss) - var completedBroadcast by remember { mutableStateOf(null) } - // State for details sheet - var selectedBroadcast by remember { mutableStateOf(null) } + var seeDetails by remember { mutableStateOf(false) } - // Collect completed broadcasts and set auto-dismiss timer - LaunchedEffect(Unit) { - accountViewModel.broadcastTracker.completedBroadcast.collect { broadcast -> - completedBroadcast = broadcast + if (activeBroadcasts.isEmpty() && !seeDetails) return - // Auto-dismiss after LONG duration - delay(BroadcastTracker.COMPLETED_DISPLAY_DURATION_MS) + if (!seeDetails) { + DisplaySnack(activeBroadcasts, { seeDetails = true }, accountViewModel) - // Only dismiss if still showing same broadcast - if (completedBroadcast?.id == broadcast.id) { - completedBroadcast = null - accountViewModel.broadcastTracker.expireBroadcast(broadcast.id) - } + LaunchedEffect(activeBroadcasts) { + // this effect gets restarted every time the active broadcast changes + delay((BroadcastTracker.TIMEOUT_SECONDS + 1) * 1000) + accountViewModel.broadcastTracker.clear() } - } - - Box(modifier = Modifier.fillMaxSize()) { - // Banner for active broadcasts (above bottom navigation) - BroadcastBanner( - broadcasts = activeBroadcasts, - onTap = { - activeBroadcasts.firstOrNull()?.let { selectedBroadcast = it } - }, - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(bottom = 50.dp), - ) - - // Completed broadcast indicator (when no active broadcasts) - if (activeBroadcasts.isEmpty()) { - val scope = rememberCoroutineScope() - CompletedBroadcastIndicator( - broadcast = completedBroadcast, - onTap = { broadcast -> - selectedBroadcast = broadcast - }, - onRetry = { b -> - scope.launch { - accountViewModel.broadcastTracker.retry( - broadcast = b, - client = accountViewModel.account.client, - ) - } - }, - onDismiss = { broadcast -> - completedBroadcast = null - accountViewModel.broadcastTracker.expireBroadcast(broadcast.id) - }, - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(bottom = 50.dp), - ) - } - } - - // Details sheet - show all broadcasts when opened - if (selectedBroadcast != null) { - val scope = rememberCoroutineScope() - // Combine active broadcasts with the selected/completed one - val allBroadcasts = - (activeBroadcasts + listOfNotNull(completedBroadcast)) - .distinctBy { it.id } - + } else { MultiBroadcastDetailsSheet( - broadcasts = allBroadcasts, - onDismiss = { selectedBroadcast = null }, + broadcasts = activeBroadcasts, + onDismiss = { + accountViewModel.runOnIO { + accountViewModel.broadcastTracker.clear() + } + seeDetails = false + }, onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl -> - scope.launch { + accountViewModel.runOnIO { accountViewModel.broadcastTracker.retry( broadcast = b, client = accountViewModel.account.client, @@ -173,7 +91,7 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) { } }, onRetryAllFailed = { b: BroadcastEvent -> - scope.launch { + accountViewModel.runOnIO { accountViewModel.broadcastTracker.retry( broadcast = b, client = accountViewModel.account.client, @@ -184,117 +102,35 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) { } } -/** - * Compact indicator for completed broadcasts. - * Styled consistently with BroadcastBanner. - * Tappable to show details sheet, with retry button for failures. - */ @Composable -private fun CompletedBroadcastIndicator( - broadcast: BroadcastEvent?, - onTap: (BroadcastEvent) -> Unit, - onRetry: (BroadcastEvent) -> Unit, - onDismiss: (BroadcastEvent) -> Unit, - modifier: Modifier = Modifier, +fun DisplaySnack( + activeBroadcasts: ImmutableList, + onTap: () -> Unit, + accountViewModel: AccountViewModel, ) { - // Status colors (for icon tint only) - val successColor = Color(0xFF22C55E) - val warningColor = Color(0xFFF59E0B) - - AnimatedVisibility( - visible = broadcast != null, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)), - modifier = modifier, - ) { - broadcast?.let { b -> - val (statusIcon, iconTint) = - when (b.status) { - BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor - BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor - BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error - BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary - } - - // Same styling as BroadcastBanner - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 2.dp, - modifier = - Modifier - .fillMaxWidth() - .clickable { onTap(b) }, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) { - // Small status icon (like BroadcastBanner) - Icon( - imageVector = statusIcon, - contentDescription = b.status.name, - tint = iconTint, - modifier = Modifier.size(18.dp), - ) - - Column(modifier = Modifier.weight(1f)) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringRes(R.string.event_sent, b.eventName), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - - Text( - text = stringRes(R.string.share_of, b.successCount, b.totalRelays), - style = MaterialTheme.typography.labelMedium, - color = iconTint, - ) - } - - if (b.failedRelays.isNotEmpty()) { - Text( - text = stringRes(R.string.tap_to_view_details), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Box(modifier = Modifier.fillMaxSize()) { + BroadcastBanner( + broadcasts = activeBroadcasts, + onTap = onTap, + onRetryAll = { + activeBroadcasts.forEach { b -> + accountViewModel.runOnIO { + accountViewModel.broadcastTracker.retry( + broadcast = b, + client = accountViewModel.account.client, + ) } - - // Retry button for failures - if (b.failedRelays.isNotEmpty()) { - IconButton( - onClick = { onRetry(b) }, - modifier = Modifier.size(32.dp), - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = stringRes(R.string.retry_failed), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - } - - // Dismiss X - Text( - text = "×", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier - .clickable { onDismiss(b) } - .padding(4.dp), - ) } - } - } + }, + onDismiss = { + accountViewModel.broadcastTracker.clear() + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 50.dp), + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 319f7c810..c1c2eca36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -309,17 +309,13 @@ class AccountViewModel( if (settings.isCompleteUIMode()) { // Tracked broadcasting with progress feedback account.createReactionEvent(note, reaction)?.let { (event, relays) -> - val result = - broadcastTracker.trackBroadcast( - event = event, - eventName = "Reaction", - relays = relays, - client = account.client, - ) - // Only consume event if at least one relay succeeded - if (result.isSuccess) { - account.consumeReactionEvent(event) - } + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + + account.consumeReactionEvent(event) } } else { // Fire-and-forget (original behavior) @@ -723,17 +719,12 @@ class AccountViewModel( // Tracked broadcasting with progress feedback launchSigner { account.createBoostEvent(note)?.let { (event, relays) -> - val result = - broadcastTracker.trackBroadcast( - event = event, - eventName = "Boost", - relays = relays, - client = account.client, - ) - // Only consume event if at least one relay succeeded - if (result.isSuccess) { - account.consumeBoostEvent(event) - } + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumeBoostEvent(event) } } } else { @@ -768,16 +759,12 @@ class AccountViewModel( if (settings.isCompleteUIMode()) { launchSigner { account.createAddBookmarkEvent(note, true)?.let { (event, relays) -> - val result = - broadcastTracker.trackBroadcast( - event = event, - eventName = "Bookmark", - relays = relays, - client = account.client, - ) - if (result.isSuccess) { - account.consumeBookmarkEvent(event) - } + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumeBookmarkEvent(event) } } } else { @@ -789,16 +776,12 @@ class AccountViewModel( if (settings.isCompleteUIMode()) { launchSigner { account.createAddBookmarkEvent(note, false)?.let { (event, relays) -> - val result = - broadcastTracker.trackBroadcast( - event = event, - eventName = "Bookmark", - relays = relays, - client = account.client, - ) - if (result.isSuccess) { - account.consumeBookmarkEvent(event) - } + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumeBookmarkEvent(event) } } } else { @@ -810,16 +793,13 @@ class AccountViewModel( if (settings.isCompleteUIMode()) { launchSigner { account.createRemoveBookmarkEvent(note, true)?.let { (event, relays) -> - val result = - broadcastTracker.trackBroadcast( - event = event, - eventName = "Remove Bookmark", - relays = relays, - client = account.client, - ) - if (result.isSuccess) { - account.consumeBookmarkEvent(event) - } + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + + account.consumeBookmarkEvent(event) } } } else { @@ -831,16 +811,12 @@ class AccountViewModel( if (settings.isCompleteUIMode()) { launchSigner { account.createRemoveBookmarkEvent(note, false)?.let { (event, relays) -> - val result = - broadcastTracker.trackBroadcast( - event = event, - eventName = "Remove Bookmark", - relays = relays, - client = account.client, - ) - if (result.isSuccess) { - account.consumeBookmarkEvent(event) - } + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumeBookmarkEvent(event) } } } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index ffe5d55b1..314e58db5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -97,7 +97,6 @@ import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds -import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag @@ -548,22 +547,15 @@ open class ShortNotePostViewModel : if (accountViewModel.settings.isCompleteUIMode()) { // Tracked broadcasting with progress feedback (non-blocking) val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) - val eventName = getEventName(event) // Launch broadcast in background - don't wait for completion accountViewModel.viewModelScope.launch { - val result = - accountViewModel.broadcastTracker.trackBroadcast( - event = event, - eventName = eventName, - relays = relays, - client = accountViewModel.account.client, - ) - - // Only consume event if at least one relay succeeded - if (result.isSuccess) { - accountViewModel.account.consumePostEvent(event, relays, extras) - } + accountViewModel.broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = accountViewModel.account.client, + ) + accountViewModel.account.consumePostEvent(event, relays, extras) } } else { // Fire-and-forget (original behavior) @@ -575,30 +567,6 @@ open class ShortNotePostViewModel : } } - private fun getEventName(event: Event): String = - when (event) { - is TextNoteEvent -> { - val quotes = event.taggedQuotes() - if (quotes.isNotEmpty()) "Quote" else "Post" - } - - is PollNoteEvent -> { - "Poll" - } - - is VoiceEvent -> { - "Voice" - } - - is VoiceReplyEvent -> { - "Voice Reply" - } - - else -> { - "Post" - } - } - suspend fun sendDraftSync() { if (message.text.isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt index 93eeb89cb..80f623b2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils class HomeLiveFilter( val account: Account, ) : AdditiveComplexFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value override fun showHiddenKey(): Boolean = false diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1798a428c..0f1b45f0a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1495,4 +1495,13 @@ Broadcasting Broadcasting %1$s Broadcasting %1$d events... + %1$d events sent + + Some events failed + All events succeeded + All events failed + + Reaction + Voice Post + Voice Reply diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f120c3d37..265282449 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ accompanistAdaptive = "0.37.3" cachemapVersion = "0.2.4" comDitchoomBufferCompression = "2.1.0" composeMultiplatform = "1.10.0" -activityCompose = "1.12.3" +activityCompose = "1.12.4" agp = "9.0.0" android-compileSdk = "36" android-minSdk = "26" @@ -12,11 +12,11 @@ androidKotlinGeohash = "b481c6a64e" androidxJunit = "1.3.0" appcompat = "1.7.1" audiowaveform = "1.1.2" -benchmark = "1.5.0-alpha02" +benchmark = "1.5.0-alpha03" biometricKtx = "1.2.0-alpha05" coil = "3.3.0" -composeBom = "2026.01.01" -composeRuntimeAnnotation = "1.10.2" +composeBom = "2026.02.00" +composeRuntimeAnnotation = "1.10.3" coreKtx = "1.17.0" datastore = "1.2.0" devWhyolegCryptography = "0.5.0" @@ -64,7 +64,7 @@ zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" androidxCamera = "1.5.3" androidxCollection = "1.5.0" -kotlinStdlib = "2.3.0" +kotlinStdlib = "2.3.10" kotlinTest = "2.2.20" core = "1.7.0" mavenPublish = "0.36.0"