diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 3cd87d96..d8d5d386 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -4,15 +4,19 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.BottomAppBarDefaults @@ -157,6 +161,49 @@ fun ChatRoomMessagingScreen( .background(BottomAppBarDefaults.containerColor) .navigationBarsPadding() ) { + // An armed composer has to be impossible to miss. The whole risk + // of this feature is somebody sending to the room what they meant + // for one person, or the reverse, and by the time either is on the + // wire it cannot be taken back. Hence three signals at once: the + // chip, the placeholder, and the tinted field. + val directMessageRecipient = chatMessageListViewModel.directMessageRecipient + + if (directMessageRecipient != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + modifier = Modifier.weight(1f), + text = "Private to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium + ) + + // The one way back to the room, and it has to be one tap. + IconButton( + onClick = { chatMessageListViewModel.cancelDirectMessage() } + ) { + Icon( + Icons.Default.Close, + contentDescription = "Send to the whole group instead", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + Box( modifier = Modifier.fillMaxWidth() ) { @@ -166,11 +213,25 @@ fun ChatRoomMessagingScreen( colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent + disabledBorderColor = Color.Transparent, + focusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + }, + unfocusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + } ), placeholder = { Text( - text = "Say what now?" + text = if (directMessageRecipient != null) { + "Private message to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}" + } else { + "Say what now?" + } ) }, trailingIcon = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 27d40b07..4a7c4722 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn @@ -31,9 +32,12 @@ import androidx.compose.material.icons.filled.WorkspacePremium import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.KeyOff +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Pending import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator @@ -72,6 +76,8 @@ import press.mantra.compose.ui.view.state.ChatMessageListUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import press.mantra.compose.database.model.Participant +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -90,6 +96,60 @@ class ChatMessageListViewModel( val isReceiverChatMessageRelayListMissing: MutableState = mutableStateOf(false) + /** + * The member the next message goes to privately, or null to send it to the room. + * + * Held here rather than on the composer because it survives recomposition and is read + * by both the input and the send call. It is cleared on send: a private message is a + * deliberate act each time, and leaving the composer armed after one is how somebody + * sends the room's next message to one person, or one person's to the room. + */ + var directMessageRecipient: Participant? by mutableStateOf(null) + private set + + /** The message whose actions are open, or null. One at a time. */ + var openMessageActionsFor: Long? by mutableStateOf(null) + private set + + fun startDirectMessage(participant: Participant) { + logger.d("Arming a direct message to ${participant.participantPublicKey}") + directMessageRecipient = participant + openMessageActionsFor = null + } + + fun cancelDirectMessage() { + directMessageRecipient = null + } + + fun openMessageActions(chatMessageId: Long?) { + openMessageActionsFor = chatMessageId + } + + /** The member behind a pubkey, if they are in this room. */ + fun participantFor(publicKey: HexKey?): Participant? = + publicKey?.let { key -> + localChatRoom.localParticipants + .map { it.participant } + .firstOrNull { it.participantPublicKey == key } + } + + /** + * What to call somebody, for a line that has to name them. + * + * Falls back to the key itself rather than to "unknown": a direct message names a + * real member of this room, and a line that cannot say which one is worse than an + * ugly one. + */ + fun nameFor(publicKey: HexKey?): String { + if (publicKey == null) return "someone" + + return localChatRoom.localParticipants + .firstOrNull { it.participant.participantPublicKey == publicKey } + ?.profile + ?.humanReadableNameOrPubkey() + ?: publicKey.shortened() + } + fun initiate() { logger.d("init") scheduleSynchronization() @@ -347,6 +407,20 @@ class ChatMessageListViewModel( return@items } + // A private message this device cannot open. Everything + // about it is known except the one thing that matters, + // so it is a notice rather than an empty bubble -- + // which would read as the sender having said nothing. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE && + localChatMessage.chatMessage.content.isBlank() + ) { + PrivateMessageNotice( + sender = nameFor(localChatMessage.chatMessage.senderPublicKey), + recipient = nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey) + ) + return@items + } + BoxWithConstraints( modifier = Modifier.fillMaxWidth() ) { @@ -360,12 +434,22 @@ class ChatMessageListViewModel( Arrangement.Start } ) { + // Only somebody else's message, and only in a + // group -- a NIP-17 room has no audience for a + // message to be private from. + val canReplyPrivately = + localChatRoom.chatRoom.mlsGroupState != null && + !localChatMessage.chatMessage.isUserMessage && + participantFor(localChatMessage.chatMessage.senderPublicKey) != null + Card( modifier = Modifier.widthIn( max = screenWidth * 0.8f ).wrapContentWidth(), onClick = { - + if (canReplyPrivately) { + openMessageActions(localChatMessage.chatMessage.id) + } }, ) { Column( @@ -387,6 +471,34 @@ class ChatMessageListViewModel( ) } + // A readable direct message must never + // pass for a public one. The label says + // who the other party is, since that is + // the thing a reader would otherwise + // assume was the whole room. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = if (localChatMessage.chatMessage.isUserMessage) { + "Private to ${nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)}" + } else { + "Private to you" + }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmall + ) + } + } + SelectionContainer { Text( @@ -435,6 +547,24 @@ class ChatMessageListViewModel( } } } + + DropdownMenu( + expanded = openMessageActionsFor == localChatMessage.chatMessage.id, + onDismissRequest = { openMessageActions(null) } + ) { + DropdownMenuItem( + text = { + Text("Reply privately to ${nameFor(localChatMessage.chatMessage.senderPublicKey)}") + }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + onClick = { + participantFor(localChatMessage.chatMessage.senderPublicKey) + ?.let { startDirectMessage(it) } + } + ) + } } } } @@ -468,12 +598,17 @@ class ChatMessageListViewModel( fun sendMessage(textFieldState: TextFieldState) { if (textFieldState.text.isNotBlank()) { val text = textFieldState.text.toString() + // Read and cleared together with the text, before the send suspends, so a + // second message cannot inherit the first one's audience. + val recipient = directMessageRecipient textFieldState.clearText() + directMessageRecipient = null viewModelScope.launch(Dispatchers.IO) { chatRepository.sendChatMessage( text = text, - localChatRoom = localChatRoom + localChatRoom = localChatRoom, + directMessageRecipientPublicKey = recipient?.participantPublicKey ) } } @@ -500,6 +635,43 @@ class ChatMessageListViewModel( } } +/** + * A private message this device cannot read, as a system line. + * + * Deliberately not a bubble. The group is meant to know that a private message was sent + * and to whom -- that is the honest half of the feature -- but an empty bubble attributed + * to the sender would read as them having said nothing, and a bubble with placeholder text + * would read as them having said the placeholder. + * + * Not tappable: there is nothing behind it to open. See docs/marmot-direct-messages.md. + */ +@Composable +private fun PrivateMessageNotice( + sender: String, + recipient: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "$sender sent a private message to $recipient", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall + ) + } +} + /** * A ChillDKG milestone, as a system line across the transcript. *