Merge remote-tracking branch 'origin/kmp-completeness' into kmp-completeness

This commit is contained in:
KotlinGeekDev
2026-02-23 20:46:09 +01:00
10 changed files with 1127 additions and 561 deletions
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.broadcast package com.vitorpamplona.amethyst.service.broadcast
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/** /**
@@ -34,8 +34,7 @@ sealed class RelayResult {
/** Relay rejected the event (OK message with success=false) */ /** Relay rejected the event (OK message with success=false) */
data class Error( data class Error(
val code: String, val message: String,
val message: String?,
) : RelayResult() ) : RelayResult()
/** Relay did not respond within timeout */ /** Relay did not respond within timeout */
@@ -71,13 +70,11 @@ enum class BroadcastStatus {
@Immutable @Immutable
data class BroadcastEvent( data class BroadcastEvent(
val id: String, val id: String,
val eventId: HexKey, val event: Event,
val eventName: String,
val kind: Int,
val targetRelays: List<NormalizedRelayUrl>, val targetRelays: List<NormalizedRelayUrl>,
val startedAt: Long = System.currentTimeMillis(),
val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(), val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(),
val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS, val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS,
val startedAt: Long = System.currentTimeMillis(),
) { ) {
/** Number of relays that accepted the event */ /** Number of relays that accepted the event */
val successCount: Int val successCount: Int
@@ -133,12 +130,3 @@ data class BroadcastEvent(
return copy(results = newResults, status = newStatus) return copy(results = newResults, status = newStatus)
} }
} }
/**
* Result of a broadcast operation, returned after all relays respond or timeout.
*/
@Immutable
data class BroadcastResult(
val broadcast: BroadcastEvent,
val isSuccess: Boolean,
)
@@ -28,16 +28,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -54,51 +54,38 @@ import java.util.UUID
class BroadcastTracker { class BroadcastTracker {
companion object { companion object {
private const val TAG = "BroadcastTracker" private const val TAG = "BroadcastTracker"
private const val TIMEOUT_SECONDS = 15L const val TIMEOUT_SECONDS = 10L
const val COMPLETED_DISPLAY_DURATION_MS = 10_000L // SnackbarDuration.Long equivalent
} }
private val _activeBroadcasts = MutableStateFlow<List<BroadcastEvent>>(emptyList()) private val _activeBroadcasts = MutableStateFlow<ImmutableList<BroadcastEvent>>(persistentListOf())
val activeBroadcasts: StateFlow<List<BroadcastEvent>> = _activeBroadcasts.asStateFlow() val activeBroadcasts: StateFlow<ImmutableList<BroadcastEvent>> = _activeBroadcasts.asStateFlow()
private val _completedBroadcast = MutableSharedFlow<BroadcastEvent>(extraBufferCapacity = 10)
val completedBroadcast: SharedFlow<BroadcastEvent> = _completedBroadcast.asSharedFlow()
// Event cache for retries - maps tracking ID to original Event
private val eventCache = mutableMapOf<String, Event>()
/** /**
* Tracks an event broadcast to relays with live progress updates. * Tracks an event broadcast to relays with live progress updates.
* *
* @param event The Nostr event to broadcast * @param event The Nostr event to broadcast
* @param eventName Human-readable name (e.g., "Boost", "Reaction")
* @param relays Target relays to send to * @param relays Target relays to send to
* @param client The Nostr client for sending * @param client The Nostr client for sending
* @return BroadcastResult with final status
*/ */
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
suspend fun trackBroadcast( suspend fun trackBroadcast(
event: Event, event: Event,
eventName: String,
relays: Set<NormalizedRelayUrl>, relays: Set<NormalizedRelayUrl>,
client: INostrClient, client: INostrClient,
): BroadcastResult { ) {
val trackingId = UUID.randomUUID().toString() val trackingId = UUID.randomUUID().toString()
val broadcast = val broadcast =
BroadcastEvent( BroadcastEvent(
id = trackingId, id = trackingId,
eventId = event.id, event = event,
eventName = eventName,
kind = event.kind,
targetRelays = relays.toList(), targetRelays = relays.toList(),
) )
// Add to active broadcasts and cache event for retries // Add to active broadcasts and cache event for retries
_activeBroadcasts.update { it + broadcast } _activeBroadcasts.update { (it + broadcast).toImmutableList() }
eventCache[trackingId] = event
Log.d(TAG, "Starting broadcast $trackingId: $eventName (kind ${event.kind}) to ${relays.size} relays") Log.d(TAG, "Starting broadcast $trackingId (kind ${event.kind}) to ${relays.size} relays")
val resultChannel = Channel<RelayResponse>(UNLIMITED) val resultChannel = Channel<RelayResponse>(UNLIMITED)
@@ -112,7 +99,7 @@ class BroadcastTracker {
resultChannel.trySend( resultChannel.trySend(
RelayResponse( RelayResponse(
relay = relay.url, relay = relay.url,
result = RelayResult.Error("CONNECTION_ERROR", errorMessage), result = RelayResult.Error(errorMessage),
), ),
) )
Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage") Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage")
@@ -124,7 +111,7 @@ class BroadcastTracker {
resultChannel.trySend( resultChannel.trySend(
RelayResponse( RelayResponse(
relay = relay.url, relay = relay.url,
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"), result = RelayResult.Error("Relay disconnected before completion"),
), ),
) )
Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}") Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}")
@@ -145,8 +132,7 @@ class BroadcastTracker {
if (msg.success) { if (msg.success) {
RelayResult.Success RelayResult.Success
} else { } else {
val (code, message) = parseOkError(msg.message) RelayResult.Error(msg.message)
RelayResult.Error(code, message)
} }
resultChannel.trySend(RelayResponse(relay.url, result)) resultChannel.trySend(RelayResponse(relay.url, result))
Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}") Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}")
@@ -177,7 +163,7 @@ class BroadcastTracker {
// Update active broadcasts with new progress // Update active broadcasts with new progress
_activeBroadcasts.update { list -> _activeBroadcasts.update { list ->
list.map { if (it.id == trackingId) currentBroadcast else it } list.map { if (it.id == trackingId) currentBroadcast else it }.toImmutableList()
} }
} }
} }
@@ -200,15 +186,11 @@ class BroadcastTracker {
resultChannel.close() resultChannel.close()
// Remove from active, emit to completed // Remove from active, emit to completed
_activeBroadcasts.update { list -> list.filter { it.id != trackingId } } _activeBroadcasts.update { list ->
_completedBroadcast.emit(finalBroadcast) list.map { if (it.id == trackingId) finalBroadcast else it }.toImmutableList()
}
Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
return BroadcastResult(
broadcast = finalBroadcast,
isSuccess = finalBroadcast.successCount > 0,
)
} }
/** /**
@@ -220,17 +202,18 @@ class BroadcastTracker {
relays: Set<NormalizedRelayUrl>, relays: Set<NormalizedRelayUrl>,
) { ) {
_activeBroadcasts.update { list -> _activeBroadcasts.update { list ->
list.map { broadcast -> list
if (broadcast.id == broadcastId) { .map { broadcast ->
var updated = broadcast if (broadcast.id == broadcastId) {
relays.forEach { relay -> var updated = broadcast
updated = updated.withResult(relay, RelayResult.Retrying) relays.forEach { relay ->
updated = updated.withResult(relay, RelayResult.Retrying)
}
updated.copy(status = BroadcastStatus.IN_PROGRESS)
} else {
broadcast
} }
updated.copy(status = BroadcastStatus.IN_PROGRESS) }.toImmutableList()
} else {
broadcast
}
}
} }
} }
@@ -248,8 +231,8 @@ class BroadcastTracker {
broadcast: BroadcastEvent, broadcast: BroadcastEvent,
client: INostrClient, client: INostrClient,
specificRelay: NormalizedRelayUrl? = null, specificRelay: NormalizedRelayUrl? = null,
): BroadcastEvent? { ): BroadcastEvent {
val event = eventCache[broadcast.id] ?: return null val event = broadcast.event
val relaysToRetry = val relaysToRetry =
if (specificRelay != null) { if (specificRelay != null) {
@@ -272,7 +255,7 @@ class BroadcastTracker {
relaysToRetry.forEach { relay -> relaysToRetry.forEach { relay ->
updated = updated.withResult(relay, RelayResult.Retrying) updated = updated.withResult(relay, RelayResult.Retrying)
} }
list + updated.copy(status = BroadcastStatus.IN_PROGRESS) (list + updated.copy(status = BroadcastStatus.IN_PROGRESS)).toImmutableList()
} }
} }
@@ -289,7 +272,7 @@ class BroadcastTracker {
resultChannel.trySend( resultChannel.trySend(
RelayResponse( RelayResponse(
relay = relay.url, relay = relay.url,
result = RelayResult.Error("CONNECTION_ERROR", errorMessage), result = RelayResult.Error(errorMessage),
), ),
) )
Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage") Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage")
@@ -301,7 +284,7 @@ class BroadcastTracker {
resultChannel.trySend( resultChannel.trySend(
RelayResponse( RelayResponse(
relay = relay.url, relay = relay.url,
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"), result = RelayResult.Error("Relay disconnected before completion"),
), ),
) )
Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}") Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}")
@@ -322,8 +305,7 @@ class BroadcastTracker {
if (msg.success) { if (msg.success) {
RelayResult.Success RelayResult.Success
} else { } else {
val (code, message) = parseOkError(msg.message) RelayResult.Error(msg.message)
RelayResult.Error(code, message)
} }
resultChannel.trySend(RelayResponse(relay.url, result)) resultChannel.trySend(RelayResponse(relay.url, result))
Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}") Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}")
@@ -353,7 +335,7 @@ class BroadcastTracker {
currentBroadcast = currentBroadcast.withResult(response.relay, response.result) currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
_activeBroadcasts.update { list -> _activeBroadcasts.update { list ->
list.map { if (it.id == broadcast.id) currentBroadcast else it } list.map { if (it.id == broadcast.id) currentBroadcast else it }.toImmutableList()
} }
} }
} }
@@ -395,12 +377,7 @@ class BroadcastTracker {
// Update in active broadcasts // Update in active broadcasts
_activeBroadcasts.update { list -> _activeBroadcasts.update { list ->
list.map { if (it.id == broadcast.id) finalBroadcast else it } list.map { if (it.id == broadcast.id) finalBroadcast else it }.toImmutableList()
}
// Emit to completed if all done
if (finalBroadcast.status != BroadcastStatus.IN_PROGRESS) {
_completedBroadcast.emit(finalBroadcast)
} }
Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
@@ -408,43 +385,11 @@ class BroadcastTracker {
return finalBroadcast return finalBroadcast
} }
/**
* Gets details for a specific broadcast by tracking ID.
*/
fun getActiveBroadcast(trackingId: String): BroadcastEvent? = _activeBroadcasts.value.find { it.id == trackingId }
/**
* Gets the cached event for a broadcast (for retries).
*/
fun getCachedEvent(trackingId: String): Event? = eventCache[trackingId]
/**
* Removes a broadcast from cache (call after COMPLETED_DISPLAY_DURATION_MS expires).
*/
fun expireBroadcast(trackingId: String) {
eventCache.remove(trackingId)
Log.d(TAG, "Expired broadcast $trackingId from cache")
}
/** /**
* Clears all active broadcasts and cache (e.g., on logout). * Clears all active broadcasts and cache (e.g., on logout).
*/ */
fun clear() { fun clear() {
_activeBroadcasts.update { emptyList() } _activeBroadcasts.update { persistentListOf() }
eventCache.clear()
}
/**
* Parses NIP-20 OK error message into code and description.
* Format: "prefix: message" or just "message"
*/
private fun parseOkError(message: String): Pair<String, String?> {
val parts = message.split(":", limit = 2)
return if (parts.size == 2) {
parts[0].trim().uppercase() to parts[1].trim()
} else {
"ERROR" to message.takeIf { it.isNotBlank() }
}
} }
private data class RelayResponse( private data class RelayResponse(
@@ -39,8 +39,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -49,11 +53,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
/** /**
* Banner showing active broadcast progress. * Banner showing active broadcast progress.
@@ -61,8 +82,10 @@ import com.vitorpamplona.amethyst.ui.stringRes
*/ */
@Composable @Composable
fun BroadcastBanner( fun BroadcastBanner(
broadcasts: List<BroadcastEvent>, broadcasts: ImmutableList<BroadcastEvent>,
onTap: () -> Unit, onTap: () -> Unit = {},
onRetryAll: () -> Unit = {},
onDismiss: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AnimatedVisibility( AnimatedVisibility(
@@ -85,10 +108,20 @@ fun BroadcastBanner(
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
.animateContentSize(), .animateContentSize(),
) { ) {
if (broadcasts.size == 1) { val isAllFinished = broadcasts.all { it.status != BroadcastStatus.IN_PROGRESS }
SingleBroadcastContent(broadcasts.first())
if (isAllFinished) {
if (broadcasts.size == 1) {
CompletedBroadcastContent(broadcasts.first(), onRetryAll, onDismiss)
} else {
MultipleCompletedBroadcastContent(broadcasts, onRetryAll, onDismiss)
}
} else { } else {
MultipleBroadcastsContent(broadcasts) if (broadcasts.size == 1) {
SingleBroadcastContent(broadcasts.first())
} else {
MultipleBroadcastsContent(broadcasts)
}
} }
} }
} }
@@ -115,7 +148,7 @@ private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
text = stringRes(R.string.broadcasting_name, broadcast.eventName), text = stringRes(R.string.broadcasting_name, broadcast.event.toKindName()),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -151,7 +184,7 @@ private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
} }
@Composable @Composable
private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) { private fun MultipleBroadcastsContent(broadcasts: ImmutableList<BroadcastEvent>) {
val totalRelays = broadcasts.sumOf { it.totalRelays } val totalRelays = broadcasts.sumOf { it.totalRelays }
val completedResponses = broadcasts.sumOf { it.results.size } val completedResponses = broadcasts.sumOf { it.results.size }
@@ -203,3 +236,464 @@ private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) {
} }
} }
} }
@Composable
fun CompletedBroadcastContent(
broadcast: BroadcastEvent,
onRetryAll: () -> Unit,
onDismiss: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
// Status colors (for icon tint only)
val successColor = Color(0xFF22C55E)
val warningColor = Color(0xFFF59E0B)
val (statusIcon, iconTint) =
when (broadcast.status) {
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor
BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor
BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
}
// Small status icon (like BroadcastBanner)
Icon(
imageVector = statusIcon,
contentDescription = broadcast.status.name,
tint = iconTint,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringRes(R.string.event_sent, broadcast.event.toKindName()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
if (broadcast.failedRelays.isNotEmpty()) {
Text(
text = stringRes(R.string.tap_to_view_details),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = stringRes(R.string.share_of, broadcast.successCount, broadcast.totalRelays),
style = MaterialTheme.typography.labelMedium,
color = iconTint,
)
// Retry button for failures
if (broadcast.failedRelays.isNotEmpty()) {
IconButton(
onClick = onRetryAll,
modifier = Modifier.size(22.dp),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringRes(R.string.retry_failed),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
// Dismiss X
Text(
text = "×",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clickable(onClick = onDismiss)
.padding(start = 2.dp),
)
}
}
@Composable
fun MultipleCompletedBroadcastContent(
broadcasts: ImmutableList<BroadcastEvent>,
onRetryAll: () -> Unit,
onDismiss: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
// Status colors (for icon tint only)
val successColor = Color(0xFF22C55E)
val warningColor = Color(0xFFF59E0B)
val totalRelayCount = broadcasts.sumOf { it.totalRelays }
val failedRelayCount = broadcasts.sumOf { it.failedRelays.size }
val allSuccess = broadcasts.all { it.status == BroadcastStatus.SUCCESS }
val allFailed = broadcasts.all { it.status == BroadcastStatus.FAILED }
val (statusIcon, iconTint) =
if (allSuccess) {
Icons.Default.CheckCircle to successColor
} else if (allFailed) {
Icons.Default.Error to MaterialTheme.colorScheme.error
} else {
Icons.Default.Error to warningColor
}
// Small status icon (like BroadcastBanner)
Icon(
imageVector = statusIcon,
contentDescription =
if (allSuccess) {
stringRes(R.string.bradcasting_result_success)
} else if (allFailed) {
stringRes(R.string.bradcasting_result_failure)
} else {
stringRes(R.string.bradcasting_result_partial)
},
tint = iconTint,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringRes(R.string.sent_number_events, broadcasts.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
if (failedRelayCount > 0) {
Text(
text = stringRes(R.string.tap_to_view_details),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = stringRes(R.string.share_of, (totalRelayCount - failedRelayCount), totalRelayCount),
style = MaterialTheme.typography.labelMedium,
color = iconTint,
)
// Retry button for failures
if (failedRelayCount > 0) {
IconButton(
onClick = onRetryAll,
modifier = Modifier.size(22.dp),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringRes(R.string.retry_failed),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
// Dismiss X
Text(
text = "×",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clickable(onClick = onDismiss)
.padding(start = 2.dp),
)
}
}
@Composable
fun Event.toKindName(): String =
when (this) {
is ReactionEvent -> stringRes(R.string.reaction)
is RepostEvent -> stringRes(R.string.boost)
is GenericRepostEvent -> stringRes(R.string.boost)
is VoiceEvent -> stringRes(R.string.voice_post)
is VoiceReplyEvent -> stringRes(R.string.voice_reply)
is BookmarkListEvent -> stringRes(R.string.bookmarks)
else -> stringRes(R.string.post)
}
@Preview
@Composable
fun BroadcastBannerSingleEventPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonColumn {
Column {
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results = mapOf(Constants.mom to RelayResult.Success),
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.SUCCESS,
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Error("code"),
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.PARTIAL,
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Error("code"),
Constants.mom to RelayResult.Error("code"),
Constants.nos to RelayResult.Error("code"),
),
status = BroadcastStatus.FAILED,
),
),
)
}
}
}
@Preview
@Composable
fun BroadcastBannerDoubleEventPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonColumn {
Column {
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results = mapOf(Constants.mom to RelayResult.Success),
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results = mapOf(Constants.mom to RelayResult.Success),
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results =
mapOf(
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.SUCCESS,
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results =
mapOf(
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.SUCCESS,
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.SUCCESS,
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results =
mapOf(
Constants.mom to RelayResult.Error("code"),
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.PARTIAL,
),
),
)
BroadcastBanner(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Error("code"),
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.FAILED,
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results =
mapOf(
Constants.mom to RelayResult.Timeout,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.FAILED,
),
),
)
}
}
}
@@ -31,6 +31,7 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -73,14 +74,22 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.service.broadcast.RelayResult import com.vitorpamplona.amethyst.service.broadcast.RelayResult
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
private const val MAX_EXPANDED_SECTIONS = 2 private const val MAX_EXPANDED_SECTIONS = 2
@@ -90,14 +99,36 @@ private const val MAX_EXPANDED_SECTIONS = 2
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MultiBroadcastDetailsSheet( fun MultiBroadcastDetailsSheet(
broadcasts: List<BroadcastEvent>, broadcasts: ImmutableList<BroadcastEvent>,
onDismiss: () -> Unit, onDismiss: () -> Unit = {},
onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit, onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit = { _, _ -> },
onRetryAllFailed: (BroadcastEvent) -> Unit, onRetryAllFailed: (BroadcastEvent) -> Unit = {},
sheetState: SheetState = sheetState: SheetState =
rememberModalBottomSheetState( rememberModalBottomSheetState(
skipPartiallyExpanded = true, skipPartiallyExpanded = true,
), ),
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
MultiBroadcastDetailsSheetContent(
broadcasts = broadcasts,
onDismiss = onDismiss,
onRetryRelay = onRetryRelay,
onRetryAllFailed = onRetryAllFailed,
)
}
}
@Composable
fun MultiBroadcastDetailsSheetContent(
broadcasts: ImmutableList<BroadcastEvent>,
onDismiss: () -> Unit = {},
onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit = { _, _ -> },
onRetryAllFailed: (BroadcastEvent) -> Unit = {},
) { ) {
// Synced rotation animation for all pending/retrying icons // Synced rotation animation for all pending/retrying icons
val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation") val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation")
@@ -113,93 +144,68 @@ fun MultiBroadcastDetailsSheet(
) )
// Track which sections are expanded (by broadcast ID) // Track which sections are expanded (by broadcast ID)
var expandedSections by remember { mutableStateOf(setOf<String>()) } var expandedSections by remember {
// Sort: in-progress first, then by start time
// Sort: in-progress first, then by start time val sortedBroadcasts =
val sortedBroadcasts = broadcasts.sortedWith(
broadcasts.sortedWith( compareBy { it.startedAt },
compareBy<BroadcastEvent> { it.status != BroadcastStatus.IN_PROGRESS }
.thenByDescending { it.startedAt },
)
// First 2 get shown expanded by default (unless completed)
val visibleBroadcasts = sortedBroadcasts.take(MAX_EXPANDED_SECTIONS)
val overflowBroadcasts = sortedBroadcasts.drop(MAX_EXPANDED_SECTIONS)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp)
.verticalScroll(rememberScrollState()),
) {
// Header
Text(
text = if (broadcasts.size == 1) stringRes(R.string.broadcast_results) else stringRes(R.string.broadcasts_number, broadcasts.size),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
) )
Spacer(Modifier.height(16.dp)) // First 2 get shown expanded by default (unless completed)
val visibleBroadcasts = sortedBroadcasts.take(MAX_EXPANDED_SECTIONS)
// Visible broadcast sections mutableStateOf(visibleBroadcasts.map { it.id }.toSet())
visibleBroadcasts.forEachIndexed { index, broadcast -> }
val isExpanded =
broadcast.id in expandedSections ||
(broadcast.status == BroadcastStatus.IN_PROGRESS && broadcast.id !in expandedSections)
// Auto-expand in-progress, auto-collapse completed (unless manually expanded) Column(
val showExpanded = verticalArrangement = spacedBy(8.dp),
if (broadcast.status == BroadcastStatus.IN_PROGRESS) { modifier =
true Modifier
} else { .fillMaxWidth()
broadcast.id in expandedSections .padding(horizontal = 16.dp)
} .padding(bottom = 32.dp)
.verticalScroll(rememberScrollState()),
) {
// Header
Text(
text = if (broadcasts.size == 1) stringRes(R.string.broadcast_results) else stringRes(R.string.broadcasts_number, broadcasts.size),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
BroadcastSection( Spacer(Modifier.height(8.dp))
broadcast = broadcast,
isExpanded = showExpanded,
onToggleExpand = {
expandedSections =
if (broadcast.id in expandedSections) {
expandedSections - broadcast.id
} else {
expandedSections + broadcast.id
}
},
onRetryRelay = { relay -> onRetryRelay(broadcast, relay) },
onRetryAllFailed = { onRetryAllFailed(broadcast) },
rotationAngle = rotationAngle,
)
if (index < visibleBroadcasts.size - 1 || overflowBroadcasts.isNotEmpty()) { // Visible broadcast sections
Spacer(Modifier.height(8.dp)) broadcasts.forEachIndexed { index, broadcast ->
HorizontalDivider() BroadcastSection(
Spacer(Modifier.height(8.dp)) broadcast = broadcast,
} isExpanded = broadcast.id in expandedSections,
onToggleExpand = {
expandedSections =
if (broadcast.id in expandedSections) {
expandedSections - broadcast.id
} else {
expandedSections + broadcast.id
}
},
onRetryRelay = { relay -> onRetryRelay(broadcast, relay) },
onRetryAllFailed = { onRetryAllFailed(broadcast) },
rotationAngle = rotationAngle,
)
if (index < broadcasts.lastIndex) {
HorizontalDivider()
} }
}
// Overflow summary Spacer(Modifier.height(8.dp))
if (overflowBroadcasts.isNotEmpty()) {
OverflowSummary(overflowBroadcasts)
}
Spacer(Modifier.height(16.dp)) // Dismiss button
OutlinedButton(
// Dismiss button onClick = onDismiss,
OutlinedButton( modifier = Modifier.fillMaxWidth(),
onClick = onDismiss, ) {
modifier = Modifier.fillMaxWidth(), Text(stringRes(R.string.dismiss))
) {
Text(stringRes(R.string.dismiss))
}
} }
} }
} }
@@ -231,7 +237,7 @@ private fun BroadcastSection(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = broadcast.eventName, text = broadcast.event.toKindName(),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -296,52 +302,6 @@ private fun BroadcastSection(
} }
} }
@Composable
private fun OverflowSummary(broadcasts: List<BroadcastEvent>) {
val totalSuccess = broadcasts.sumOf { it.successCount }
val totalRelays = broadcasts.sumOf { it.totalRelays }
val inProgressCount = broadcasts.count { it.status == BroadcastStatus.IN_PROGRESS }
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp),
) {
Icon(
imageVector = Icons.Default.HourglassEmpty,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.more_broadcasts_number, broadcasts.size),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text =
buildString {
append(stringResource(R.string.share_of_relays, totalSuccess, totalRelays))
if (inProgressCount > 0) {
append(stringResource(R.string.number_in_progress, inProgressCount))
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable @Composable
private fun statusColor(status: BroadcastStatus): Color = private fun statusColor(status: BroadcastStatus): Color =
when (status) { when (status) {
@@ -404,7 +364,7 @@ private fun RelayResultRow(
when (result) { when (result) {
is RelayResult.Success -> Triple(Icons.Default.CheckCircle, successColor, false) is RelayResult.Success -> Triple(Icons.Default.CheckCircle, successColor, false)
is RelayResult.Error -> Triple(Icons.Default.Error, errorColor, false) is RelayResult.Error -> Triple(Icons.Default.Error, errorColor, false)
is RelayResult.Timeout -> Triple(Icons.Default.HourglassEmpty, warningColor, false) is RelayResult.Timeout -> Triple(Icons.Default.Error, warningColor, false)
is RelayResult.Pending -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.onSurfaceVariant, true) is RelayResult.Pending -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.onSurfaceVariant, true)
is RelayResult.Retrying -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true) is RelayResult.Retrying -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true)
} }
@@ -441,7 +401,7 @@ private fun RelayResultRow(
when (result) { when (result) {
is RelayResult.Error -> { is RelayResult.Error -> {
Text( Text(
text = "[${result.code}]${result.message?.let { " $it" } ?: ""}", text = result.message,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = errorColor, color = errorColor,
@@ -486,3 +446,393 @@ private fun RelayResultRow(
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerSingleEventSheetInProgressStartPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerSingleEventSheetInProgressOneSuccessPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results = mapOf(Constants.mom to RelayResult.Success),
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerSingleEventSheetSuccessPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.SUCCESS,
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerSingleEventSheetPartialPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Error("code"),
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.PARTIAL,
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerSingleEventSheetErrorsPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Error("code"),
Constants.mom to RelayResult.Error("code"),
Constants.nos to RelayResult.Error("code"),
),
status = BroadcastStatus.FAILED,
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerDoubleEventSheetInProgressStartPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerDoubleEventSheetInProgressMiddlePreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results = mapOf(Constants.mom to RelayResult.Success),
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results = mapOf(Constants.mom to RelayResult.Success),
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerDoubleEventSheetInProgressEndPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results =
mapOf(
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerDoubleEventSheetDoneAllGoodPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.SUCCESS,
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results =
mapOf(
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.SUCCESS,
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerDoubleEventSheetDoneOneGoodPreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Success,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.SUCCESS,
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results =
mapOf(
Constants.mom to RelayResult.Error("code"),
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.PARTIAL,
),
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(device = "spec:width=1500px,height=2340px,dpi=440")
@Composable
fun BroadcastBannerDoubleEventSheetDonePreview() {
val repost =
RepostEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
tags = emptyArray(),
content = "",
sig = "",
)
ThemeComparisonRow {
MultiBroadcastDetailsSheetContent(
persistentListOf(
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.antiprimal, Constants.mom, Constants.nos),
results =
mapOf(
Constants.antiprimal to RelayResult.Success,
Constants.mom to RelayResult.Error("code"),
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.PARTIAL,
),
BroadcastEvent(
id = UUID.randomUUID().toString(),
event = repost,
targetRelays = listOf(Constants.mom, Constants.nos),
results =
mapOf(
Constants.mom to RelayResult.Timeout,
Constants.nos to RelayResult.Success,
),
status = BroadcastStatus.PARTIAL,
),
),
)
}
}
@@ -20,54 +20,28 @@
*/ */
package com.vitorpamplona.amethyst.ui.broadcast package com.vitorpamplona.amethyst.ui.broadcast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** /**
* Displays broadcast progress UI components: * Displays broadcast progress UI components:
@@ -85,86 +59,30 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle() val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle()
// State for completed broadcast (with auto-dismiss)
var completedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) }
// State for details sheet // State for details sheet
var selectedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) } var seeDetails by remember { mutableStateOf(false) }
// Collect completed broadcasts and set auto-dismiss timer if (activeBroadcasts.isEmpty() && !seeDetails) return
LaunchedEffect(Unit) {
accountViewModel.broadcastTracker.completedBroadcast.collect { broadcast ->
completedBroadcast = broadcast
// Auto-dismiss after LONG duration if (!seeDetails) {
delay(BroadcastTracker.COMPLETED_DISPLAY_DURATION_MS) DisplaySnack(activeBroadcasts, { seeDetails = true }, accountViewModel)
// Only dismiss if still showing same broadcast LaunchedEffect(activeBroadcasts) {
if (completedBroadcast?.id == broadcast.id) { // this effect gets restarted every time the active broadcast changes
completedBroadcast = null delay((BroadcastTracker.TIMEOUT_SECONDS + 1) * 1000)
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id) accountViewModel.broadcastTracker.clear()
}
} }
} } else {
Box(modifier = Modifier.fillMaxSize()) {
// Banner for active broadcasts (above bottom navigation)
BroadcastBanner(
broadcasts = activeBroadcasts,
onTap = {
activeBroadcasts.firstOrNull()?.let { selectedBroadcast = it }
},
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 50.dp),
)
// Completed broadcast indicator (when no active broadcasts)
if (activeBroadcasts.isEmpty()) {
val scope = rememberCoroutineScope()
CompletedBroadcastIndicator(
broadcast = completedBroadcast,
onTap = { broadcast ->
selectedBroadcast = broadcast
},
onRetry = { b ->
scope.launch {
accountViewModel.broadcastTracker.retry(
broadcast = b,
client = accountViewModel.account.client,
)
}
},
onDismiss = { broadcast ->
completedBroadcast = null
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id)
},
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 50.dp),
)
}
}
// Details sheet - show all broadcasts when opened
if (selectedBroadcast != null) {
val scope = rememberCoroutineScope()
// Combine active broadcasts with the selected/completed one
val allBroadcasts =
(activeBroadcasts + listOfNotNull(completedBroadcast))
.distinctBy { it.id }
MultiBroadcastDetailsSheet( MultiBroadcastDetailsSheet(
broadcasts = allBroadcasts, broadcasts = activeBroadcasts,
onDismiss = { selectedBroadcast = null }, onDismiss = {
accountViewModel.runOnIO {
accountViewModel.broadcastTracker.clear()
}
seeDetails = false
},
onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl -> onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl ->
scope.launch { accountViewModel.runOnIO {
accountViewModel.broadcastTracker.retry( accountViewModel.broadcastTracker.retry(
broadcast = b, broadcast = b,
client = accountViewModel.account.client, client = accountViewModel.account.client,
@@ -173,7 +91,7 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
} }
}, },
onRetryAllFailed = { b: BroadcastEvent -> onRetryAllFailed = { b: BroadcastEvent ->
scope.launch { accountViewModel.runOnIO {
accountViewModel.broadcastTracker.retry( accountViewModel.broadcastTracker.retry(
broadcast = b, broadcast = b,
client = accountViewModel.account.client, client = accountViewModel.account.client,
@@ -184,117 +102,35 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
} }
} }
/**
* Compact indicator for completed broadcasts.
* Styled consistently with BroadcastBanner.
* Tappable to show details sheet, with retry button for failures.
*/
@Composable @Composable
private fun CompletedBroadcastIndicator( fun DisplaySnack(
broadcast: BroadcastEvent?, activeBroadcasts: ImmutableList<BroadcastEvent>,
onTap: (BroadcastEvent) -> Unit, onTap: () -> Unit,
onRetry: (BroadcastEvent) -> Unit, accountViewModel: AccountViewModel,
onDismiss: (BroadcastEvent) -> Unit,
modifier: Modifier = Modifier,
) { ) {
// Status colors (for icon tint only) Box(modifier = Modifier.fillMaxSize()) {
val successColor = Color(0xFF22C55E) BroadcastBanner(
val warningColor = Color(0xFFF59E0B) broadcasts = activeBroadcasts,
onTap = onTap,
AnimatedVisibility( onRetryAll = {
visible = broadcast != null, activeBroadcasts.forEach { b ->
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)), accountViewModel.runOnIO {
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)), accountViewModel.broadcastTracker.retry(
modifier = modifier, broadcast = b,
) { client = accountViewModel.account.client,
broadcast?.let { b -> )
val (statusIcon, iconTint) =
when (b.status) {
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor
BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor
BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
}
// Same styling as BroadcastBanner
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 2.dp,
modifier =
Modifier
.fillMaxWidth()
.clickable { onTap(b) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
// Small status icon (like BroadcastBanner)
Icon(
imageVector = statusIcon,
contentDescription = b.status.name,
tint = iconTint,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringRes(R.string.event_sent, b.eventName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringRes(R.string.share_of, b.successCount, b.totalRelays),
style = MaterialTheme.typography.labelMedium,
color = iconTint,
)
}
if (b.failedRelays.isNotEmpty()) {
Text(
text = stringRes(R.string.tap_to_view_details),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
// Retry button for failures
if (b.failedRelays.isNotEmpty()) {
IconButton(
onClick = { onRetry(b) },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringRes(R.string.retry_failed),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
// Dismiss X
Text(
text = "×",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clickable { onDismiss(b) }
.padding(4.dp),
)
} }
} },
} onDismiss = {
accountViewModel.broadcastTracker.clear()
},
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 50.dp),
)
} }
} }
@@ -309,17 +309,13 @@ class AccountViewModel(
if (settings.isCompleteUIMode()) { if (settings.isCompleteUIMode()) {
// Tracked broadcasting with progress feedback // Tracked broadcasting with progress feedback
account.createReactionEvent(note, reaction)?.let { (event, relays) -> account.createReactionEvent(note, reaction)?.let { (event, relays) ->
val result = broadcastTracker.trackBroadcast(
broadcastTracker.trackBroadcast( event = event,
event = event, relays = relays,
eventName = "Reaction", client = account.client,
relays = relays, )
client = account.client,
) account.consumeReactionEvent(event)
// Only consume event if at least one relay succeeded
if (result.isSuccess) {
account.consumeReactionEvent(event)
}
} }
} else { } else {
// Fire-and-forget (original behavior) // Fire-and-forget (original behavior)
@@ -723,17 +719,12 @@ class AccountViewModel(
// Tracked broadcasting with progress feedback // Tracked broadcasting with progress feedback
launchSigner { launchSigner {
account.createBoostEvent(note)?.let { (event, relays) -> account.createBoostEvent(note)?.let { (event, relays) ->
val result = broadcastTracker.trackBroadcast(
broadcastTracker.trackBroadcast( event = event,
event = event, relays = relays,
eventName = "Boost", client = account.client,
relays = relays, )
client = account.client, account.consumeBoostEvent(event)
)
// Only consume event if at least one relay succeeded
if (result.isSuccess) {
account.consumeBoostEvent(event)
}
} }
} }
} else { } else {
@@ -768,16 +759,12 @@ class AccountViewModel(
if (settings.isCompleteUIMode()) { if (settings.isCompleteUIMode()) {
launchSigner { launchSigner {
account.createAddBookmarkEvent(note, true)?.let { (event, relays) -> account.createAddBookmarkEvent(note, true)?.let { (event, relays) ->
val result = broadcastTracker.trackBroadcast(
broadcastTracker.trackBroadcast( event = event,
event = event, relays = relays,
eventName = "Bookmark", client = account.client,
relays = relays, )
client = account.client, account.consumeBookmarkEvent(event)
)
if (result.isSuccess) {
account.consumeBookmarkEvent(event)
}
} }
} }
} else { } else {
@@ -789,16 +776,12 @@ class AccountViewModel(
if (settings.isCompleteUIMode()) { if (settings.isCompleteUIMode()) {
launchSigner { launchSigner {
account.createAddBookmarkEvent(note, false)?.let { (event, relays) -> account.createAddBookmarkEvent(note, false)?.let { (event, relays) ->
val result = broadcastTracker.trackBroadcast(
broadcastTracker.trackBroadcast( event = event,
event = event, relays = relays,
eventName = "Bookmark", client = account.client,
relays = relays, )
client = account.client, account.consumeBookmarkEvent(event)
)
if (result.isSuccess) {
account.consumeBookmarkEvent(event)
}
} }
} }
} else { } else {
@@ -810,16 +793,13 @@ class AccountViewModel(
if (settings.isCompleteUIMode()) { if (settings.isCompleteUIMode()) {
launchSigner { launchSigner {
account.createRemoveBookmarkEvent(note, true)?.let { (event, relays) -> account.createRemoveBookmarkEvent(note, true)?.let { (event, relays) ->
val result = broadcastTracker.trackBroadcast(
broadcastTracker.trackBroadcast( event = event,
event = event, relays = relays,
eventName = "Remove Bookmark", client = account.client,
relays = relays, )
client = account.client,
) account.consumeBookmarkEvent(event)
if (result.isSuccess) {
account.consumeBookmarkEvent(event)
}
} }
} }
} else { } else {
@@ -831,16 +811,12 @@ class AccountViewModel(
if (settings.isCompleteUIMode()) { if (settings.isCompleteUIMode()) {
launchSigner { launchSigner {
account.createRemoveBookmarkEvent(note, false)?.let { (event, relays) -> account.createRemoveBookmarkEvent(note, false)?.let { (event, relays) ->
val result = broadcastTracker.trackBroadcast(
broadcastTracker.trackBroadcast( event = event,
event = event, relays = relays,
eventName = "Remove Bookmark", client = account.client,
relays = relays, )
client = account.client, account.consumeBookmarkEvent(event)
)
if (result.isSuccess) {
account.consumeBookmarkEvent(event)
}
} }
} }
} else { } else {
@@ -97,7 +97,6 @@ import com.vitorpamplona.quartz.nip10Notes.tags.notify
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
@@ -548,22 +547,15 @@ open class ShortNotePostViewModel :
if (accountViewModel.settings.isCompleteUIMode()) { if (accountViewModel.settings.isCompleteUIMode()) {
// Tracked broadcasting with progress feedback (non-blocking) // Tracked broadcasting with progress feedback (non-blocking)
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
val eventName = getEventName(event)
// Launch broadcast in background - don't wait for completion // Launch broadcast in background - don't wait for completion
accountViewModel.viewModelScope.launch { accountViewModel.viewModelScope.launch {
val result = accountViewModel.broadcastTracker.trackBroadcast(
accountViewModel.broadcastTracker.trackBroadcast( event = event,
event = event, relays = relays,
eventName = eventName, client = accountViewModel.account.client,
relays = relays, )
client = accountViewModel.account.client, accountViewModel.account.consumePostEvent(event, relays, extras)
)
// Only consume event if at least one relay succeeded
if (result.isSuccess) {
accountViewModel.account.consumePostEvent(event, relays, extras)
}
} }
} else { } else {
// Fire-and-forget (original behavior) // Fire-and-forget (original behavior)
@@ -575,30 +567,6 @@ open class ShortNotePostViewModel :
} }
} }
private fun getEventName(event: Event): String =
when (event) {
is TextNoteEvent -> {
val quotes = event.taggedQuotes()
if (quotes.isNotEmpty()) "Quote" else "Post"
}
is PollNoteEvent -> {
"Poll"
}
is VoiceEvent -> {
"Voice"
}
is VoiceReplyEvent -> {
"Voice Reply"
}
else -> {
"Post"
}
}
suspend fun sendDraftSync() { suspend fun sendDraftSync() {
if (message.text.isBlank()) { if (message.text.isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
@@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class HomeLiveFilter( class HomeLiveFilter(
val account: Account, val account: Account,
) : AdditiveComplexFeedFilter<Channel, Note>() { ) : AdditiveComplexFeedFilter<Channel, Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
override fun showHiddenKey(): Boolean = false override fun showHiddenKey(): Boolean = false
+9
View File
@@ -1495,4 +1495,13 @@
<string name="broadcasting">Broadcasting</string> <string name="broadcasting">Broadcasting</string>
<string name="broadcasting_name">Broadcasting %1$s</string> <string name="broadcasting_name">Broadcasting %1$s</string>
<string name="broadcasting_number_events">Broadcasting %1$d events...</string> <string name="broadcasting_number_events">Broadcasting %1$d events...</string>
<string name="sent_number_events">%1$d events sent</string>
<string name="bradcasting_result_partial">Some events failed</string>
<string name="bradcasting_result_success">All events succeeded</string>
<string name="bradcasting_result_failure">All events failed</string>
<string name="reaction">Reaction</string>
<string name="voice_post">Voice Post</string>
<string name="voice_reply">Voice Reply</string>
</resources> </resources>
+5 -5
View File
@@ -3,7 +3,7 @@ accompanistAdaptive = "0.37.3"
cachemapVersion = "0.2.4" cachemapVersion = "0.2.4"
comDitchoomBufferCompression = "2.1.0" comDitchoomBufferCompression = "2.1.0"
composeMultiplatform = "1.10.0" composeMultiplatform = "1.10.0"
activityCompose = "1.12.3" activityCompose = "1.12.4"
agp = "9.0.0" agp = "9.0.0"
android-compileSdk = "36" android-compileSdk = "36"
android-minSdk = "26" android-minSdk = "26"
@@ -12,11 +12,11 @@ androidKotlinGeohash = "b481c6a64e"
androidxJunit = "1.3.0" androidxJunit = "1.3.0"
appcompat = "1.7.1" appcompat = "1.7.1"
audiowaveform = "1.1.2" audiowaveform = "1.1.2"
benchmark = "1.5.0-alpha02" benchmark = "1.5.0-alpha03"
biometricKtx = "1.2.0-alpha05" biometricKtx = "1.2.0-alpha05"
coil = "3.3.0" coil = "3.3.0"
composeBom = "2026.01.01" composeBom = "2026.02.00"
composeRuntimeAnnotation = "1.10.2" composeRuntimeAnnotation = "1.10.3"
coreKtx = "1.17.0" coreKtx = "1.17.0"
datastore = "1.2.0" datastore = "1.2.0"
devWhyolegCryptography = "0.5.0" devWhyolegCryptography = "0.5.0"
@@ -64,7 +64,7 @@ zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.5.1" windowCoreAndroid = "1.5.1"
androidxCamera = "1.5.3" androidxCamera = "1.5.3"
androidxCollection = "1.5.0" androidxCollection = "1.5.0"
kotlinStdlib = "2.3.0" kotlinStdlib = "2.3.10"
kotlinTest = "2.2.20" kotlinTest = "2.2.20"
core = "1.7.0" core = "1.7.0"
mavenPublish = "0.36.0" mavenPublish = "0.36.0"