feat(desktop): add DM broadcast status banner and audit fixes

- Show relay confirmation URLs in broadcast banner
- Add relayUrls field to DmBroadcastStatus.Sent
- Wire send progress tracking with per-relay confirmations
- Fix empty chatroom Loading state transition
- Improve NewDmDialog npub metadata resolution
- Harden sendAndWaitForResponse timeout handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-23 06:53:53 +02:00
parent 35a15f0462
commit 78aa7962e9
10 changed files with 557 additions and 155 deletions
@@ -127,6 +127,15 @@ fun DmBroadcastBanner(
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
if (status is DmBroadcastStatus.Sent && status.relayUrls.isNotEmpty()) {
Text(
text = status.relayUrls.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
modifier = Modifier.padding(start = 28.dp),
)
}
}
}
}
@@ -38,6 +38,7 @@ sealed class DmBroadcastStatus {
data class Sent(
val relayCount: Int,
val relayUrls: List<String> = emptyList(),
) : DmBroadcastStatus()
data class Failed(
@@ -286,38 +286,6 @@ fun App(
}
}
// Subscribe to DMs when user logs in; unsubscribe on account change
LaunchedEffect(accountState) {
// Clean up previous DM subscriptions on any account change (logout, switch)
subscriptionsCoordinator.unsubscribeFromDms()
val currentAccount = accountState
if (currentAccount is AccountState.LoggedIn) {
// Wait for at least one relay to connect before subscribing
relayManager.connectedRelays.first { it.isNotEmpty() }
val dmRelayState =
DesktopDmRelayState(
dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()),
connectedRelays = relayManager.connectedRelays,
scope = scope,
)
subscriptionsCoordinator.subscribeToDms(
userPubKeyHex = currentAccount.pubKeyHex,
dmRelayState = dmRelayState,
onDmEvent = { event, relay ->
// Store event as a note in cache so ChatroomFeedViewModel can display it
val note = localCache.getOrCreateNote(event.id)
val author = localCache.getOrCreateUser(event.pubKey)
if (note.event == null) {
note.loadEvent(event, author, emptyList())
note.addRelay(relay)
}
},
)
}
}
MaterialTheme(
colorScheme = darkColorScheme(),
) {
@@ -386,6 +354,97 @@ fun MainContent(
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
// DM infrastructure — hoisted here so it survives screen navigation
val dmSendTracker =
remember(relayManager) {
DmSendTracker(relayManager.client)
}
val iAccount =
remember(account, localCache, relayManager, dmSendTracker) {
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
}
// Subscribe to incoming DMs and process into chatroomList
LaunchedEffect(account) {
relayManager.connectedRelays.first { it.isNotEmpty() }
val dmRelayState =
DesktopDmRelayState(
dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()),
connectedRelays = relayManager.connectedRelays,
scope = scope,
)
subscriptionsCoordinator.subscribeToDms(
userPubKeyHex = account.pubKeyHex,
dmRelayState = dmRelayState,
onDmEvent = { event, relay ->
// Store raw event in cache
val note = localCache.getOrCreateNote(event.id)
val author = localCache.getOrCreateUser(event.pubKey)
if (note.event == null) {
note.loadEvent(event, author, emptyList())
note.addRelay(relay)
}
// Process into chatroomList based on event type
when (event) {
is com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -> {
iAccount.chatroomList.addMessage(
event.chatroomKey(iAccount.pubKey),
note,
)
}
is com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -> {
// NIP-17: unwrap gift wrap → seal → inner event
scope.launch {
val seal =
event.unwrapOrNull(iAccount.signer)
as? com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
?: return@launch
val innerEvent = seal.unsealOrNull(iAccount.signer) ?: return@launch
when (innerEvent) {
is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -> {
val innerNote = localCache.getOrCreateNote(innerEvent.id)
val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey)
if (innerNote.event == null) {
innerNote.loadEvent(innerEvent, innerAuthor, emptyList())
}
iAccount.chatroomList.addMessage(
innerEvent.chatroomKey(iAccount.pubKey),
innerNote,
)
}
is com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -> {
val reactionNote = localCache.getOrCreateNote(innerEvent.id)
val reactionAuthor = localCache.getOrCreateUser(innerEvent.pubKey)
if (reactionNote.event == null) {
reactionNote.loadEvent(innerEvent, reactionAuthor, emptyList())
}
// Attach reaction to the target message note
innerEvent.originalPost().forEach { targetId ->
val targetNote = localCache.getNoteIfExists(targetId)
targetNote?.addReaction(reactionNote)
}
}
else -> {
println("Unhandled NIP-17 inner event: ${innerEvent.kind}")
}
}
}
}
}
},
)
}
// Clean up DM subscriptions when this composable leaves (logout)
DisposableEffect(Unit) {
onDispose { subscriptionsCoordinator.unsubscribeFromDms() }
}
val onZapFeedback: (ZapFeedback) -> Unit = { feedback ->
scope.launch {
val message =
@@ -543,17 +602,11 @@ fun MainContent(
}
DesktopScreen.Messages -> {
val dmSendTracker =
remember(relayManager) {
DmSendTracker(relayManager.client)
}
val iAccount =
remember(account, localCache, relayManager, dmSendTracker) {
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
}
DesktopMessagesScreen(
account = iAccount,
cacheProvider = localCache,
relayManager = relayManager,
localCache = localCache,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
@@ -111,6 +111,9 @@ class DesktopIAccount(
val signedEvent = signer.sign<PrivateDmEvent>(eventTemplate)
val recipient = signedEvent.verifiedRecipientPubKey()
// Optimistic local add so the message appears immediately
addEventToChatroom(signedEvent, signedEvent.chatroomKey(pubKey))
// Broadcast to connected relays + recipient's DM inbox relays
val targetRelays = relayManager.connectedRelays.value.toMutableSet()
if (recipient != null) {
@@ -127,43 +130,63 @@ class DesktopIAccount(
val result = NIP17Factory().createMessageNIP17(template, signer)
// Broadcast each gift wrap to the recipient's inbox relays with tracking
result.wraps.forEach { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
// Optimistic local add — use the inner ChatMessageEvent, not the wraps
val innerMsg = result.msg as ChatMessageEvent
addEventToChatroom(innerMsg, innerMsg.chatroomKey(pubKey))
scope.launch { dmSendTracker.sendAndTrack(wrap, targetRelays) }
}
// Collect all wraps with their target relays for batch sending
val batch =
result.wraps.map { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
wrap to targetRelays
}
scope.launch { dmSendTracker.sendBatch(batch) }
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
wraps.forEach { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
val batch =
wraps.map { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
wrap to targetRelays
}
scope.launch { dmSendTracker.sendAndTrack(wrap, targetRelays) }
scope.launch { dmSendTracker.sendBatch(batch) }
}
private fun addEventToChatroom(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey,
) {
val note = localCache.getOrCreateNote(event.id)
val author = localCache.getOrCreateUser(event.pubKey)
if (note.event == null) {
note.loadEvent(event, author, emptyList())
}
chatroomList.addMessage(roomKey, note)
}
}
@@ -20,9 +20,6 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,7 +28,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -39,10 +35,12 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -72,7 +70,10 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
@@ -88,6 +89,7 @@ import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.launch
@@ -195,7 +197,11 @@ fun ChatPane(
onAuthorClick = onNavigateToProfile,
onReaction = { note, emoji ->
scope.launch {
sendWrappedReaction(note, emoji, roomKey, account)
try {
sendWrappedReaction(note, emoji, roomKey, account)
} catch (e: Exception) {
println("Failed to send reaction: ${e.message}")
}
}
},
)
@@ -271,6 +277,7 @@ private fun MessageList(
note = note,
isMe = isMe,
isDraft = isDraft,
account = account,
onAuthorClick = onAuthorClick,
onReaction = { emoji -> onReaction(note, emoji) },
)
@@ -290,7 +297,8 @@ private val QUICK_REACTIONS =
)
/**
* Wraps a ChatMessageCompose with hover-triggered reaction bar.
* Wraps a ChatMessageCompose with reaction icon in the detail row
* and displays existing reactions below the bubble.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -298,10 +306,37 @@ private fun MessageWithReactions(
note: Note,
isMe: Boolean,
isDraft: Boolean,
account: IAccount,
onAuthorClick: (String) -> Unit,
onReaction: (String) -> Unit,
) {
var isHovered by remember { mutableStateOf(false) }
var showPicker by remember { mutableStateOf(false) }
val showIcon = (isHovered || showPicker) && !isDraft
// Decrypt NIP-04 content asynchronously; NIP-17 content is already plaintext
val event = note.event
var decryptedContent by remember(note.idHex) { mutableStateOf<String?>(null) }
LaunchedEffect(note.idHex, event) {
decryptedContent =
when (event) {
is PrivateDmEvent -> {
try {
event.decryptContent(account.signer)
} catch (_: Exception) {
event.content
}
}
else -> {
event?.content
}
}
}
// Observe reaction changes
val reactions = note.reactions
Box(
modifier =
@@ -310,60 +345,105 @@ private fun MessageWithReactions(
.onPointerEvent(PointerEventType.Enter) { isHovered = true }
.onPointerEvent(PointerEventType.Exit) { isHovered = false },
) {
ChatMessageCompose(
note = note,
isLoggedInUser = isMe,
isDraft = isDraft,
isComplete = true,
drawAuthorInfo = !isMe,
onClick = { false },
onAuthorClick = {
note.author?.pubkeyHex?.let { onAuthorClick(it) }
},
authorLine = {
note.author?.let { author ->
Text(
text = author.toBestDisplayName(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
detailRow = {
note.createdAt()?.let { timestamp ->
Text(
text = timestamp.toTimeAgo(withDot = false),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
},
) { _ ->
// Message body content
Text(
text = note.event?.content ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
Column {
ChatMessageCompose(
note = note,
isLoggedInUser = isMe,
isDraft = isDraft,
isComplete = true,
hasDetailsToShow = reactions.isNotEmpty(),
drawAuthorInfo = !isMe,
onClick = { false },
onAuthorClick = {
note.author?.pubkeyHex?.let { onAuthorClick(it) }
},
authorLine = {
note.author?.let { author ->
Text(
text = author.toBestDisplayName(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
detailRow = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Timestamp
note.createdAt()?.let { timestamp ->
Text(
text = timestamp.toTimeAgo(withDot = false),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
// Reaction bar - shown on hover, positioned at top-right for others' messages,
// top-left for own messages
AnimatedVisibility(
visible = isHovered && !isDraft,
enter = fadeIn(),
exit = fadeOut(),
modifier =
Modifier
.align(if (isMe) Alignment.TopStart else Alignment.TopEnd)
.offset(
x = if (isMe) 8.dp else (-8).dp,
y = (-4).dp,
),
) {
ReactionBar(onReaction = onReaction)
// Reaction counts
reactions.forEach { (emoji, notes) ->
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
) {
Text(
text =
if (notes.size > 1) {
"$emoji ${notes.size}"
} else {
emoji
},
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
}
// AddReaction icon on hover
if (showIcon) {
Box {
IconButton(
onClick = { showPicker = !showPicker },
modifier = Modifier.size(20.dp),
) {
Icon(
imageVector = Icons.Outlined.AddReaction,
contentDescription = "React",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (showPicker) {
Popup(
alignment = Alignment.TopCenter,
offset = IntOffset(0, -44),
onDismissRequest = { showPicker = false },
properties = PopupProperties(focusable = true),
) {
ReactionBar(
onReaction = { emoji ->
onReaction(emoji)
showPicker = false
},
)
}
}
}
}
}
},
) { _ ->
SelectionContainer {
Text(
text = decryptedContent ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
@@ -377,9 +457,14 @@ private suspend fun sendWrappedReaction(
roomKey: ChatroomKey,
account: IAccount,
) {
val event = note.event ?: return
val event = note.event
if (event == null) {
println("sendWrappedReaction: note.event is null for ${note.idHex}")
return
}
val eventBundle = EventHintBundle(event)
val recipients = roomKey.users.toList()
println("sendWrappedReaction: sending '$emoji' to ${recipients.size} recipients for event ${event.id.take(8)}")
val result =
NIP17Factory().createReactionWithinGroup(
@@ -389,6 +474,7 @@ private suspend fun sendWrappedReaction(
signer = account.signer,
)
println("sendWrappedReaction: created ${result.wraps.size} wraps, broadcasting...")
account.sendGiftWraps(result.wraps)
}
@@ -25,6 +25,14 @@ import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -71,6 +79,8 @@ enum class ConversationTab {
class ChatroomListState(
private val account: IAccount,
private val cacheProvider: ICacheProvider,
private val relayManager: RelayConnectionManager,
private val localCache: DesktopLocalCache,
private val scope: CoroutineScope,
) {
private val _selectedTab = MutableStateFlow(ConversationTab.KNOWN)
@@ -85,10 +95,13 @@ class ChatroomListState(
private val _newRooms = MutableStateFlow<List<ConversationItem>>(emptyList())
val newRooms: StateFlow<List<ConversationItem>> = _newRooms.asStateFlow()
// Cache decrypted NIP-04 content by event id to avoid re-decrypting every poll
private val decryptedContentCache = mutableMapOf<String, String>()
// Track pubkeys we've already requested metadata for
private val fetchedMetadataKeys = mutableSetOf<String>()
init {
// Periodically refresh the room list from the account's chatroom list.
// This is a simple polling approach; a production implementation would
// observe the chatroom list's changesFlow per room.
scope.launch(Dispatchers.IO) {
while (isActive) {
refreshRooms()
@@ -109,13 +122,93 @@ class ChatroomListState(
_selectedRoom.value = null
}
private fun refreshRooms() {
private suspend fun decryptPreview(event: com.vitorpamplona.quartz.nip01Core.core.Event?): String {
if (event == null) return ""
return when (event) {
is PrivateDmEvent -> {
decryptedContentCache.getOrPut(event.id) {
try {
event.decryptContent(account.signer)
} catch (_: Exception) {
event.content
}
}
}
else -> {
event.content
}
}.take(80)
}
private var metadataSubCounter = 0
private fun fetchMetadataIfNeeded(pubkeys: List<String>) {
val needed = pubkeys.filter { it !in fetchedMetadataKeys && it.length == 64 }
if (needed.isEmpty()) return
val relays = relayManager.relayStatuses.value.keys
if (relays.isEmpty()) return
fetchedMetadataKeys.addAll(needed)
val subId = "dm-meta-${metadataSubCounter++}"
val filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = needed,
limit = needed.size,
)
val listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
}
}
relayManager.subscribe(subId, listOf(filter), relays, listener)
// Auto-close after 15s — enough time for all relays to respond
scope.launch {
delay(15_000)
relayManager.unsubscribe(subId)
}
}
private suspend fun refreshRooms() {
val chatroomList = account.chatroomList
val known = mutableListOf<ConversationItem>()
val new = mutableListOf<ConversationItem>()
chatroomList.rooms.forEach { key, chatroom ->
// Collect room entries (forEach is non-suspend)
val entries = mutableListOf<Pair<ChatroomKey, Chatroom>>()
chatroomList.rooms.forEach { key, chatroom -> entries.add(key to chatroom) }
// Collect pubkeys needing metadata across all rooms
val pubkeysNeedingMetadata = mutableListOf<String>()
for ((key, chatroom) in entries) {
// Skip rooms with no messages
if (chatroom.messages.isEmpty()) continue
val users = key.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }
// Collect pubkeys without profile info
for (pubkey in key.users) {
val user = cacheProvider.getUserIfExists(pubkey) as? User
if (user == null || user.metadataOrNull() == null) {
pubkeysNeedingMetadata.add(pubkey)
}
}
val displayName =
if (users.isNotEmpty()) {
users.joinToString(", ") { it.toBestDisplayName() }
@@ -127,12 +220,9 @@ class ChatroomListState(
}
val newestMessage = chatroom.newestMessage
val lastPreview = newestMessage?.event?.content?.take(80) ?: ""
val lastPreview = decryptPreview(newestMessage?.event)
val lastTimestamp = newestMessage?.createdAt() ?: 0L
// Skip rooms with no messages
if (chatroom.messages.isEmpty()) return@forEach
val item =
ConversationItem(
roomKey = key,
@@ -152,6 +242,9 @@ class ChatroomListState(
}
}
// Batch-request metadata for all users who need it
fetchMetadataIfNeeded(pubkeysNeedingMetadata)
// Sort by most recent message
_knownRooms.value = known.sortedByDescending { it.lastMessageTimestamp }
_newRooms.value = new.sortedByDescending { it.lastMessageTimestamp }
@@ -57,7 +57,9 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@@ -75,12 +77,14 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
fun DesktopMessagesScreen(
account: IAccount,
cacheProvider: ICacheProvider,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
onNavigateToProfile: (String) -> Unit = {},
) {
val scope = rememberCoroutineScope()
val listState =
remember(account) {
ChatroomListState(account, cacheProvider, scope)
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
}
val selectedRoom by listState.selectedRoom.collectAsState()
val listFocusRequester = remember { FocusRequester() }
@@ -168,6 +172,8 @@ fun DesktopMessagesScreen(
if (showNewDmDialog) {
NewDmDialog(
cacheProvider = cacheProvider,
relayManager = relayManager,
localCache = localCache,
onUserSelected = { roomKey ->
listState.selectRoom(roomKey)
showNewDmDialog = false
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.chats
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponseDetailed
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,24 +36,37 @@ class DmSendTracker(
private val _status = MutableStateFlow<DmBroadcastStatus>(DmBroadcastStatus.Idle)
val status: StateFlow<DmBroadcastStatus> = _status.asStateFlow()
suspend fun sendAndTrack(
event: Event,
relays: Set<NormalizedRelayUrl>,
) {
if (relays.isEmpty()) {
/**
* Sends multiple event/relay pairs (e.g. NIP-17 wraps) and aggregates results.
* Shows per-relay success/failure across all wraps.
*/
suspend fun sendBatch(events: List<Pair<Event, Set<NormalizedRelayUrl>>>) {
if (events.isEmpty()) return
val totalRelays = events.sumOf { it.second.size }
if (totalRelays == 0) {
_status.value = DmBroadcastStatus.Failed("No relays available")
delay(3000)
_status.value = DmBroadcastStatus.Idle
return
}
_status.value = DmBroadcastStatus.Sending(0, relays.size)
_status.value = DmBroadcastStatus.Sending(0, totalRelays)
val success = client.sendAndWaitForResponse(event, relays, 10)
val allSuccessful = mutableSetOf<NormalizedRelayUrl>()
for ((event, relays) in events) {
val results = client.sendAndWaitForResponseDetailed(event, relays, 10)
allSuccessful.addAll(results.filter { it.value }.keys)
_status.value = DmBroadcastStatus.Sending(allSuccessful.size, totalRelays)
}
_status.value =
if (success) {
DmBroadcastStatus.Sent(relays.size)
if (allSuccessful.isNotEmpty()) {
DmBroadcastStatus.Sent(
relayCount = allSuccessful.size,
relayUrls = allSuccessful.map { it.url },
)
} else {
DmBroadcastStatus.Failed("No relay accepted the message")
}
@@ -61,4 +74,14 @@ class DmSendTracker(
delay(3000)
_status.value = DmBroadcastStatus.Idle
}
/**
* Sends a single event to relays with tracking.
*/
suspend fun sendAndTrack(
event: Event,
relays: Set<NormalizedRelayUrl>,
) {
sendBatch(listOf(event to relays))
}
}
@@ -21,15 +21,18 @@
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -42,6 +45,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -52,11 +56,20 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.search.SearchResult
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
@Composable
fun NewDmDialog(
cacheProvider: ICacheProvider,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
onUserSelected: (ChatroomKey) -> Unit,
onDismiss: () -> Unit,
) {
@@ -65,8 +78,63 @@ fun NewDmDialog(
val searchText by searchState.searchText.collectAsState()
val bech32Results by searchState.bech32Results.collectAsState()
val cachedUsers by searchState.cachedUserResults.collectAsState()
val relaySearchResults by searchState.relaySearchResults.collectAsState()
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val focusRequester = remember { FocusRequester() }
// NIP-50 relay search when local cache has few/no results
rememberSubscription(relayStatuses, searchText, cachedUsers.size, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) return@rememberSubscription null
if (searchState.shouldSearchRelays) {
searchState.startRelaySearch()
createSearchPeopleSubscription(
relays = configuredRelays,
searchQuery = searchText,
limit = 20,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
val user = localCache.getUserIfExists(event.pubKey)
if (user != null) {
searchState.addRelaySearchResult(user)
}
}
},
onEose = { _, _ ->
searchState.endRelaySearch()
},
)
} else {
null
}
}
// Bech32 npub metadata loading
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || searchText.length < 2) {
return@rememberSubscription null
}
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
if (pubkeyHex != null) {
createMetadataSubscription(
relays = configuredRelays,
pubKeyHex = pubkeyHex,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
} else {
null
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
@@ -154,10 +222,39 @@ fun NewDmDialog(
)
}
// Relay search results
items(relaySearchResults) { user ->
UserSearchCard(
user = user,
onClick = {
onUserSelected(
ChatroomKey(setOf(user.pubkeyHex)),
)
},
)
}
// Relay search loading indicator
if (isSearchingRelays) {
item {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
}
}
// No results hint
if (searchText.length >= 2 &&
!isSearchingRelays &&
userResults.isEmpty() &&
cachedUsers.isEmpty()
cachedUsers.isEmpty() &&
relaySearchResults.isEmpty()
) {
item {
Text(
@@ -45,7 +45,18 @@ suspend fun INostrClient.sendAndWaitForResponse(
event: Event,
relayList: Set<NormalizedRelayUrl>,
timeoutInSeconds: Long = 15,
): Boolean {
): Boolean = sendAndWaitForResponseDetailed(event, relayList, timeoutInSeconds).any { it.value }
/**
* Sends an event to the given relays and waits for OK responses.
* Returns per-relay results: relay URL -> accepted (true/false).
*/
@OptIn(DelicateCoroutinesApi::class)
suspend fun INostrClient.sendAndWaitForResponseDetailed(
event: Event,
relayList: Set<NormalizedRelayUrl>,
timeoutInSeconds: Long = 15,
): Map<NormalizedRelayUrl, Boolean> {
val resultChannel = Channel<Result>(UNLIMITED)
Log.d("sendAndWaitForResponse", "Waiting for ${relayList.size} responses")
@@ -124,5 +135,5 @@ suspend fun INostrClient.sendAndWaitForResponse(
Log.d("sendAndWaitForResponse", "Finished with ${receivedResults.size} results")
return receivedResults.any { it.value }
return receivedResults
}