feat(broadcast): Enhance retry, multi-broadcast UI, and post tracking (#1682)
- Add Retrying state to RelayResult for in-flight retry tracking - Implement synced 360° rotating animation for pending/retrying icons - Refactor BroadcastDetailsSheet for multi-broadcast sections (max 2 expanded) - Add overflow summary for additional broadcasts beyond 2 - Fix sheet text colors using theme colors for better visibility - Make sheet fully expandable (skipPartiallyExpanded) - Add event cache to BroadcastTracker for retry support - Implement retry that updates existing broadcast in-place - Add tracked broadcasting for posts and quotes via createPostEvent/consumePostEvent - Fix relay list computation for new posts (use event-based, not empty note) - Make post editor close immediately, broadcast continues in background - Style CompletedBroadcastIndicator consistently with BroadcastBanner Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -149,6 +149,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
@@ -511,6 +512,38 @@ class Account(
|
||||
onPrivate = ::broadcastPrivately,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a reaction event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
* Returns null if note has already been reacted to or note has no event.
|
||||
*/
|
||||
suspend fun createReactionEvent(
|
||||
note: Note,
|
||||
reaction: String,
|
||||
): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
if (!signer.isWriteable()) return null
|
||||
if (note.hasReacted(userProfile(), reaction)) return null
|
||||
|
||||
val noteEvent = note.event ?: return null
|
||||
|
||||
// For NIP-17 private groups, we don't support tracked mode (too complex)
|
||||
if (noteEvent is NIP17Group) return null
|
||||
|
||||
val relayHint = note.relays.firstOrNull()?.url
|
||||
val event = ReactionAction.reactTo(noteEvent, reaction, signer, relayHint)
|
||||
val relays = computeRelayListToBroadcast(event)
|
||||
|
||||
return event to relays
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a reaction event into local cache.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumeReactionEvent(event: Event) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun createZapRequestFor(
|
||||
event: Event,
|
||||
pollOption: Int?,
|
||||
@@ -632,6 +665,35 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a boost event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
*/
|
||||
suspend fun createBoostEvent(note: Note): Pair<Event, Set<NormalizedRelayUrl>>? =
|
||||
RepostAction.repost(note, signer)?.let { event ->
|
||||
event to computeMyReactionToNote(note, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a boost event and updates the local cache.
|
||||
* Used after tracked broadcasting completes.
|
||||
*/
|
||||
fun sendBoostEvent(
|
||||
event: Event,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
client.send(event, relays)
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local cache with a boost event.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumeBoostEvent(event: Event) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
fun computeMyReactionToNote(
|
||||
note: Note,
|
||||
reaction: Event,
|
||||
@@ -1178,6 +1240,36 @@ class Account(
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a post event without sending it.
|
||||
* Returns the event, target relays, and extra events to broadcast.
|
||||
* For use with tracked broadcasting.
|
||||
*/
|
||||
suspend fun <T : Event> createPostEvent(
|
||||
template: EventTemplate<T>,
|
||||
extraNotesToBroadcast: List<Event> = emptyList(),
|
||||
): Triple<T, Set<NormalizedRelayUrl>, List<Event>> {
|
||||
val event = signer.sign(template)
|
||||
|
||||
// Use event-based relay computation (not note-based, since note is empty)
|
||||
val relayList = computeRelayListToBroadcast(event)
|
||||
|
||||
return Triple(event, relayList, extraNotesToBroadcast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a post event into local cache and sends extra events.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumePostEvent(
|
||||
event: Event,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
extraNotesToBroadcast: List<Event>,
|
||||
) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
extraNotesToBroadcast.forEach { client.send(it, relays) }
|
||||
}
|
||||
|
||||
suspend fun createAndSendDraftIgnoreErrors(
|
||||
draftTag: String,
|
||||
template: EventTemplate<out Event>,
|
||||
@@ -1573,6 +1665,46 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bookmark event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
*/
|
||||
suspend fun createAddBookmarkEvent(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
if (!isWriteable() || note.isDraft()) return null
|
||||
|
||||
val event = bookmarkState.addBookmark(note, isPrivate)
|
||||
val relays = outboxRelays.flow.value
|
||||
|
||||
return event to relays
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a remove bookmark event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
*/
|
||||
suspend fun createRemoveBookmarkEvent(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
if (!isWriteable() || note.isDraft()) return null
|
||||
|
||||
val event = bookmarkState.removeBookmark(note, isPrivate) ?: return null
|
||||
val relays = outboxRelays.flow.value
|
||||
|
||||
return event to relays
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a bookmark event into local cache.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumeBookmarkEvent(event: Event) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun createAuthEvent(
|
||||
relay: NormalizedRelayUrl,
|
||||
challenge: String,
|
||||
|
||||
+15
-2
@@ -43,6 +43,9 @@ sealed class RelayResult {
|
||||
|
||||
/** Waiting for relay response */
|
||||
data object Pending : RelayResult()
|
||||
|
||||
/** Retry in progress for this relay */
|
||||
data object Retrying : RelayResult()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,9 +103,19 @@ data class BroadcastEvent(
|
||||
val isComplete: Boolean
|
||||
get() = results.size >= targetRelays.size
|
||||
|
||||
/** List of relays that failed (for retry) */
|
||||
/** List of relays that failed and are not currently retrying */
|
||||
val failedRelays: List<NormalizedRelayUrl>
|
||||
get() = results.filter { it.value is RelayResult.Error || it.value is RelayResult.Timeout }.keys.toList()
|
||||
get() =
|
||||
results
|
||||
.filter {
|
||||
(it.value is RelayResult.Error || it.value is RelayResult.Timeout) &&
|
||||
it.value !is RelayResult.Retrying
|
||||
}.keys
|
||||
.toList()
|
||||
|
||||
/** List of relays currently being retried */
|
||||
val retryingRelays: List<NormalizedRelayUrl>
|
||||
get() = results.filter { it.value is RelayResult.Retrying }.keys.toList()
|
||||
|
||||
/** Creates a copy with an updated relay result */
|
||||
fun withResult(
|
||||
|
||||
+191
-16
@@ -55,6 +55,7 @@ class BroadcastTracker {
|
||||
companion object {
|
||||
private const val TAG = "BroadcastTracker"
|
||||
private const val TIMEOUT_SECONDS = 15L
|
||||
const val COMPLETED_DISPLAY_DURATION_MS = 10_000L // SnackbarDuration.Long equivalent
|
||||
}
|
||||
|
||||
private val _activeBroadcasts = MutableStateFlow<List<BroadcastEvent>>(emptyList())
|
||||
@@ -63,6 +64,9 @@ class BroadcastTracker {
|
||||
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.
|
||||
*
|
||||
@@ -90,8 +94,9 @@ class BroadcastTracker {
|
||||
targetRelays = relays.toList(),
|
||||
)
|
||||
|
||||
// Add to active broadcasts
|
||||
// Add to active broadcasts and cache event for retries
|
||||
_activeBroadcasts.update { it + broadcast }
|
||||
eventCache[trackingId] = event
|
||||
|
||||
Log.d(TAG, "Starting broadcast $trackingId: $eventName (kind ${event.kind}) to ${relays.size} relays")
|
||||
|
||||
@@ -207,36 +212,192 @@ class BroadcastTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries sending an event to failed relays.
|
||||
* Marks relays as Retrying in an existing broadcast.
|
||||
* Call this before starting the retry to show immediate feedback.
|
||||
*/
|
||||
fun markRelaysRetrying(
|
||||
broadcastId: String,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
_activeBroadcasts.update { list ->
|
||||
list.map { broadcast ->
|
||||
if (broadcast.id == broadcastId) {
|
||||
var updated = broadcast
|
||||
relays.forEach { relay ->
|
||||
updated = updated.withResult(relay, RelayResult.Retrying)
|
||||
}
|
||||
updated.copy(status = BroadcastStatus.IN_PROGRESS)
|
||||
} else {
|
||||
broadcast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries sending an event to failed relays using cached event.
|
||||
* Updates the existing broadcast in-place with retry results.
|
||||
*
|
||||
* @param originalBroadcast The original broadcast with failures
|
||||
* @param event The original event to resend
|
||||
* @param broadcast The broadcast to retry (must be in activeBroadcasts or recently completed)
|
||||
* @param client The Nostr client
|
||||
* @param specificRelay Optional specific relay to retry (null = all failed)
|
||||
* @return Updated BroadcastEvent or null if event not in cache
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
suspend fun retry(
|
||||
originalBroadcast: BroadcastEvent,
|
||||
event: Event,
|
||||
broadcast: BroadcastEvent,
|
||||
client: INostrClient,
|
||||
specificRelay: NormalizedRelayUrl? = null,
|
||||
): BroadcastResult {
|
||||
): BroadcastEvent? {
|
||||
val event = eventCache[broadcast.id] ?: return null
|
||||
|
||||
val relaysToRetry =
|
||||
if (specificRelay != null) {
|
||||
setOf(specificRelay)
|
||||
} else {
|
||||
originalBroadcast.failedRelays.toSet()
|
||||
broadcast.failedRelays.toSet()
|
||||
}
|
||||
|
||||
if (relaysToRetry.isEmpty()) {
|
||||
return BroadcastResult(originalBroadcast, originalBroadcast.successCount > 0)
|
||||
return broadcast
|
||||
}
|
||||
|
||||
return trackBroadcast(
|
||||
event = event,
|
||||
eventName = "${originalBroadcast.eventName} (retry)",
|
||||
relays = relaysToRetry,
|
||||
client = client,
|
||||
)
|
||||
// Mark relays as retrying for immediate feedback
|
||||
markRelaysRetrying(broadcast.id, relaysToRetry)
|
||||
|
||||
// If broadcast not in active list, re-add it
|
||||
if (_activeBroadcasts.value.none { it.id == broadcast.id }) {
|
||||
_activeBroadcasts.update { list ->
|
||||
var updated = broadcast
|
||||
relaysToRetry.forEach { relay ->
|
||||
updated = updated.withResult(relay, RelayResult.Retrying)
|
||||
}
|
||||
list + updated.copy(status = BroadcastStatus.IN_PROGRESS)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup result collection
|
||||
val resultChannel = Channel<RelayResponse>(UNLIMITED)
|
||||
|
||||
val subscription =
|
||||
object : IRelayClientListener {
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
if (relay.url in relaysToRetry) {
|
||||
resultChannel.trySend(
|
||||
RelayResponse(
|
||||
relay = relay.url,
|
||||
result = RelayResult.Error("CONNECTION_ERROR", errorMessage),
|
||||
),
|
||||
)
|
||||
Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
if (relay.url in relaysToRetry) {
|
||||
resultChannel.trySend(
|
||||
RelayResponse(
|
||||
relay = relay.url,
|
||||
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"),
|
||||
),
|
||||
)
|
||||
Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
super.onIncomingMessage(relay, msgStr, msg)
|
||||
|
||||
when (msg) {
|
||||
is OkMessage -> {
|
||||
if (msg.eventId == event.id) {
|
||||
val result =
|
||||
if (msg.success) {
|
||||
RelayResult.Success
|
||||
} else {
|
||||
val (code, message) = parseOkError(msg.message)
|
||||
RelayResult.Error(code, message)
|
||||
}
|
||||
resultChannel.trySend(RelayResponse(relay.url, result))
|
||||
Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subscription)
|
||||
|
||||
val finalBroadcast =
|
||||
coroutineScope {
|
||||
val resultCollector =
|
||||
async {
|
||||
val receivedRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
var currentBroadcast = _activeBroadcasts.value.find { it.id == broadcast.id } ?: broadcast
|
||||
|
||||
withTimeoutOrNull(TIMEOUT_SECONDS * 1000) {
|
||||
while (receivedRelays.size < relaysToRetry.size) {
|
||||
val response = resultChannel.receive()
|
||||
|
||||
if (response.relay !in relaysToRetry) continue
|
||||
if (response.relay in receivedRelays) continue
|
||||
|
||||
receivedRelays.add(response.relay)
|
||||
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
|
||||
|
||||
_activeBroadcasts.update { list ->
|
||||
list.map { if (it.id == broadcast.id) currentBroadcast else it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark remaining as timeout
|
||||
relaysToRetry.filter { it !in receivedRelays }.forEach { relay ->
|
||||
currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout)
|
||||
}
|
||||
|
||||
// Recalculate status
|
||||
val newStatus =
|
||||
when {
|
||||
currentBroadcast.results.values.any { it is RelayResult.Pending || it is RelayResult.Retrying } ->
|
||||
BroadcastStatus.IN_PROGRESS
|
||||
currentBroadcast.results.all { it.value is RelayResult.Success } ->
|
||||
BroadcastStatus.SUCCESS
|
||||
currentBroadcast.results.none { it.value is RelayResult.Success } ->
|
||||
BroadcastStatus.FAILED
|
||||
else -> BroadcastStatus.PARTIAL
|
||||
}
|
||||
currentBroadcast.copy(status = newStatus)
|
||||
}
|
||||
|
||||
client.send(event, relaysToRetry)
|
||||
|
||||
resultCollector.await()
|
||||
}
|
||||
|
||||
client.unsubscribe(subscription)
|
||||
resultChannel.close()
|
||||
|
||||
// Update in active broadcasts
|
||||
_activeBroadcasts.update { list ->
|
||||
list.map { if (it.id == broadcast.id) finalBroadcast else it }
|
||||
}
|
||||
|
||||
// Emit to completed if all done
|
||||
if (finalBroadcast.status != BroadcastStatus.IN_PROGRESS) {
|
||||
_completedBroadcast.emit(finalBroadcast)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
|
||||
|
||||
return finalBroadcast
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,10 +406,24 @@ class BroadcastTracker {
|
||||
fun getActiveBroadcast(trackingId: String): BroadcastEvent? = _activeBroadcasts.value.find { it.id == trackingId }
|
||||
|
||||
/**
|
||||
* Clears all active broadcasts (e.g., on logout).
|
||||
* Gets the cached event for a broadcast (for retries).
|
||||
*/
|
||||
fun getCachedEvent(trackingId: String): Event? = eventCache[trackingId]
|
||||
|
||||
/**
|
||||
* Removes a broadcast from cache (call after COMPLETED_DISPLAY_DURATION_MS expires).
|
||||
*/
|
||||
fun expireBroadcast(trackingId: String) {
|
||||
eventCache.remove(trackingId)
|
||||
Log.d(TAG, "Expired broadcast $trackingId from cache")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all active broadcasts and cache (e.g., on logout).
|
||||
*/
|
||||
fun clear() {
|
||||
_activeBroadcasts.update { emptyList() }
|
||||
eventCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+304
-69
@@ -20,6 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.broadcast
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -29,11 +39,13 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
@@ -46,12 +58,18 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -61,8 +79,11 @@ import com.vitorpamplona.amethyst.service.broadcast.RelayResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
|
||||
private const val MAX_EXPANDED_SECTIONS = 2
|
||||
|
||||
/**
|
||||
* Modal bottom sheet showing detailed relay results for a broadcast.
|
||||
* Modal bottom sheet showing detailed relay results for broadcasts.
|
||||
* Shows up to 2 expanded sections, with a summary for additional broadcasts.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -71,93 +92,218 @@ fun BroadcastDetailsSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onRetryRelay: (NormalizedRelayUrl) -> Unit,
|
||||
onRetryAllFailed: () -> Unit,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
sheetState: SheetState =
|
||||
rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
),
|
||||
) {
|
||||
MultiBroadcastDetailsSheet(
|
||||
broadcasts = listOf(broadcast),
|
||||
onDismiss = onDismiss,
|
||||
onRetryRelay = { _, relay -> onRetryRelay(relay) },
|
||||
onRetryAllFailed = { onRetryAllFailed() },
|
||||
sheetState = sheetState,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal bottom sheet showing detailed relay results for multiple broadcasts.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MultiBroadcastDetailsSheet(
|
||||
broadcasts: List<BroadcastEvent>,
|
||||
onDismiss: () -> Unit,
|
||||
onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit,
|
||||
onRetryAllFailed: (BroadcastEvent) -> Unit,
|
||||
sheetState: SheetState =
|
||||
rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
),
|
||||
) {
|
||||
// Synced rotation animation for all pending/retrying icons
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation")
|
||||
val rotationAngle by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1500, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "rotation",
|
||||
)
|
||||
|
||||
// Track which sections are expanded (by broadcast ID)
|
||||
var expandedSections by remember { mutableStateOf(setOf<String>()) }
|
||||
|
||||
// Sort: in-progress first, then by start time
|
||||
val sortedBroadcasts =
|
||||
broadcasts.sortedWith(
|
||||
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),
|
||||
.padding(bottom = 32.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "Broadcast Results",
|
||||
text = if (broadcasts.size == 1) "Broadcast Results" else "Broadcasts (${broadcasts.size})",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Summary
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatusIcon(broadcast.status)
|
||||
// Visible broadcast sections
|
||||
visibleBroadcasts.forEachIndexed { index, broadcast ->
|
||||
val isExpanded =
|
||||
broadcast.id in expandedSections ||
|
||||
(broadcast.status == BroadcastStatus.IN_PROGRESS && broadcast.id !in expandedSections)
|
||||
|
||||
Text(
|
||||
text = "${broadcast.eventName} (kind ${broadcast.kind})",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
// Auto-expand in-progress, auto-collapse completed (unless manually expanded)
|
||||
val showExpanded =
|
||||
if (broadcast.status == BroadcastStatus.IN_PROGRESS) {
|
||||
true
|
||||
} else {
|
||||
broadcast.id in expandedSections
|
||||
}
|
||||
|
||||
BroadcastSection(
|
||||
broadcast = broadcast,
|
||||
isExpanded = showExpanded,
|
||||
onToggleExpand = {
|
||||
expandedSections =
|
||||
if (broadcast.id in expandedSections) {
|
||||
expandedSections - broadcast.id
|
||||
} else {
|
||||
expandedSections + broadcast.id
|
||||
}
|
||||
},
|
||||
onRetryRelay = { relay -> onRetryRelay(broadcast, relay) },
|
||||
onRetryAllFailed = { onRetryAllFailed(broadcast) },
|
||||
rotationAngle = rotationAngle,
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (index < visibleBroadcasts.size - 1 || overflowBroadcasts.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${broadcast.successCount}/${broadcast.totalRelays} relays",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color =
|
||||
when (broadcast.status) {
|
||||
BroadcastStatus.SUCCESS -> Color(0xFF22C55E)
|
||||
BroadcastStatus.PARTIAL -> Color(0xFFF59E0B)
|
||||
BroadcastStatus.FAILED -> Color(0xFFEF4444)
|
||||
BroadcastStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
// Overflow summary
|
||||
if (overflowBroadcasts.isNotEmpty()) {
|
||||
OverflowSummary(overflowBroadcasts)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Relay list
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
// Dismiss button
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
items(broadcast.targetRelays) { relay ->
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BroadcastSection(
|
||||
broadcast: BroadcastEvent,
|
||||
isExpanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onRetryRelay: (NormalizedRelayUrl) -> Unit,
|
||||
onRetryAllFailed: () -> Unit,
|
||||
rotationAngle: Float,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Section header (clickable to expand/collapse)
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onToggleExpand() },
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
) {
|
||||
StatusIcon(broadcast.status, rotationAngle)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = broadcast.eventName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "kind ${broadcast.kind}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${broadcast.successCount}/${broadcast.totalRelays}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = statusColor(broadcast.status),
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Expandable relay list
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Column {
|
||||
broadcast.targetRelays.forEach { relay ->
|
||||
val result = broadcast.results[relay] ?: RelayResult.Pending
|
||||
RelayResultRow(
|
||||
relay = relay,
|
||||
result = result,
|
||||
onRetry = { onRetryRelay(relay) },
|
||||
rotationAngle = rotationAngle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
|
||||
// Retry all button
|
||||
if (broadcast.failedRelays.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onRetryAllFailed,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
@@ -178,20 +324,87 @@ fun BroadcastDetailsSheet(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusIcon(status: BroadcastStatus) {
|
||||
val (icon, tint) =
|
||||
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 = "+${broadcasts.size} more broadcasts",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
buildString {
|
||||
append("$totalSuccess/$totalRelays relays")
|
||||
if (inProgressCount > 0) {
|
||||
append(" ($inProgressCount in progress)")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun statusColor(status: BroadcastStatus): Color =
|
||||
when (status) {
|
||||
BroadcastStatus.SUCCESS -> Color(0xFF22C55E)
|
||||
BroadcastStatus.PARTIAL -> Color(0xFFF59E0B)
|
||||
BroadcastStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||
BroadcastStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusIcon(
|
||||
status: BroadcastStatus,
|
||||
rotationAngle: Float = 0f,
|
||||
) {
|
||||
val (icon, tint, shouldRotate) =
|
||||
when (status) {
|
||||
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to Color(0xFF22C55E)
|
||||
BroadcastStatus.PARTIAL -> Icons.Default.Error to Color(0xFFF59E0B)
|
||||
BroadcastStatus.FAILED -> Icons.Default.Error to Color(0xFFEF4444)
|
||||
BroadcastStatus.IN_PROGRESS -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.primary
|
||||
BroadcastStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, Color(0xFF22C55E), false)
|
||||
BroadcastStatus.PARTIAL -> Triple(Icons.Default.Error, Color(0xFFF59E0B), false)
|
||||
BroadcastStatus.FAILED -> Triple(Icons.Default.Error, MaterialTheme.colorScheme.error, false)
|
||||
BroadcastStatus.IN_PROGRESS -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = status.name,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.then(
|
||||
if (shouldRotate) {
|
||||
Modifier.graphicsLayer { rotationZ = rotationAngle }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -200,7 +413,12 @@ private fun RelayResultRow(
|
||||
relay: NormalizedRelayUrl,
|
||||
result: RelayResult,
|
||||
onRetry: () -> Unit,
|
||||
rotationAngle: Float = 0f,
|
||||
) {
|
||||
val successColor = Color(0xFF22C55E)
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
val warningColor = Color(0xFFF59E0B)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -209,19 +427,29 @@ private fun RelayResultRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Status icon
|
||||
val (icon, tint) =
|
||||
val (icon, tint, shouldRotate) =
|
||||
when (result) {
|
||||
is RelayResult.Success -> Icons.Default.CheckCircle to Color(0xFF22C55E)
|
||||
is RelayResult.Error -> Icons.Default.Error to Color(0xFFEF4444)
|
||||
is RelayResult.Timeout -> Icons.Default.HourglassEmpty to Color(0xFFF59E0B)
|
||||
is RelayResult.Pending -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
is RelayResult.Success -> Triple(Icons.Default.CheckCircle, successColor, false)
|
||||
is RelayResult.Error -> Triple(Icons.Default.Error, errorColor, false)
|
||||
is RelayResult.Timeout -> Triple(Icons.Default.HourglassEmpty, warningColor, false)
|
||||
is RelayResult.Pending -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.onSurfaceVariant, true)
|
||||
is RelayResult.Retrying -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.then(
|
||||
if (shouldRotate) {
|
||||
Modifier.graphicsLayer { rotationZ = rotationAngle }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
@@ -243,7 +471,7 @@ private fun RelayResultRow(
|
||||
text = "[${result.code}]${result.message?.let { " $it" } ?: ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color(0xFFEF4444),
|
||||
color = errorColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -252,15 +480,22 @@ private fun RelayResultRow(
|
||||
Text(
|
||||
text = "Timeout",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFFF59E0B),
|
||||
color = warningColor,
|
||||
)
|
||||
}
|
||||
is RelayResult.Retrying -> {
|
||||
Text(
|
||||
text = "Retrying...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Retry button for failed relays
|
||||
if (result is RelayResult.Error || result is RelayResult.Timeout) {
|
||||
// Retry button for failed relays (not when already retrying)
|
||||
if ((result is RelayResult.Error || result is RelayResult.Timeout) && result !is RelayResult.Retrying) {
|
||||
IconButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.size(32.dp),
|
||||
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.broadcast
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Displays broadcast progress UI components:
|
||||
* - BroadcastBanner: Shows active broadcasts with progress
|
||||
* - CompletedBroadcastIndicator: Shows completed broadcast for tap-to-view (auto-dismisses after 10s)
|
||||
* - BroadcastDetailsSheet: Shows detailed relay status on tap
|
||||
*
|
||||
* Only shown when FeatureSetType.COMPLETE is enabled.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
|
||||
// Only show in COMPLETE UI mode
|
||||
if (!accountViewModel.settings.isCompleteUIMode()) return
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle()
|
||||
|
||||
// State for completed broadcast (with auto-dismiss)
|
||||
var completedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) }
|
||||
|
||||
// State for details sheet
|
||||
var selectedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) }
|
||||
|
||||
// Collect completed broadcasts and set auto-dismiss timer
|
||||
LaunchedEffect(Unit) {
|
||||
accountViewModel.broadcastTracker.completedBroadcast.collect { broadcast ->
|
||||
completedBroadcast = broadcast
|
||||
|
||||
// Auto-dismiss after LONG duration
|
||||
delay(BroadcastTracker.COMPLETED_DISPLAY_DURATION_MS)
|
||||
|
||||
// Only dismiss if still showing same broadcast
|
||||
if (completedBroadcast?.id == broadcast.id) {
|
||||
completedBroadcast = null
|
||||
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.padding(bottom = 56.dp),
|
||||
)
|
||||
|
||||
// Completed broadcast indicator (when no active broadcasts)
|
||||
if (activeBroadcasts.isEmpty()) {
|
||||
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)
|
||||
.padding(bottom = 56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Details sheet - show all broadcasts when opened
|
||||
if (selectedBroadcast != null) {
|
||||
// Combine active broadcasts with the selected/completed one
|
||||
val allBroadcasts =
|
||||
(activeBroadcasts + listOfNotNull(completedBroadcast))
|
||||
.distinctBy { it.id }
|
||||
|
||||
MultiBroadcastDetailsSheet(
|
||||
broadcasts = allBroadcasts,
|
||||
onDismiss = { selectedBroadcast = null },
|
||||
onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl ->
|
||||
scope.launch {
|
||||
accountViewModel.broadcastTracker.retry(
|
||||
broadcast = b,
|
||||
client = accountViewModel.account.client,
|
||||
specificRelay = relay,
|
||||
)
|
||||
}
|
||||
},
|
||||
onRetryAllFailed = { b: BroadcastEvent ->
|
||||
scope.launch {
|
||||
accountViewModel.broadcastTracker.retry(
|
||||
broadcast = b,
|
||||
client = accountViewModel.account.client,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact indicator for completed broadcasts.
|
||||
* Styled consistently with BroadcastBanner.
|
||||
* Tappable to show details sheet, with retry button for failures.
|
||||
*/
|
||||
@Composable
|
||||
private fun CompletedBroadcastIndicator(
|
||||
broadcast: BroadcastEvent?,
|
||||
onTap: (BroadcastEvent) -> Unit,
|
||||
onRetry: (BroadcastEvent) -> Unit,
|
||||
onDismiss: (BroadcastEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Status colors (for icon tint only)
|
||||
val successColor = Color(0xFF22C55E)
|
||||
val warningColor = Color(0xFFF59E0B)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = broadcast != null,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
|
||||
modifier = modifier,
|
||||
) {
|
||||
broadcast?.let { b ->
|
||||
val (statusIcon, iconTint) =
|
||||
when (b.status) {
|
||||
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor
|
||||
BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor
|
||||
BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
|
||||
BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
// Same styling as BroadcastBanner
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = 2.dp,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onTap(b) },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
// Small status icon (like BroadcastBanner)
|
||||
Icon(
|
||||
imageVector = statusIcon,
|
||||
contentDescription = b.status.name,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "${b.eventName} sent",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "[${b.successCount}/${b.totalRelays}]",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = iconTint,
|
||||
)
|
||||
}
|
||||
|
||||
if (b.failedRelays.isNotEmpty()) {
|
||||
Text(
|
||||
text = "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 = "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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
@@ -310,6 +311,7 @@ fun AppNavigation(
|
||||
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
|
||||
DisplayNotifyMessages(accountViewModel, nav)
|
||||
DisplayCrashMessages(accountViewModel, nav)
|
||||
DisplayBroadcastProgress(accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+122
-6
@@ -317,7 +317,25 @@ class AccountViewModel(
|
||||
if (currentReactions.isNotEmpty()) {
|
||||
account.delete(currentReactions)
|
||||
} else {
|
||||
account.reactTo(note, reaction)
|
||||
if (settings.isCompleteUIMode()) {
|
||||
// Tracked broadcasting with progress feedback
|
||||
account.createReactionEvent(note, reaction)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Reaction",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
// Only consume event if at least one relay succeeded
|
||||
if (result.isSuccess) {
|
||||
account.consumeReactionEvent(event)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fire-and-forget (original behavior)
|
||||
account.reactTo(note, reaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -711,7 +729,29 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun boost(note: Note) = launchSigner { account.boost(note) }
|
||||
fun boost(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
// Tracked broadcasting with progress feedback
|
||||
launchSigner {
|
||||
account.createBoostEvent(note)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Boost",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
// Only consume event if at least one relay succeeded
|
||||
if (result.isSuccess) {
|
||||
account.consumeBoostEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fire-and-forget (original behavior)
|
||||
launchSigner { account.boost(note) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeEmojiPack(emojiPack: Note) = launchSigner { account.removeEmojiPack(emojiPack) }
|
||||
|
||||
@@ -733,13 +773,89 @@ class AccountViewModel(
|
||||
|
||||
fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
|
||||
|
||||
fun addPrivateBookmark(note: Note) = launchSigner { account.addBookmark(note, true) }
|
||||
fun addPrivateBookmark(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
launchSigner {
|
||||
account.createAddBookmarkEvent(note, true)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Bookmark",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
account.consumeBookmarkEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchSigner { account.addBookmark(note, true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addPublicBookmark(note: Note) = launchSigner { account.addBookmark(note, false) }
|
||||
fun addPublicBookmark(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
launchSigner {
|
||||
account.createAddBookmarkEvent(note, false)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Bookmark",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
account.consumeBookmarkEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchSigner { account.addBookmark(note, false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removePrivateBookmark(note: Note) = launchSigner { account.removeBookmark(note, true) }
|
||||
fun removePrivateBookmark(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
launchSigner {
|
||||
account.createRemoveBookmarkEvent(note, true)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Remove Bookmark",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
account.consumeBookmarkEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchSigner { account.removeBookmark(note, true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removePublicBookmark(note: Note) = launchSigner { account.removeBookmark(note, false) }
|
||||
fun removePublicBookmark(note: Note) {
|
||||
if (settings.isCompleteUIMode()) {
|
||||
launchSigner {
|
||||
account.createRemoveBookmarkEvent(note, false)?.let { (event, relays) ->
|
||||
val result =
|
||||
broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = "Remove Bookmark",
|
||||
relays = relays,
|
||||
client = account.client,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
account.consumeBookmarkEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchSigner { account.removeBookmark(note, false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun broadcast(note: Note) = launchSigner { account.broadcast(note) }
|
||||
|
||||
|
||||
+38
-1
@@ -97,6 +97,7 @@ import com.vitorpamplona.quartz.nip10Notes.tags.notify
|
||||
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
@@ -544,12 +545,48 @@ open class ShortNotePostViewModel :
|
||||
val version = draftTag.current
|
||||
cancel()
|
||||
|
||||
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
|
||||
if (accountViewModel.settings.isCompleteUIMode()) {
|
||||
// Tracked broadcasting with progress feedback (non-blocking)
|
||||
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
|
||||
val eventName = getEventName(event)
|
||||
|
||||
// Launch broadcast in background - don't wait for completion
|
||||
accountViewModel.viewModelScope.launch {
|
||||
val result =
|
||||
accountViewModel.broadcastTracker.trackBroadcast(
|
||||
event = event,
|
||||
eventName = eventName,
|
||||
relays = relays,
|
||||
client = accountViewModel.account.client,
|
||||
)
|
||||
|
||||
// Only consume event if at least one relay succeeded
|
||||
if (result.isSuccess) {
|
||||
accountViewModel.account.consumePostEvent(event, relays, extras)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fire-and-forget (original behavior)
|
||||
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
|
||||
}
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(version)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEventName(event: Event): String =
|
||||
when (event) {
|
||||
is TextNoteEvent -> {
|
||||
val quotes = event.taggedQuotes()
|
||||
if (quotes.isNotEmpty()) "Quote" else "Post"
|
||||
}
|
||||
is PollNoteEvent -> "Poll"
|
||||
is VoiceEvent -> "Voice"
|
||||
is VoiceReplyEvent -> "Voice Reply"
|
||||
else -> "Post"
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank()) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
|
||||
Reference in New Issue
Block a user