From 3fad331969e30722135b517a4ca0f618722b0266 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 00:47:02 +0200 Subject: [PATCH] feat: name every member on the shared key ceremony screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ladder said "2 of 3" and stopped there. That is the one thing a stalled ceremony never needs explaining — you can see it is stuck. What the group has no way to find out is *who* it is stuck on, and since a ChillDKG ritual cannot finish until every member's device has taken part, knowing whose door to knock on is the group's entire recourse. A roster now sits under the ladder, one row per member, each showing how far they have got: ✓ You confirmed everyone's part ✓ Alice committed their part ○ Bob not here yet The wording is deliberately about what a member has *done* rather than a rung number, since the rungs are named for the group's progress ("Round one") and a member's own state is a different question. Each member is named by the furthest round they have published, because that is the only thing this device knows about them for certain — there is no liveness signal in ChillDKG, and a member who published a host key an hour ago and then closed the app is indistinguishable from one still working. ## Members, not counts, in the state DkgRitualUIState carried three Ints. It now carries the three sets of public keys they were counting, with the counts derived, so the ladder keeps working unchanged and the roster has something to name people from. The roster is drawn from `ritualMembers`: the room's participants deduplicated, plus anyone who has published a ritual message and is not among them. The union matters because the two sources can disagree — `n` is fixed from the proposal's p-tags while the room's rows are local and can drift — and somebody who has actually taken part is in the ceremony whatever the room's rows say. Showing a count of 3 above a list of 2 names would be the worst of both. Members are sorted by public key rather than by progress, so a row does not jump around under the reader's finger as messages arrive. ## Only while it is running The roster is skipped once a ceremony is COMPLETE, where the key card says everything, and it is never reached for a FAILED one, which returns early on the abandoned card. A half-climbed ladder of names next to "the ceremony was abandoned" is noise: the ritual is over and who got how far no longer changes what anyone should do. Co-Authored-By: Claude Opus 5 --- .../compose/ui/composable/DkgRitualScreen.kt | 107 ++++++++++++++++-- .../ui/view/model/DkgRitualViewModel.kt | 12 +- .../compose/ui/view/state/DkgRitualUIState.kt | 41 ++++++- 3 files changed, 146 insertions(+), 14 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index ec209ce5..45d9bf91 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -45,11 +45,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.DkgRitualViewModel import press.mantra.compose.ui.view.state.DkgRitualUIState @@ -184,9 +186,11 @@ fun DkgRitualScreen( RitualProgress( session = session, participantCount = participantCount, - hostKeyCount = dkgRitualUIState.hostKeyCount, - round1Count = dkgRitualUIState.round1Count, - round2Count = dkgRitualUIState.round2Count + activeUserPublicKey = activeUserPublicKey, + members = dkgRitualUIState.ritualMembers, + hostKeyParticipants = dkgRitualUIState.hostKeyParticipants, + round1Participants = dkgRitualUIState.round1Participants, + round2Participants = dkgRitualUIState.round2Participants ) } } @@ -217,16 +221,21 @@ fun DkgRitualScreen( } } -/** The ladder. Each round names who it is still waiting on, by count. */ +/** The ladder, and the members it is climbing. */ @Composable private fun RitualProgress( session: DkgSession, participantCount: Int, - hostKeyCount: Int, - round1Count: Int, - round2Count: Int, + activeUserPublicKey: HexKey, + members: List, + hostKeyParticipants: Set, + round1Participants: Set, + round2Participants: Set, ) { val stage = session.stage + val hostKeyCount = hostKeyParticipants.size + val round1Count = round1Participants.size + val round2Count = round2Participants.size if (stage == DkgRitualStage.FAILED) { Card( @@ -286,6 +295,16 @@ private fun RitualProgress( isDone = stage == DkgRitualStage.COMPLETE ) + if (stage != DkgRitualStage.COMPLETE) { + RitualRoster( + members = members, + activeUserPublicKey = activeUserPublicKey, + hostKeyParticipants = hostKeyParticipants, + round1Participants = round1Participants, + round2Participants = round2Participants + ) + } + if (stage == DkgRitualStage.COMPLETE) { Card( modifier = Modifier.fillMaxWidth(), @@ -322,6 +341,80 @@ private fun RitualProgress( } } +/** + * Every member, and how far each has got. + * + * The ladder above says "2 of 3", which is the one thing a stalled ceremony never + * needs explaining — what it needs is a name. A DKG finishes only once every + * member's device has taken part, so the group's whole recourse when it stops + * moving is knowing whose door to knock on. + */ +@Composable +private fun RitualRoster( + members: List, + activeUserPublicKey: HexKey, + hostKeyParticipants: Set, + round1Participants: Set, + round2Participants: Set, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(15.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(text = "Members", style = MaterialTheme.typography.titleSmall) + + members + .sortedBy { it.participant.participantPublicKey } + .forEach { member -> + val publicKey = member.participant.participantPublicKey + + // Named by the furthest round they have published, since that is + // the only thing this device knows about them for certain. + val progress = when (publicKey) { + in round2Participants -> "confirmed everyone's part" + in round1Participants -> "committed their part" + in hostKeyParticipants -> "joined" + else -> "not here yet" + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (publicKey in hostKeyParticipants) { + Icons.Default.CheckCircle + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null + ) + + Text( + modifier = Modifier.weight(1f), + text = if (publicKey == activeUserPublicKey) { + "You" + } else { + member.profile?.humanReadableNameOrPubkey() ?: publicKey.take(8) + }, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + color = ProfileColor.fromPublicKey(publicKey), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = progress, + style = MaterialTheme.typography.labelMedium + ) + } + } + } + } +} + @Composable private fun RitualStep( title: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 7c9cd771..87547734 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -18,6 +18,7 @@ import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.view.state.DkgRitualUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import fr.acinq.phoenix.data.ActiveWallet import fr.acinq.phoenix.managers.nostrPrivateKey import kotlinx.coroutines.Dispatchers @@ -121,10 +122,15 @@ class DkgRitualViewModel( dkgRepository.observeMessages(session.id).collect { messages -> val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect + fun senders(kind: Kind) = messages + .filter { it.kind == kind } + .map { it.participantPublicKey } + .toSet() + dkgRitualUIState = current.copy( - hostKeyCount = messages.count { it.kind == DkgRitualEvents.HOST_KEY }, - round1Count = messages.count { it.kind == DkgRitualEvents.ROUND_1 }, - round2Count = messages.count { it.kind == DkgRitualEvents.ROUND_2 } + hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY), + round1Participants = senders(DkgRitualEvents.ROUND_1), + round2Participants = senders(DkgRitualEvents.ROUND_2) ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt index b5f2089d..2ad95ef8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt @@ -1,7 +1,10 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.managers.ChillDkgRitualManager sealed interface DkgRitualUIState { @@ -9,11 +12,41 @@ sealed interface DkgRitualUIState { val localChatRoom: LocalChatRoom, /** Null until somebody opens a ritual. */ val session: DkgSession? = null, - /** How many members have published each round's message, for the progress ladder. */ - val hostKeyCount: Int = 0, - val round1Count: Int = 0, - val round2Count: Int = 0, + /** + * Who has published each round's message, for the progress ladder. + * + * Members rather than counts, because the one thing this screen exists to + * answer is which member the ceremony is waiting on — a DKG cannot finish + * until every one of them has taken part, and "2 of 3" does not tell anyone + * whose door to knock on. + */ + val hostKeyParticipants: Set = emptySet(), + val round1Participants: Set = emptySet(), + val round2Participants: Set = emptySet(), ): DkgRitualUIState { + val hostKeyCount: Int get() = hostKeyParticipants.size + val round1Count: Int get() = round1Participants.size + val round2Count: Int get() = round2Participants.size + + /** + * Everyone the ceremony involves, as far as this device can tell. + * + * The room's members, plus anyone who has actually taken part. The union + * matters because the two can disagree: `n` is fixed from the proposal's + * p-tags, while the room's rows are local and can drift, and a participant + * who has published a host key is in the ceremony whatever the room says. + */ + val ritualMembers: List get() { + val known = localChatRoom.localParticipants.distinctBy { it.participant.participantPublicKey } + val knownKeys = known.map { it.participant.participantPublicKey }.toSet() + + val strangers = (hostKeyParticipants + round1Participants + round2Participants) + .filterNot { it in knownKeys } + .map { LocalParticipant(Participant(participantPublicKey = it, chatRoomId = localChatRoom.chatRoom.id, relayHint = null), null) } + + return known + strangers + } + /** * The `n` of the t-of-n. Counted over people, not participant rows — a room * can hold the same member twice — and taken from the session once one