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 + ) + } +}