feat: name every member on the shared key ceremony screen
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<LocalParticipant>,
|
||||
hostKeyParticipants: Set<HexKey>,
|
||||
round1Participants: Set<HexKey>,
|
||||
round2Participants: Set<HexKey>,
|
||||
) {
|
||||
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<LocalParticipant>,
|
||||
activeUserPublicKey: HexKey,
|
||||
hostKeyParticipants: Set<HexKey>,
|
||||
round1Participants: Set<HexKey>,
|
||||
round2Participants: Set<HexKey>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HexKey> = emptySet(),
|
||||
val round1Participants: Set<HexKey> = emptySet(),
|
||||
val round2Participants: Set<HexKey> = 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<LocalParticipant> 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
|
||||
|
||||
Reference in New Issue
Block a user