Merge pull request #1683 from nrobi144/feat/1682-transparent-broadcast-feedback

add transparent event broadcasting feedback
This commit is contained in:
Vitor Pamplona
2026-01-26 09:57:41 -05:00
committed by GitHub
10 changed files with 2038 additions and 7 deletions
@@ -150,6 +150,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
@@ -514,6 +515,38 @@ class Account(
onPrivate = ::broadcastPrivately,
)
/**
* Creates a reaction event without sending it.
* Returns the event and target relays for tracked broadcasting.
* Returns null if note has already been reacted to or note has no event.
*/
suspend fun createReactionEvent(
note: Note,
reaction: String,
): Pair<Event, Set<NormalizedRelayUrl>>? {
if (!signer.isWriteable()) return null
if (note.hasReacted(userProfile(), reaction)) return null
val noteEvent = note.event ?: return null
// For NIP-17 private groups, we don't support tracked mode (too complex)
if (noteEvent is NIP17Group) return null
val relayHint = note.relays.firstOrNull()?.url
val event = ReactionAction.reactTo(noteEvent, reaction, signer, relayHint)
val relays = computeRelayListToBroadcast(event)
return event to relays
}
/**
* Consumes a reaction event into local cache.
* Called when tracked broadcasting succeeds.
*/
fun consumeReactionEvent(event: Event) {
cache.justConsumeMyOwnEvent(event)
}
suspend fun createZapRequestFor(
event: Event,
pollOption: Int?,
@@ -635,6 +668,35 @@ class Account(
}
}
/**
* Creates a boost event without sending it.
* Returns the event and target relays for tracked broadcasting.
*/
suspend fun createBoostEvent(note: Note): Pair<Event, Set<NormalizedRelayUrl>>? =
RepostAction.repost(note, signer)?.let { event ->
event to computeMyReactionToNote(note, event)
}
/**
* Sends a boost event and updates the local cache.
* Used after tracked broadcasting completes.
*/
fun sendBoostEvent(
event: Event,
relays: Set<NormalizedRelayUrl>,
) {
client.send(event, relays)
cache.justConsumeMyOwnEvent(event)
}
/**
* Updates the local cache with a boost event.
* Called when tracked broadcasting succeeds.
*/
fun consumeBoostEvent(event: Event) {
cache.justConsumeMyOwnEvent(event)
}
fun computeMyReactionToNote(
note: Note,
reaction: Event,
@@ -1181,6 +1243,36 @@ class Account(
return event
}
/**
* Creates a post event without sending it.
* Returns the event, target relays, and extra events to broadcast.
* For use with tracked broadcasting.
*/
suspend fun <T : Event> createPostEvent(
template: EventTemplate<T>,
extraNotesToBroadcast: List<Event> = emptyList(),
): Triple<T, Set<NormalizedRelayUrl>, List<Event>> {
val event = signer.sign(template)
// Use event-based relay computation (not note-based, since note is empty)
val relayList = computeRelayListToBroadcast(event)
return Triple(event, relayList, extraNotesToBroadcast)
}
/**
* Consumes a post event into local cache and sends extra events.
* Called when tracked broadcasting succeeds.
*/
fun consumePostEvent(
event: Event,
relays: Set<NormalizedRelayUrl>,
extraNotesToBroadcast: List<Event>,
) {
cache.justConsumeMyOwnEvent(event)
extraNotesToBroadcast.forEach { client.send(it, relays) }
}
suspend fun createAndSendDraftIgnoreErrors(
draftTag: String,
template: EventTemplate<out Event>,
@@ -1576,6 +1668,46 @@ class Account(
}
}
/**
* Creates a bookmark event without sending it.
* Returns the event and target relays for tracked broadcasting.
*/
suspend fun createAddBookmarkEvent(
note: Note,
isPrivate: Boolean,
): Pair<Event, Set<NormalizedRelayUrl>>? {
if (!isWriteable() || note.isDraft()) return null
val event = bookmarkState.addBookmark(note, isPrivate)
val relays = outboxRelays.flow.value
return event to relays
}
/**
* Creates a remove bookmark event without sending it.
* Returns the event and target relays for tracked broadcasting.
*/
suspend fun createRemoveBookmarkEvent(
note: Note,
isPrivate: Boolean,
): Pair<Event, Set<NormalizedRelayUrl>>? {
if (!isWriteable() || note.isDraft()) return null
val event = bookmarkState.removeBookmark(note, isPrivate) ?: return null
val relays = outboxRelays.flow.value
return event to relays
}
/**
* Consumes a bookmark event into local cache.
* Called when tracked broadcasting succeeds.
*/
fun consumeBookmarkEvent(event: Event) {
cache.justConsumeMyOwnEvent(event)
}
suspend fun createAuthEvent(
relay: NormalizedRelayUrl,
challenge: String,
@@ -0,0 +1,147 @@
/**
* 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.service.broadcast
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Result of a relay's response to an event publish.
*/
@Immutable
sealed class RelayResult {
/** Relay accepted the event (OK message with success=true) */
data object Success : RelayResult()
/** Relay rejected the event (OK message with success=false) */
data class Error(
val code: String,
val message: String?,
) : RelayResult()
/** Relay did not respond within timeout */
data object Timeout : RelayResult()
/** Waiting for relay response */
data object Pending : RelayResult()
/** Retry in progress for this relay */
data object Retrying : RelayResult()
}
/**
* Overall status of a broadcast operation.
*/
enum class BroadcastStatus {
/** Currently waiting for relay responses */
IN_PROGRESS,
/** All relays accepted the event */
SUCCESS,
/** Some relays accepted, some failed */
PARTIAL,
/** No relays accepted the event */
FAILED,
}
/**
* Tracks a single event broadcast to multiple relays.
*/
@Immutable
data class BroadcastEvent(
val id: String,
val eventId: HexKey,
val eventName: String,
val kind: Int,
val targetRelays: List<NormalizedRelayUrl>,
val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(),
val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS,
val startedAt: Long = System.currentTimeMillis(),
) {
/** Number of relays that accepted the event */
val successCount: Int
get() = results.count { it.value is RelayResult.Success }
/** Number of relays that rejected or timed out */
val failureCount: Int
get() = results.count { it.value is RelayResult.Error || it.value is RelayResult.Timeout }
/** Number of relays still pending response */
val pendingCount: Int
get() = targetRelays.size - results.size
/** Total number of target relays */
val totalRelays: Int
get() = targetRelays.size
/** Progress as a fraction (0.0 to 1.0) */
val progress: Float
get() = if (totalRelays == 0) 0f else results.size.toFloat() / totalRelays
/** Whether all relays have responded */
val isComplete: Boolean
get() = results.size >= targetRelays.size
/** List of relays that failed and are not currently retrying */
val failedRelays: List<NormalizedRelayUrl>
get() =
results
.filter {
(it.value is RelayResult.Error || it.value is RelayResult.Timeout) &&
it.value !is RelayResult.Retrying
}.keys
.toList()
/** List of relays currently being retried */
val retryingRelays: List<NormalizedRelayUrl>
get() = results.filter { it.value is RelayResult.Retrying }.keys.toList()
/** Creates a copy with an updated relay result */
fun withResult(
relay: NormalizedRelayUrl,
result: RelayResult,
): BroadcastEvent {
val newResults = results + (relay to result)
val newStatus =
when {
newResults.size < targetRelays.size -> BroadcastStatus.IN_PROGRESS
newResults.all { it.value is RelayResult.Success } -> BroadcastStatus.SUCCESS
newResults.none { it.value is RelayResult.Success } -> BroadcastStatus.FAILED
else -> BroadcastStatus.PARTIAL
}
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,
) {
val successCount: Int get() = broadcast.successCount
val totalRelays: Int get() = broadcast.totalRelays
}
@@ -0,0 +1,446 @@
/**
* 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.service.broadcast
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
/**
* Tracks event broadcasts to relays with live progress updates.
*
* Provides:
* - Real-time progress as relays respond
* - Detailed per-relay success/error information
* - Retry functionality for failed relays
*/
class BroadcastTracker {
companion object {
private const val TAG = "BroadcastTracker"
private const val TIMEOUT_SECONDS = 15L
const val COMPLETED_DISPLAY_DURATION_MS = 10_000L // SnackbarDuration.Long equivalent
}
private val _activeBroadcasts = MutableStateFlow<List<BroadcastEvent>>(emptyList())
val activeBroadcasts: StateFlow<List<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.
*
* @param event The Nostr event to broadcast
* @param eventName Human-readable name (e.g., "Boost", "Reaction")
* @param relays Target relays to send to
* @param client The Nostr client for sending
* @return BroadcastResult with final status
*/
@OptIn(DelicateCoroutinesApi::class)
suspend fun trackBroadcast(
event: Event,
eventName: String,
relays: Set<NormalizedRelayUrl>,
client: INostrClient,
): BroadcastResult {
val trackingId = UUID.randomUUID().toString()
val broadcast =
BroadcastEvent(
id = trackingId,
eventId = event.id,
eventName = eventName,
kind = event.kind,
targetRelays = relays.toList(),
)
// 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")
val resultChannel = Channel<RelayResponse>(UNLIMITED)
val subscription =
object : IRelayClientListener {
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
if (relay.url in relays) {
resultChannel.trySend(
RelayResponse(
relay = relay.url,
result = RelayResult.Error("CONNECTION_ERROR", errorMessage),
),
)
Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage")
}
}
override fun onDisconnected(relay: IRelayClient) {
if (relay.url in relays) {
resultChannel.trySend(
RelayResponse(
relay = relay.url,
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"),
),
)
Log.d(TAG, "[$trackingId] 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, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}")
}
}
}
}
}
client.subscribe(subscription)
val finalBroadcast =
coroutineScope {
val resultCollector =
async {
val receivedRelays = mutableSetOf<NormalizedRelayUrl>()
var currentBroadcast = broadcast
withTimeoutOrNull(TIMEOUT_SECONDS * 1000) {
while (receivedRelays.size < relays.size) {
val response = resultChannel.receive()
// Skip if already received (don't override success)
if (response.relay in receivedRelays) continue
receivedRelays.add(response.relay)
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
// Update active broadcasts with new progress
_activeBroadcasts.update { list ->
list.map { if (it.id == trackingId) currentBroadcast else it }
}
}
}
// Mark remaining relays as timeout
relays.filter { it !in receivedRelays }.forEach { relay ->
currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout)
}
currentBroadcast
}
// Send after setting up listener
client.send(event, relays)
resultCollector.await()
}
client.unsubscribe(subscription)
resultChannel.close()
// Remove from active, emit to completed
_activeBroadcasts.update { list -> list.filter { it.id != trackingId } }
_completedBroadcast.emit(finalBroadcast)
Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
return BroadcastResult(
broadcast = finalBroadcast,
isSuccess = finalBroadcast.successCount > 0,
)
}
/**
* Marks relays as Retrying in an existing broadcast.
* Call this before starting the retry to show immediate feedback.
*/
fun markRelaysRetrying(
broadcastId: String,
relays: Set<NormalizedRelayUrl>,
) {
_activeBroadcasts.update { list ->
list.map { broadcast ->
if (broadcast.id == broadcastId) {
var updated = broadcast
relays.forEach { relay ->
updated = updated.withResult(relay, RelayResult.Retrying)
}
updated.copy(status = BroadcastStatus.IN_PROGRESS)
} else {
broadcast
}
}
}
}
/**
* Retries sending an event to failed relays using cached event.
* Updates the existing broadcast in-place with retry results.
*
* @param 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(
broadcast: BroadcastEvent,
client: INostrClient,
specificRelay: NormalizedRelayUrl? = null,
): BroadcastEvent? {
val event = eventCache[broadcast.id] ?: return null
val relaysToRetry =
if (specificRelay != null) {
setOf(specificRelay)
} else {
broadcast.failedRelays.toSet()
}
if (relaysToRetry.isEmpty()) {
return broadcast
}
// Mark relays as retrying for immediate feedback
markRelaysRetrying(broadcast.id, relaysToRetry)
// If broadcast not in active list, re-add it
if (_activeBroadcasts.value.none { it.id == broadcast.id }) {
_activeBroadcasts.update { list ->
var updated = broadcast
relaysToRetry.forEach { relay ->
updated = updated.withResult(relay, RelayResult.Retrying)
}
list + updated.copy(status = BroadcastStatus.IN_PROGRESS)
}
}
// Setup result collection
val resultChannel = Channel<RelayResponse>(UNLIMITED)
val subscription =
object : IRelayClientListener {
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
if (relay.url in relaysToRetry) {
resultChannel.trySend(
RelayResponse(
relay = relay.url,
result = RelayResult.Error("CONNECTION_ERROR", errorMessage),
),
)
Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage")
}
}
override fun onDisconnected(relay: IRelayClient) {
if (relay.url in relaysToRetry) {
resultChannel.trySend(
RelayResponse(
relay = relay.url,
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"),
),
)
Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}")
}
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
super.onIncomingMessage(relay, msgStr, msg)
when (msg) {
is OkMessage -> {
if (msg.eventId == event.id) {
val result =
if (msg.success) {
RelayResult.Success
} else {
val (code, message) = parseOkError(msg.message)
RelayResult.Error(code, message)
}
resultChannel.trySend(RelayResponse(relay.url, result))
Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}")
}
}
}
}
}
client.subscribe(subscription)
val finalBroadcast =
coroutineScope {
val resultCollector =
async {
val receivedRelays = mutableSetOf<NormalizedRelayUrl>()
var currentBroadcast = _activeBroadcasts.value.find { it.id == broadcast.id } ?: broadcast
withTimeoutOrNull(TIMEOUT_SECONDS * 1000) {
while (receivedRelays.size < relaysToRetry.size) {
val response = resultChannel.receive()
if (response.relay !in relaysToRetry) continue
if (response.relay in receivedRelays) continue
receivedRelays.add(response.relay)
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
_activeBroadcasts.update { list ->
list.map { if (it.id == broadcast.id) currentBroadcast else it }
}
}
}
// Mark remaining as timeout
relaysToRetry.filter { it !in receivedRelays }.forEach { relay ->
currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout)
}
// Recalculate status
val newStatus =
when {
currentBroadcast.results.values.any { it is RelayResult.Pending || it is RelayResult.Retrying } ->
BroadcastStatus.IN_PROGRESS
currentBroadcast.results.all { it.value is RelayResult.Success } ->
BroadcastStatus.SUCCESS
currentBroadcast.results.none { it.value is RelayResult.Success } ->
BroadcastStatus.FAILED
else -> BroadcastStatus.PARTIAL
}
currentBroadcast.copy(status = newStatus)
}
client.send(event, relaysToRetry)
resultCollector.await()
}
client.unsubscribe(subscription)
resultChannel.close()
// Update in active broadcasts
_activeBroadcasts.update { list ->
list.map { if (it.id == broadcast.id) finalBroadcast else it }
}
// Emit to completed if all done
if (finalBroadcast.status != BroadcastStatus.IN_PROGRESS) {
_completedBroadcast.emit(finalBroadcast)
}
Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
return finalBroadcast
}
/**
* 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).
*/
fun clear() {
_activeBroadcasts.update { emptyList() }
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(
val relay: NormalizedRelayUrl,
val result: RelayResult,
)
}
@@ -0,0 +1,203 @@
/**
* 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.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.material.icons.Icons
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
/**
* Banner showing active broadcast progress.
* Displayed above bottom navigation when events are being sent to relays.
*/
@Composable
fun BroadcastBanner(
broadcasts: List<BroadcastEvent>,
onTap: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = broadcasts.isNotEmpty(),
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
modifier = modifier,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 2.dp,
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onTap),
) {
Column(
modifier =
Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.animateContentSize(),
) {
if (broadcasts.size == 1) {
SingleBroadcastContent(broadcasts.first())
} else {
MultipleBroadcastsContent(broadcasts)
}
}
}
}
}
@Composable
private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Default.Sync,
contentDescription = "Broadcasting",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Broadcasting ${broadcast.eventName} (kind ${broadcast.kind})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
text = "[${broadcast.results.size}/${broadcast.totalRelays}]",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(Modifier.height(4.dp))
val animatedProgress by animateFloatAsState(
targetValue = broadcast.progress,
animationSpec = tween(300),
label = "progress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
@Composable
private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) {
val totalRelays = broadcasts.sumOf { it.totalRelays }
val completedResponses = broadcasts.sumOf { it.results.size }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Default.Sync,
contentDescription = "Broadcasting",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Broadcasting ${broadcasts.size} events...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "[$completedResponses/$totalRelays]",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(Modifier.height(4.dp))
val progress = if (totalRelays > 0) completedResponses.toFloat() / totalRelays else 0f
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(300),
label = "progress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
@@ -0,0 +1,512 @@
/**
* 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.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
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.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
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
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 broadcasts.
* Shows up to 2 expanded sections, with a summary for additional broadcasts.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BroadcastDetailsSheet(
broadcast: BroadcastEvent,
onDismiss: () -> Unit,
onRetryRelay: (NormalizedRelayUrl) -> Unit,
onRetryAllFailed: () -> Unit,
sheetState: SheetState =
rememberModalBottomSheetState(
skipPartiallyExpanded = true,
),
) {
MultiBroadcastDetailsSheet(
broadcasts = listOf(broadcast),
onDismiss = onDismiss,
onRetryRelay = { _, relay -> onRetryRelay(relay) },
onRetryAllFailed = { onRetryAllFailed() },
sheetState = sheetState,
)
}
/**
* Modal bottom sheet showing detailed relay results for multiple broadcasts.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MultiBroadcastDetailsSheet(
broadcasts: List<BroadcastEvent>,
onDismiss: () -> Unit,
onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit,
onRetryAllFailed: (BroadcastEvent) -> Unit,
sheetState: SheetState =
rememberModalBottomSheetState(
skipPartiallyExpanded = true,
),
) {
// Synced rotation animation for all pending/retrying icons
val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation")
val rotationAngle by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 1500, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "rotation",
)
// Track which sections are expanded (by broadcast ID)
var expandedSections by remember { mutableStateOf(setOf<String>()) }
// Sort: in-progress first, then by start time
val sortedBroadcasts =
broadcasts.sortedWith(
compareBy<BroadcastEvent> { it.status != BroadcastStatus.IN_PROGRESS }
.thenByDescending { it.startedAt },
)
// First 2 get shown expanded by default (unless completed)
val visibleBroadcasts = sortedBroadcasts.take(MAX_EXPANDED_SECTIONS)
val overflowBroadcasts = sortedBroadcasts.drop(MAX_EXPANDED_SECTIONS)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp)
.verticalScroll(rememberScrollState()),
) {
// Header
Text(
text = if (broadcasts.size == 1) "Broadcast Results" else "Broadcasts (${broadcasts.size})",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(16.dp))
// Visible broadcast sections
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)
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,
)
if (index < visibleBroadcasts.size - 1 || overflowBroadcasts.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
}
}
// Overflow summary
if (overflowBroadcasts.isNotEmpty()) {
OverflowSummary(overflowBroadcasts)
}
Spacer(Modifier.height(16.dp))
// Dismiss button
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth(),
) {
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,
)
}
// Retry all button
if (broadcast.failedRelays.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Button(
onClick = onRetryAllFailed,
modifier = Modifier.fillMaxWidth(),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text("Retry Failed (${broadcast.failedRelays.size})")
}
}
}
}
}
}
@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 = "+${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 -> 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)
.then(
if (shouldRotate) {
Modifier.graphicsLayer { rotationZ = rotationAngle }
} else {
Modifier
},
),
)
}
@Composable
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
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Status icon
val (icon, tint, shouldRotate) =
when (result) {
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)
.then(
if (shouldRotate) {
Modifier.graphicsLayer { rotationZ = rotationAngle }
} else {
Modifier
},
),
)
Spacer(Modifier.width(12.dp))
// Relay URL
Column(modifier = Modifier.weight(1f)) {
Text(
text = relay.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Error message if present
when (result) {
is RelayResult.Error -> {
Text(
text = "[${result.code}]${result.message?.let { " $it" } ?: ""}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = errorColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
is RelayResult.Timeout -> {
Text(
text = "Timeout",
style = MaterialTheme.typography.bodySmall,
color = warningColor,
)
}
is RelayResult.Retrying -> {
Text(
text = "Retrying...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
else -> {}
}
}
// 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),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Retry",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
}
@@ -0,0 +1,140 @@
/**
* 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.foundation.layout.padding
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import kotlinx.coroutines.flow.SharedFlow
/**
* Custom snackbar visuals for broadcast results.
*/
data class BroadcastSnackbarVisuals(
val broadcast: BroadcastEvent,
override val actionLabel: String? = "View",
override val duration: SnackbarDuration = SnackbarDuration.Short,
override val withDismissAction: Boolean = true,
) : SnackbarVisuals {
override val message: String
get() =
when (broadcast.status) {
BroadcastStatus.SUCCESS -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays"
BroadcastStatus.PARTIAL -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays"
BroadcastStatus.FAILED -> "${broadcast.eventName} failed - 0/${broadcast.totalRelays} relays"
BroadcastStatus.IN_PROGRESS -> "Broadcasting ${broadcast.eventName}..."
}
}
/**
* Snackbar host that listens to completed broadcasts and shows result notifications.
*/
@Composable
fun BroadcastSnackbarHost(
completedBroadcast: SharedFlow<BroadcastEvent>,
onViewDetails: (BroadcastEvent) -> Unit,
onRetry: (BroadcastEvent) -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(completedBroadcast) {
completedBroadcast.collect { broadcast ->
val visuals = BroadcastSnackbarVisuals(broadcast)
val result = snackbarHostState.showSnackbar(visuals)
when (result) {
SnackbarResult.ActionPerformed -> {
if (broadcast.status == BroadcastStatus.FAILED) {
onRetry(broadcast)
} else {
onViewDetails(broadcast)
}
}
SnackbarResult.Dismissed -> { /* No action */ }
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = modifier.padding(bottom = 8.dp),
) { snackbarData ->
BroadcastSnackbarContent(snackbarData)
}
}
@Composable
private fun BroadcastSnackbarContent(snackbarData: SnackbarData) {
val visuals = snackbarData.visuals
val broadcastVisuals = visuals as? BroadcastSnackbarVisuals
val containerColor =
when (broadcastVisuals?.broadcast?.status) {
BroadcastStatus.FAILED -> Color(0xFF7F1D1D) // Dark red
BroadcastStatus.PARTIAL -> Color(0xFF78350F) // Dark amber
else -> Color(0xFF1E3A5F) // Dark blue (default)
}
val actionLabel =
when (broadcastVisuals?.broadcast?.status) {
BroadcastStatus.FAILED -> "Retry"
else -> "View"
}
Snackbar(
action = {
snackbarData.visuals.actionLabel?.let {
TextButton(onClick = { snackbarData.performAction() }) {
Text(actionLabel)
}
}
},
dismissAction =
if (visuals.withDismissAction) {
{
TextButton(onClick = { snackbarData.dismiss() }) {
Text("Dismiss")
}
}
} else {
null
},
containerColor = containerColor,
) {
Text(visuals.message)
}
}
@@ -0,0 +1,294 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.broadcast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Displays broadcast progress UI components:
* - BroadcastBanner: Shows active broadcasts with progress
* - CompletedBroadcastIndicator: Shows completed broadcast for tap-to-view (auto-dismisses after 10s)
* - BroadcastDetailsSheet: Shows detailed relay status on tap
*
* Only shown when FeatureSetType.COMPLETE is enabled.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
// Only show in COMPLETE UI mode
if (!accountViewModel.settings.isCompleteUIMode()) return
val scope = rememberCoroutineScope()
val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle()
// State for completed broadcast (with auto-dismiss)
var completedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) }
// State for details sheet
var selectedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) }
// Collect completed broadcasts and set auto-dismiss timer
LaunchedEffect(Unit) {
accountViewModel.broadcastTracker.completedBroadcast.collect { broadcast ->
completedBroadcast = broadcast
// Auto-dismiss after LONG duration
delay(BroadcastTracker.COMPLETED_DISPLAY_DURATION_MS)
// Only dismiss if still showing same broadcast
if (completedBroadcast?.id == broadcast.id) {
completedBroadcast = null
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id)
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
// Banner for active broadcasts (above bottom navigation)
BroadcastBanner(
broadcasts = activeBroadcasts,
onTap = {
activeBroadcasts.firstOrNull()?.let { selectedBroadcast = it }
},
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 56.dp),
)
// Completed broadcast indicator (when no active broadcasts)
if (activeBroadcasts.isEmpty()) {
CompletedBroadcastIndicator(
broadcast = completedBroadcast,
onTap = { broadcast ->
selectedBroadcast = broadcast
},
onRetry = { b ->
scope.launch {
accountViewModel.broadcastTracker.retry(
broadcast = b,
client = accountViewModel.account.client,
)
}
},
onDismiss = { broadcast ->
completedBroadcast = null
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id)
},
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 56.dp),
)
}
}
// Details sheet - show all broadcasts when opened
if (selectedBroadcast != null) {
// Combine active broadcasts with the selected/completed one
val allBroadcasts =
(activeBroadcasts + listOfNotNull(completedBroadcast))
.distinctBy { it.id }
MultiBroadcastDetailsSheet(
broadcasts = allBroadcasts,
onDismiss = { selectedBroadcast = null },
onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl ->
scope.launch {
accountViewModel.broadcastTracker.retry(
broadcast = b,
client = accountViewModel.account.client,
specificRelay = relay,
)
}
},
onRetryAllFailed = { b: BroadcastEvent ->
scope.launch {
accountViewModel.broadcastTracker.retry(
broadcast = b,
client = accountViewModel.account.client,
)
}
},
)
}
}
/**
* Compact indicator for completed broadcasts.
* Styled consistently with BroadcastBanner.
* Tappable to show details sheet, with retry button for failures.
*/
@Composable
private fun CompletedBroadcastIndicator(
broadcast: BroadcastEvent?,
onTap: (BroadcastEvent) -> Unit,
onRetry: (BroadcastEvent) -> Unit,
onDismiss: (BroadcastEvent) -> Unit,
modifier: Modifier = Modifier,
) {
// Status colors (for icon tint only)
val successColor = Color(0xFF22C55E)
val warningColor = Color(0xFFF59E0B)
AnimatedVisibility(
visible = broadcast != null,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
modifier = modifier,
) {
broadcast?.let { b ->
val (statusIcon, iconTint) =
when (b.status) {
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor
BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor
BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
}
// Same styling as BroadcastBanner
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 2.dp,
modifier =
Modifier
.fillMaxWidth()
.clickable { onTap(b) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
// Small status icon (like BroadcastBanner)
Icon(
imageVector = statusIcon,
contentDescription = b.status.name,
tint = iconTint,
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "${b.eventName} sent",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = "[${b.successCount}/${b.totalRelays}]",
style = MaterialTheme.typography.labelMedium,
color = iconTint,
)
}
if (b.failedRelays.isNotEmpty()) {
Text(
text = "Tap to view details",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Retry button for failures
if (b.failedRelays.isNotEmpty()) {
IconButton(
onClick = { onRetry(b) },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Retry failed",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
// Dismiss X
Text(
text = "×",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clickable { onDismiss(b) }
.padding(4.dp),
)
}
}
}
}
}
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
@@ -310,6 +311,7 @@ fun AppNavigation(
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
DisplayNotifyMessages(accountViewModel, nav)
DisplayCrashMessages(accountViewModel, nav)
DisplayBroadcastProgress(accountViewModel)
}
@Composable
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -168,6 +169,7 @@ class AccountViewModel(
var firstRoute: Route? = null
val toastManager = ToastManager()
val broadcastTracker = BroadcastTracker()
val feedStates = AccountFeedContentStates(account, viewModelScope)
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
@@ -315,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)
}
}
}
}
@@ -709,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) }
@@ -731,13 +773,89 @@ class AccountViewModel(
fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
fun addPrivateBookmark(note: Note) = launchSigner { account.addBookmark(note, true) }
fun addPrivateBookmark(note: Note) {
if (settings.isCompleteUIMode()) {
launchSigner {
account.createAddBookmarkEvent(note, true)?.let { (event, relays) ->
val result =
broadcastTracker.trackBroadcast(
event = event,
eventName = "Bookmark",
relays = relays,
client = account.client,
)
if (result.isSuccess) {
account.consumeBookmarkEvent(event)
}
}
}
} else {
launchSigner { account.addBookmark(note, true) }
}
}
fun addPublicBookmark(note: Note) = launchSigner { account.addBookmark(note, false) }
fun addPublicBookmark(note: Note) {
if (settings.isCompleteUIMode()) {
launchSigner {
account.createAddBookmarkEvent(note, false)?.let { (event, relays) ->
val result =
broadcastTracker.trackBroadcast(
event = event,
eventName = "Bookmark",
relays = relays,
client = account.client,
)
if (result.isSuccess) {
account.consumeBookmarkEvent(event)
}
}
}
} else {
launchSigner { account.addBookmark(note, false) }
}
}
fun removePrivateBookmark(note: Note) = launchSigner { account.removeBookmark(note, true) }
fun removePrivateBookmark(note: Note) {
if (settings.isCompleteUIMode()) {
launchSigner {
account.createRemoveBookmarkEvent(note, true)?.let { (event, relays) ->
val result =
broadcastTracker.trackBroadcast(
event = event,
eventName = "Remove Bookmark",
relays = relays,
client = account.client,
)
if (result.isSuccess) {
account.consumeBookmarkEvent(event)
}
}
}
} else {
launchSigner { account.removeBookmark(note, true) }
}
}
fun removePublicBookmark(note: Note) = launchSigner { account.removeBookmark(note, false) }
fun removePublicBookmark(note: Note) {
if (settings.isCompleteUIMode()) {
launchSigner {
account.createRemoveBookmarkEvent(note, false)?.let { (event, relays) ->
val result =
broadcastTracker.trackBroadcast(
event = event,
eventName = "Remove Bookmark",
relays = relays,
client = account.client,
)
if (result.isSuccess) {
account.consumeBookmarkEvent(event)
}
}
}
} else {
launchSigner { account.removeBookmark(note, false) }
}
}
fun broadcast(note: Note) = launchSigner { account.broadcast(note) }
@@ -97,6 +97,7 @@ import com.vitorpamplona.quartz.nip10Notes.tags.notify
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
@@ -544,12 +545,48 @@ open class ShortNotePostViewModel :
val version = draftTag.current
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
if (accountViewModel.settings.isCompleteUIMode()) {
// Tracked broadcasting with progress feedback (non-blocking)
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
val eventName = getEventName(event)
// Launch broadcast in background - don't wait for completion
accountViewModel.viewModelScope.launch {
val result =
accountViewModel.broadcastTracker.trackBroadcast(
event = event,
eventName = eventName,
relays = relays,
client = accountViewModel.account.client,
)
// Only consume event if at least one relay succeeded
if (result.isSuccess) {
accountViewModel.account.consumePostEvent(event, relays, extras)
}
}
} else {
// Fire-and-forget (original behavior)
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
}
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
private fun getEventName(event: Event): String =
when (event) {
is TextNoteEvent -> {
val quotes = event.taggedQuotes()
if (quotes.isNotEmpty()) "Quote" else "Post"
}
is PollNoteEvent -> "Poll"
is VoiceEvent -> "Voice"
is VoiceReplyEvent -> "Voice Reply"
else -> "Post"
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)