feat(desktop): add DM broadcast banner and fix empty room loading

- Fix ListChangeFeedViewModel stuck on Loading for empty chatrooms by
  adding invalidateData() call in init block
- Add DmBroadcastStatus sealed class and DmBroadcastBanner composable
  in commons for relay send progress display
- Add DmSendTracker using sendAndWaitForResponse for confirmed delivery
- Wire send tracking through DesktopIAccount into ChatPane banner
- Fix audit risks: unsubscribe DMs on account change, wait for relay
  connect before subscribing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-19 12:20:04 +02:00
parent 5d983c5d27
commit 35a15f0462
8 changed files with 289 additions and 7 deletions
@@ -0,0 +1,133 @@
/*
* 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.commons.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
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.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.CheckCircle
import androidx.compose.material.icons.filled.Error
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun DmBroadcastBanner(
status: DmBroadcastStatus,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = status !is DmBroadcastStatus.Idle,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
modifier = modifier,
) {
val isFailed = status is DmBroadcastStatus.Failed
val containerColor =
if (isFailed) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
val contentColor =
if (isFailed) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
color = containerColor,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val icon =
when (status) {
is DmBroadcastStatus.Subscribing,
is DmBroadcastStatus.Sending,
-> Icons.Default.Sync
is DmBroadcastStatus.Sent -> Icons.Default.CheckCircle
is DmBroadcastStatus.Failed -> Icons.Default.Error
is DmBroadcastStatus.Idle -> Icons.Default.Sync
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = contentColor,
)
Spacer(Modifier.width(4.dp))
Text(
text =
when (status) {
is DmBroadcastStatus.Subscribing -> "Connecting to DM relays..."
is DmBroadcastStatus.Sending -> "Sending message... [${status.successCount}/${status.totalRelays}]"
is DmBroadcastStatus.Sent -> "Sent to ${status.relayCount} relays"
is DmBroadcastStatus.Failed -> "Send failed: ${status.error}"
is DmBroadcastStatus.Idle -> ""
},
style = MaterialTheme.typography.labelMedium,
color = contentColor,
)
}
if (status is DmBroadcastStatus.Sending) {
LinearProgressIndicator(
progress = { status.progress },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
}
@@ -0,0 +1,46 @@
/*
* 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.commons.ui.chat
import androidx.compose.runtime.Immutable
@Immutable
sealed class DmBroadcastStatus {
data object Idle : DmBroadcastStatus()
data object Subscribing : DmBroadcastStatus()
data class Sending(
val successCount: Int,
val totalRelays: Int,
) : DmBroadcastStatus() {
val progress: Float
get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f
}
data class Sent(
val relayCount: Int,
) : DmBroadcastStatus()
data class Failed(
val error: String,
) : DmBroadcastStatus()
}
@@ -47,6 +47,10 @@ abstract class ListChangeFeedViewModel(
init {
Log.d("Init", "Starting new Model: ${this::class.simpleName}")
// Trigger initial load so empty rooms show Empty instead of Loading
viewModelScope.launch(Dispatchers.IO) {
feedState.invalidateData(ignoreIfDoing = false)
}
viewModelScope.launch(Dispatchers.IO) {
localFilter.changesFlow().collect {
Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}")