From 695d3c7a230d8bf1d439851c2c6845d31b9f5eed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 15:22:13 +0000 Subject: [PATCH] feat: deprecate NIP-04 DM sending, always use NIP-17 NIP-04 encryption for sending DMs is now deprecated. All new messages are sent using NIP-17 (gift-wrapped sealed messages). Reading NIP-04 messages remains supported for backward compatibility. When a recipient lacks both a DM relay list (kind 10050) and NIP-65 inbox relays, the send button is disabled and a warning is shown explaining that messages cannot be delivered. Changes: - ChatNewMessageState: Remove nip17/requiresNip17 toggles, add recipientsMissingDmRelays state, always send via NIP-17 - ChatNewMessageViewModel: Always use NIP-17, remove NIP-04 send paths, add recipient relay status checking - ToggleNip17Button -> Nip17Indicator: Replace toggle with static NIP-17 indicator (always on) - PrivateMessageEditFieldRow: Show warning when recipients lack DM relay lists, hide message input - ChatFileSender: sendAll() always uses NIP-17 - Desktop ChatPane: Remove NIP-17 toggle, show relay warning - ChatroomView: Reactively check recipient DM relay availability https://claude.ai/code/session_01T7QhUW9cZogk4DxDXbbbJJ --- .../loggedIn/chats/privateDM/ChatroomView.kt | 8 +- .../privateDM/send/ChatNewMessageViewModel.kt | 210 +++++++----------- .../chats/privateDM/send/NewGroupDMScreen.kt | 6 +- .../send/PrivateMessageEditFieldRow.kt | 90 +++++--- .../chats/privateDM/send/ToggleNip17Button.kt | 87 +------- .../privateDM/send/upload/ChatFileSender.kt | 8 +- amethyst/src/main/res/values/strings.xml | 1 + .../commons/viewmodels/ChatNewMessageState.kt | 86 ++----- .../amethyst/desktop/ui/chats/ChatPane.kt | 61 ++--- 9 files changed, 195 insertions(+), 362 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index b996354f9..34709b437 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -92,10 +92,10 @@ fun ChatroomView( } } - if (room.users.size == 1) { - // Activates NIP-17 if the user has DM relays - ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) { - newPostModel.nip17 = !it?.relays().isNullOrEmpty() + // Reactively check if recipients have DM relays for NIP-17 delivery + for (userHex in room.users) { + ObserveRelayListForDMs(pubkey = userHex, accountViewModel = accountViewModel) { + newPostModel.updateRecipientRelayStatus() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 44d7f8c75..4e80558b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -88,7 +88,6 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -128,7 +127,8 @@ class ChatNewMessageViewModel : var room: ChatroomKey? by mutableStateOf(null) - var requiresNIP17: Boolean = false + /** Whether any recipients are missing DM relay lists */ + var recipientsMissingDmRelays by mutableStateOf(false) val replyTo = mutableStateOf(null) @@ -180,8 +180,8 @@ class ChatNewMessageViewModel : var wantsZapraiser by mutableStateOf(false) override var zapRaiserAmount = mutableStateOf(null) - // NIP17 Wrapped DMs / Group messages - var nip17 by mutableStateOf(false) + // NIP17 is always enabled - NIP-04 is deprecated for sending + val nip17: Boolean get() = true fun lnAddress(): String? = account.userProfile().lnAddress() @@ -214,19 +214,19 @@ class ChatNewMessageViewModel : room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, ) - updateNIP17StatusFromRoom() + updateRecipientRelayStatus() } - fun updateNIP17StatusFromRoom() { + fun updateRecipientRelayStatus() { val room = this.room if (room != null) { - this.requiresNIP17 = room.users.size > 1 - if (this.requiresNIP17) { - this.nip17 = true - } + this.recipientsMissingDmRelays = + room.users.any { hexKey -> + val user = LocalCache.getOrCreateUser(hexKey) + user.dmInboxRelays().isNullOrEmpty() + } } else { - this.requiresNIP17 = false - this.nip17 = false + this.recipientsMissingDmRelays = false } } @@ -361,8 +361,7 @@ class ChatNewMessageViewModel : iMetaAttachments.addAll(draftEvent.imetas()) - requiresNIP17 = draftEvent is NIP17Group - nip17 = draftEvent is NIP17Group + updateRecipientRelayStatus() } suspend fun sendPostSync() { @@ -402,27 +401,19 @@ class ChatNewMessageViewModel : val uploadState = uploadState ?: return accountViewModel.launchSigner { - if (nip17) { - ChatFileUploader(account).justUploadNIP17( - uploadState, - onError, - onEncryptedUploadError = { title, message -> - encryptedUploadErrorTitle = title - encryptedUploadErrorMessage = message - pendingRetryMode = RetryMode.HOLD - }, - context, - ) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() - } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() - } + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.HOLD + }, + context, + ) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded() } } } @@ -436,30 +427,22 @@ class ChatNewMessageViewModel : val uploadState = uploadState ?: return accountViewModel.launchSigner { - if (nip17) { - ChatFileUploader(account).justUploadNIP17( - uploadState, - onError, - onEncryptedUploadError = { title, message -> - encryptedUploadErrorTitle = title - encryptedUploadErrorMessage = message - pendingRetryMode = RetryMode.SEND - pendingRetryOnError = onError - pendingRetryContext = context - pendingRetryOnceUploaded = onceUploaded - }, - context, - ) { - ChatFileSender(room, account).sendNIP17(it) - draftTag.newVersion() - onceUploaded() - } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) { - ChatFileSender(room, account).sendNIP04(it) - draftTag.newVersion() - onceUploaded() - } + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.SEND + pendingRetryOnError = onError + pendingRetryContext = context + pendingRetryOnceUploaded = onceUploaded + }, + context, + ) { + ChatFileSender(room, account).sendNIP17(it) + draftTag.newVersion() + onceUploaded() } } } @@ -541,70 +524,49 @@ class ChatNewMessageViewModel : val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - if (nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) { - val replyHint = replyTo.value?.toEventHint() + val replyHint = replyTo.value?.toEventHint() - val template = - if (replyHint == null) { - ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) { - hashtags(findHashtags(message)) - references(findURLs(message)) - quotes(findNostrEventUris(message)) + val template = + if (replyHint == null) { + ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) { + hashtags(findHashtags(message)) + references(findURLs(message)) + quotes(findNostrEventUris(message)) - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - localExpirationDate?.let { expiration(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } else { - ChatMessageEvent.reply(message, replyHint) { - hashtags(findHashtags(message)) - references(findURLs(message)) - quotes(findNostrEventUris(message)) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - localExpirationDate?.let { expiration(it) } - - emojis(emojis) - imetas(usedAttachments) - } - } - - if (draftTag != null) { - accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template) - } else { - accountViewModel.account.sendNip17PrivateMessage(template) - } - } else { - val toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() } - - val template = - PrivateDmEvent.build( - toUser = toUser, - message = message, - imetas = usedAttachments, - replyingTo = replyTo.value?.toEventHint(), - signer = accountViewModel.account.signer, - ) { + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } localExpirationDate?.let { expiration(it) } - } - if (draftTag != null) { - accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template) + emojis(emojis) + imetas(usedAttachments) + } } else { - accountViewModel.account.sendNip04PrivateMessage(template) + ChatMessageEvent.reply(message, replyHint) { + hashtags(findHashtags(message)) + references(findURLs(message)) + quotes(findNostrEventUris(message)) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } + + emojis(emojis) + imetas(usedAttachments) + } } + + if (draftTag != null) { + accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template) + } else { + accountViewModel.account.sendNip17PrivateMessage(template) } if (draftTag == null) { - ChatFileSender(room, accountViewModel.account).sendAll(uploadsWaitingToBeSent) + ChatFileSender(room, accountViewModel.account).sendNIP17(uploadsWaitingToBeSent) } } @@ -700,11 +662,11 @@ class ChatNewMessageViewModel : val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } if (users.isNullOrEmpty()) { room = null - updateNIP17StatusFromRoom() + updateRecipientRelayStatus() } else { if (users != room?.users) { room = ChatroomKey(users) - updateNIP17StatusFromRoom() + updateRecipientRelayStatus() } } } @@ -737,9 +699,6 @@ class ChatNewMessageViewModel : val lastWord = toUsers.currentWord() toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) updateRoomFromUsersInput() - - val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm() - nip17 = relayList != null } userSuggestionsMainMessage = null @@ -783,7 +742,8 @@ class ChatNewMessageViewModel : !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && (toUsers.text.isNotBlank()) && - uploadState?.multiOrchestrator == null + uploadState?.multiOrchestrator == null && + !recipientsMissingDmRelays fun insertAtCursor(newElement: String) { message = message.insertUrlAtCursor(newElement) @@ -795,15 +755,7 @@ class ChatNewMessageViewModel : Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") } - fun toggleNIP04And24() { - if (requiresNIP17) { - nip17 = true - } else { - nip17 = !nip17 - } - - draftTag.newVersion() - } + // NIP-04 sending is deprecated. NIP-17 is always used. override fun updateZapPercentage( index: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 117f96d36..8b815f3c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -332,6 +332,10 @@ fun GroupDMScreenContent( ) } + if (postViewModel.recipientsMissingDmRelays) { + RecipientMissingRelaysWarning() + } + BottomRowActions(postViewModel, accountViewModel) } } @@ -551,7 +555,7 @@ fun SendDirectMessageTo( ), ) - ToggleNip17Button(postViewModel, accountViewModel) + Nip17Indicator(postViewModel) } HorizontalDivider(thickness = DividerThickness) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index b5b38901d..65436e43f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -174,39 +174,63 @@ fun EditField( onSendNewMessage: () -> Unit, accountViewModel: AccountViewModel, ) { - ThinPaddingTextField( - value = channelScreenModel.message, - onValueChange = { channelScreenModel.updateMessage(it) }, - keyboardOptions = PostKeyboard, - shape = EditFieldBorder, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringRes(R.string.reply_here), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - ThinSendButton( - isActive = channelScreenModel.canPost(), - modifier = EditFieldTrailingIconModifier, - ) { - accountViewModel.launchSigner { - channelScreenModel.sendPostSync() - onSendNewMessage() + if (channelScreenModel.recipientsMissingDmRelays) { + RecipientMissingRelaysWarning() + } else { + ThinPaddingTextField( + value = channelScreenModel.message, + onValueChange = { channelScreenModel.updateMessage(it) }, + keyboardOptions = PostKeyboard, + shape = EditFieldBorder, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringRes(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + ThinSendButton( + isActive = channelScreenModel.canPost(), + modifier = EditFieldTrailingIconModifier, + ) { + accountViewModel.launchSigner { + channelScreenModel.sendPostSync() + onSendNewMessage() + } } - } - }, - leadingIcon = { - KeyboardLeadingIcon(channelScreenModel, accountViewModel) - }, - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), - ) + }, + leadingIcon = { + KeyboardLeadingIcon(channelScreenModel, accountViewModel) + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + ) + } +} + +@Composable +fun RecipientMissingRelaysWarning() { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringRes(R.string.recipient_missing_dm_relays), + color = MaterialTheme.colorScheme.error, + fontSize = Font12SP, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } } @Composable @@ -225,7 +249,7 @@ fun KeyboardLeadingIcon( onImageChosen = channelScreenModel::pickedMedia, ) - ToggleNip17Button(channelScreenModel, accountViewModel) + Nip17Indicator(channelScreenModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt index 15c6aec34..586d71f71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ToggleNip17Button.kt @@ -25,94 +25,25 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.launch @Composable -fun ToggleNip17Button( - channelScreenModel: ChatNewMessageViewModel, - accountViewModel: AccountViewModel, -) { - var wantsToActivateNIP17 by remember { mutableStateOf(false) } - - if (wantsToActivateNIP17) { - NewFeatureNIP17AlertDialog( - accountViewModel = accountViewModel, - onConfirm = { channelScreenModel.toggleNIP04And24() }, - onDismiss = { wantsToActivateNIP17 = false }, - ) - } - +fun Nip17Indicator(channelScreenModel: ChatNewMessageViewModel) { IconButton( modifier = Modifier.width(30.dp), - onClick = { - if ( - !accountViewModel.account.settings.hideNIP17WarningDialog && - !channelScreenModel.nip17 && - !channelScreenModel.requiresNIP17 - ) { - wantsToActivateNIP17 = true - } else { - channelScreenModel.toggleNIP04And24() - } - }, + onClick = { }, + enabled = false, ) { - if (channelScreenModel.nip17) { - Icon( - painter = painterRes(R.drawable.incognito, 2), - contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), - modifier = IncognitoIconButtonModifier, - tint = MaterialTheme.colorScheme.primary, - ) - } else { - Icon( - painter = painterRes(R.drawable.incognito_off, 2), - contentDescription = stringRes(id = R.string.accessibility_turn_on_sealed_message), - modifier = IncognitoIconButtonModifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } + Icon( + painter = painterRes(R.drawable.incognito, 2), + contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), + modifier = IncognitoIconButtonModifier, + tint = MaterialTheme.colorScheme.primary, + ) } } - -@Composable -fun NewFeatureNIP17AlertDialog( - accountViewModel: AccountViewModel, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - val scope = rememberCoroutineScope() - - QuickActionAlertDialog( - title = stringRes(R.string.new_feature_nip17_might_not_be_available_title), - textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description), - buttonIconResource = R.drawable.incognito, - buttonIconReference = 3, - buttonText = stringRes(R.string.new_feature_nip17_activate), - onClickDoOnce = { - scope.launch { onConfirm() } - onDismiss() - }, - onClickDontShowAgain = { - scope.launch { - onConfirm() - accountViewModel.account.settings.setHideNIP17WarningDialog() - } - onDismiss() - }, - onDismiss = onDismiss, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index 9164a505b..17b4c5831 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -135,12 +135,6 @@ class ChatFileSender( } suspend fun sendAll(uploads: List) { - uploads.forEach { - if (it.cipher != null) { - sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) - } else { - sendNIP04(it.result, it.caption, it.contentWarningReason) - } - } + sendNIP17(uploads) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5df2fa4d1..3f186a6d5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1351,6 +1351,7 @@ Good options are:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free) Insert between 1–3 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them. Set up now + This user hasn\'t set up DM inbox relays. Messages cannot be delivered until they configure their relay list. Search Relays Set up your Search relays diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt index 9b9cc7f06..f9b1086bb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -28,13 +28,11 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub @@ -62,9 +60,6 @@ class ChatNewMessageState( private val _message = MutableStateFlow(TextFieldValue("")) val message: StateFlow = _message.asStateFlow() - private val _nip17 = MutableStateFlow(false) - val nip17: StateFlow = _nip17.asStateFlow() - private val _replyTo = MutableStateFlow(null) val replyTo: StateFlow = _replyTo.asStateFlow() @@ -74,38 +69,37 @@ class ChatNewMessageState( private val _room = MutableStateFlow(null) val room: StateFlow = _room.asStateFlow() - /** Whether NIP-17 is required (group chat with >1 recipient) */ - private val _requiresNip17 = MutableStateFlow(false) - val requiresNip17: StateFlow = _requiresNip17.asStateFlow() + /** Whether any recipients are missing DM relay lists, preventing message delivery */ + private val _recipientsMissingDmRelays = MutableStateFlow(false) + val recipientsMissingDmRelays: StateFlow = _recipientsMissingDmRelays.asStateFlow() - /** Whether a message can be sent (non-blank text + room set) */ + /** Whether a message can be sent (non-blank text + room set + all recipients have DM relays) */ val canSend: Boolean - get() = _message.value.text.isNotBlank() && _room.value != null + get() = _message.value.text.isNotBlank() && _room.value != null && !_recipientsMissingDmRelays.value /** - * Load a chatroom. Sets the room key, formats toUsers display, - * and auto-detects NIP-17 requirement (group chats require NIP-17). + * Load a chatroom. Sets the room key and checks recipient DM relay availability. */ fun load(roomKey: ChatroomKey) { _room.value = roomKey - updateNip17FromRoom() + updateRecipientRelayStatus() } /** - * Auto-detect NIP-17 based on room: - * - Group chats (>1 recipient) always require NIP-17 - * - Single recipient: NIP-17 off by default (can be toggled) + * Check if all recipients have DM relay lists. + * Messages can only be sent via NIP-17, so recipients must have + * either a DM inbox relay list (kind 10050) or NIP-65 inbox relays. */ - fun updateNip17FromRoom() { + fun updateRecipientRelayStatus() { val currentRoom = _room.value if (currentRoom != null) { - _requiresNip17.value = currentRoom.users.size > 1 - if (_requiresNip17.value) { - _nip17.value = true - } + _recipientsMissingDmRelays.value = + currentRoom.users.any { hexKey -> + val user = cache.getOrCreateUser(hexKey) as? User + user?.dmInboxRelays().isNullOrEmpty() + } } else { - _requiresNip17.value = false - _nip17.value = false + _recipientsMissingDmRelays.value = false } } @@ -126,27 +120,7 @@ class ChatNewMessageState( } /** - * Toggle NIP-04/NIP-17 mode. - * If NIP-17 is required (group chat), stays on NIP-17. - */ - fun toggleNip17() { - if (_requiresNip17.value) { - _nip17.value = true - } else { - _nip17.value = !_nip17.value - } - } - - /** - * Enable NIP-17 (e.g., when recipient has DM relay list). - */ - fun enableNip17() { - _nip17.value = true - } - - /** - * Send the current message. Builds the appropriate event template - * (NIP-04 or NIP-17) and delegates to IAccount for signing/broadcasting. + * Send the current message as NIP-17. NIP-04 is deprecated for sending. * * @return true if send was initiated, false if preconditions not met */ @@ -154,12 +128,9 @@ class ChatNewMessageState( val currentRoom = _room.value ?: return false val messageText = _message.value.text if (messageText.isBlank()) return false + if (_recipientsMissingDmRelays.value) return false - if (_nip17.value || currentRoom.users.size > 1 || _replyTo.value?.event is NIP17Group) { - sendNip17(currentRoom, messageText) - } else { - sendNip04(currentRoom, messageText) - } + sendNip17(currentRoom, messageText) return true } @@ -193,23 +164,6 @@ class ChatNewMessageState( account.sendNip17PrivateMessage(template) } - private suspend fun sendNip04( - room: ChatroomKey, - messageText: String, - ) { - val toUser = (cache.getOrCreateUser(room.users.first()) as? User)?.toPTag() ?: return - - val template = - PrivateDmEvent.build( - toUser = toUser, - message = messageText, - replyingTo = _replyTo.value?.toEventHint(), - signer = account.signer, - ) - - account.sendNip04PrivateMessage(template) - } - /** * Clear all composition state after sending or cancelling. */ 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 a5e360c1b..da3745002 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 @@ -150,25 +150,18 @@ fun ChatPane( val scope = rememberCoroutineScope() val feedState by feedViewModel.feedState.feedContent.collectAsState() val messageText by messageState.message.collectAsState() - val isNip17 by messageState.nip17.collectAsState() - val requiresNip17 by messageState.requiresNip17.collectAsState() + val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState() // File attachment state val attachedFiles = remember { mutableStateListOf() } var isUploading by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } - // Helper: attach files and auto-force NIP-17 if needed + // Helper: attach files fun attachFiles(files: List) { val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS } if (mediaFiles.isEmpty()) return attachedFiles.addAll(mediaFiles) - if (!isNip17) { - messageState.toggleNip17() - scope.launch { - snackbarHostState.showSnackbar("Switched to NIP-17 — file attachments require encrypted messaging") - } - } } // Drag-and-drop target for file attachments (NIP-17 only) @@ -346,13 +339,11 @@ fun ChatPane( // Message input MessageInput( messageText = messageText.text, - isNip17 = isNip17, - requiresNip17 = requiresNip17, + recipientsMissingRelays = recipientsMissingRelays, canSend = messageState.canSend || attachedFiles.isNotEmpty(), isUploading = isUploading, hasAttachments = attachedFiles.isNotEmpty(), onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, - onToggleNip17 = { messageState.toggleNip17() }, onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) }, onSend = { scope.launch { @@ -698,13 +689,11 @@ private fun ReactionBar(onReaction: (String) -> Unit) { @Composable private fun MessageInput( messageText: String, - isNip17: Boolean, - requiresNip17: Boolean, + recipientsMissingRelays: Boolean, canSend: Boolean, isUploading: Boolean = false, hasAttachments: Boolean = false, onMessageChange: (String) -> Unit, - onToggleNip17: () -> Unit, onAttach: () -> Unit = {}, onSend: () -> Unit, ) { @@ -773,46 +762,30 @@ private fun MessageInput( } } - // NIP-17 indicator + // NIP-17 indicator / recipient warning Spacer(Modifier.height(4.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp), ) { - IconButton( - onClick = onToggleNip17, - enabled = !requiresNip17, - modifier = Modifier.size(20.dp), - ) { - Icon( - imageVector = if (isNip17) Icons.Default.Lock else Icons.Default.LockOpen, - contentDescription = if (isNip17) "NIP-17 (encrypted)" else "NIP-04 (legacy)", - modifier = Modifier.size(16.dp), - tint = - if (isNip17) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - }, - ) - } + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "NIP-17 (encrypted)", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) Spacer(Modifier.width(4.dp)) Text( - text = if (isNip17) "NIP-17" else "NIP-04", + text = "NIP-17", style = MaterialTheme.typography.labelSmall, - color = - if (isNip17) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - }, + color = MaterialTheme.colorScheme.primary, ) - if (requiresNip17) { - Spacer(Modifier.width(4.dp)) + if (recipientsMissingRelays) { + Spacer(Modifier.width(8.dp)) Text( - text = "(required for groups)", + text = "Recipient has no DM relay list — messages cannot be delivered", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + color = MaterialTheme.colorScheme.error, ) } }