Merge remote-tracking branch 'origin/kmp-completeness' into kmp-completeness
This commit is contained in:
+4
-16
@@ -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,
|
||||
)
|
||||
|
||||
+29
-84
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+441
-91
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-206
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-26
@@ -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) }
|
||||
}
|
||||
|
||||
-32
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user