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