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, 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( data class Sent(
val relayCount: Int, val relayCount: Int,
val relayUrls: List<String> = emptyList(),
) : DmBroadcastStatus() ) : DmBroadcastStatus()
data class Failed( 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( MaterialTheme(
colorScheme = darkColorScheme(), colorScheme = darkColorScheme(),
) { ) {
@@ -386,6 +354,97 @@ fun MainContent(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() 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 -> val onZapFeedback: (ZapFeedback) -> Unit = { feedback ->
scope.launch { scope.launch {
val message = val message =
@@ -543,17 +602,11 @@ fun MainContent(
} }
DesktopScreen.Messages -> { DesktopScreen.Messages -> {
val dmSendTracker =
remember(relayManager) {
DmSendTracker(relayManager.client)
}
val iAccount =
remember(account, localCache, relayManager, dmSendTracker) {
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
}
DesktopMessagesScreen( DesktopMessagesScreen(
account = iAccount, account = iAccount,
cacheProvider = localCache, cacheProvider = localCache,
relayManager = relayManager,
localCache = localCache,
onNavigateToProfile = { pubKeyHex -> onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
}, },
@@ -111,6 +111,9 @@ class DesktopIAccount(
val signedEvent = signer.sign<PrivateDmEvent>(eventTemplate) val signedEvent = signer.sign<PrivateDmEvent>(eventTemplate)
val recipient = signedEvent.verifiedRecipientPubKey() 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 // Broadcast to connected relays + recipient's DM inbox relays
val targetRelays = relayManager.connectedRelays.value.toMutableSet() val targetRelays = relayManager.connectedRelays.value.toMutableSet()
if (recipient != null) { if (recipient != null) {
@@ -127,43 +130,63 @@ class DesktopIAccount(
val result = NIP17Factory().createMessageNIP17(template, signer) val result = NIP17Factory().createMessageNIP17(template, signer)
// Broadcast each gift wrap to the recipient's inbox relays with tracking // Optimistic local add — use the inner ChatMessageEvent, not the wraps
result.wraps.forEach { wrap -> val innerMsg = result.msg as ChatMessageEvent
val recipientKey = wrap.recipientPubKey() addEventToChatroom(innerMsg, innerMsg.chatroomKey(pubKey))
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
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>) { override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
wraps.forEach { wrap -> val batch =
val recipientKey = wrap.recipientPubKey() wraps.map { wrap ->
val targetRelays = val recipientKey = wrap.recipientPubKey()
if (recipientKey != null) { val targetRelays =
val dmRelays = if (recipientKey != null) {
localCache val dmRelays =
.getOrCreateUser(recipientKey) localCache
.dmInboxRelays() .getOrCreateUser(recipientKey)
?.toSet() .dmInboxRelays()
dmRelays?.ifEmpty { null } ?.toSet()
?: relayManager.connectedRelays.value dmRelays?.ifEmpty { null }
} else { ?: relayManager.connectedRelays.value
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 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -39,10 +35,12 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton 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.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp 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.IAccount
import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User 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.ChatNewMessageState
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle 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.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -195,7 +197,11 @@ fun ChatPane(
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onReaction = { note, emoji -> onReaction = { note, emoji ->
scope.launch { 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, note = note,
isMe = isMe, isMe = isMe,
isDraft = isDraft, isDraft = isDraft,
account = account,
onAuthorClick = onAuthorClick, onAuthorClick = onAuthorClick,
onReaction = { emoji -> onReaction(note, emoji) }, 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) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
@@ -298,10 +306,37 @@ private fun MessageWithReactions(
note: Note, note: Note,
isMe: Boolean, isMe: Boolean,
isDraft: Boolean, isDraft: Boolean,
account: IAccount,
onAuthorClick: (String) -> Unit, onAuthorClick: (String) -> Unit,
onReaction: (String) -> Unit, onReaction: (String) -> Unit,
) { ) {
var isHovered by remember { mutableStateOf(false) } 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( Box(
modifier = modifier =
@@ -310,60 +345,105 @@ private fun MessageWithReactions(
.onPointerEvent(PointerEventType.Enter) { isHovered = true } .onPointerEvent(PointerEventType.Enter) { isHovered = true }
.onPointerEvent(PointerEventType.Exit) { isHovered = false }, .onPointerEvent(PointerEventType.Exit) { isHovered = false },
) { ) {
ChatMessageCompose( Column {
note = note, ChatMessageCompose(
isLoggedInUser = isMe, note = note,
isDraft = isDraft, isLoggedInUser = isMe,
isComplete = true, isDraft = isDraft,
drawAuthorInfo = !isMe, isComplete = true,
onClick = { false }, hasDetailsToShow = reactions.isNotEmpty(),
onAuthorClick = { drawAuthorInfo = !isMe,
note.author?.pubkeyHex?.let { onAuthorClick(it) } onClick = { false },
}, onAuthorClick = {
authorLine = { note.author?.pubkeyHex?.let { onAuthorClick(it) }
note.author?.let { author -> },
Text( authorLine = {
text = author.toBestDisplayName(), note.author?.let { author ->
style = MaterialTheme.typography.labelSmall, Text(
color = MaterialTheme.colorScheme.primary, text = author.toBestDisplayName(),
maxLines = 1, style = MaterialTheme.typography.labelSmall,
overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.primary,
) maxLines = 1,
} overflow = TextOverflow.Ellipsis,
}, )
detailRow = { }
note.createdAt()?.let { timestamp -> },
Text( detailRow = {
text = timestamp.toTimeAgo(withDot = false), Row(
style = MaterialTheme.typography.labelSmall, verticalAlignment = Alignment.CenterVertically,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), horizontalArrangement = Arrangement.spacedBy(4.dp),
) ) {
} // Timestamp
}, note.createdAt()?.let { timestamp ->
) { _ -> Text(
// Message body content text = timestamp.toTimeAgo(withDot = false),
Text( style = MaterialTheme.typography.labelSmall,
text = note.event?.content ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodyMedium, )
color = MaterialTheme.colorScheme.onSurface, }
)
}
// Reaction bar - shown on hover, positioned at top-right for others' messages, // Reaction counts
// top-left for own messages reactions.forEach { (emoji, notes) ->
AnimatedVisibility( Surface(
visible = isHovered && !isDraft, shape = RoundedCornerShape(10.dp),
enter = fadeIn(), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
exit = fadeOut(), ) {
modifier = Text(
Modifier text =
.align(if (isMe) Alignment.TopStart else Alignment.TopEnd) if (notes.size > 1) {
.offset( "$emoji ${notes.size}"
x = if (isMe) 8.dp else (-8).dp, } else {
y = (-4).dp, emoji
), },
) { style = MaterialTheme.typography.labelSmall,
ReactionBar(onReaction = onReaction) 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, roomKey: ChatroomKey,
account: IAccount, 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 eventBundle = EventHintBundle(event)
val recipients = roomKey.users.toList() val recipients = roomKey.users.toList()
println("sendWrappedReaction: sending '$emoji' to ${recipients.size} recipients for event ${event.id.take(8)}")
val result = val result =
NIP17Factory().createReactionWithinGroup( NIP17Factory().createReactionWithinGroup(
@@ -389,6 +474,7 @@ private suspend fun sendWrappedReaction(
signer = account.signer, signer = account.signer,
) )
println("sendWrappedReaction: created ${result.wraps.size} wraps, broadcasting...")
account.sendGiftWraps(result.wraps) 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.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom 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 com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -71,6 +79,8 @@ enum class ConversationTab {
class ChatroomListState( class ChatroomListState(
private val account: IAccount, private val account: IAccount,
private val cacheProvider: ICacheProvider, private val cacheProvider: ICacheProvider,
private val relayManager: RelayConnectionManager,
private val localCache: DesktopLocalCache,
private val scope: CoroutineScope, private val scope: CoroutineScope,
) { ) {
private val _selectedTab = MutableStateFlow(ConversationTab.KNOWN) private val _selectedTab = MutableStateFlow(ConversationTab.KNOWN)
@@ -85,10 +95,13 @@ class ChatroomListState(
private val _newRooms = MutableStateFlow<List<ConversationItem>>(emptyList()) private val _newRooms = MutableStateFlow<List<ConversationItem>>(emptyList())
val newRooms: StateFlow<List<ConversationItem>> = _newRooms.asStateFlow() 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 { 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) { scope.launch(Dispatchers.IO) {
while (isActive) { while (isActive) {
refreshRooms() refreshRooms()
@@ -109,13 +122,93 @@ class ChatroomListState(
_selectedRoom.value = null _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 chatroomList = account.chatroomList
val known = mutableListOf<ConversationItem>() val known = mutableListOf<ConversationItem>()
val new = 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 } 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 = val displayName =
if (users.isNotEmpty()) { if (users.isNotEmpty()) {
users.joinToString(", ") { it.toBestDisplayName() } users.joinToString(", ") { it.toBestDisplayName() }
@@ -127,12 +220,9 @@ class ChatroomListState(
} }
val newestMessage = chatroom.newestMessage val newestMessage = chatroom.newestMessage
val lastPreview = newestMessage?.event?.content?.take(80) ?: "" val lastPreview = decryptPreview(newestMessage?.event)
val lastTimestamp = newestMessage?.createdAt() ?: 0L val lastTimestamp = newestMessage?.createdAt() ?: 0L
// Skip rooms with no messages
if (chatroom.messages.isEmpty()) return@forEach
val item = val item =
ConversationItem( ConversationItem(
roomKey = key, roomKey = key,
@@ -152,6 +242,9 @@ class ChatroomListState(
} }
} }
// Batch-request metadata for all users who need it
fetchMetadataIfNeeded(pubkeysNeedingMetadata)
// Sort by most recent message // Sort by most recent message
_knownRooms.value = known.sortedByDescending { it.lastMessageTimestamp } _knownRooms.value = known.sortedByDescending { it.lastMessageTimestamp }
_newRooms.value = new.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.ui.chat.DmBroadcastStatus
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel 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.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") 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( fun DesktopMessagesScreen(
account: IAccount, account: IAccount,
cacheProvider: ICacheProvider, cacheProvider: ICacheProvider,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = val listState =
remember(account) { remember(account) {
ChatroomListState(account, cacheProvider, scope) ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
} }
val selectedRoom by listState.selectedRoom.collectAsState() val selectedRoom by listState.selectedRoom.collectAsState()
val listFocusRequester = remember { FocusRequester() } val listFocusRequester = remember { FocusRequester() }
@@ -168,6 +172,8 @@ fun DesktopMessagesScreen(
if (showNewDmDialog) { if (showNewDmDialog) {
NewDmDialog( NewDmDialog(
cacheProvider = cacheProvider, cacheProvider = cacheProvider,
relayManager = relayManager,
localCache = localCache,
onUserSelected = { roomKey -> onUserSelected = { roomKey ->
listState.selectRoom(roomKey) listState.selectRoom(roomKey)
showNewDmDialog = false showNewDmDialog = false
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.chats
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient 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 com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,24 +36,37 @@ class DmSendTracker(
private val _status = MutableStateFlow<DmBroadcastStatus>(DmBroadcastStatus.Idle) private val _status = MutableStateFlow<DmBroadcastStatus>(DmBroadcastStatus.Idle)
val status: StateFlow<DmBroadcastStatus> = _status.asStateFlow() val status: StateFlow<DmBroadcastStatus> = _status.asStateFlow()
suspend fun sendAndTrack( /**
event: Event, * Sends multiple event/relay pairs (e.g. NIP-17 wraps) and aggregates results.
relays: Set<NormalizedRelayUrl>, * Shows per-relay success/failure across all wraps.
) { */
if (relays.isEmpty()) { 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") _status.value = DmBroadcastStatus.Failed("No relays available")
delay(3000) delay(3000)
_status.value = DmBroadcastStatus.Idle _status.value = DmBroadcastStatus.Idle
return 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 = _status.value =
if (success) { if (allSuccessful.isNotEmpty()) {
DmBroadcastStatus.Sent(relays.size) DmBroadcastStatus.Sent(
relayCount = allSuccessful.size,
relayUrls = allSuccessful.map { it.url },
)
} else { } else {
DmBroadcastStatus.Failed("No relay accepted the message") DmBroadcastStatus.Failed("No relay accepted the message")
} }
@@ -61,4 +74,14 @@ class DmSendTracker(
delay(3000) delay(3000)
_status.value = DmBroadcastStatus.Idle _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 package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -42,6 +45,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.search.SearchResult
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState 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.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
@Composable @Composable
fun NewDmDialog( fun NewDmDialog(
cacheProvider: ICacheProvider, cacheProvider: ICacheProvider,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
onUserSelected: (ChatroomKey) -> Unit, onUserSelected: (ChatroomKey) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -65,8 +78,63 @@ fun NewDmDialog(
val searchText by searchState.searchText.collectAsState() val searchText by searchState.searchText.collectAsState()
val bech32Results by searchState.bech32Results.collectAsState() val bech32Results by searchState.bech32Results.collectAsState()
val cachedUsers by searchState.cachedUserResults.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() } 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) { LaunchedEffect(Unit) {
focusRequester.requestFocus() 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 // No results hint
if (searchText.length >= 2 && if (searchText.length >= 2 &&
!isSearchingRelays &&
userResults.isEmpty() && userResults.isEmpty() &&
cachedUsers.isEmpty() cachedUsers.isEmpty() &&
relaySearchResults.isEmpty()
) { ) {
item { item {
Text( Text(
@@ -45,7 +45,18 @@ suspend fun INostrClient.sendAndWaitForResponse(
event: Event, event: Event,
relayList: Set<NormalizedRelayUrl>, relayList: Set<NormalizedRelayUrl>,
timeoutInSeconds: Long = 15, 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) val resultChannel = Channel<Result>(UNLIMITED)
Log.d("sendAndWaitForResponse", "Waiting for ${relayList.size} responses") Log.d("sendAndWaitForResponse", "Waiting for ${relayList.size} responses")
@@ -124,5 +135,5 @@ suspend fun INostrClient.sendAndWaitForResponse(
Log.d("sendAndWaitForResponse", "Finished with ${receivedResults.size} results") Log.d("sendAndWaitForResponse", "Finished with ${receivedResults.size} results")
return receivedResults.any { it.value } return receivedResults
} }