feat: let a member send and read a private message in the room
The UI half. Tapping somebody else's message in a group offers "Reply privately to <name>", which arms the composer; sending clears it. The riskiest thing about this feature is not cryptographic. It is somebody sending to the room what they meant for one person, or the reverse, and neither can be taken back once it is on the wire. So an armed composer carries three signals at once -- a chip naming the recipient, a placeholder that says "Private message to <name>" instead of "Say what now?", and a tinted field -- and the chip's close button is the single tap back to the room. The recipient is read and cleared together with the text before the send suspends, so a second message cannot inherit the first one's audience. Two renderings, because a direct message looks different depending on whether this device can open it. Readable: the ordinary bubble, plus a lock and "Private to <name>" (or "Private to you"). A private message must never pass for a public one, and the label names the other party because that is what a reader would otherwise assume was the whole room. Opaque: a system line -- "Alice sent a private message to Bob" -- in the shape of RitualNotice rather than a bubble. An empty bubble attributed to Alice would read as her having said nothing, and one with placeholder text would read as her having said the placeholder. It is not tappable; there is nothing behind it to open. Reply privately is offered only on somebody else's message in an MLS room. A NIP-17 room has no audience for a message to be private from, so there the option would be meaningless. Names resolve from the room's participants, which the view model already holds -- the recipient is a member by definition -- so neither line needs a join, and both fall back to a shortened key rather than to "unknown". A line that cannot say which member it means is worse than an ugly one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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<Boolean> = 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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user