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 44d7f8c75..1d9c4dcbc 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 @@ -73,6 +73,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 @@ -88,16 +89,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 @@ -126,9 +140,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) @@ -180,9 +242,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 @@ -208,26 +267,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) { @@ -360,9 +404,6 @@ class ChatNewMessageViewModel : urlPreviews.update(message) iMetaAttachments.addAll(draftEvent.imetas()) - - requiresNIP17 = draftEvent is NIP17Group - nip17 = draftEvent is NIP17Group } suspend fun sendPostSync() { @@ -402,27 +443,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() } } } @@ -432,34 +465,26 @@ 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, - ) { - 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() } } } @@ -488,12 +513,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 @@ -512,7 +537,6 @@ class ChatNewMessageViewModel : } RetryMode.SEND -> { - if (room == null) return@launchSigner ChatFileUploader(account).justUploadNIP17Unencrypted( uploadState, onError ?: accountViewModel.toastManager::toast, @@ -528,7 +552,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()) @@ -541,70 +565,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) } } @@ -699,12 +702,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)) } } } @@ -737,9 +738,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 +781,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) @@ -795,15 +794,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..ac194c757 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 b5b38901d..57cdd853f 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 @@ -20,35 +20,50 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send -import android.R.attr.maxLines 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.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 +76,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 @@ -164,7 +183,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) + } } } @@ -209,6 +233,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, @@ -216,7 +324,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, @@ -224,8 +332,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 d5c06cfcf..e1570b75a 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( @@ -74,6 +76,6 @@ fun RoomChatFileUploadDialog( onCancel, accountViewModel, nav, - isNip17 = channelScreenModel.nip17, + isNip17 = true, ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5df2fa4d1..136d0096d 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 + 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, ) } }