diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9b08ad91e..5ff728e24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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>? { + 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>? = + 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, + ) { + 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 createPostEvent( + template: EventTemplate, + extraNotesToBroadcast: List = emptyList(), + ): Triple, List> { + 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, + extraNotesToBroadcast: List, + ) { + cache.justConsumeMyOwnEvent(event) + extraNotesToBroadcast.forEach { client.send(it, relays) } + } + suspend fun createAndSendDraftIgnoreErrors( draftTag: String, template: EventTemplate, @@ -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>? { + 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>? { + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt index 5a5980a02..6688e7c7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt @@ -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 - 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 + get() = results.filter { it.value is RelayResult.Retrying }.keys.toList() /** Creates a copy with an updated relay result */ fun withResult( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt index 2a71fea2b..0e0c4e41e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt @@ -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>(emptyList()) @@ -63,6 +64,9 @@ class BroadcastTracker { private val _completedBroadcast = MutableSharedFlow(extraBufferCapacity = 10) val completedBroadcast: SharedFlow = _completedBroadcast.asSharedFlow() + // Event cache for retries - maps tracking ID to original Event + private val eventCache = mutableMapOf() + /** * 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, + ) { + _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(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() + 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() } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt index 3cbdee92f..4358ef697 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt @@ -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, + 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()) } + + // Sort: in-progress first, then by start time + val sortedBroadcasts = + broadcasts.sortedWith( + compareBy { 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) { + 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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt new file mode 100644 index 000000000..465aa9c50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt @@ -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(null) } + + // State for details sheet + var selectedBroadcast by remember { mutableStateOf(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), + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 9266e299e..6cad3e11a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a9abbf968..ed50011f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 90ee62b3a..984bcbfd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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)