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:
+9
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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))
|
||||
},
|
||||
|
||||
+30
-7
@@ -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,8 +130,13 @@ class DesktopIAccount(
|
||||
|
||||
val result = NIP17Factory().createMessageNIP17(template, signer)
|
||||
|
||||
// Broadcast each gift wrap to the recipient's inbox relays with tracking
|
||||
result.wraps.forEach { wrap ->
|
||||
// Optimistic local add — use the inner ChatMessageEvent, not the wraps
|
||||
val innerMsg = result.msg as ChatMessageEvent
|
||||
addEventToChatroom(innerMsg, innerMsg.chatroomKey(pubKey))
|
||||
|
||||
// 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) {
|
||||
@@ -142,13 +150,15 @@ class DesktopIAccount(
|
||||
} else {
|
||||
relayManager.connectedRelays.value
|
||||
}
|
||||
|
||||
scope.launch { dmSendTracker.sendAndTrack(wrap, targetRelays) }
|
||||
wrap to targetRelays
|
||||
}
|
||||
|
||||
scope.launch { dmSendTracker.sendBatch(batch) }
|
||||
}
|
||||
|
||||
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
|
||||
wraps.forEach { wrap ->
|
||||
val batch =
|
||||
wraps.map { wrap ->
|
||||
val recipientKey = wrap.recipientPubKey()
|
||||
val targetRelays =
|
||||
if (recipientKey != null) {
|
||||
@@ -162,8 +172,21 @@ class DesktopIAccount(
|
||||
} else {
|
||||
relayManager.connectedRelays.value
|
||||
}
|
||||
|
||||
scope.launch { dmSendTracker.sendAndTrack(wrap, targetRelays) }
|
||||
wrap to 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)
|
||||
}
|
||||
}
|
||||
|
||||
+110
-24
@@ -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 {
|
||||
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,11 +345,13 @@ private fun MessageWithReactions(
|
||||
.onPointerEvent(PointerEventType.Enter) { isHovered = true }
|
||||
.onPointerEvent(PointerEventType.Exit) { isHovered = false },
|
||||
) {
|
||||
Column {
|
||||
ChatMessageCompose(
|
||||
note = note,
|
||||
isLoggedInUser = isMe,
|
||||
isDraft = isDraft,
|
||||
isComplete = true,
|
||||
hasDetailsToShow = reactions.isNotEmpty(),
|
||||
drawAuthorInfo = !isMe,
|
||||
onClick = { false },
|
||||
onAuthorClick = {
|
||||
@@ -332,6 +369,11 @@ private fun MessageWithReactions(
|
||||
}
|
||||
},
|
||||
detailRow = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Timestamp
|
||||
note.createdAt()?.let { timestamp ->
|
||||
Text(
|
||||
text = timestamp.toTimeAgo(withDot = false),
|
||||
@@ -339,31 +381,69 @@ private fun MessageWithReactions(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { _ ->
|
||||
// Message body content
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = note.event?.content ?: "",
|
||||
text = decryptedContent ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+102
-9
@@ -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 }
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+33
-10
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+98
-1
@@ -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(
|
||||
|
||||
+13
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user