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:
nrobi144
2026-01-23 16:27:29 +02:00
parent c5d9fc3c15
commit dedfd59d0b
8 changed files with 1098 additions and 94 deletions
@@ -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,
@@ -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(
@@ -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()
}
/**
@@ -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),
@@ -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
@@ -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) }
@@ -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)