From 95db7c2f73bea2d28840852a0b0d9ee8b8e94f7c Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 00:44:53 +0200 Subject: [PATCH] fix: name the member behind a shared key ceremony notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ritual notices landed without an author. Dropping the bubble was right -- "a shared key ceremony started" is not something the coordinator said -- but dropping the actor with it threw away the part of the line that matters most. Any member can open a ceremony, and it settles the group's signing quorum for good, so *who* opened this one is exactly what the group needs to see. Same for who abandoned one. System lines carry the actor in the sentence rather than in a header, so the content of the authored types is now a predicate to be read after a name: Alice started a shared key ceremony. It will take 2 of 3 members to sign with the key, and it finishes once everyone has taken part. Bob abandoned the shared key ceremony. No key was created, and it is safe to run it again. ✓ The group has a shared key. It takes 2 of 3 members to sign with it. A finished ceremony keeps no author, which is why DKG_AUTHORED_TYPES is a subset rather than all three: the group ends up with a key, nobody hands it to them. ## The actor is resolved at render time, not written into the content The manager could look the name up when it writes the row, and it would be wrong twice over: the name would be frozen against later renames, and a member first seen through this very proposal is sitting on the "LOADING..." placeholder that getOrCreateNip17ChatRoom just inserted for them -- so the line would read "LOADING... started a shared key ceremony" forever. LocalChatMessage already joins Profile on senderPublicKey, so the renderer resolves it live, colours it with ProfileColor like the message bubbles do, and falls back to a short key when there is no profile yet. ## senderPublicKey now holds who acted It was the coordinator on all three notices, which was wrong for an abandoned ceremony: the member who sent FAILURE is the one who ended it. fail() takes a culprit -- the FAILURE sender, defaulting to this device for a fault raised locally, which amounts to the same thing from the group's side since hitting one makes this device broadcast FAILURE in turn. isUserMessage follows from it, so the line reads "You" for your own actions. The fault itself is deliberately no longer in the chat line. "Abandoned by 1a2b3c4d: ChillDKG round 2 failed: a participant is faulty (participant 2)" in the middle of a sentence about who walked away reads badly, and the detail is already on the ritual screen the notice taps through to. DkgSession.failureReason keeps it verbatim, so that screen is unchanged. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 11 +++++ .../compose/managers/ChillDkgRitualManager.kt | 44 ++++++++++++++----- .../ui/view/model/ChatMessageListViewModel.kt | 38 ++++++++++++++-- 3 files changed, 78 insertions(+), 15 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 a9294b40..814e41ae 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 @@ -103,6 +103,17 @@ data class ChatMessage( val DKG_TYPES = setOf(TYPE_DKG_STARTED, TYPE_DKG_COMPLETE, TYPE_DKG_FAILED) + /** + * The ritual milestones somebody did, as opposed to ones that simply + * happened. For these [senderPublicKey] is the member who acted -- the one + * who opened the ceremony, or the one who abandoned it -- and their content + * is written as a predicate for their name to be read in front of. + * + * A finished ceremony has no actor: the group ends up with a key, nobody + * hands it to them. + */ + val DKG_AUTHORED_TYPES = setOf(TYPE_DKG_STARTED, 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 95a62adc..9364720a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -383,9 +383,10 @@ object ChillDkgRitualManager { DkgRitualEvents.FAILURE -> { fail( - database, - session, - "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}" + database = database, + session = session, + reason = "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}", + culprit = giftWrapPayload.publicKey ) return false } @@ -512,7 +513,8 @@ object ChillDkgRitualManager { 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." + "${session.participantCount} members to sign with it.", + actor = session.coordinatorPublicKey ) logger.i("DKG ritual $sessionId complete") @@ -705,7 +707,15 @@ object ChillDkgRitualManager { if (settled || current.stage.ordinal >= stage.ordinal) current else current.copy(stage = stage) } - private suspend fun fail(database: MantraDatabase, session: DkgSession, reason: String) { + private suspend fun fail( + database: MantraDatabase, + session: DkgSession, + reason: String, + // Whoever ended it. A member who sent FAILURE, or this device when it hit a + // fault of its own -- which amounts to the same thing from the group's side, + // since hitting one makes this device broadcast FAILURE in turn. + culprit: HexKey = session.userPublicKey + ) { // 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. @@ -719,8 +729,13 @@ object ChillDkgRitualManager { 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." + // The fault itself is deliberately left out and kept on the ritual + // screen this line taps through to. "a participant is faulty + // (participant 2)" is worth having, but not in the middle of a + // sentence about who walked away. + content = "abandoned the shared key ceremony. No key was created, " + + "and it is safe to run it again.", + actor = culprit ) } @@ -728,9 +743,13 @@ object ChillDkgRitualManager { database = database, session = session, messageType = ChatMessage.TYPE_DKG_STARTED, - content = "A shared key ceremony started. It will take ${session.threshold} of " + + // Reads after the actor's name -- see ChatMessage.DKG_AUTHORED_TYPES. Who + // opened a ceremony matters: any member can, and it settles the group's + // signing quorum for good. + content = "started a shared key ceremony. It will take ${session.threshold} of " + "${session.participantCount} members to sign with the key, and it finishes once " + - "everyone has taken part." + "everyone has taken part.", + actor = session.coordinatorPublicKey ) /** @@ -752,12 +771,13 @@ object ChillDkgRitualManager { database: MantraDatabase, session: DkgSession, messageType: String, - content: String + content: String, + actor: HexKey ) { database.chatMessageDao().upsert( ChatMessage( - senderPublicKey = session.coordinatorPublicKey, - isUserMessage = session.isCoordinator(), + senderPublicKey = actor, + isUserMessage = actor == session.userPublicKey, giftWrapPayloadId = null, marmotGroupEventId = null, marmotInnerEventId = null, 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 34eece0b..0c55289c 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 @@ -40,8 +40,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -50,6 +53,7 @@ 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.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.extensions.toFormattedTimeAndDateString @@ -306,7 +310,7 @@ class ChatMessageListViewModel( // started" to the coordinator as if they had said it. if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) { RitualNotice( - chatMessage = localChatMessage.chatMessage, + localChatMessage = localChatMessage, onClick = onOpenSharedKey ) return@items @@ -476,9 +480,11 @@ class ChatMessageListViewModel( */ @Composable private fun RitualNotice( - chatMessage: ChatMessage, + localChatMessage: LocalChatMessage, onClick: () -> Unit, ) { + val chatMessage = localChatMessage.chatMessage + val icon = when (chatMessage.messageType) { ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline @@ -509,7 +515,30 @@ private fun RitualNotice( verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( - text = chatMessage.content, + text = buildAnnotatedString { + // Somebody opened this ceremony, or somebody walked away from it, + // and which member that was is the point of the line. Resolved + // from the joined profile rather than written into the content, + // so it follows a rename and is not stuck on the "LOADING..." + // placeholder a member is given the moment they are first seen. + if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES) { + withStyle( + SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey)) + ) { + append( + if (chatMessage.isUserMessage) { + "You" + } else { + localChatMessage.profile?.humanReadableNameOrPubkey() + ?: chatMessage.senderPublicKey.take(SHORTENED_PUBLIC_KEY_LENGTH) + } + ) + } + append(" ") + } + + append(chatMessage.content) + }, style = MaterialTheme.typography.bodySmall, color = tint ) @@ -528,3 +557,6 @@ private fun RitualNotice( ) } } + +/** Enough of a public key to tell two members apart when neither has a profile yet. */ +private const val SHORTENED_PUBLIC_KEY_LENGTH = 8