Merge remote-tracking branch 'origin/kmp-completeness' into kmp-completeness

This commit is contained in:
KotlinGeekDev
2026-02-23 20:46:09 +01:00
10 changed files with 1127 additions and 561 deletions
@@ -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<NormalizedRelayUrl>,
val startedAt: Long = System.currentTimeMillis(),
val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(),
val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS,
val startedAt: Long = System.currentTimeMillis(),
) {
/** Number of relays that accepted the event */
val successCount: Int
@@ -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,
)
@@ -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<List<BroadcastEvent>>(emptyList())
val activeBroadcasts: StateFlow<List<BroadcastEvent>> = _activeBroadcasts.asStateFlow()
private val _completedBroadcast = MutableSharedFlow<BroadcastEvent>(extraBufferCapacity = 10)
val completedBroadcast: SharedFlow<BroadcastEvent> = _completedBroadcast.asSharedFlow()
// Event cache for retries - maps tracking ID to original Event
private val eventCache = mutableMapOf<String, Event>()
private val _activeBroadcasts = MutableStateFlow<ImmutableList<BroadcastEvent>>(persistentListOf())
val activeBroadcasts: StateFlow<ImmutableList<BroadcastEvent>> = _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<NormalizedRelayUrl>,
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<RelayResponse>(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,7 +202,8 @@ class BroadcastTracker {
relays: Set<NormalizedRelayUrl>,
) {
_activeBroadcasts.update { list ->
list.map { broadcast ->
list
.map { broadcast ->
if (broadcast.id == broadcastId) {
var updated = broadcast
relays.forEach { relay ->
@@ -230,7 +213,7 @@ class BroadcastTracker {
} 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<String, String?> {
val parts = message.split(":", limit = 2)
return if (parts.size == 2) {
parts[0].trim().uppercase() to parts[1].trim()
} else {
"ERROR" to message.takeIf { it.isNotBlank() }
}
_activeBroadcasts.update { persistentListOf() }
}
private data class RelayResponse(
@@ -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<BroadcastEvent>,
onTap: () -> Unit,
broadcasts: ImmutableList<BroadcastEvent>,
onTap: () -> Unit = {},
onRetryAll: () -> Unit = {},
onDismiss: () -> Unit = {},
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
@@ -85,6 +108,15 @@ fun BroadcastBanner(
.padding(horizontal = 16.dp, vertical = 8.dp)
.animateContentSize(),
) {
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 {
if (broadcasts.size == 1) {
SingleBroadcastContent(broadcasts.first())
} else {
@@ -94,6 +126,7 @@ fun BroadcastBanner(
}
}
}
}
@Composable
private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
@@ -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<BroadcastEvent>) {
private fun MultipleBroadcastsContent(broadcasts: ImmutableList<BroadcastEvent>) {
val totalRelays = broadcasts.sumOf { it.totalRelays }
val completedResponses = broadcasts.sumOf { it.results.size }
@@ -203,3 +236,464 @@ private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) {
}
}
}
@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<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 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,
),
),
)
}
}
}
@@ -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<BroadcastEvent>,
onDismiss: () -> Unit,
onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit,
onRetryAllFailed: (BroadcastEvent) -> Unit,
broadcasts: ImmutableList<BroadcastEvent>,
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<BroadcastEvent>,
onDismiss: () -> Unit = {},
onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit = { _, _ -> },
onRetryAllFailed: (BroadcastEvent) -> Unit = {},
) {
// Synced rotation animation for all pending/retrying icons
val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation")
@@ -113,26 +144,21 @@ fun MultiBroadcastDetailsSheet(
)
// Track which sections are expanded (by broadcast ID)
var expandedSections by remember { mutableStateOf(setOf<String>()) }
var expandedSections by remember {
// Sort: in-progress first, then by start time
val sortedBroadcasts =
broadcasts.sortedWith(
compareBy<BroadcastEvent> { it.status != BroadcastStatus.IN_PROGRESS }
.thenByDescending { it.startedAt },
compareBy { 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,
) {
mutableStateOf(visibleBroadcasts.map { it.id }.toSet())
}
Column(
verticalArrangement = spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
@@ -147,25 +173,13 @@ fun MultiBroadcastDetailsSheet(
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(8.dp))
// Visible broadcast sections
visibleBroadcasts.forEachIndexed { index, broadcast ->
val isExpanded =
broadcast.id in expandedSections ||
(broadcast.status == BroadcastStatus.IN_PROGRESS && broadcast.id !in expandedSections)
// Auto-expand in-progress, auto-collapse completed (unless manually expanded)
val showExpanded =
if (broadcast.status == BroadcastStatus.IN_PROGRESS) {
true
} else {
broadcast.id in expandedSections
}
broadcasts.forEachIndexed { index, broadcast ->
BroadcastSection(
broadcast = broadcast,
isExpanded = showExpanded,
isExpanded = broadcast.id in expandedSections,
onToggleExpand = {
expandedSections =
if (broadcast.id in expandedSections) {
@@ -179,19 +193,12 @@ fun MultiBroadcastDetailsSheet(
rotationAngle = rotationAngle,
)
if (index < visibleBroadcasts.size - 1 || overflowBroadcasts.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
if (index < broadcasts.lastIndex) {
HorizontalDivider()
}
}
Spacer(Modifier.height(8.dp))
}
}
// Overflow summary
if (overflowBroadcasts.isNotEmpty()) {
OverflowSummary(overflowBroadcasts)
}
Spacer(Modifier.height(16.dp))
// Dismiss button
OutlinedButton(
@@ -202,7 +209,6 @@ fun MultiBroadcastDetailsSheet(
}
}
}
}
@Composable
private fun BroadcastSection(
@@ -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<BroadcastEvent>) {
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,
),
),
)
}
}
@@ -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<BroadcastEvent?>(null) }
// State for details sheet
var selectedBroadcast by remember { mutableStateOf<BroadcastEvent?>(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<BroadcastEvent>,
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
Box(modifier = Modifier.fillMaxSize()) {
BroadcastBanner(
broadcasts = activeBroadcasts,
onTap = onTap,
onRetryAll = {
activeBroadcasts.forEach { b ->
accountViewModel.runOnIO {
accountViewModel.broadcastTracker.retry(
broadcast = b,
client = accountViewModel.account.client,
)
}
// Same styling as BroadcastBanner
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 2.dp,
}
},
onDismiss = {
accountViewModel.broadcastTracker.clear()
},
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,
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 50.dp),
)
}
}
// 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),
)
}
}
}
}
}
@@ -309,18 +309,14 @@ 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)
}
}
} else {
// Fire-and-forget (original behavior)
account.reactTo(note, reaction)
@@ -723,19 +719,14 @@ 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)
}
}
}
} else {
// Fire-and-forget (original behavior)
launchSigner { account.boost(note) }
@@ -768,18 +759,14 @@ 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)
}
}
}
} else {
launchSigner { account.addBookmark(note, true) }
}
@@ -789,18 +776,14 @@ 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)
}
}
}
} else {
launchSigner { account.addBookmark(note, false) }
}
@@ -810,18 +793,15 @@ 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)
}
}
}
} else {
launchSigner { account.removeBookmark(note, true) }
}
@@ -831,18 +811,14 @@ 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)
}
}
}
} else {
launchSigner { account.removeBookmark(note, false) }
}
@@ -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,23 +547,16 @@ 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)
}
}
} else {
// Fire-and-forget (original behavior)
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
@@ -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)
@@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class HomeLiveFilter(
val account: Account,
) : AdditiveComplexFeedFilter<Channel, Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
override fun showHiddenKey(): Boolean = false
+9
View File
@@ -1495,4 +1495,13 @@
<string name="broadcasting">Broadcasting</string>
<string name="broadcasting_name">Broadcasting %1$s</string>
<string name="broadcasting_number_events">Broadcasting %1$d events...</string>
<string name="sent_number_events">%1$d events sent</string>
<string name="bradcasting_result_partial">Some events failed</string>
<string name="bradcasting_result_success">All events succeeded</string>
<string name="bradcasting_result_failure">All events failed</string>
<string name="reaction">Reaction</string>
<string name="voice_post">Voice Post</string>
<string name="voice_reply">Voice Reply</string>
</resources>
+5 -5
View File
@@ -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"