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