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..358247a94 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 @@ -26,14 +26,16 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView @@ -44,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.Privat import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.launch @Composable @@ -92,10 +95,15 @@ 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) { + LoadAddressableNote( + ChatMessageRelayListEvent.createAddress(userHex), + accountViewModel, + ) { note -> + if (note != null) { + EventFinderFilterAssemblerSubscription(note, accountViewModel) + } } } 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 5c9569a7c..19e917bd7 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 @@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji @@ -89,16 +90,29 @@ 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 import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch @Stable @@ -127,9 +141,57 @@ class ChatNewMessageViewModel : } } - var room: ChatroomKey? by mutableStateOf(null) + val room = MutableStateFlow(null) - var requiresNIP17: Boolean = false + val roomUsers: StateFlow> = + room + .mapNotNull { + it?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList() + }.flowOn(Dispatchers.IO) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + room.value?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val recipientsMissingDmRelays: StateFlow> = + roomUsers + .transformLatest { + val dmRelayListNoteFlows = + it.map { user -> + user.dmRelayListNote + .flow() + .metadata.stateFlow + } + + if (dmRelayListNoteFlows.isEmpty()) { + emitAll(MutableStateFlow(persistentListOf())) + } else { + val flow = + combine(dmRelayListNoteFlows) { dmRelayListNotes -> + dmRelayListNotes + .mapNotNull { noteState -> + val noteEvent = noteState.note.event as? ChatMessageRelayListEvent + if (noteEvent == null || noteEvent.relays().isEmpty()) { + noteState.note.author + } else { + null + } + }.toImmutableList() + } + emitAll(flow) + } + }.onStart { + }.onCompletion { + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + roomUsers.value + .filter { user -> + user.dmInboxRelays().isNullOrEmpty() + }.toImmutableList(), + ) val replyTo = mutableStateOf(null) @@ -185,9 +247,6 @@ class ChatNewMessageViewModel : var wantsZapraiser by mutableStateOf(false) override var zapRaiserAmount = mutableStateOf(null) - // NIP17 Wrapped DMs / Group messages - var nip17 by mutableStateOf(false) - fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -214,26 +273,11 @@ class ChatNewMessageViewModel : } fun load(room: ChatroomKey) { - this.room = room + this.room.tryEmit(room) this.toUsers = TextFieldValue( room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, ) - - updateNIP17StatusFromRoom() - } - - fun updateNIP17StatusFromRoom() { - val room = this.room - if (room != null) { - this.requiresNIP17 = room.users.size > 1 - if (this.requiresNIP17) { - this.nip17 = true - } - } else { - this.requiresNIP17 = false - this.nip17 = false - } } fun reply(replyNote: Note) { @@ -366,9 +410,6 @@ class ChatNewMessageViewModel : urlPreviews.update(message) iMetaAttachments.addAll(draftEvent.imetas()) - - requiresNIP17 = draftEvent is NIP17Group - nip17 = draftEvent is NIP17Group } suspend fun sendPostSync() { @@ -408,28 +449,20 @@ 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, - onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, - ) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() - } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, onError, context, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation) { - uploadsWaitingToBeSent += it - draftTag.newVersion() - onceUploaded() - } + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.HOLD + }, + context, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded() } } } @@ -439,35 +472,27 @@ class ChatNewMessageViewModel : context: Context, onceUploaded: () -> Unit, ) { - val room = room ?: return + val room = room.value ?: return 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, - onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, - ) { - ChatFileSender(room, account).sendNIP17(it) - draftTag.newVersion() - onceUploaded() - } - } else { - ChatFileUploader(account).justUploadNIP04(uploadState, onError, context, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation) { - 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, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) { + ChatFileSender(room, account).sendNIP17(it) + draftTag.newVersion() + onceUploaded() } } } @@ -496,12 +521,12 @@ class ChatNewMessageViewModel : val onError = pendingRetryOnError val context = pendingRetryContext val onceUploaded = pendingRetryOnceUploaded - val room = room + val room = room.value val uploadState = uploadState dismissEncryptedUploadError() - if (uploadState == null || context == null) return + if (room == null || uploadState == null || context == null) return uploadState.encryptFiles = false @@ -521,7 +546,6 @@ class ChatNewMessageViewModel : } RetryMode.SEND -> { - if (room == null) return@launchSigner ChatFileUploader(account).justUploadNIP17Unencrypted( uploadState, onError ?: accountViewModel.toastManager::toast, @@ -538,7 +562,7 @@ class ChatNewMessageViewModel : } private suspend fun innerSendPost(draftTag: String?) { - val room = room ?: return + val room = room.value ?: return val urls = findURLs(message.text) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -551,70 +575,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) } } @@ -709,12 +712,10 @@ class ChatNewMessageViewModel : val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } if (users.isNullOrEmpty()) { - room = null - updateNIP17StatusFromRoom() + room.emit(null) } else { - if (users != room?.users) { - room = ChatroomKey(users) - updateNIP17StatusFromRoom() + if (users != room.value?.users) { + room.emit(ChatroomKey(users)) } } } @@ -747,9 +748,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 @@ -793,7 +791,8 @@ class ChatNewMessageViewModel : !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && (toUsers.text.isNotBlank()) && - uploadState?.multiOrchestrator == null + uploadState?.multiOrchestrator == null && + recipientsMissingDmRelays.value.isEmpty() fun insertAtCursor(newElement: String) { message = message.insertUrlAtCursor(newElement) @@ -805,15 +804,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 3509b2450..5d3a7ba1d 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 @@ -197,7 +197,7 @@ fun NewGroupDMScreen( // function when the postViewModel is released accountViewModel.launchSigner { postViewModel.sendPostSync() - postViewModel.room?.let { + postViewModel.room.value?.let { nav.nav(routeToMessage(it, null, null, null, null, accountViewModel)) } } @@ -332,6 +332,11 @@ fun GroupDMScreenContent( ) } + val missingRelays by postViewModel.recipientsMissingDmRelays.collectAsStateWithLifecycle() + if (missingRelays.isNotEmpty()) { + RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav) + } + BottomRowActions(postViewModel, accountViewModel) } } @@ -393,7 +398,9 @@ private fun BottomRowActions( .height(50.dp), verticalAlignment = CenterVertically, ) { - if (postViewModel.room != null) { + val room by postViewModel.room.collectAsStateWithLifecycle() + + if (room != null) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, enabled = !postViewModel.isUploadingFile, @@ -424,7 +431,7 @@ private fun BottomRowActions( } } - if (postViewModel.room != null) { + if (room != null) { TakePictureButton( onPictureTaken = { postViewModel.pickedMedia(it) }, ) @@ -443,7 +450,7 @@ private fun BottomRowActions( } } - if (postViewModel.room != null) { + if (room != null) { TakeVideoButton( onVideoTaken = { postViewModel.pickedMedia(it) }, ) @@ -550,8 +557,6 @@ fun SendDirectMessageTo( focusedBorderColor = Color.Transparent, ), ) - - ToggleNip17Button(postViewModel, accountViewModel) } 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 130c88e52..9ef4170ff 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 @@ -21,34 +21,50 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.input.InputTransformation.Companion.keyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog @@ -61,9 +77,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.PostKeyboard +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Preview @Composable @@ -166,7 +186,12 @@ fun PrivateMessageEditFieldRow( } } - EditField(channelScreenModel, onSendNewMessage, accountViewModel) + val missingRelays by channelScreenModel.recipientsMissingDmRelays.collectAsStateWithLifecycle() + if (missingRelays.isNotEmpty()) { + RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav) + } else { + EditField(channelScreenModel, onSendNewMessage, accountViewModel) + } } } @@ -211,6 +236,90 @@ fun EditField( ) } +@Preview +@Composable +fun RecipientMissingRelaysWarningPreview() { + val user1 = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val user2 = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + + ThemeComparisonColumn { + RecipientMissingRelaysWarning( + persistentListOf(user1, user2), + mockAccountViewModel(), + EmptyNav(), + ) + } +} + +@Composable +fun RecipientMissingRelaysWarning( + users: ImmutableList, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy10dp, + ) { + UserGallery(users) { user -> + ClickableUserPicture( + user, + Size25dp, + accountViewModel, + onClick = { + nav.nav { routeFor(user) } + }, + ) + } + + Text( + text = stringRes(R.string.recipient_missing_dm_relays), + color = MaterialTheme.colorScheme.error, + fontSize = Font12SP, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +fun UserGallery( + users: ImmutableList, + galleryUser: @Composable RowScope.(user: User) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((-10).dp), + ) { + users.take(6).forEach { + key(it.pubkeyHex) { + galleryUser(it) + } + } + + if (users.size > 6) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(Size25dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "+" + showCount(users.size - 6), + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + @Composable fun KeyboardLeadingIcon( channelScreenModel: ChatNewMessageViewModel, @@ -218,7 +327,7 @@ fun KeyboardLeadingIcon( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 4.dp, end = 10.dp), + modifier = Modifier.padding(start = 4.dp, end = 4.dp), ) { SelectFromGallery( isUploading = channelScreenModel.isUploadingImage, @@ -226,8 +335,6 @@ fun KeyboardLeadingIcon( modifier = Modifier, onImageChosen = channelScreenModel::pickedMedia, ) - - ToggleNip17Button(channelScreenModel, accountViewModel) } } 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..4337bffa8 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 @@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -100,47 +99,7 @@ class ChatFileSender( account.sendNip17PrivateMessage(template) } - // ------ - // NIP 04 - // ------ - - suspend fun sendNIP04(uploads: List) { - uploads.forEach { - if (it.cipher == null) { - sendNIP04(it.result, it.caption, it.contentWarningReason) - } - } - } - - suspend fun sendNIP04( - result: UploadOrchestrator.OrchestratorResult.ServerResult, - caption: String?, - contentWarningReason: String?, - ) { - val iMetaAttachments = IMetaAttachments() - iMetaAttachments.add(result, caption, contentWarningReason) - - val toUser = chatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() } - - val template = - PrivateDmEvent.build( - toUser = toUser, - message = result.url, - imetas = iMetaAttachments.iMetaAttachments, - replyingTo = null, - signer = account.signer, - ) - - account.sendNip04PrivateMessage(template) - } - 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/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index efafb483c..da5dbbd4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -46,7 +47,8 @@ fun RoomChatFileUploadDialog( accountViewModel: AccountViewModel, nav: INav, ) { - val room = channelScreenModel.room ?: return + val roomState = channelScreenModel.room.collectAsStateWithLifecycle() + val room = roomState.value ?: return val context = LocalContext.current ChatFileUploadDialog( @@ -75,6 +77,6 @@ fun RoomChatFileUploadDialog( onCancel, accountViewModel, nav, - isNip17 = channelScreenModel.nip17, + isNip17 = true, ) } diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index b63b16724..6ad5be5c8 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1532,8 +1532,20 @@ Obserwujesz %1$d kont(a)… "Wprowadź profil znajomego lub lidera społeczności. Możesz użyć ich npub-u, adresu NIP-05 lub nazwy jak alicja@domena.pl lub id/alicja dla zweryfikowanych przez blockchain tożsamości." Wybierz Wszystkie + To narzędzie skanuje wszystkie transmitery, z którymi zetknęła się Twoja aplikacja, i przekierowuje zdarzenia do właściwych miejsc docelowych: + Pobierz wszystkie wydarzenia, których jesteś autorem, i wyślij je do transmiterów nadawczych. + Pobierz wszystkie wydarzenia, w których jesteś wymieniony, i prześlij je do swoich transmiterów odbiorczych. + Pobierz wszystkie bezpośrednie wiadomości adresowane do Ciebie i wyślij je do swoich transmiterów DM. + ⚠️ Wygląda na to, że korzystasz z połączenia komórkowego lub z limitem transferu danych. Ta operacja może wymagać przesłania bardzo dużej ilości danych. Przed rozpoczęciem podłącz się do sieci Wi-Fi. + Włączyć transmisje danych? + Pauza + Wznów + Zacznij od początku + Anuluj + Transmitery: %1$d / %2$d wydarzenia profile ustawienia transmiterów Ostatnio widziano %1$s temu + Błąd diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index e4e1ee3bf..66d0dbf3b 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -1328,4 +1328,202 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem in %1$d ostalih Odstrani %1$s + Zvočna glava + Zvočni zapis + Nagradne značke + Definicija značke + Značke profila + Blokirani releji + Blossom strežniki + Blossom avtentikacija + Oddajni releji + Seznam zaznamkov + Dnevni termin + Koledar + Sestanek + Potrditev termina + Šahovske igre + Šahovska avtentikacija + Priljubljeni releji + Šahovski izzivi + Sprejmi partijo + Šahovska poteza + Konec partije + Predlagaj remi + Definicija kanala + Skrij sporočilo v kanalu + Seznam kanalov + Sporočilo v kanalu + Metapodatki kanala + Utišani uporabniki kanala + Datoteka ZS + ZS sporočilo + Releji ZS + Oglasi + Komentarji + Opis skupnosti + Seznam skupnosti + Objava skupnosti + Seznam sledenih + Izbrisano + Osnutki + Zbirke emojijev + Pregled zbirk emojijev + Minljivi klepet + Minljive klepetalnice + Glave datotek + Galerija profila + Datotečni strežniki + Blob podatki + Blob glava + Zdravstveni podatki + Paketi za sledenje + Ponovno preposlani (16) + Sledeni geohash-i + GiftWraps + Git zadeva + Git popravek + Git repozitorij + Git odgovor + Ciljni znesek zapov + Sledeni ključniki + Osvetlitve + Http Auth + Indeksiraj seznam relejev + Pustolovski prolog + Pustolovska scena + Pustolovsko branje + Poimenovani zaznamki + Pogovori v živo + Prenosi v živo + Zapi + NWC zahtevek + NWC odgovor + Zasebni zapi + Zap zahtevek + Blogi + Sejna soba + Prisotnost v sobi + Srečevalnica + Profil + Seznam utišanih + NNS + NIP + Nostr Connect + DVM status + DVM zahtevek vsebine + DVM odgovor na vsebino + DVM zahtevek uporabnika + DVM odgovor uporabnika + OTS + Plačaj + Seznam oseb + Fotografije + Bucike + Zap sklad + Glasovanje + Odziv na glasovanje + NIP-04 ZS + Zasebni releji + Proxy releji + Javno sporočilo + Odzivne ikone + Kartica kontakta + Autorizacija releja + Odkrivanje relejev + Obvestilo nadzornika relejev + Nabor relejev + Prijave + Ponovne objave + Brisanje uporabnika + Pečat + Iskalni releji + Status uporabnika + Zapiski + Urejeni + Torenti + Komentarji torentov + Zaupanja vredni releji + Zaupanja vredni ponudniki + Videoposnetki (odgovor) + Kratki posnetki (odgovor) + Videoposnetki + Kratki videoposnetki + Zvočno sporočilo + Zvočni odgovori + Wiki + Zagotovite si odličen vir objav tako, da sledite istim ljudem kot nekdo, ki mu zaupate. + Uvozi seznam sledenih + Izberite uporabnike, ki jim želite slediti + Profil, iz katerega želite uvoziti + Iskanje, npub1…, janez@primer.si + Podpira npub, nprofile, NIP-05, hex in namecoin (.bit, d/, id/) + Poišči seznam sledenih + Daj napitnino + Najdeno %1$d računov + Izbranih %1$d + Razrešeno prek Namecoina + Zdaj sledite %1$d računom + Vaš vsebina objav je pripravljen. + Preskoči + Poišči še koga + Sledi %1$d računom + Dodaten uvoz + Nadaljuj + Preskoči za zdaj + Razreševanje %1$s… + Pridobivanje seznama sledenih… + Ni najdenih sledilcev + Sledenje %1$d računom… + "Vnesite profil prijatelja ali vodje skupnosti. Uporabite lahko njihov npub, naslov NIP-05 ali ime Namecoin (npr. janez@primer.bit ali id/janez) za identitete, preverjene prek verige blokov." + Izberi vse + %1$d%% časa delovanja + Namecoin nastavitve + Sinhronizacija relejev + Sinhronizacija relejev + Ponovno objavite svoje dogodke na vseh znanih relejih, da posodobite svoje izhodne, vhodne in releje ZS. Zahtevana je povezava Wi-Fi — postopek lahko porabi veliko podatkov. + Odpri sinhronizacijo relejev… + Kaj to naredi + To orodje pregleda vse releje, ki jih je zaznala vaša aplikacija, in vaše dogodke prerazporedi na ustrezne cilje: + Prenesite vse dogodke katere ste ustvarili, in jih pošljite v vaše izhodne releje. + Prenesite vse dogodke, v katerih ste omenjeni, in jih pošljite v vaše vhodne releje. + Prenesite vsa zasebna sporočila, naslovljena na vas, in jih pošljite v svoje releje za zasebna sporočila. + ⚠ Kaže, da uporabljate omejeno ali mobilno povezavo. Ta postopek lahko prenese zelo veliko količino podatkov. Pred začetkom se povežite v omrežje Wi-Fi. + Uporabi mobilne podatke? + Začni sinhronizacijo + Vseeno začni (mobilni podatki) + Premor + Nadaljuj + Začni znova + Prekini + Releji: %1$d / %2$d + Prerazporejeni dogodki: %1$d novih od %2$d poslanih in %3$d prejetih + Premor sinhronizacije + Zaključenih %1$d od %2$d relejev — do zdaj prerazporejenih %3$d dogodkov. Dotaknite se »Nadaljuj« za nadaljevanje. + Sinhronizacija zaključena + Naprej posredovanih %1$d od %2$d prejetih dogodkov do ciljnih relejev. + Število dogodkov, ki jih je ciljni rele sprejel kot nove: %1$d. + Zaključeno v %1$d sekundah + Napaka pri sinhronizaciji + Pošiljam k + Odhodni + Vhodni + ZS + Trenutno preverjam (%1$d relejev) + Finished (%1$d relays) + poslano %1$s + sprejeto %1$s + nov %1$s + ni dogodkov + Bitcoin Raziskovalec (OTS) + dogodki + ZS + profili + Nastavitve releja + Nazadnje viden pred %1$s + <%1$s + Povezujem + Prenašam + Napaka + Zaključeno diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 85c1e1bbf..d0f119791 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1359,6 +1359,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 + DM inbox relays not found. 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, ) } }