Merge pull request #1683 from nrobi144/feat/1682-transparent-broadcast-feedback
add transparent event broadcasting feedback
This commit is contained in:
@@ -150,6 +150,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
|||||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
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.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||||
@@ -514,6 +515,38 @@ class Account(
|
|||||||
onPrivate = ::broadcastPrivately,
|
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(
|
suspend fun createZapRequestFor(
|
||||||
event: Event,
|
event: Event,
|
||||||
pollOption: Int?,
|
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(
|
fun computeMyReactionToNote(
|
||||||
note: Note,
|
note: Note,
|
||||||
reaction: Event,
|
reaction: Event,
|
||||||
@@ -1181,6 +1243,36 @@ class Account(
|
|||||||
return event
|
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(
|
suspend fun createAndSendDraftIgnoreErrors(
|
||||||
draftTag: String,
|
draftTag: String,
|
||||||
template: EventTemplate<out Event>,
|
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(
|
suspend fun createAuthEvent(
|
||||||
relay: NormalizedRelayUrl,
|
relay: NormalizedRelayUrl,
|
||||||
challenge: String,
|
challenge: String,
|
||||||
|
|||||||
+147
@@ -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
|
||||||
|
}
|
||||||
+446
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+512
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+294
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.broadcast
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||||
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays broadcast progress UI components:
|
||||||
|
* - BroadcastBanner: Shows active broadcasts with progress
|
||||||
|
* - CompletedBroadcastIndicator: Shows completed broadcast for tap-to-view (auto-dismisses after 10s)
|
||||||
|
* - BroadcastDetailsSheet: Shows detailed relay status on tap
|
||||||
|
*
|
||||||
|
* Only shown when FeatureSetType.COMPLETE is enabled.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) {
|
||||||
|
// Only show in COMPLETE UI mode
|
||||||
|
if (!accountViewModel.settings.isCompleteUIMode()) return
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// State for completed broadcast (with auto-dismiss)
|
||||||
|
var completedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) }
|
||||||
|
|
||||||
|
// State for details sheet
|
||||||
|
var selectedBroadcast by remember { mutableStateOf<BroadcastEvent?>(null) }
|
||||||
|
|
||||||
|
// Collect completed broadcasts and set auto-dismiss timer
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
accountViewModel.broadcastTracker.completedBroadcast.collect { broadcast ->
|
||||||
|
completedBroadcast = broadcast
|
||||||
|
|
||||||
|
// Auto-dismiss after LONG duration
|
||||||
|
delay(BroadcastTracker.COMPLETED_DISPLAY_DURATION_MS)
|
||||||
|
|
||||||
|
// Only dismiss if still showing same broadcast
|
||||||
|
if (completedBroadcast?.id == broadcast.id) {
|
||||||
|
completedBroadcast = null
|
||||||
|
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Banner for active broadcasts (above bottom navigation)
|
||||||
|
BroadcastBanner(
|
||||||
|
broadcasts = activeBroadcasts,
|
||||||
|
onTap = {
|
||||||
|
activeBroadcasts.firstOrNull()?.let { selectedBroadcast = it }
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 56.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Completed broadcast indicator (when no active broadcasts)
|
||||||
|
if (activeBroadcasts.isEmpty()) {
|
||||||
|
CompletedBroadcastIndicator(
|
||||||
|
broadcast = completedBroadcast,
|
||||||
|
onTap = { broadcast ->
|
||||||
|
selectedBroadcast = broadcast
|
||||||
|
},
|
||||||
|
onRetry = { b ->
|
||||||
|
scope.launch {
|
||||||
|
accountViewModel.broadcastTracker.retry(
|
||||||
|
broadcast = b,
|
||||||
|
client = accountViewModel.account.client,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { broadcast ->
|
||||||
|
completedBroadcast = null
|
||||||
|
accountViewModel.broadcastTracker.expireBroadcast(broadcast.id)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details sheet - show all broadcasts when opened
|
||||||
|
if (selectedBroadcast != null) {
|
||||||
|
// Combine active broadcasts with the selected/completed one
|
||||||
|
val allBroadcasts =
|
||||||
|
(activeBroadcasts + listOfNotNull(completedBroadcast))
|
||||||
|
.distinctBy { it.id }
|
||||||
|
|
||||||
|
MultiBroadcastDetailsSheet(
|
||||||
|
broadcasts = allBroadcasts,
|
||||||
|
onDismiss = { selectedBroadcast = null },
|
||||||
|
onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl ->
|
||||||
|
scope.launch {
|
||||||
|
accountViewModel.broadcastTracker.retry(
|
||||||
|
broadcast = b,
|
||||||
|
client = accountViewModel.account.client,
|
||||||
|
specificRelay = relay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRetryAllFailed = { b: BroadcastEvent ->
|
||||||
|
scope.launch {
|
||||||
|
accountViewModel.broadcastTracker.retry(
|
||||||
|
broadcast = b,
|
||||||
|
client = accountViewModel.account.client,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact indicator for completed broadcasts.
|
||||||
|
* Styled consistently with BroadcastBanner.
|
||||||
|
* Tappable to show details sheet, with retry button for failures.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CompletedBroadcastIndicator(
|
||||||
|
broadcast: BroadcastEvent?,
|
||||||
|
onTap: (BroadcastEvent) -> Unit,
|
||||||
|
onRetry: (BroadcastEvent) -> Unit,
|
||||||
|
onDismiss: (BroadcastEvent) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Status colors (for icon tint only)
|
||||||
|
val successColor = Color(0xFF22C55E)
|
||||||
|
val warningColor = Color(0xFFF59E0B)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = broadcast != null,
|
||||||
|
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
broadcast?.let { b ->
|
||||||
|
val (statusIcon, iconTint) =
|
||||||
|
when (b.status) {
|
||||||
|
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor
|
||||||
|
BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor
|
||||||
|
BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
|
||||||
|
BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same styling as BroadcastBanner
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onTap(b) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
// Small status icon (like BroadcastBanner)
|
||||||
|
Icon(
|
||||||
|
imageVector = statusIcon,
|
||||||
|
contentDescription = b.status.name,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${b.eventName} sent",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "[${b.successCount}/${b.totalRelays}]",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b.failedRelays.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Tap to view details",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry button for failures
|
||||||
|
if (b.failedRelays.isNotEmpty()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onRetry(b) },
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = "Retry failed",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss X
|
||||||
|
Text(
|
||||||
|
text = "×",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clickable { onDismiss(b) }
|
||||||
|
.padding(4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
|||||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
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.getActivity
|
||||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||||
@@ -310,6 +311,7 @@ fun AppNavigation(
|
|||||||
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
|
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
|
||||||
DisplayNotifyMessages(accountViewModel, nav)
|
DisplayNotifyMessages(accountViewModel, nav)
|
||||||
DisplayCrashMessages(accountViewModel, nav)
|
DisplayCrashMessages(accountViewModel, nav)
|
||||||
|
DisplayBroadcastProgress(accountViewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+124
-6
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
|
|||||||
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
|
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
|
||||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
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.CashuToken
|
||||||
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
|
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
|
||||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||||
@@ -168,6 +169,7 @@ class AccountViewModel(
|
|||||||
var firstRoute: Route? = null
|
var firstRoute: Route? = null
|
||||||
|
|
||||||
val toastManager = ToastManager()
|
val toastManager = ToastManager()
|
||||||
|
val broadcastTracker = BroadcastTracker()
|
||||||
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
||||||
|
|
||||||
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
|
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
|
||||||
@@ -315,7 +317,25 @@ class AccountViewModel(
|
|||||||
if (currentReactions.isNotEmpty()) {
|
if (currentReactions.isNotEmpty()) {
|
||||||
account.delete(currentReactions)
|
account.delete(currentReactions)
|
||||||
} else {
|
} 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) }
|
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 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) }
|
fun broadcast(note: Note) = launchSigner { account.broadcast(note) }
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -97,6 +97,7 @@ import com.vitorpamplona.quartz.nip10Notes.tags.notify
|
|||||||
import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo
|
import com.vitorpamplona.quartz.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
|
||||||
@@ -544,12 +545,48 @@ open class ShortNotePostViewModel :
|
|||||||
val version = draftTag.current
|
val version = draftTag.current
|
||||||
cancel()
|
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.launchSigner {
|
||||||
accountViewModel.account.deleteDraftIgnoreErrors(version)
|
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() {
|
suspend fun sendDraftSync() {
|
||||||
if (message.text.isBlank()) {
|
if (message.text.isBlank()) {
|
||||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||||
|
|||||||
Reference in New Issue
Block a user