From 61480dd8b75406056c34cd977dd2e0984cb48b6b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 00:35:52 +0200 Subject: [PATCH] feat: tell the group in chat when a shared key ceremony happens A ChillDKG ritual was invisible to everyone it happened to. Kinds 30310-30316 are routed to ChillDkgRitualManager and never become ChatMessage rows, so a member's device published a host key and joined a ceremony that fixes the group's signing quorum for good, with nothing appearing anywhere they would look. The only way to find out was to open the group's details and press Shared Key on the off chance. Worse in the flow this is reached through: a group whose first event is the ceremony now materialises as a room with no messages in it at all and no explanation of why it appeared. That matters more than it would for a chat feature, because a ritual cannot finish until every member's device has taken part. The progress ladder on the ritual screen exists to show that it is waiting on 2 of 3 -- but nothing told member 3 they were the one being waited on. Three milestones now land in the transcript: the ceremony starting, the group getting a key, and the ceremony being abandoned (with the reason, which names the culprit participant when ChillDKG identified one). ## Derived locally, not sent Nothing new goes on the wire. Every member already receives the proposal, and computes the completion and any failure for themselves, so each device writes its own row from what it already has. That costs no traffic, needs no new event kind, and -- the reason it is worth doing this way -- makes it impossible for the transcript to disagree with the ritual it describes. A "ceremony started" message that was itself sent could arrive without the proposal, or outlive a session that never existed on that device. The rows are written where the state changes: announceStarted() at both places a DkgSession is created (proposeRitual for the member who opens it, acceptProposal for everyone else), and announce() at the COMPLETE write and in fail(). They are written once by construction rather than by de-duplication, which is worth spelling out because ChatMessage.id is autogenerated and a second insert would simply be a second line. A session is created once, since acceptProposal returns early when the row exists; advance() leaves a COMPLETE ritual alone; and fail() now re-reads the session and returns if it is already FAILED. That last one is also a fix in its own right -- a ritual can be failed from two directions, a FAILURE message from a member and a fault raised locally, and while the second write was previously harmless it would now have told the group twice. ## Rendered as a system line, not a bubble ChatMessage.messageType already carries "message", "artifact", "pendingCommit" and eleven others, so TYPE_DKG_STARTED / _COMPLETE / _FAILED join it with no schema change. But the list renders every row as a bubble with the sender's name and a delivery-status icon, and neither fits: "a shared key ceremony started" is not something the coordinator said, and a row with no gift wrap behind it would show the KeyOff "unsealed" icon as though it had failed to send. RitualNotice renders them across the width instead -- icon, text, timestamp, no author, no side, no delivery state -- and is tappable through to the ritual screen, since the point of telling the group is to give them somewhere to go. It branches out of the items() lambda with an early return so the existing bubble layout is untouched. The other informational types ("pendingCommit", "processedCommit", "proposalStaged", "undecryptableOuterLayer") have the same problem and are deliberately left alone: how MLS commit rows should read is a separate call from making the key ceremony visible. ## Not covered by tests The whole change is Room writes and Compose rendering, neither of which runs under :composeApp:testDebugUnitTest -- there is no sqlite driver on the JVM test classpath. Verified by compilation only. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 18 ++++ .../compose/managers/ChillDkgRitualManager.kt | 69 ++++++++++++++- .../ui/composable/ChatRoomMessagingScreen.kt | 12 ++- .../ui/view/model/ChatMessageListViewModel.kt | 85 ++++++++++++++++++- 4 files changed, 180 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 50dcd17d..a9294b40 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -85,6 +85,24 @@ data class ChatMessage( ): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity { companion object { + /** + * A ChillDKG ritual reached a point the group should be told about. + * + * These rows are not anybody's words: no [ChatMessage.giftWrapPayloadId], no + * event behind them, and nothing sent to say them. Each device writes its own + * from the ritual messages it already received, which is why they cost no + * traffic and cannot disagree with the ritual they describe. + * + * [ChatMessageListViewModel] renders these as a system line rather than a + * bubble, since attributing "a shared key ceremony started" to the + * coordinator would read as something they said. + */ + const val TYPE_DKG_STARTED = "dkgStarted" + const val TYPE_DKG_COMPLETE = "dkgComplete" + const val TYPE_DKG_FAILED = "dkgFailed" + + val DKG_TYPES = setOf(TYPE_DKG_STARTED, TYPE_DKG_COMPLETE, TYPE_DKG_FAILED) + suspend fun fromGroupEventResult( database: MantraDatabase, activeKeyPair: KeyPair, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index b4b9e6c1..95a62adc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -1,6 +1,7 @@ package press.mantra.compose.managers import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.GiftWrapPayload @@ -150,6 +151,7 @@ object ChillDkgRitualManager { round2AuxRandom = RandomInstance.bytes(32).toHex() ) database.dkgSessionDao().upsert(session) + announceStarted(database, session) logger.i("Proposing DKG ritual $sessionId: $threshold-of-${members.size}") @@ -285,6 +287,7 @@ object ChillDkgRitualManager { round2AuxRandom = RandomInstance.bytes(32).toHex() ) database.dkgSessionDao().upsert(session) + announceStarted(database, session) logger.i("Joined DKG ritual $sessionId ($threshold-of-${members.size})") @@ -504,6 +507,13 @@ object ChillDkgRitualManager { recoveryData = output.recovery?.toHex() ) } + announce( + database = database, + session = session, + messageType = ChatMessage.TYPE_DKG_COMPLETE, + content = "The group has a shared key. It takes ${session.threshold} of " + + "${session.participantCount} members to sign with it." + ) logger.i("DKG ritual $sessionId complete") } catch (e: CancellationException) { @@ -696,9 +706,66 @@ object ChillDkgRitualManager { } private suspend fun fail(database: MantraDatabase, session: DkgSession, reason: String) { - update(database, session) { + // Read before writing so the notice goes out once. A ritual can be failed + // from two directions -- a FAILURE message from a member, and a fault raised + // locally -- and the group does not need to be told twice. + val current = database.dkgSessionDao().getSessionById(session.id) ?: session + if (current.stage == DkgRitualStage.FAILED) return + + update(database, current) { it.copy(stage = DkgRitualStage.FAILED, failureReason = reason) } + announce( + database = database, + session = current, + messageType = ChatMessage.TYPE_DKG_FAILED, + content = "The shared key ceremony was abandoned — $reason. " + + "No key was created, and it is safe to run it again." + ) + } + + private suspend fun announceStarted(database: MantraDatabase, session: DkgSession) = announce( + database = database, + session = session, + messageType = ChatMessage.TYPE_DKG_STARTED, + content = "A shared key ceremony started. It will take ${session.threshold} of " + + "${session.participantCount} members to sign with the key, and it finishes once " + + "everyone has taken part." + ) + + /** + * Puts a ritual milestone in the group's chat. + * + * The ritual is otherwise invisible: its kinds never become chat messages, so a + * member's device would join a ceremony that fixes the group's signing quorum + * for good without anything appearing where they would look. Each device writes + * its own row from the ritual messages it has already received, so this costs no + * traffic and cannot disagree with the ritual it describes. + * + * Written once per milestone by construction, not by de-duplication: a session + * is created once (its caller returns early if the row exists), [advance] leaves + * a completed ritual alone, and [fail] checks the stage before it writes. There + * is no key on ChatMessage to make a second insert idempotent -- its id is + * autogenerated -- so those guards are what keep the transcript honest. + */ + private suspend fun announce( + database: MantraDatabase, + session: DkgSession, + messageType: String, + content: String + ) { + database.chatMessageDao().upsert( + ChatMessage( + senderPublicKey = session.coordinatorPublicKey, + isUserMessage = session.isCoordinator(), + giftWrapPayloadId = null, + marmotGroupEventId = null, + marmotInnerEventId = null, + chatRoomId = session.chatRoomId, + content = content, + messageType = messageType + ) + ) } private suspend fun publishHostKey( 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 6dceca7f..3cd87d96 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 @@ -42,6 +42,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme @@ -132,7 +133,16 @@ fun ChatRoomMessagingScreen( modifier = Modifier.weight(1f).fillMaxWidth() ) { key(true) { - chatMessageListViewModel.RenderMessages() + chatMessageListViewModel.RenderMessages( + onOpenSharedKey = { + onNavigateToRoute.invoke( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + } + ) } } 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 b0463182..34eece0b 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 @@ -1,5 +1,6 @@ package press.mantra.compose.ui.view.model +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -18,7 +19,11 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.ErrorOutline 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.Pending import androidx.compose.material3.Card @@ -43,6 +48,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter @@ -198,7 +204,7 @@ class ChatMessageListViewModel( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable - fun RenderMessages() { + fun RenderMessages(onOpenSharedKey: () -> Unit) { Column( modifier = Modifier.fillMaxWidth(), @@ -295,6 +301,17 @@ class ChatMessageListViewModel( items = chatRoomDetailMessageListUIState.chatMessageList, key = { it.chatMessage.id } ) { localChatMessage -> + // Not somebody's words -- see ChatMessage.DKG_TYPES. + // A bubble would attribute "a shared key ceremony + // started" to the coordinator as if they had said it. + if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) { + RitualNotice( + chatMessage = localChatMessage.chatMessage, + onClick = onOpenSharedKey + ) + return@items + } + BoxWithConstraints( modifier = Modifier.fillMaxWidth() ) { @@ -446,4 +463,68 @@ class ChatMessageListViewModel( } } } -} \ No newline at end of file +} + +/** + * A ChillDKG milestone, as a system line across the transcript. + * + * Deliberately not a bubble: nobody said this, and giving it a sender and a side + * would make the coordinator appear to have announced it. It is tappable because + * the point of telling the group is to give them somewhere to go — a ritual only + * finishes once every member's device has taken part, and the ladder that shows + * who it is waiting on lives on the shared-key screen. + */ +@Composable +private fun RitualNotice( + chatMessage: ChatMessage, + onClick: () -> Unit, +) { + val icon = when (chatMessage.messageType) { + ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle + ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline + else -> Icons.Default.Key + } + + val tint = when (chatMessage.messageType) { + ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = chatMessage.content, + style = MaterialTheme.typography.bodySmall, + color = tint + ) + + Text( + text = chatMessage.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Open the shared key ceremony", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +}