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,
|
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(
|
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))
|
||||||
},
|
},
|
||||||
|
|||||||
+55
-32
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-60
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.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 }
|
||||||
|
|||||||
+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.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
|
||||||
|
|||||||
+33
-10
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-1
@@ -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(
|
||||||
|
|||||||
+13
-2
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user