diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt index 7f2b53646..2b9b8b1a5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt @@ -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), + ) + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt index 42a6f3aad..0d6973560 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt @@ -38,6 +38,7 @@ sealed class DmBroadcastStatus { data class Sent( val relayCount: Int, + val relayUrls: List = emptyList(), ) : DmBroadcastStatus() data class Failed( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index e97b2f18a..3b41cdbeb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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)) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index cb6051321..e0f5086d1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -111,6 +111,9 @@ class DesktopIAccount( val signedEvent = signer.sign(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) { - 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) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index aa8778210..6c936d885 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -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(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) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt index 6c7dcd136..26e0cbd81 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -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>(emptyList()) val newRooms: StateFlow> = _newRooms.asStateFlow() + // Cache decrypted NIP-04 content by event id to avoid re-decrypting every poll + private val decryptedContentCache = mutableMapOf() + + // Track pubkeys we've already requested metadata for + private val fetchedMetadataKeys = mutableSetOf() + 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) { + 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?, + ) { + 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() val new = mutableListOf() - chatroomList.rooms.forEach { key, chatroom -> + // Collect room entries (forEach is non-suspend) + val entries = mutableListOf>() + chatroomList.rooms.forEach { key, chatroom -> entries.add(key to chatroom) } + + // Collect pubkeys needing metadata across all rooms + val pubkeysNeedingMetadata = mutableListOf() + + 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 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 4cb952421..3e97172e8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt index c07e8cd65..216d3ab43 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt @@ -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.Idle) val status: StateFlow = _status.asStateFlow() - suspend fun sendAndTrack( - event: Event, - relays: Set, - ) { - 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>>) { + 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() + + 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, + ) { + sendBatch(listOf(event to relays)) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index 75d23d90c..a01593013 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -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( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt index 25af7f7eb..4af4749ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt @@ -45,7 +45,18 @@ suspend fun INostrClient.sendAndWaitForResponse( event: Event, relayList: Set, 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, + timeoutInSeconds: Long = 15, +): Map { val resultChannel = Channel(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 }