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
|
package com.vitorpamplona.amethyst.service.broadcast
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
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
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,8 +34,7 @@ sealed class RelayResult {
|
|||||||
|
|
||||||
/** Relay rejected the event (OK message with success=false) */
|
/** Relay rejected the event (OK message with success=false) */
|
||||||
data class Error(
|
data class Error(
|
||||||
val code: String,
|
val message: String,
|
||||||
val message: String?,
|
|
||||||
) : RelayResult()
|
) : RelayResult()
|
||||||
|
|
||||||
/** Relay did not respond within timeout */
|
/** Relay did not respond within timeout */
|
||||||
@@ -71,13 +70,11 @@ enum class BroadcastStatus {
|
|||||||
@Immutable
|
@Immutable
|
||||||
data class BroadcastEvent(
|
data class BroadcastEvent(
|
||||||
val id: String,
|
val id: String,
|
||||||
val eventId: HexKey,
|
val event: Event,
|
||||||
val eventName: String,
|
|
||||||
val kind: Int,
|
|
||||||
val targetRelays: List<NormalizedRelayUrl>,
|
val targetRelays: List<NormalizedRelayUrl>,
|
||||||
|
val startedAt: Long = System.currentTimeMillis(),
|
||||||
val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(),
|
val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(),
|
||||||
val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS,
|
val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS,
|
||||||
val startedAt: Long = System.currentTimeMillis(),
|
|
||||||
) {
|
) {
|
||||||
/** Number of relays that accepted the event */
|
/** Number of relays that accepted the event */
|
||||||
val successCount: Int
|
val successCount: Int
|
||||||
@@ -133,12 +130,3 @@ data class BroadcastEvent(
|
|||||||
return copy(results = newResults, status = newStatus)
|
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,
|
|
||||||
)
|
|
||||||
|
|||||||
+37
-92
@@ -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.commands.toClient.OkMessage
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
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.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -54,51 +54,38 @@ import java.util.UUID
|
|||||||
class BroadcastTracker {
|
class BroadcastTracker {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BroadcastTracker"
|
private const val TAG = "BroadcastTracker"
|
||||||
private const val TIMEOUT_SECONDS = 15L
|
const val TIMEOUT_SECONDS = 10L
|
||||||
const val COMPLETED_DISPLAY_DURATION_MS = 10_000L // SnackbarDuration.Long equivalent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _activeBroadcasts = MutableStateFlow<List<BroadcastEvent>>(emptyList())
|
private val _activeBroadcasts = MutableStateFlow<ImmutableList<BroadcastEvent>>(persistentListOf())
|
||||||
val activeBroadcasts: StateFlow<List<BroadcastEvent>> = _activeBroadcasts.asStateFlow()
|
val activeBroadcasts: StateFlow<ImmutableList<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>()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks an event broadcast to relays with live progress updates.
|
* Tracks an event broadcast to relays with live progress updates.
|
||||||
*
|
*
|
||||||
* @param event The Nostr event to broadcast
|
* @param event The Nostr event to broadcast
|
||||||
* @param eventName Human-readable name (e.g., "Boost", "Reaction")
|
|
||||||
* @param relays Target relays to send to
|
* @param relays Target relays to send to
|
||||||
* @param client The Nostr client for sending
|
* @param client The Nostr client for sending
|
||||||
* @return BroadcastResult with final status
|
|
||||||
*/
|
*/
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
suspend fun trackBroadcast(
|
suspend fun trackBroadcast(
|
||||||
event: Event,
|
event: Event,
|
||||||
eventName: String,
|
|
||||||
relays: Set<NormalizedRelayUrl>,
|
relays: Set<NormalizedRelayUrl>,
|
||||||
client: INostrClient,
|
client: INostrClient,
|
||||||
): BroadcastResult {
|
) {
|
||||||
val trackingId = UUID.randomUUID().toString()
|
val trackingId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
val broadcast =
|
val broadcast =
|
||||||
BroadcastEvent(
|
BroadcastEvent(
|
||||||
id = trackingId,
|
id = trackingId,
|
||||||
eventId = event.id,
|
event = event,
|
||||||
eventName = eventName,
|
|
||||||
kind = event.kind,
|
|
||||||
targetRelays = relays.toList(),
|
targetRelays = relays.toList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add to active broadcasts and cache event for retries
|
// Add to active broadcasts and cache event for retries
|
||||||
_activeBroadcasts.update { it + broadcast }
|
_activeBroadcasts.update { (it + broadcast).toImmutableList() }
|
||||||
eventCache[trackingId] = event
|
|
||||||
|
|
||||||
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)
|
val resultChannel = Channel<RelayResponse>(UNLIMITED)
|
||||||
|
|
||||||
@@ -112,7 +99,7 @@ class BroadcastTracker {
|
|||||||
resultChannel.trySend(
|
resultChannel.trySend(
|
||||||
RelayResponse(
|
RelayResponse(
|
||||||
relay = relay.url,
|
relay = relay.url,
|
||||||
result = RelayResult.Error("CONNECTION_ERROR", errorMessage),
|
result = RelayResult.Error(errorMessage),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage")
|
Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage")
|
||||||
@@ -124,7 +111,7 @@ class BroadcastTracker {
|
|||||||
resultChannel.trySend(
|
resultChannel.trySend(
|
||||||
RelayResponse(
|
RelayResponse(
|
||||||
relay = relay.url,
|
relay = relay.url,
|
||||||
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"),
|
result = RelayResult.Error("Relay disconnected before completion"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}")
|
Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}")
|
||||||
@@ -145,8 +132,7 @@ class BroadcastTracker {
|
|||||||
if (msg.success) {
|
if (msg.success) {
|
||||||
RelayResult.Success
|
RelayResult.Success
|
||||||
} else {
|
} else {
|
||||||
val (code, message) = parseOkError(msg.message)
|
RelayResult.Error(msg.message)
|
||||||
RelayResult.Error(code, message)
|
|
||||||
}
|
}
|
||||||
resultChannel.trySend(RelayResponse(relay.url, result))
|
resultChannel.trySend(RelayResponse(relay.url, result))
|
||||||
Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}")
|
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
|
// Update active broadcasts with new progress
|
||||||
_activeBroadcasts.update { list ->
|
_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()
|
resultChannel.close()
|
||||||
|
|
||||||
// Remove from active, emit to completed
|
// Remove from active, emit to completed
|
||||||
_activeBroadcasts.update { list -> list.filter { it.id != trackingId } }
|
_activeBroadcasts.update { list ->
|
||||||
_completedBroadcast.emit(finalBroadcast)
|
list.map { if (it.id == trackingId) finalBroadcast else it }.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
|
Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
|
||||||
|
|
||||||
return BroadcastResult(
|
|
||||||
broadcast = finalBroadcast,
|
|
||||||
isSuccess = finalBroadcast.successCount > 0,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,17 +202,18 @@ class BroadcastTracker {
|
|||||||
relays: Set<NormalizedRelayUrl>,
|
relays: Set<NormalizedRelayUrl>,
|
||||||
) {
|
) {
|
||||||
_activeBroadcasts.update { list ->
|
_activeBroadcasts.update { list ->
|
||||||
list.map { broadcast ->
|
list
|
||||||
if (broadcast.id == broadcastId) {
|
.map { broadcast ->
|
||||||
var updated = broadcast
|
if (broadcast.id == broadcastId) {
|
||||||
relays.forEach { relay ->
|
var updated = broadcast
|
||||||
updated = updated.withResult(relay, RelayResult.Retrying)
|
relays.forEach { relay ->
|
||||||
|
updated = updated.withResult(relay, RelayResult.Retrying)
|
||||||
|
}
|
||||||
|
updated.copy(status = BroadcastStatus.IN_PROGRESS)
|
||||||
|
} else {
|
||||||
|
broadcast
|
||||||
}
|
}
|
||||||
updated.copy(status = BroadcastStatus.IN_PROGRESS)
|
}.toImmutableList()
|
||||||
} else {
|
|
||||||
broadcast
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,8 +231,8 @@ class BroadcastTracker {
|
|||||||
broadcast: BroadcastEvent,
|
broadcast: BroadcastEvent,
|
||||||
client: INostrClient,
|
client: INostrClient,
|
||||||
specificRelay: NormalizedRelayUrl? = null,
|
specificRelay: NormalizedRelayUrl? = null,
|
||||||
): BroadcastEvent? {
|
): BroadcastEvent {
|
||||||
val event = eventCache[broadcast.id] ?: return null
|
val event = broadcast.event
|
||||||
|
|
||||||
val relaysToRetry =
|
val relaysToRetry =
|
||||||
if (specificRelay != null) {
|
if (specificRelay != null) {
|
||||||
@@ -272,7 +255,7 @@ class BroadcastTracker {
|
|||||||
relaysToRetry.forEach { relay ->
|
relaysToRetry.forEach { relay ->
|
||||||
updated = updated.withResult(relay, RelayResult.Retrying)
|
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(
|
resultChannel.trySend(
|
||||||
RelayResponse(
|
RelayResponse(
|
||||||
relay = relay.url,
|
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")
|
Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage")
|
||||||
@@ -301,7 +284,7 @@ class BroadcastTracker {
|
|||||||
resultChannel.trySend(
|
resultChannel.trySend(
|
||||||
RelayResponse(
|
RelayResponse(
|
||||||
relay = relay.url,
|
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}")
|
Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}")
|
||||||
@@ -322,8 +305,7 @@ class BroadcastTracker {
|
|||||||
if (msg.success) {
|
if (msg.success) {
|
||||||
RelayResult.Success
|
RelayResult.Success
|
||||||
} else {
|
} else {
|
||||||
val (code, message) = parseOkError(msg.message)
|
RelayResult.Error(msg.message)
|
||||||
RelayResult.Error(code, message)
|
|
||||||
}
|
}
|
||||||
resultChannel.trySend(RelayResponse(relay.url, result))
|
resultChannel.trySend(RelayResponse(relay.url, result))
|
||||||
Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}")
|
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)
|
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
|
||||||
|
|
||||||
_activeBroadcasts.update { list ->
|
_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
|
// Update in active broadcasts
|
||||||
_activeBroadcasts.update { list ->
|
_activeBroadcasts.update { list ->
|
||||||
list.map { if (it.id == broadcast.id) finalBroadcast else it }
|
list.map { if (it.id == broadcast.id) finalBroadcast else it }.toImmutableList()
|
||||||
}
|
|
||||||
|
|
||||||
// Emit to completed if all done
|
|
||||||
if (finalBroadcast.status != BroadcastStatus.IN_PROGRESS) {
|
|
||||||
_completedBroadcast.emit(finalBroadcast)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
|
Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
|
||||||
@@ -408,43 +385,11 @@ class BroadcastTracker {
|
|||||||
return finalBroadcast
|
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).
|
* Clears all active broadcasts and cache (e.g., on logout).
|
||||||
*/
|
*/
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_activeBroadcasts.update { emptyList() }
|
_activeBroadcasts.update { persistentListOf() }
|
||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class RelayResponse(
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
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.material.icons.filled.Sync
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -49,11 +53,28 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.Constants
|
||||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
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.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.
|
* Banner showing active broadcast progress.
|
||||||
@@ -61,8 +82,10 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BroadcastBanner(
|
fun BroadcastBanner(
|
||||||
broadcasts: List<BroadcastEvent>,
|
broadcasts: ImmutableList<BroadcastEvent>,
|
||||||
onTap: () -> Unit,
|
onTap: () -> Unit = {},
|
||||||
|
onRetryAll: () -> Unit = {},
|
||||||
|
onDismiss: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -85,10 +108,20 @@ fun BroadcastBanner(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
.animateContentSize(),
|
.animateContentSize(),
|
||||||
) {
|
) {
|
||||||
if (broadcasts.size == 1) {
|
val isAllFinished = broadcasts.all { it.status != BroadcastStatus.IN_PROGRESS }
|
||||||
SingleBroadcastContent(broadcasts.first())
|
|
||||||
|
if (isAllFinished) {
|
||||||
|
if (broadcasts.size == 1) {
|
||||||
|
CompletedBroadcastContent(broadcasts.first(), onRetryAll, onDismiss)
|
||||||
|
} else {
|
||||||
|
MultipleCompletedBroadcastContent(broadcasts, onRetryAll, onDismiss)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
MultipleBroadcastsContent(broadcasts)
|
if (broadcasts.size == 1) {
|
||||||
|
SingleBroadcastContent(broadcasts.first())
|
||||||
|
} else {
|
||||||
|
MultipleBroadcastsContent(broadcasts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +148,7 @@ private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(R.string.broadcasting_name, broadcast.eventName),
|
text = stringRes(R.string.broadcasting_name, broadcast.event.toKindName()),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -151,7 +184,7 @@ private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) {
|
private fun MultipleBroadcastsContent(broadcasts: ImmutableList<BroadcastEvent>) {
|
||||||
val totalRelays = broadcasts.sumOf { it.totalRelays }
|
val totalRelays = broadcasts.sumOf { it.totalRelays }
|
||||||
val completedResponses = broadcasts.sumOf { it.results.size }
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+481
-131
@@ -31,6 +31,7 @@ import androidx.compose.animation.expandVertically
|
|||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.Constants
|
||||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||||
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
|
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
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.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
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
|
private const val MAX_EXPANDED_SECTIONS = 2
|
||||||
|
|
||||||
@@ -90,14 +99,36 @@ private const val MAX_EXPANDED_SECTIONS = 2
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MultiBroadcastDetailsSheet(
|
fun MultiBroadcastDetailsSheet(
|
||||||
broadcasts: List<BroadcastEvent>,
|
broadcasts: ImmutableList<BroadcastEvent>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit = {},
|
||||||
onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit,
|
onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit = { _, _ -> },
|
||||||
onRetryAllFailed: (BroadcastEvent) -> Unit,
|
onRetryAllFailed: (BroadcastEvent) -> Unit = {},
|
||||||
sheetState: SheetState =
|
sheetState: SheetState =
|
||||||
rememberModalBottomSheetState(
|
rememberModalBottomSheetState(
|
||||||
skipPartiallyExpanded = true,
|
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
|
// Synced rotation animation for all pending/retrying icons
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation")
|
val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation")
|
||||||
@@ -113,93 +144,68 @@ fun MultiBroadcastDetailsSheet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Track which sections are expanded (by broadcast ID)
|
// 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
|
||||||
// Sort: in-progress first, then by start time
|
val sortedBroadcasts =
|
||||||
val sortedBroadcasts =
|
broadcasts.sortedWith(
|
||||||
broadcasts.sortedWith(
|
compareBy { it.startedAt },
|
||||||
compareBy<BroadcastEvent> { it.status != BroadcastStatus.IN_PROGRESS }
|
|
||||||
.thenByDescending { it.startedAt },
|
|
||||||
)
|
|
||||||
|
|
||||||
// First 2 get shown expanded by default (unless completed)
|
|
||||||
val visibleBroadcasts = sortedBroadcasts.take(MAX_EXPANDED_SECTIONS)
|
|
||||||
val overflowBroadcasts = sortedBroadcasts.drop(MAX_EXPANDED_SECTIONS)
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = sheetState,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = 32.dp)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
) {
|
|
||||||
// Header
|
|
||||||
Text(
|
|
||||||
text = if (broadcasts.size == 1) stringRes(R.string.broadcast_results) else stringRes(R.string.broadcasts_number, broadcasts.size),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
// First 2 get shown expanded by default (unless completed)
|
||||||
|
val visibleBroadcasts = sortedBroadcasts.take(MAX_EXPANDED_SECTIONS)
|
||||||
|
|
||||||
// Visible broadcast sections
|
mutableStateOf(visibleBroadcasts.map { it.id }.toSet())
|
||||||
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)
|
Column(
|
||||||
val showExpanded =
|
verticalArrangement = spacedBy(8.dp),
|
||||||
if (broadcast.status == BroadcastStatus.IN_PROGRESS) {
|
modifier =
|
||||||
true
|
Modifier
|
||||||
} else {
|
.fillMaxWidth()
|
||||||
broadcast.id in expandedSections
|
.padding(horizontal = 16.dp)
|
||||||
}
|
.padding(bottom = 32.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Text(
|
||||||
|
text = if (broadcasts.size == 1) stringRes(R.string.broadcast_results) else stringRes(R.string.broadcasts_number, broadcasts.size),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
|
||||||
BroadcastSection(
|
Spacer(Modifier.height(8.dp))
|
||||||
broadcast = broadcast,
|
|
||||||
isExpanded = showExpanded,
|
|
||||||
onToggleExpand = {
|
|
||||||
expandedSections =
|
|
||||||
if (broadcast.id in expandedSections) {
|
|
||||||
expandedSections - broadcast.id
|
|
||||||
} else {
|
|
||||||
expandedSections + broadcast.id
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRetryRelay = { relay -> onRetryRelay(broadcast, relay) },
|
|
||||||
onRetryAllFailed = { onRetryAllFailed(broadcast) },
|
|
||||||
rotationAngle = rotationAngle,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (index < visibleBroadcasts.size - 1 || overflowBroadcasts.isNotEmpty()) {
|
// Visible broadcast sections
|
||||||
Spacer(Modifier.height(8.dp))
|
broadcasts.forEachIndexed { index, broadcast ->
|
||||||
HorizontalDivider()
|
BroadcastSection(
|
||||||
Spacer(Modifier.height(8.dp))
|
broadcast = broadcast,
|
||||||
}
|
isExpanded = broadcast.id in expandedSections,
|
||||||
|
onToggleExpand = {
|
||||||
|
expandedSections =
|
||||||
|
if (broadcast.id in expandedSections) {
|
||||||
|
expandedSections - broadcast.id
|
||||||
|
} else {
|
||||||
|
expandedSections + broadcast.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRetryRelay = { relay -> onRetryRelay(broadcast, relay) },
|
||||||
|
onRetryAllFailed = { onRetryAllFailed(broadcast) },
|
||||||
|
rotationAngle = rotationAngle,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (index < broadcasts.lastIndex) {
|
||||||
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Overflow summary
|
Spacer(Modifier.height(8.dp))
|
||||||
if (overflowBroadcasts.isNotEmpty()) {
|
|
||||||
OverflowSummary(overflowBroadcasts)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
// Dismiss button
|
||||||
|
OutlinedButton(
|
||||||
// Dismiss button
|
onClick = onDismiss,
|
||||||
OutlinedButton(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = onDismiss,
|
) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Text(stringRes(R.string.dismiss))
|
||||||
) {
|
|
||||||
Text(stringRes(R.string.dismiss))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,7 +237,7 @@ private fun BroadcastSection(
|
|||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = broadcast.eventName,
|
text = broadcast.event.toKindName(),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
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
|
@Composable
|
||||||
private fun statusColor(status: BroadcastStatus): Color =
|
private fun statusColor(status: BroadcastStatus): Color =
|
||||||
when (status) {
|
when (status) {
|
||||||
@@ -404,7 +364,7 @@ private fun RelayResultRow(
|
|||||||
when (result) {
|
when (result) {
|
||||||
is RelayResult.Success -> Triple(Icons.Default.CheckCircle, successColor, false)
|
is RelayResult.Success -> Triple(Icons.Default.CheckCircle, successColor, false)
|
||||||
is RelayResult.Error -> Triple(Icons.Default.Error, errorColor, 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.Pending -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.onSurfaceVariant, true)
|
||||||
is RelayResult.Retrying -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true)
|
is RelayResult.Retrying -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true)
|
||||||
}
|
}
|
||||||
@@ -441,7 +401,7 @@ private fun RelayResultRow(
|
|||||||
when (result) {
|
when (result) {
|
||||||
is RelayResult.Error -> {
|
is RelayResult.Error -> {
|
||||||
Text(
|
Text(
|
||||||
text = "[${result.code}]${result.message?.let { " $it" } ?: ""}",
|
text = result.message,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = errorColor,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+45
-209
@@ -20,54 +20,28 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.broadcast
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
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.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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.R
|
|
||||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
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.service.broadcast.BroadcastTracker
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays broadcast progress UI components:
|
* Displays broadcast progress UI components:
|
||||||
@@ -85,86 +59,30 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
|
|||||||
|
|
||||||
val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle()
|
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
|
// 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
|
if (activeBroadcasts.isEmpty() && !seeDetails) return
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
accountViewModel.broadcastTracker.completedBroadcast.collect { broadcast ->
|
|
||||||
completedBroadcast = broadcast
|
|
||||||
|
|
||||||
// Auto-dismiss after LONG duration
|
if (!seeDetails) {
|
||||||
delay(BroadcastTracker.COMPLETED_DISPLAY_DURATION_MS)
|
DisplaySnack(activeBroadcasts, { seeDetails = true }, accountViewModel)
|
||||||
|
|
||||||
// Only dismiss if still showing same broadcast
|
LaunchedEffect(activeBroadcasts) {
|
||||||
if (completedBroadcast?.id == broadcast.id) {
|
// this effect gets restarted every time the active broadcast changes
|
||||||
completedBroadcast = null
|
delay((BroadcastTracker.TIMEOUT_SECONDS + 1) * 1000)
|
||||||
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id)
|
accountViewModel.broadcastTracker.clear()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
|
||||||
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 }
|
|
||||||
|
|
||||||
MultiBroadcastDetailsSheet(
|
MultiBroadcastDetailsSheet(
|
||||||
broadcasts = allBroadcasts,
|
broadcasts = activeBroadcasts,
|
||||||
onDismiss = { selectedBroadcast = null },
|
onDismiss = {
|
||||||
|
accountViewModel.runOnIO {
|
||||||
|
accountViewModel.broadcastTracker.clear()
|
||||||
|
}
|
||||||
|
seeDetails = false
|
||||||
|
},
|
||||||
onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl ->
|
onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl ->
|
||||||
scope.launch {
|
accountViewModel.runOnIO {
|
||||||
accountViewModel.broadcastTracker.retry(
|
accountViewModel.broadcastTracker.retry(
|
||||||
broadcast = b,
|
broadcast = b,
|
||||||
client = accountViewModel.account.client,
|
client = accountViewModel.account.client,
|
||||||
@@ -173,7 +91,7 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRetryAllFailed = { b: BroadcastEvent ->
|
onRetryAllFailed = { b: BroadcastEvent ->
|
||||||
scope.launch {
|
accountViewModel.runOnIO {
|
||||||
accountViewModel.broadcastTracker.retry(
|
accountViewModel.broadcastTracker.retry(
|
||||||
broadcast = b,
|
broadcast = b,
|
||||||
client = accountViewModel.account.client,
|
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
|
@Composable
|
||||||
private fun CompletedBroadcastIndicator(
|
fun DisplaySnack(
|
||||||
broadcast: BroadcastEvent?,
|
activeBroadcasts: ImmutableList<BroadcastEvent>,
|
||||||
onTap: (BroadcastEvent) -> Unit,
|
onTap: () -> Unit,
|
||||||
onRetry: (BroadcastEvent) -> Unit,
|
accountViewModel: AccountViewModel,
|
||||||
onDismiss: (BroadcastEvent) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
// Status colors (for icon tint only)
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
val successColor = Color(0xFF22C55E)
|
BroadcastBanner(
|
||||||
val warningColor = Color(0xFFF59E0B)
|
broadcasts = activeBroadcasts,
|
||||||
|
onTap = onTap,
|
||||||
AnimatedVisibility(
|
onRetryAll = {
|
||||||
visible = broadcast != null,
|
activeBroadcasts.forEach { b ->
|
||||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
|
accountViewModel.runOnIO {
|
||||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
|
accountViewModel.broadcastTracker.retry(
|
||||||
modifier = modifier,
|
broadcast = b,
|
||||||
) {
|
client = accountViewModel.account.client,
|
||||||
broadcast?.let { b ->
|
)
|
||||||
val (statusIcon, iconTint) =
|
|
||||||
when (b.status) {
|
|
||||||
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor
|
|
||||||
BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor
|
|
||||||
BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
|
|
||||||
BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same styling as BroadcastBanner
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
tonalElevation = 2.dp,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onTap(b) },
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
// Small status icon (like BroadcastBanner)
|
|
||||||
Icon(
|
|
||||||
imageVector = statusIcon,
|
|
||||||
contentDescription = b.status.name,
|
|
||||||
tint = iconTint,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringRes(R.string.event_sent, b.eventName),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringRes(R.string.share_of, b.successCount, b.totalRelays),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = iconTint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (b.failedRelays.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringRes(R.string.tap_to_view_details),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry button for failures
|
|
||||||
if (b.failedRelays.isNotEmpty()) {
|
|
||||||
IconButton(
|
|
||||||
onClick = { onRetry(b) },
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Refresh,
|
|
||||||
contentDescription = stringRes(R.string.retry_failed),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismiss X
|
|
||||||
Text(
|
|
||||||
text = "×",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.clickable { onDismiss(b) }
|
|
||||||
.padding(4.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
onDismiss = {
|
||||||
|
accountViewModel.broadcastTracker.clear()
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(bottom = 50.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-62
@@ -309,17 +309,13 @@ class AccountViewModel(
|
|||||||
if (settings.isCompleteUIMode()) {
|
if (settings.isCompleteUIMode()) {
|
||||||
// Tracked broadcasting with progress feedback
|
// Tracked broadcasting with progress feedback
|
||||||
account.createReactionEvent(note, reaction)?.let { (event, relays) ->
|
account.createReactionEvent(note, reaction)?.let { (event, relays) ->
|
||||||
val result =
|
broadcastTracker.trackBroadcast(
|
||||||
broadcastTracker.trackBroadcast(
|
event = event,
|
||||||
event = event,
|
relays = relays,
|
||||||
eventName = "Reaction",
|
client = account.client,
|
||||||
relays = relays,
|
)
|
||||||
client = account.client,
|
|
||||||
)
|
account.consumeReactionEvent(event)
|
||||||
// Only consume event if at least one relay succeeded
|
|
||||||
if (result.isSuccess) {
|
|
||||||
account.consumeReactionEvent(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fire-and-forget (original behavior)
|
// Fire-and-forget (original behavior)
|
||||||
@@ -723,17 +719,12 @@ class AccountViewModel(
|
|||||||
// Tracked broadcasting with progress feedback
|
// Tracked broadcasting with progress feedback
|
||||||
launchSigner {
|
launchSigner {
|
||||||
account.createBoostEvent(note)?.let { (event, relays) ->
|
account.createBoostEvent(note)?.let { (event, relays) ->
|
||||||
val result =
|
broadcastTracker.trackBroadcast(
|
||||||
broadcastTracker.trackBroadcast(
|
event = event,
|
||||||
event = event,
|
relays = relays,
|
||||||
eventName = "Boost",
|
client = account.client,
|
||||||
relays = relays,
|
)
|
||||||
client = account.client,
|
account.consumeBoostEvent(event)
|
||||||
)
|
|
||||||
// Only consume event if at least one relay succeeded
|
|
||||||
if (result.isSuccess) {
|
|
||||||
account.consumeBoostEvent(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -768,16 +759,12 @@ class AccountViewModel(
|
|||||||
if (settings.isCompleteUIMode()) {
|
if (settings.isCompleteUIMode()) {
|
||||||
launchSigner {
|
launchSigner {
|
||||||
account.createAddBookmarkEvent(note, true)?.let { (event, relays) ->
|
account.createAddBookmarkEvent(note, true)?.let { (event, relays) ->
|
||||||
val result =
|
broadcastTracker.trackBroadcast(
|
||||||
broadcastTracker.trackBroadcast(
|
event = event,
|
||||||
event = event,
|
relays = relays,
|
||||||
eventName = "Bookmark",
|
client = account.client,
|
||||||
relays = relays,
|
)
|
||||||
client = account.client,
|
account.consumeBookmarkEvent(event)
|
||||||
)
|
|
||||||
if (result.isSuccess) {
|
|
||||||
account.consumeBookmarkEvent(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -789,16 +776,12 @@ class AccountViewModel(
|
|||||||
if (settings.isCompleteUIMode()) {
|
if (settings.isCompleteUIMode()) {
|
||||||
launchSigner {
|
launchSigner {
|
||||||
account.createAddBookmarkEvent(note, false)?.let { (event, relays) ->
|
account.createAddBookmarkEvent(note, false)?.let { (event, relays) ->
|
||||||
val result =
|
broadcastTracker.trackBroadcast(
|
||||||
broadcastTracker.trackBroadcast(
|
event = event,
|
||||||
event = event,
|
relays = relays,
|
||||||
eventName = "Bookmark",
|
client = account.client,
|
||||||
relays = relays,
|
)
|
||||||
client = account.client,
|
account.consumeBookmarkEvent(event)
|
||||||
)
|
|
||||||
if (result.isSuccess) {
|
|
||||||
account.consumeBookmarkEvent(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -810,16 +793,13 @@ class AccountViewModel(
|
|||||||
if (settings.isCompleteUIMode()) {
|
if (settings.isCompleteUIMode()) {
|
||||||
launchSigner {
|
launchSigner {
|
||||||
account.createRemoveBookmarkEvent(note, true)?.let { (event, relays) ->
|
account.createRemoveBookmarkEvent(note, true)?.let { (event, relays) ->
|
||||||
val result =
|
broadcastTracker.trackBroadcast(
|
||||||
broadcastTracker.trackBroadcast(
|
event = event,
|
||||||
event = event,
|
relays = relays,
|
||||||
eventName = "Remove Bookmark",
|
client = account.client,
|
||||||
relays = relays,
|
)
|
||||||
client = account.client,
|
|
||||||
)
|
account.consumeBookmarkEvent(event)
|
||||||
if (result.isSuccess) {
|
|
||||||
account.consumeBookmarkEvent(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -831,16 +811,12 @@ class AccountViewModel(
|
|||||||
if (settings.isCompleteUIMode()) {
|
if (settings.isCompleteUIMode()) {
|
||||||
launchSigner {
|
launchSigner {
|
||||||
account.createRemoveBookmarkEvent(note, false)?.let { (event, relays) ->
|
account.createRemoveBookmarkEvent(note, false)?.let { (event, relays) ->
|
||||||
val result =
|
broadcastTracker.trackBroadcast(
|
||||||
broadcastTracker.trackBroadcast(
|
event = event,
|
||||||
event = event,
|
relays = relays,
|
||||||
eventName = "Remove Bookmark",
|
client = account.client,
|
||||||
relays = relays,
|
)
|
||||||
client = account.client,
|
account.consumeBookmarkEvent(event)
|
||||||
)
|
|
||||||
if (result.isSuccess) {
|
|
||||||
account.consumeBookmarkEvent(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+6
-38
@@ -97,7 +97,6 @@ import com.vitorpamplona.quartz.nip10Notes.tags.notify
|
|||||||
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
|
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes
|
|
||||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||||
@@ -548,22 +547,15 @@ open class ShortNotePostViewModel :
|
|||||||
if (accountViewModel.settings.isCompleteUIMode()) {
|
if (accountViewModel.settings.isCompleteUIMode()) {
|
||||||
// Tracked broadcasting with progress feedback (non-blocking)
|
// Tracked broadcasting with progress feedback (non-blocking)
|
||||||
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
|
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
|
||||||
val eventName = getEventName(event)
|
|
||||||
|
|
||||||
// Launch broadcast in background - don't wait for completion
|
// Launch broadcast in background - don't wait for completion
|
||||||
accountViewModel.viewModelScope.launch {
|
accountViewModel.viewModelScope.launch {
|
||||||
val result =
|
accountViewModel.broadcastTracker.trackBroadcast(
|
||||||
accountViewModel.broadcastTracker.trackBroadcast(
|
event = event,
|
||||||
event = event,
|
relays = relays,
|
||||||
eventName = eventName,
|
client = accountViewModel.account.client,
|
||||||
relays = relays,
|
)
|
||||||
client = accountViewModel.account.client,
|
accountViewModel.account.consumePostEvent(event, relays, extras)
|
||||||
)
|
|
||||||
|
|
||||||
// Only consume event if at least one relay succeeded
|
|
||||||
if (result.isSuccess) {
|
|
||||||
accountViewModel.account.consumePostEvent(event, relays, extras)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fire-and-forget (original behavior)
|
// Fire-and-forget (original behavior)
|
||||||
@@ -575,30 +567,6 @@ open class ShortNotePostViewModel :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEventName(event: Event): String =
|
|
||||||
when (event) {
|
|
||||||
is TextNoteEvent -> {
|
|
||||||
val quotes = event.taggedQuotes()
|
|
||||||
if (quotes.isNotEmpty()) "Quote" else "Post"
|
|
||||||
}
|
|
||||||
|
|
||||||
is PollNoteEvent -> {
|
|
||||||
"Poll"
|
|
||||||
}
|
|
||||||
|
|
||||||
is VoiceEvent -> {
|
|
||||||
"Voice"
|
|
||||||
}
|
|
||||||
|
|
||||||
is VoiceReplyEvent -> {
|
|
||||||
"Voice Reply"
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
"Post"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendDraftSync() {
|
suspend fun sendDraftSync() {
|
||||||
if (message.text.isBlank()) {
|
if (message.text.isBlank()) {
|
||||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
class HomeLiveFilter(
|
class HomeLiveFilter(
|
||||||
val account: Account,
|
val account: Account,
|
||||||
) : AdditiveComplexFeedFilter<Channel, Note>() {
|
) : 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
|
override fun showHiddenKey(): Boolean = false
|
||||||
|
|
||||||
|
|||||||
@@ -1495,4 +1495,13 @@
|
|||||||
<string name="broadcasting">Broadcasting</string>
|
<string name="broadcasting">Broadcasting</string>
|
||||||
<string name="broadcasting_name">Broadcasting %1$s</string>
|
<string name="broadcasting_name">Broadcasting %1$s</string>
|
||||||
<string name="broadcasting_number_events">Broadcasting %1$d events...</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>
|
</resources>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ accompanistAdaptive = "0.37.3"
|
|||||||
cachemapVersion = "0.2.4"
|
cachemapVersion = "0.2.4"
|
||||||
comDitchoomBufferCompression = "2.1.0"
|
comDitchoomBufferCompression = "2.1.0"
|
||||||
composeMultiplatform = "1.10.0"
|
composeMultiplatform = "1.10.0"
|
||||||
activityCompose = "1.12.3"
|
activityCompose = "1.12.4"
|
||||||
agp = "9.0.0"
|
agp = "9.0.0"
|
||||||
android-compileSdk = "36"
|
android-compileSdk = "36"
|
||||||
android-minSdk = "26"
|
android-minSdk = "26"
|
||||||
@@ -12,11 +12,11 @@ androidKotlinGeohash = "b481c6a64e"
|
|||||||
androidxJunit = "1.3.0"
|
androidxJunit = "1.3.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
audiowaveform = "1.1.2"
|
audiowaveform = "1.1.2"
|
||||||
benchmark = "1.5.0-alpha02"
|
benchmark = "1.5.0-alpha03"
|
||||||
biometricKtx = "1.2.0-alpha05"
|
biometricKtx = "1.2.0-alpha05"
|
||||||
coil = "3.3.0"
|
coil = "3.3.0"
|
||||||
composeBom = "2026.01.01"
|
composeBom = "2026.02.00"
|
||||||
composeRuntimeAnnotation = "1.10.2"
|
composeRuntimeAnnotation = "1.10.3"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
datastore = "1.2.0"
|
datastore = "1.2.0"
|
||||||
devWhyolegCryptography = "0.5.0"
|
devWhyolegCryptography = "0.5.0"
|
||||||
@@ -64,7 +64,7 @@ zxingAndroidEmbedded = "4.3.0"
|
|||||||
windowCoreAndroid = "1.5.1"
|
windowCoreAndroid = "1.5.1"
|
||||||
androidxCamera = "1.5.3"
|
androidxCamera = "1.5.3"
|
||||||
androidxCollection = "1.5.0"
|
androidxCollection = "1.5.0"
|
||||||
kotlinStdlib = "2.3.0"
|
kotlinStdlib = "2.3.10"
|
||||||
kotlinTest = "2.2.20"
|
kotlinTest = "2.2.20"
|
||||||
core = "1.7.0"
|
core = "1.7.0"
|
||||||
mavenPublish = "0.36.0"
|
mavenPublish = "0.36.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user