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:
Kgothatso Ngako
2026-09-05 19:08:36 +02:00
parent 79e62d8239
commit b0d0199113
2 changed files with 237 additions and 4 deletions

View File

@@ -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 = {

View File

@@ -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.
*