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
This commit is contained in:
Claude
2026-03-19 15:22:13 +00:00
parent 78ffb9fce4
commit 695d3c7a23
9 changed files with 195 additions and 362 deletions
@@ -92,10 +92,10 @@ fun ChatroomView(
} }
} }
if (room.users.size == 1) { // Reactively check if recipients have DM relays for NIP-17 delivery
// Activates NIP-17 if the user has DM relays for (userHex in room.users) {
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) { ObserveRelayListForDMs(pubkey = userHex, accountViewModel = accountViewModel) {
newPostModel.nip17 = !it?.relays().isNullOrEmpty() newPostModel.updateRecipientRelayStatus()
} }
} }
@@ -88,7 +88,6 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
@@ -128,7 +127,8 @@ class ChatNewMessageViewModel :
var room: ChatroomKey? by mutableStateOf(null) 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<Note?>(null) val replyTo = mutableStateOf<Note?>(null)
@@ -180,8 +180,8 @@ class ChatNewMessageViewModel :
var wantsZapraiser by mutableStateOf(false) var wantsZapraiser by mutableStateOf(false)
override var zapRaiserAmount = mutableStateOf<Long?>(null) override var zapRaiserAmount = mutableStateOf<Long?>(null)
// NIP17 Wrapped DMs / Group messages // NIP17 is always enabled - NIP-04 is deprecated for sending
var nip17 by mutableStateOf(false) val nip17: Boolean get() = true
fun lnAddress(): String? = account.userProfile().lnAddress() fun lnAddress(): String? = account.userProfile().lnAddress()
@@ -214,19 +214,19 @@ class ChatNewMessageViewModel :
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
) )
updateNIP17StatusFromRoom() updateRecipientRelayStatus()
} }
fun updateNIP17StatusFromRoom() { fun updateRecipientRelayStatus() {
val room = this.room val room = this.room
if (room != null) { if (room != null) {
this.requiresNIP17 = room.users.size > 1 this.recipientsMissingDmRelays =
if (this.requiresNIP17) { room.users.any { hexKey ->
this.nip17 = true val user = LocalCache.getOrCreateUser(hexKey)
} user.dmInboxRelays().isNullOrEmpty()
}
} else { } else {
this.requiresNIP17 = false this.recipientsMissingDmRelays = false
this.nip17 = false
} }
} }
@@ -361,8 +361,7 @@ class ChatNewMessageViewModel :
iMetaAttachments.addAll(draftEvent.imetas()) iMetaAttachments.addAll(draftEvent.imetas())
requiresNIP17 = draftEvent is NIP17Group updateRecipientRelayStatus()
nip17 = draftEvent is NIP17Group
} }
suspend fun sendPostSync() { suspend fun sendPostSync() {
@@ -402,27 +401,19 @@ class ChatNewMessageViewModel :
val uploadState = uploadState ?: return val uploadState = uploadState ?: return
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { ChatFileUploader(account).justUploadNIP17(
ChatFileUploader(account).justUploadNIP17( uploadState,
uploadState, onError,
onError, onEncryptedUploadError = { title, message ->
onEncryptedUploadError = { title, message -> encryptedUploadErrorTitle = title
encryptedUploadErrorTitle = title encryptedUploadErrorMessage = message
encryptedUploadErrorMessage = message pendingRetryMode = RetryMode.HOLD
pendingRetryMode = RetryMode.HOLD },
}, context,
context, ) {
) { uploadsWaitingToBeSent += it
uploadsWaitingToBeSent += it draftTag.newVersion()
draftTag.newVersion() onceUploaded()
onceUploaded()
}
} else {
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded()
}
} }
} }
} }
@@ -436,30 +427,22 @@ class ChatNewMessageViewModel :
val uploadState = uploadState ?: return val uploadState = uploadState ?: return
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { ChatFileUploader(account).justUploadNIP17(
ChatFileUploader(account).justUploadNIP17( uploadState,
uploadState, onError,
onError, onEncryptedUploadError = { title, message ->
onEncryptedUploadError = { title, message -> encryptedUploadErrorTitle = title
encryptedUploadErrorTitle = title encryptedUploadErrorMessage = message
encryptedUploadErrorMessage = message pendingRetryMode = RetryMode.SEND
pendingRetryMode = RetryMode.SEND pendingRetryOnError = onError
pendingRetryOnError = onError pendingRetryContext = context
pendingRetryContext = context pendingRetryOnceUploaded = onceUploaded
pendingRetryOnceUploaded = onceUploaded },
}, context,
context, ) {
) { ChatFileSender(room, account).sendNIP17(it)
ChatFileSender(room, account).sendNIP17(it) draftTag.newVersion()
draftTag.newVersion() onceUploaded()
onceUploaded()
}
} else {
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) {
ChatFileSender(room, account).sendNIP04(it)
draftTag.newVersion()
onceUploaded()
}
} }
} }
} }
@@ -541,70 +524,49 @@ class ChatNewMessageViewModel :
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() 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<BaseDMGroupEvent>()
val replyHint = replyTo.value?.toEventHint<BaseDMGroupEvent>()
val template = val template =
if (replyHint == null) { if (replyHint == null) {
ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) { ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) {
hashtags(findHashtags(message)) hashtags(findHashtags(message))
references(findURLs(message)) references(findURLs(message))
quotes(findNostrEventUris(message)) quotes(findNostrEventUris(message))
geoHash?.let { geohash(it) } geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) } localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) } zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(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<PrivateDmEvent>(),
signer = accountViewModel.account.signer,
) {
localExpirationDate?.let { expiration(it) } localExpirationDate?.let { expiration(it) }
}
if (draftTag != null) { emojis(emojis)
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template) imetas(usedAttachments)
}
} else { } 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) { 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 } val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
if (users.isNullOrEmpty()) { if (users.isNullOrEmpty()) {
room = null room = null
updateNIP17StatusFromRoom() updateRecipientRelayStatus()
} else { } else {
if (users != room?.users) { if (users != room?.users) {
room = ChatroomKey(users) room = ChatroomKey(users)
updateNIP17StatusFromRoom() updateRecipientRelayStatus()
} }
} }
} }
@@ -737,9 +699,6 @@ class ChatNewMessageViewModel :
val lastWord = toUsers.currentWord() val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
updateRoomFromUsersInput() updateRoomFromUsersInput()
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm()
nip17 = relayList != null
} }
userSuggestionsMainMessage = null userSuggestionsMainMessage = null
@@ -783,7 +742,8 @@ class ChatNewMessageViewModel :
!wantsInvoice && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) && (!wantsZapraiser || zapRaiserAmount.value != null) &&
(toUsers.text.isNotBlank()) && (toUsers.text.isNotBlank()) &&
uploadState?.multiOrchestrator == null uploadState?.multiOrchestrator == null &&
!recipientsMissingDmRelays
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message = message.insertUrlAtCursor(newElement)
@@ -795,15 +755,7 @@ class ChatNewMessageViewModel :
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
} }
fun toggleNIP04And24() { // NIP-04 sending is deprecated. NIP-17 is always used.
if (requiresNIP17) {
nip17 = true
} else {
nip17 = !nip17
}
draftTag.newVersion()
}
override fun updateZapPercentage( override fun updateZapPercentage(
index: Int, index: Int,
@@ -332,6 +332,10 @@ fun GroupDMScreenContent(
) )
} }
if (postViewModel.recipientsMissingDmRelays) {
RecipientMissingRelaysWarning()
}
BottomRowActions(postViewModel, accountViewModel) BottomRowActions(postViewModel, accountViewModel)
} }
} }
@@ -551,7 +555,7 @@ fun SendDirectMessageTo(
), ),
) )
ToggleNip17Button(postViewModel, accountViewModel) Nip17Indicator(postViewModel)
} }
HorizontalDivider(thickness = DividerThickness) HorizontalDivider(thickness = DividerThickness)
@@ -174,39 +174,63 @@ fun EditField(
onSendNewMessage: () -> Unit, onSendNewMessage: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
ThinPaddingTextField( if (channelScreenModel.recipientsMissingDmRelays) {
value = channelScreenModel.message, RecipientMissingRelaysWarning()
onValueChange = { channelScreenModel.updateMessage(it) }, } else {
keyboardOptions = PostKeyboard, ThinPaddingTextField(
shape = EditFieldBorder, value = channelScreenModel.message,
modifier = Modifier.fillMaxWidth(), onValueChange = { channelScreenModel.updateMessage(it) },
placeholder = { keyboardOptions = PostKeyboard,
Text( shape = EditFieldBorder,
text = stringRes(R.string.reply_here), modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.placeholderText, placeholder = {
) Text(
}, text = stringRes(R.string.reply_here),
trailingIcon = { color = MaterialTheme.colorScheme.placeholderText,
ThinSendButton( )
isActive = channelScreenModel.canPost(), },
modifier = EditFieldTrailingIconModifier, trailingIcon = {
) { ThinSendButton(
accountViewModel.launchSigner { isActive = channelScreenModel.canPost(),
channelScreenModel.sendPostSync() modifier = EditFieldTrailingIconModifier,
onSendNewMessage() ) {
accountViewModel.launchSigner {
channelScreenModel.sendPostSync()
onSendNewMessage()
}
} }
} },
}, leadingIcon = {
leadingIcon = { KeyboardLeadingIcon(channelScreenModel, accountViewModel)
KeyboardLeadingIcon(channelScreenModel, accountViewModel) },
}, colors =
colors = TextFieldDefaults.colors(
TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, ),
), visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
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 @Composable
@@ -225,7 +249,7 @@ fun KeyboardLeadingIcon(
onImageChosen = channelScreenModel::pickedMedia, onImageChosen = channelScreenModel::pickedMedia,
) )
ToggleNip17Button(channelScreenModel, accountViewModel) Nip17Indicator(channelScreenModel)
} }
} }
@@ -25,94 +25,25 @@ 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
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.painterRes 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
@Composable @Composable
fun ToggleNip17Button( fun Nip17Indicator(channelScreenModel: ChatNewMessageViewModel) {
channelScreenModel: ChatNewMessageViewModel,
accountViewModel: AccountViewModel,
) {
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
if (wantsToActivateNIP17) {
NewFeatureNIP17AlertDialog(
accountViewModel = accountViewModel,
onConfirm = { channelScreenModel.toggleNIP04And24() },
onDismiss = { wantsToActivateNIP17 = false },
)
}
IconButton( IconButton(
modifier = Modifier.width(30.dp), modifier = Modifier.width(30.dp),
onClick = { onClick = { },
if ( enabled = false,
!accountViewModel.account.settings.hideNIP17WarningDialog &&
!channelScreenModel.nip17 &&
!channelScreenModel.requiresNIP17
) {
wantsToActivateNIP17 = true
} else {
channelScreenModel.toggleNIP04And24()
}
},
) { ) {
if (channelScreenModel.nip17) { Icon(
Icon( painter = painterRes(R.drawable.incognito, 2),
painter = painterRes(R.drawable.incognito, 2), contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message),
contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), modifier = IncognitoIconButtonModifier,
modifier = IncognitoIconButtonModifier, tint = MaterialTheme.colorScheme.primary,
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,
)
}
} }
} }
@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,
)
}
@@ -135,12 +135,6 @@ class ChatFileSender(
} }
suspend fun sendAll(uploads: List<SuccessfulUploads>) { suspend fun sendAll(uploads: List<SuccessfulUploads>) {
uploads.forEach { sendNIP17(uploads)
if (it.cipher != null) {
sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher)
} else {
sendNIP04(it.result, it.caption, it.contentWarningReason)
}
}
} }
} }
+1
View File
@@ -1351,6 +1351,7 @@
<string name="dm_relays_not_found_examples2">Good options are:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free)</string> <string name="dm_relays_not_found_examples2">Good options are:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free)</string>
<string name="dm_relays_not_found_editing">Insert between 13 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them.</string> <string name="dm_relays_not_found_editing">Insert between 13 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them.</string>
<string name="dm_relays_not_found_create_now">Set up now</string> <string name="dm_relays_not_found_create_now">Set up now</string>
<string name="recipient_missing_dm_relays">This user hasn\'t set up DM inbox relays. Messages cannot be delivered until they configure their relay list.</string>
<string name="search_relays_title">Search Relays</string> <string name="search_relays_title">Search Relays</string>
<string name="search_relays_not_found">Set up your Search relays</string> <string name="search_relays_not_found">Set up your Search relays</string>
@@ -28,13 +28,11 @@ 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.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references 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.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey 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.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
@@ -62,9 +60,6 @@ class ChatNewMessageState(
private val _message = MutableStateFlow(TextFieldValue("")) private val _message = MutableStateFlow(TextFieldValue(""))
val message: StateFlow<TextFieldValue> = _message.asStateFlow() val message: StateFlow<TextFieldValue> = _message.asStateFlow()
private val _nip17 = MutableStateFlow(false)
val nip17: StateFlow<Boolean> = _nip17.asStateFlow()
private val _replyTo = MutableStateFlow<Note?>(null) private val _replyTo = MutableStateFlow<Note?>(null)
val replyTo: StateFlow<Note?> = _replyTo.asStateFlow() val replyTo: StateFlow<Note?> = _replyTo.asStateFlow()
@@ -74,38 +69,37 @@ class ChatNewMessageState(
private val _room = MutableStateFlow<ChatroomKey?>(null) private val _room = MutableStateFlow<ChatroomKey?>(null)
val room: StateFlow<ChatroomKey?> = _room.asStateFlow() val room: StateFlow<ChatroomKey?> = _room.asStateFlow()
/** Whether NIP-17 is required (group chat with >1 recipient) */ /** Whether any recipients are missing DM relay lists, preventing message delivery */
private val _requiresNip17 = MutableStateFlow(false) private val _recipientsMissingDmRelays = MutableStateFlow(false)
val requiresNip17: StateFlow<Boolean> = _requiresNip17.asStateFlow() val recipientsMissingDmRelays: StateFlow<Boolean> = _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 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, * Load a chatroom. Sets the room key and checks recipient DM relay availability.
* and auto-detects NIP-17 requirement (group chats require NIP-17).
*/ */
fun load(roomKey: ChatroomKey) { fun load(roomKey: ChatroomKey) {
_room.value = roomKey _room.value = roomKey
updateNip17FromRoom() updateRecipientRelayStatus()
} }
/** /**
* Auto-detect NIP-17 based on room: * Check if all recipients have DM relay lists.
* - Group chats (>1 recipient) always require NIP-17 * Messages can only be sent via NIP-17, so recipients must have
* - Single recipient: NIP-17 off by default (can be toggled) * either a DM inbox relay list (kind 10050) or NIP-65 inbox relays.
*/ */
fun updateNip17FromRoom() { fun updateRecipientRelayStatus() {
val currentRoom = _room.value val currentRoom = _room.value
if (currentRoom != null) { if (currentRoom != null) {
_requiresNip17.value = currentRoom.users.size > 1 _recipientsMissingDmRelays.value =
if (_requiresNip17.value) { currentRoom.users.any { hexKey ->
_nip17.value = true val user = cache.getOrCreateUser(hexKey) as? User
} user?.dmInboxRelays().isNullOrEmpty()
}
} else { } else {
_requiresNip17.value = false _recipientsMissingDmRelays.value = false
_nip17.value = false
} }
} }
@@ -126,27 +120,7 @@ class ChatNewMessageState(
} }
/** /**
* Toggle NIP-04/NIP-17 mode. * Send the current message as NIP-17. NIP-04 is deprecated for sending.
* 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.
* *
* @return true if send was initiated, false if preconditions not met * @return true if send was initiated, false if preconditions not met
*/ */
@@ -154,12 +128,9 @@ class ChatNewMessageState(
val currentRoom = _room.value ?: return false val currentRoom = _room.value ?: return false
val messageText = _message.value.text val messageText = _message.value.text
if (messageText.isBlank()) return false 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)
sendNip17(currentRoom, messageText)
} else {
sendNip04(currentRoom, messageText)
}
return true return true
} }
@@ -193,23 +164,6 @@ class ChatNewMessageState(
account.sendNip17PrivateMessage(template) 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<PrivateDmEvent>(),
signer = account.signer,
)
account.sendNip04PrivateMessage(template)
}
/** /**
* Clear all composition state after sending or cancelling. * Clear all composition state after sending or cancelling.
*/ */
@@ -150,25 +150,18 @@ fun ChatPane(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val feedState by feedViewModel.feedState.feedContent.collectAsState() val feedState by feedViewModel.feedState.feedContent.collectAsState()
val messageText by messageState.message.collectAsState() val messageText by messageState.message.collectAsState()
val isNip17 by messageState.nip17.collectAsState() val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState()
val requiresNip17 by messageState.requiresNip17.collectAsState()
// File attachment state // File attachment state
val attachedFiles = remember { mutableStateListOf<File>() } val attachedFiles = remember { mutableStateListOf<File>() }
var isUploading by remember { mutableStateOf(false) } var isUploading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Helper: attach files and auto-force NIP-17 if needed // Helper: attach files
fun attachFiles(files: List<File>) { fun attachFiles(files: List<File>) {
val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS } val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS }
if (mediaFiles.isEmpty()) return if (mediaFiles.isEmpty()) return
attachedFiles.addAll(mediaFiles) 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) // Drag-and-drop target for file attachments (NIP-17 only)
@@ -346,13 +339,11 @@ fun ChatPane(
// Message input // Message input
MessageInput( MessageInput(
messageText = messageText.text, messageText = messageText.text,
isNip17 = isNip17, recipientsMissingRelays = recipientsMissingRelays,
requiresNip17 = requiresNip17,
canSend = messageState.canSend || attachedFiles.isNotEmpty(), canSend = messageState.canSend || attachedFiles.isNotEmpty(),
isUploading = isUploading, isUploading = isUploading,
hasAttachments = attachedFiles.isNotEmpty(), hasAttachments = attachedFiles.isNotEmpty(),
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
onToggleNip17 = { messageState.toggleNip17() },
onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) }, onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) },
onSend = { onSend = {
scope.launch { scope.launch {
@@ -698,13 +689,11 @@ private fun ReactionBar(onReaction: (String) -> Unit) {
@Composable @Composable
private fun MessageInput( private fun MessageInput(
messageText: String, messageText: String,
isNip17: Boolean, recipientsMissingRelays: Boolean,
requiresNip17: Boolean,
canSend: Boolean, canSend: Boolean,
isUploading: Boolean = false, isUploading: Boolean = false,
hasAttachments: Boolean = false, hasAttachments: Boolean = false,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
onToggleNip17: () -> Unit,
onAttach: () -> Unit = {}, onAttach: () -> Unit = {},
onSend: () -> Unit, onSend: () -> Unit,
) { ) {
@@ -773,46 +762,30 @@ private fun MessageInput(
} }
} }
// NIP-17 indicator // NIP-17 indicator / recipient warning
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp), modifier = Modifier.padding(start = 4.dp),
) { ) {
IconButton( Icon(
onClick = onToggleNip17, imageVector = Icons.Default.Lock,
enabled = !requiresNip17, contentDescription = "NIP-17 (encrypted)",
modifier = Modifier.size(20.dp), modifier = Modifier.size(16.dp),
) { tint = MaterialTheme.colorScheme.primary,
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)
},
)
}
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text( Text(
text = if (isNip17) "NIP-17" else "NIP-04", text = "NIP-17",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color = MaterialTheme.colorScheme.primary,
if (isNip17) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
) )
if (requiresNip17) { if (recipientsMissingRelays) {
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
text = "(required for groups)", text = "Recipient has no DM relay list — messages cannot be delivered",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.error,
) )
} }
} }