diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 771819ef..2f5f0b2d 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -501,4 +501,17 @@
Accept as a parent
A group is a subgroup of another only once its own quorum says so. Accepting asks this group to agree what it signs with again, naming them.
Asked this group to accept that parent
+
+ Key ceremonies
+ This group has not held a key ceremony.
+ The group's shared key
+ A subgroup's key
+ A subgroup: %1$s
+ Waiting for members to join.
+ Waiting for everyone to commit their part.
+ Waiting for everyone to confirm the result.
+ The group has a key from this ceremony.
+ Abandoned. No key was created.
+ %1$s of %2$s members will sign with it
+ Set up a shared key
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
index b490e05d..9125dfbb 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
@@ -70,6 +70,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin
import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
+import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
import press.mantra.compose.extensions.toFormattedTimeAndDateString
@@ -123,6 +124,7 @@ import mantra.composeapp.generated.resources.no_projects_exists_in_this_group
import mantra.composeapp.generated.resources.projects
import mantra.composeapp.generated.resources.proposals
import mantra.composeapp.generated.resources.reindex_events
+import mantra.composeapp.generated.resources.key_ceremonies
import mantra.composeapp.generated.resources.shared_key
import mantra.composeapp.generated.resources.signing_key
import mantra.composeapp.generated.resources.the_key_this_room_signs_as
@@ -742,6 +744,39 @@ fun ChatRoomDetailScreen(
}
}
+ // Every room, including the Marmot ones the `mlsGroupState`
+ // check above keeps "Shared key" out of. That button offers
+ // a room a key of its own, which only a NIP-17 room can
+ // want; this one lists the ceremonies a room *holds*, and a
+ // parent's Marmot room holds one per subgroup the group has
+ // ever made. Until this there was no way to reach the second
+ // of them except by scrolling the transcript to its request
+ // line, and no way at all to reach one whose line had never
+ // arrived.
+ item {
+ TextButton(
+ onClick = {
+ onNavigateToRoute.invoke(
+ DkgRitualListRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId
+ )
+ )
+ }
+ ) {
+ Icon(
+ Icons.Default.Key,
+ contentDescription = "Key ceremonies"
+ )
+
+ Spacer(
+ modifier = Modifier.width(MaterialTheme.spacing.space125)
+ )
+
+ Text(stringResource(Res.string.key_ceremonies))
+ }
+ }
+
// Every room, not only the one that holds the key. Most
// of what a group signs it signs in its Marmot room --
// its subgroups' certificates and key states included --
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 e678a623..e5b842ae 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
@@ -49,6 +49,7 @@ 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.FrostSigningRoute
+import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
@@ -176,12 +177,37 @@ fun ChatRoomMessagingScreen(
ChatTranscript(
viewModel = chatMessageListViewModel,
onOpenSharedKey = { dkgSessionId ->
+ // The same two reasons the signing lines open
+ // the queue instead of one proposal. A line
+ // written before chat rows named their ceremony
+ // cannot say which it is about, and the room may
+ // hold several -- the list is the honest answer
+ // there, showing all of them with their own
+ // state rather than guessing at the newest,
+ // which since subgroups moved into the parent's
+ // room is routinely the finished one.
+ //
+ // The other is that the ceremony they tapped is
+ // not the only one waiting on them, in which
+ // case it is not the whole of what is being
+ // asked and a screen showing only it would say
+ // it was.
onNavigateToRoute.invoke(
- DkgRitualRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- dkgSessionId = dkgSessionId
- )
+ if (dkgSessionId == null ||
+ chatMessageListViewModel
+ .hidesOtherCeremonies(dkgSessionId)
+ ) {
+ DkgRitualListRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId
+ )
+ } else {
+ DkgRitualRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ dkgSessionId = dkgSessionId
+ )
+ }
)
},
onOpenSigning = { sessionId ->
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt
new file mode 100644
index 00000000..aa7121b6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt
@@ -0,0 +1,504 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+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.HourglassEmpty
+import androidx.compose.material.icons.filled.Key
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.a_subgroup_named_s
+import mantra.composeapp.generated.resources.a_subgroups_key
+import mantra.composeapp.generated.resources.abandoned_no_key_was_created
+import mantra.composeapp.generated.resources.check_the_combined_result
+import mantra.composeapp.generated.resources.everything_else
+import mantra.composeapp.generated.resources.join_the_ceremony_by_publishing_your_key
+import mantra.composeapp.generated.resources.key_ceremonies
+import mantra.composeapp.generated.resources.open
+import mantra.composeapp.generated.resources.review
+import mantra.composeapp.generated.resources.s_of_s_members_will_sign_with_it
+import mantra.composeapp.generated.resources.send_your_contribution_to_the_key
+import mantra.composeapp.generated.resources.set_up_a_shared_key
+import mantra.composeapp.generated.resources.the_group_has_a_key_from_this_ceremony
+import mantra.composeapp.generated.resources.the_group_has_its_key_finish_setting_it_up
+import mantra.composeapp.generated.resources.the_groups_shared_key
+import mantra.composeapp.generated.resources.this_group_has_held_no_key_ceremony
+import mantra.composeapp.generated.resources.waiting_for_everyone_to_commit_their_part
+import mantra.composeapp.generated.resources.waiting_for_everyone_to_confirm_the_result
+import mantra.composeapp.generated.resources.waiting_for_members_to_join
+import mantra.composeapp.generated.resources.waiting_for_you
+import org.jetbrains.compose.resources.stringResource
+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.types.DkgApprovalStep
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.extensions.toFormattedTimeAndDateString
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+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.Decorative
+import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
+import press.mantra.compose.ui.composable.widgets.NavigateBackButton
+import press.mantra.compose.ui.theme.ConformancePreviews
+import press.mantra.compose.ui.theme.MantraTheme
+import press.mantra.compose.ui.theme.readableContent
+import press.mantra.compose.ui.theme.spacing
+import press.mantra.compose.ui.view.model.DkgRitualListViewModel
+import press.mantra.compose.ui.view.state.DkgRitualListUIState
+
+/**
+ * Every key ceremony a room holds.
+ *
+ * The transcript shows a ceremony as it happens, in among everything else that
+ * was said, and the standing notice above the composer says when one is waiting
+ * on the reader. Neither can say *which*, once there are two -- and since a
+ * subgroup's ChillDKG moved into its parent's room there are routinely two: the
+ * group's own, long finished, and whatever subgroup is being made this week.
+ *
+ * So: one row per ceremony, each carrying its own state, the finished ones kept
+ * rather than dropped. The ones still waiting on the reader come first, because
+ * those are the only ones with anything to do in them; the rest are the record
+ * of how this group came to hold the keys it holds, which is the other half of
+ * what this screen is for.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DkgRitualListScreen(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading,
+ chatRepository: ChatRepository,
+ dkgRepository: DkgRepository,
+ onNavigateBack: () -> Unit,
+ onNavigateToRoute: (Route) -> Unit,
+) {
+ val dkgRitualListViewModel: DkgRitualListViewModel = viewModel(
+ factory = DkgRitualListViewModel.factory(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = activeUserPublicKey,
+ initialDkgRitualListUIState = initialDkgRitualListUIState,
+ chatRepository = chatRepository,
+ dkgRepository = dkgRepository
+ )
+ )
+
+ LaunchedEffect(true) {
+ if (initialDkgRitualListUIState == DkgRitualListUIState.Loading) {
+ dkgRitualListViewModel.initiate()
+ }
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(Res.string.key_ceremonies),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = { NavigateBackButton(onNavigateBack) }
+ )
+ }
+ ) { padding ->
+ when (val state = dkgRitualListViewModel.dkgRitualListUIState.collectAsState().value) {
+ is DkgRitualListUIState.Loading -> Column(
+ modifier = Modifier.fillMaxWidth().padding(padding).readableContent()
+ .padding(MaterialTheme.spacing.space250),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
+ CircularProgressIndicator()
+ }
+
+ is DkgRitualListUIState.Error -> Column(
+ modifier = Modifier.fillMaxWidth().padding(padding).readableContent()
+ .padding(MaterialTheme.spacing.space250),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
+ Text(text = state.message, textAlign = TextAlign.Center)
+ }
+
+ is DkgRitualListUIState.Loaded -> {
+ val open: (DkgRitualListUIState.Ceremony) -> Unit = { ceremony ->
+ onNavigateToRoute.invoke(
+ DkgRitualRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ dkgSessionId = ceremony.session.id
+ )
+ )
+ }
+
+ LazyColumn(
+ modifier = Modifier.padding(padding).readableContent().fillMaxSize()
+ .padding(MaterialTheme.spacing.space250),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
+ ) {
+ if (state.ceremonies.isEmpty()) {
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ .padding(top = MaterialTheme.spacing.space600),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.sectionGap
+ )
+ ) {
+ Text(
+ text = stringResource(
+ Res.string.this_group_has_held_no_key_ceremony
+ ),
+ textAlign = TextAlign.Center
+ )
+
+ // The way out of an empty list, rather than a
+ // dead end that sends a member back to the
+ // group's details to find the other door. The
+ // ceremony screen decides whether the group is
+ // in a position to hold one; this only offers
+ // to ask it.
+ FilledTonalButton(onClick = {
+ onNavigateToRoute.invoke(
+ DkgRitualRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId
+ )
+ )
+ }) {
+ Icon(Icons.Default.Key, contentDescription = Decorative)
+
+ Spacer(
+ modifier = Modifier.width(
+ MaterialTheme.spacing.relatedGap
+ )
+ )
+
+ Text(stringResource(Res.string.set_up_a_shared_key))
+ }
+ }
+ }
+ }
+
+ if (state.waitingForYou.isNotEmpty()) {
+ item {
+ Text(
+ text = stringResource(Res.string.waiting_for_you),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ items(
+ items = state.waitingForYou,
+ key = { it.session.id }
+ ) { ceremony ->
+ CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) })
+ }
+ }
+
+ if (state.rest.isNotEmpty()) {
+ // Headed only when there is a section above to tell it
+ // apart from; on its own it is the whole screen, which
+ // the title already says.
+ if (state.waitingForYou.isNotEmpty()) {
+ item {
+ Text(
+ text = stringResource(Res.string.everything_else),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ }
+
+ items(
+ items = state.rest,
+ key = { it.session.id }
+ ) { ceremony ->
+ CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) })
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * One ceremony, named after the key it is making.
+ *
+ * What a ceremony is *for* is the first thing to say about it, because a room
+ * holding two holds them for different groups: one is this room's own key and
+ * the other belongs to a subgroup that does not exist yet. A row that only said
+ * how far along it was would leave a member guessing which of the two they were
+ * looking at.
+ */
+@Composable
+private fun CeremonyCard(
+ ceremony: DkgRitualListUIState.Ceremony,
+ onClick: () -> Unit,
+) {
+ val session = ceremony.session
+
+ // Every colour inside this card is derived from the card rather than reached
+ // for independently, because ListItem does not inherit LocalContentColor --
+ // its headline comes from `onSurface` whatever the container is. See
+ // ProposalListScreen, where that combination drew #1B1B1B on #1B1B1B.
+ val cardColors = if (ceremony.awaitsYou) {
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ } else {
+ CardDefaults.cardColors()
+ }
+ val cardContentColor = cardColors.contentColor
+
+ // A subgroup is named by its proposal, and a member who did not open it may
+ // be looking at the ceremony before that name has arrived -- hence the third
+ // branch, which still says which of the two kinds of key this is.
+ val subject = session.subject
+
+ val label = when {
+ !ceremony.isSubgroup -> stringResource(Res.string.the_groups_shared_key)
+ subject != null -> stringResource(Res.string.a_subgroup_named_s, subject)
+ else -> stringResource(Res.string.a_subgroups_key)
+ }
+
+ Card(onClick = onClick, colors = cardColors) {
+ ListItem(
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ headlineColor = cardContentColor,
+ overlineColor = cardContentColor,
+ supportingColor = cardContentColor,
+ leadingIconColor = cardContentColor,
+ trailingIconColor = cardContentColor,
+ ),
+ leadingContent = {
+ Icon(
+ imageVector = when {
+ ceremony.awaitsYou -> Icons.Default.Key
+ session.stage == DkgRitualStage.COMPLETE -> Icons.Default.CheckCircle
+ session.stage == DkgRitualStage.FAILED -> Icons.Default.ErrorOutline
+ else -> Icons.Default.HourglassEmpty
+ },
+ // Carries the stage on its own, and carries it alone on the
+ // highlighted card where the failure state has no colour to
+ // spare.
+ contentDescription = when {
+ ceremony.awaitsYou -> "Waiting on you"
+ session.stage == DkgRitualStage.COMPLETE -> "Finished"
+ session.stage == DkgRitualStage.FAILED -> "Abandoned"
+ else -> "Waiting on others"
+ },
+ tint = when {
+ // `error` is 2.67:1 on the highlighted card, so there the
+ // failure is carried by the icon's shape and by the status
+ // line rather than by colour.
+ session.stage == DkgRitualStage.FAILED &&
+ !ceremony.awaitsYou -> MaterialTheme.colorScheme.error
+ else -> cardContentColor
+ }
+ )
+ },
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // The same word the transcript's notice uses for the same
+ // thing, so a member who came here looking for the card they
+ // tapped finds it saying what it said there.
+ Text(
+ text = if (ceremony.awaitsYou) {
+ stringResource(Res.string.review)
+ } else {
+ stringResource(Res.string.open)
+ },
+ style = MaterialTheme.typography.labelLarge,
+ color = cardContentColor
+ )
+
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = "Open this ceremony"
+ )
+ }
+ },
+ overlineContent = { Text(text = label) },
+ headlineContent = {
+ Text(
+ text = statusOf(ceremony),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ supportingContent = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25)
+ ) {
+ // The quorum a ceremony settles is fixed by it and cannot be
+ // changed afterwards, so it belongs on the row rather than
+ // only on the screen behind it.
+ Text(
+ text = stringResource(
+ Res.string.s_of_s_members_will_sign_with_it,
+ session.threshold,
+ session.participantCount
+ )
+ )
+
+ Text(
+ text = "${ceremony.coordinatorName} ยท " +
+ session.createdAt.toFormattedTimeAndDateString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ )
+ }
+}
+
+/**
+ * Where a ceremony has got to, in a line.
+ *
+ * Shorter than the ceremony screen's ladder, and it has to be: this is one row
+ * of a list somebody is scanning, not the page they are acting on. What it must
+ * never do is call a ceremony finished when it is not, or waiting when there is
+ * nothing left to wait for.
+ *
+ * What it owes this member outranks the stage, in the words the approval screens
+ * use for the same step, so nobody is asked twice in two vocabularies.
+ */
+@Composable
+private fun statusOf(ceremony: DkgRitualListUIState.Ceremony): String {
+ ceremony.pendingApproval?.let {
+ return when (it) {
+ DkgApprovalStep.HOST_KEY ->
+ stringResource(Res.string.join_the_ceremony_by_publishing_your_key)
+ DkgApprovalStep.ROUND_1 ->
+ stringResource(Res.string.send_your_contribution_to_the_key)
+ DkgApprovalStep.ROUND_2 ->
+ stringResource(Res.string.check_the_combined_result)
+ }
+ }
+
+ if (ceremony.roomAwaitingCreation != null) {
+ return stringResource(Res.string.the_group_has_its_key_finish_setting_it_up)
+ }
+
+ return when (ceremony.session.stage) {
+ DkgRitualStage.COLLECTING_HOST_KEYS ->
+ stringResource(Res.string.waiting_for_members_to_join)
+
+ DkgRitualStage.COLLECTING_ROUND_1 ->
+ stringResource(Res.string.waiting_for_everyone_to_commit_their_part)
+
+ DkgRitualStage.COLLECTING_ROUND_2 ->
+ stringResource(Res.string.waiting_for_everyone_to_confirm_the_result)
+
+ DkgRitualStage.COMPLETE ->
+ stringResource(Res.string.the_group_has_a_key_from_this_ceremony)
+
+ // The reason is the whole content of a failure -- "a member refused" and
+ // "the group could not agree" are different things to have happened -- so
+ // it stands in for the bare word whenever there is one.
+ DkgRitualStage.FAILED ->
+ ceremony.session.failureReason
+ ?: stringResource(Res.string.abandoned_no_key_was_created)
+ }
+}
+
+@ConformancePreviews
+@Composable
+private fun DkgRitualListScreenPreview() {
+ val session = DkgSession(
+ id = "sessionId",
+ chatRoomId = "chatRoomId",
+ coordinatorPublicKey = "c".repeat(64),
+ userPublicKey = "u".repeat(64),
+ threshold = 2,
+ participantCount = 3,
+ hostPublicKey = "h".repeat(64),
+ round1Random = "1".repeat(64),
+ round2AuxRandom = "2".repeat(64),
+ )
+
+ MantraTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ DkgRitualListScreen(
+ activeUserPublicKey = "u".repeat(64),
+ chatRoomId = "chatRoomId",
+ initialDkgRitualListUIState = DkgRitualListUIState.Loaded(
+ localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = "chatRoomId",
+ userPublicKey = "u".repeat(64),
+ subject = "#Ekklesia",
+ description = null,
+ initialGiftWrapPayloadId = "sdfaer",
+ mlsGroupState = null
+ )
+ ),
+ ceremonies = listOf(
+ DkgRitualListUIState.Ceremony(
+ session = session.copy(
+ id = "subgroupSessionId",
+ parentChatRoomId = "parentChatRoomId",
+ subject = "Translators",
+ stage = DkgRitualStage.COLLECTING_ROUND_1
+ ),
+ coordinatorName = "Ada",
+ pendingApproval = DkgApprovalStep.ROUND_1
+ ),
+ DkgRitualListUIState.Ceremony(
+ session = session.copy(stage = DkgRitualStage.COMPLETE),
+ coordinatorName = "You"
+ )
+ )
+ ),
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
+ onNavigateBack = {},
+ onNavigateToRoute = {}
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index ae13c349..a5d67444 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -38,6 +38,7 @@ import press.mantra.compose.ui.composable.ChatRoomDetailScreen
import press.mantra.compose.ui.composable.ChatRoomMessagingScreen
import press.mantra.compose.ui.composable.CreateProfileScreen
import press.mantra.compose.ui.composable.DkgJoinApprovalScreen
+import press.mantra.compose.ui.composable.DkgRitualListScreen
import press.mantra.compose.ui.composable.DkgRitualScreen
import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen
import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
@@ -140,6 +141,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
+import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
@@ -1198,6 +1200,22 @@ fun MantraNavHost(
}
)
}
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ DkgRitualListScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ chatRepository = databaseChatRepository,
+ dkgRepository = databaseDkgRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onNavigateToRoute = { ceremonyRoute ->
+ navController.navigate(route = ceremonyRoute)
+ }
+ )
+ }
composable { backStackEntry ->
val route = backStackEntry.toRoute()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt
new file mode 100644
index 00000000..e23efb54
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt
@@ -0,0 +1,23 @@
+package press.mantra.compose.ui.composable.navigation.routes
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Every key ceremony a room holds.
+ *
+ * The room's list, not the reader's: a ceremony is run by the whole group, and
+ * which of them are waiting on this member is a status on each row rather than
+ * a different list -- the same shape as [ProposalListRoute], for the same
+ * reason.
+ *
+ * A room holds more than one as a matter of course since a subgroup's ChillDKG
+ * moved into its parent's room, and until this there was no screen that could
+ * name the second: [DkgRitualRoute] without a session id shows the newest, and
+ * the newest is routinely the group's own, long finished, with the subgroup's
+ * request buried underneath it.
+ */
+@Serializable
+data class DkgRitualListRoute(
+ val activeUserPublicKey: String,
+ val chatRoomId: String
+): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt
index bfefd1b6..a69c8b8d 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt
@@ -578,7 +578,8 @@ fun ChatTranscript(
@Composable
private fun CeremoniesAwaitingYouNotice(
ceremonies: List,
- onClick: (dkgSessionId: String) -> Unit,
+ /** Null where the notice stands for more than one, which no single screen can show. */
+ onClick: (dkgSessionId: String?) -> Unit,
) {
val single = ceremonies.singleOrNull()
@@ -602,7 +603,11 @@ private fun CeremoniesAwaitingYouNotice(
}
Card(
- onClick = { onClick(ceremonies.first().sessionId) },
+ // The one it names when it names one, and the list otherwise. A notice
+ // reading "3 key ceremonies need you" that opened the first of them --
+ // which is what this did -- answered a different question from the one
+ // it asked, and left the other two exactly as hard to find as before.
+ onClick = { onClick(single?.sessionId) },
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
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 7ef1bdb0..16eb4c5c 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
@@ -156,6 +156,25 @@ class ChatMessageListViewModel(
proposalsAwaitingYou.size > 1 &&
proposalsAwaitingYou.any { it.sessionId == sessionId }
+ /**
+ * Whether opening [sessionId] by itself would hide other ceremonies the room
+ * is waiting on this member for.
+ *
+ * [hidesOtherDecisions]'s counterpart, and the same argument: a ceremony line
+ * names one ceremony, and opening it is right while that is the only one
+ * asking. A room holds more than one as a matter of course since a subgroup's
+ * ChillDKG moved into its parent's room, and the second is not in the
+ * transcript beside the first -- so answering the one they tapped and leaving
+ * would look exactly like being done.
+ *
+ * Only for a ceremony that is itself waiting on them. A line about a ceremony
+ * the group finished is history, and a reader who taps one is asking to see
+ * that, not to be handed the queue.
+ */
+ fun hidesOtherCeremonies(sessionId: String): Boolean =
+ ceremoniesAwaitingYou.size > 1 &&
+ ceremoniesAwaitingYou.any { it.sessionId == sessionId }
+
fun startDirectMessage(participant: Participant) {
logger.d("Arming a direct message to ${participant.participantPublicKey}")
directMessageRecipient = participant
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt
new file mode 100644
index 00000000..38677d1c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt
@@ -0,0 +1,140 @@
+package press.mantra.compose.ui.view.model
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import co.touchlab.kermit.Logger
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.extensions.memberName
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.ui.view.state.DkgRitualListUIState
+
+/**
+ * Every key ceremony a room holds.
+ *
+ * A room routinely holds more than one: a parent's Marmot room hosts the
+ * ceremony of every subgroup the group ever makes, alongside whatever it holds
+ * of its own. Watching the whole list rather than the newest is the point --
+ * which of them is waiting on the reader is a fact about each one, and the
+ * newest is routinely the finished one.
+ *
+ * The counterpart to [ProposalListViewModel], and deliberately the same shape.
+ */
+class DkgRitualListViewModel(
+ val chatRoomId: String,
+ val activeUserPublicKey: HexKey,
+ initialDkgRitualListUIState: DkgRitualListUIState,
+ val chatRepository: ChatRepository,
+ val dkgRepository: DkgRepository,
+): ViewModel() {
+
+ private val _dkgRitualListUIState = MutableStateFlow(initialDkgRitualListUIState)
+
+ /**
+ * A flow rather than a `mutableStateOf`: this is written from
+ * `Dispatchers.IO`, and a `mutableStateOf` created in a view model's
+ * constructor silently drops a write that lands during the composition that
+ * created it. See [DkgRitualViewModel.dkgRitualUIState].
+ */
+ val dkgRitualListUIState: StateFlow =
+ _dkgRitualListUIState.asStateFlow()
+
+ private val logger = Logger.withTag(TAG)
+
+ /**
+ * Loads the room, then watches its ceremonies for as long as the screen
+ * lives.
+ *
+ * Every one of them moves on messages arriving from other members, so a
+ * screen that read the list once would go stale the moment somebody else
+ * took their step -- which is exactly when a member is likely to be looking
+ * at it.
+ */
+ fun initiate() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
+ if (localChatRoom == null) {
+ _dkgRitualListUIState.value =
+ DkgRitualListUIState.Error("Couldn't find the group")
+ return@launch
+ }
+
+ dkgRepository.observeSessionsForChatRoom(chatRoomId).collect { sessions ->
+ logger.d("${sessions.size} key ceremonies in $chatRoomId")
+
+ _dkgRitualListUIState.value = DkgRitualListUIState.Loaded(
+ localChatRoom = localChatRoom,
+ ceremonies = sessions.map { asCeremony(it, localChatRoom) }
+ )
+ }
+ }
+ }
+
+ /**
+ * One ceremony as a row.
+ *
+ * What it wants is asked of the repository rather than worked out here, so
+ * this list, the transcript's standing notice and the ceremony screen cannot
+ * disagree about which ceremonies still have something in them -- the same
+ * rule the proposal list keeps.
+ *
+ * An abandoned ceremony is asked nothing. It can want nothing: there is no
+ * step left to approve and no key to build a room from, and a row claiming
+ * otherwise would send a member to a screen with nothing on it.
+ */
+ private suspend fun asCeremony(
+ session: DkgSession,
+ localChatRoom: LocalChatRoom
+ ): DkgRitualListUIState.Ceremony {
+ val live = session.takeIf { it.stage != DkgRitualStage.FAILED }
+
+ return DkgRitualListUIState.Ceremony(
+ session = session,
+ coordinatorName = session.coordinatorPublicKey.memberName(
+ members = localChatRoom.localParticipants,
+ activeUserPublicKey = activeUserPublicKey
+ ),
+ pendingApproval = live?.let { dkgRepository.pendingApproval(it) },
+ // Only of the member who opened it: creating the room is the
+ // coordinator's job, and a row telling everybody else to do it would
+ // be telling them to do something they cannot.
+ roomAwaitingCreation = live
+ ?.takeIf { it.isCoordinator() }
+ ?.let { dkgRepository.roomAwaitingCreation(it) }
+ )
+ }
+
+ companion object {
+ private const val TAG = "DkgRitualListViewModel"
+
+ fun factory(
+ chatRoomId: String,
+ activeUserPublicKey: HexKey,
+ initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading,
+ chatRepository: ChatRepository,
+ dkgRepository: DkgRepository,
+ ): ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ DkgRitualListViewModel(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = activeUserPublicKey,
+ initialDkgRitualListUIState = initialDkgRitualListUIState,
+ chatRepository = chatRepository,
+ dkgRepository = dkgRepository
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt
new file mode 100644
index 00000000..0e9e533e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt
@@ -0,0 +1,68 @@
+package press.mantra.compose.ui.view.state
+
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.types.DkgApprovalStep
+
+sealed interface DkgRitualListUIState {
+ data class Loaded(
+ val localChatRoom: LocalChatRoom,
+
+ /** Every ceremony the room holds, newest first. */
+ val ceremonies: List = emptyList(),
+ ): DkgRitualListUIState {
+ /**
+ * The ones that cannot move until this member acts.
+ *
+ * Split out rather than sorted to the top, the way the proposal list
+ * splits its own -- the rest of the list is what the room has done,
+ * these are what it is waiting on the reader for. A room can have two
+ * at once the moment a subgroup's admins are the whole group, and
+ * burying the second under the first one's history is the whole reason
+ * this screen exists.
+ */
+ val waitingForYou: List = ceremonies.filter { it.awaitsYou }
+
+ val rest: List = ceremonies.filterNot { it.awaitsYou }
+ }
+
+ /**
+ * One ceremony, as a row.
+ *
+ * Both reasons a ceremony can want something are carried separately rather
+ * than folded into one flag, because they are owed by different people and
+ * read differently: an approval is this member's gate and nobody else can
+ * pass it, while finishing the setup is work the coordinator alone can come
+ * back to. The row says which.
+ */
+ data class Ceremony(
+ val session: DkgSession,
+
+ /** Whoever opened it, as a name to read. */
+ val coordinatorName: String,
+
+ /** What the ceremony is waiting on this member for, if anything. */
+ val pendingApproval: DkgApprovalStep? = null,
+
+ /**
+ * The room this finished ceremony's key derives, when it does not exist
+ * yet and this member is the one who would make it.
+ *
+ * Reaching COMPLETE finishes nothing on its own: the group has a key and
+ * somebody still has to get its state signed and create the room. The
+ * same reading the transcript's standing notice makes.
+ */
+ val roomAwaitingCreation: String? = null,
+ ) {
+ /** Whether this ceremony is making a subgroup rather than the room's own key. */
+ val isSubgroup: Boolean get() = session.parentChatRoomId != null
+
+ val awaitsYou: Boolean get() = pendingApproval != null || roomAwaitingCreation != null
+ }
+
+ data class Error(
+ val message: String
+ ): DkgRitualListUIState
+
+ data object Loading: DkgRitualListUIState
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt
new file mode 100644
index 00000000..7d49853d
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt
@@ -0,0 +1,157 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.runDesktopComposeUiTest
+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.types.DkgApprovalStep
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+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.ProvideSnackbarHost
+import press.mantra.compose.ui.theme.MantraTheme
+import press.mantra.compose.ui.view.state.DkgRitualListUIState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * What the ceremony list is for, read off the rendered screen.
+ *
+ * The ordering is settled in [press.mantra.compose.ui.view.model.DkgRitualListViewModel]
+ * and tested there. What is only true once it is drawn is that a row goes to
+ * the ceremony it names -- the whole reason this screen exists is that the
+ * screens before it could not say which of a room's ceremonies they meant --
+ * and that a room with none offers the way to hold one rather than ending in a
+ * blank page.
+ */
+@OptIn(ExperimentalTestApi::class)
+class DkgRitualListScreenJvmTest {
+ private val me = "11".repeat(32)
+ private val chatRoomId = "22".repeat(32)
+
+ @Test
+ fun `a row opens the ceremony it names, not the room's newest`() =
+ runDesktopComposeUiTest(500, 1200) {
+ var opened: Route? = null
+
+ setContent {
+ Screen(
+ state = loaded(
+ ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy(
+ pendingApproval = DkgApprovalStep.HOST_KEY
+ ),
+ ceremony("newest", DkgRitualStage.COMPLETE)
+ ),
+ onNavigateToRoute = { opened = it }
+ )
+ }
+
+ onNodeWithText("Join it by publishing your device's key")
+ .assertIsDisplayed()
+ .performClick()
+
+ assertEquals(
+ DkgRitualRoute(
+ activeUserPublicKey = me,
+ chatRoomId = chatRoomId,
+ dkgSessionId = "subgroup"
+ ),
+ opened
+ )
+ }
+
+ /** The two sections, so a ceremony owing nothing cannot be mistaken for one that does. */
+ @Test
+ fun `the ones waiting on you are headed apart from the rest`() =
+ runDesktopComposeUiTest(500, 1200) {
+ setContent {
+ Screen(
+ state = loaded(
+ ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy(
+ pendingApproval = DkgApprovalStep.HOST_KEY
+ ),
+ ceremony("newest", DkgRitualStage.COMPLETE)
+ )
+ )
+ }
+
+ onNodeWithText("Waiting for you").assertIsDisplayed()
+ onNodeWithText("Everything else").assertIsDisplayed()
+ onNodeWithText("The group has a key from this ceremony.").assertIsDisplayed()
+ }
+
+ @Test
+ fun `a room that has held none offers to hold one`() =
+ runDesktopComposeUiTest(500, 1200) {
+ var opened: Route? = null
+
+ setContent { Screen(state = loaded(), onNavigateToRoute = { opened = it }) }
+
+ onNodeWithText("This group has not held a key ceremony.").assertIsDisplayed()
+ onNodeWithText("Set up a shared key").assertIsDisplayed().performClick()
+
+ // No session id: there is no ceremony to name, and the ceremony
+ // screen is what decides whether the group can hold one at all.
+ assertEquals(
+ DkgRitualRoute(activeUserPublicKey = me, chatRoomId = chatRoomId),
+ opened
+ )
+ }
+
+ @Composable
+ private fun Screen(
+ state: DkgRitualListUIState,
+ onNavigateToRoute: (Route) -> Unit = {},
+ ) {
+ MantraTheme {
+ ProvideSnackbarHost {
+ DkgRitualListScreen(
+ activeUserPublicKey = me,
+ chatRoomId = chatRoomId,
+ initialDkgRitualListUIState = state,
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
+ onNavigateBack = {},
+ onNavigateToRoute = onNavigateToRoute,
+ )
+ }
+ }
+ }
+
+ private fun loaded(vararg ceremonies: DkgRitualListUIState.Ceremony) =
+ DkgRitualListUIState.Loaded(
+ localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = null,
+ )
+ ),
+ ceremonies = ceremonies.toList()
+ )
+
+ private fun ceremony(id: String, stage: DkgRitualStage) = DkgRitualListUIState.Ceremony(
+ session = DkgSession(
+ id = id,
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = me,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = stage,
+ ),
+ coordinatorName = "You",
+ )
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt
new file mode 100644
index 00000000..80798701
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt
@@ -0,0 +1,198 @@
+package press.mantra.compose.ui.view.model
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeout
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.Participant
+import press.mantra.compose.database.model.Profile
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalParticipant
+import press.mantra.compose.database.model.types.DkgApprovalStep
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.ui.view.state.DkgRitualListUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * What the key-ceremony list decides that nothing else can check.
+ *
+ * **Which ceremonies come first.** A room holds one per subgroup the group has
+ * ever made plus its own, and the newest is routinely the finished one -- so
+ * the list is only worth having if the two that want something from the reader
+ * are at the top of it rather than wherever their dates put them.
+ *
+ * **What counts as wanting something.** Two different things do, owed by
+ * different people: an approval gate this member cannot delegate, and a
+ * finished ceremony whose room only its coordinator can create. An abandoned
+ * ceremony wants neither, whatever the rows underneath it say.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class DkgRitualListViewModelJvmTest {
+ private val chatRoomId = "a1b2c3d4".repeat(8)
+ private val me = "11".repeat(32)
+ private val ada = "22".repeat(32)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `the ceremonies waiting on this member are separated from the rest`() = runBlocking {
+ val mine = session("mine", DkgRitualStage.COLLECTING_ROUND_1)
+ val finished = session("finished", DkgRitualStage.COMPLETE)
+ val subgroup = session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS)
+ .copy(parentChatRoomId = "parent", subject = "Translators")
+
+ val loaded = load(
+ // Newest first, the way the query returns them -- and the one that
+ // wants something is deliberately not the newest.
+ sessions = listOf(finished, subgroup, mine),
+ pending = mapOf(
+ "mine" to DkgApprovalStep.ROUND_1,
+ "subgroup" to DkgApprovalStep.HOST_KEY
+ )
+ )
+
+ assertEquals(
+ listOf("subgroup", "mine"),
+ loaded.waitingForYou.map { it.session.id },
+ "a ceremony waiting on this member was left down the list"
+ )
+ assertEquals(listOf("finished"), loaded.rest.map { it.session.id })
+ assertTrue(loaded.waitingForYou.single { it.session.id == "subgroup" }.isSubgroup)
+ }
+
+ /**
+ * A finished ceremony still owes its coordinator a room, and owes everybody
+ * else nothing -- a row telling the rest of the group to create it would be
+ * telling them to do something they cannot.
+ */
+ @Test
+ fun `only the coordinator is told to finish setting up a finished ceremony`() = runBlocking {
+ val theirs = session("theirs", DkgRitualStage.COMPLETE).copy(coordinatorPublicKey = ada)
+ val ours = session("ours", DkgRitualStage.COMPLETE)
+
+ val loaded = load(
+ sessions = listOf(theirs, ours),
+ roomAwaitingCreation = "theRoomItDerives"
+ )
+
+ assertEquals(listOf("ours"), loaded.waitingForYou.map { it.session.id })
+ assertEquals("Ada", loaded.rest.single().coordinatorName)
+ assertEquals("You", loaded.waitingForYou.single().coordinatorName)
+ }
+
+ /**
+ * An abandoned ceremony is asked nothing at all. There is no step left to
+ * approve and no key to build a room from, so a row that claimed otherwise
+ * would send a member to a screen with nothing on it.
+ */
+ @Test
+ fun `an abandoned ceremony is never waiting on anybody`() = runBlocking {
+ val loaded = load(
+ sessions = listOf(session("abandoned", DkgRitualStage.FAILED)),
+ // Both answers say yes, and both have to be ignored on a failure.
+ pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1),
+ roomAwaitingCreation = "theRoomItDerives"
+ )
+
+ assertTrue(loaded.waitingForYou.isEmpty())
+
+ val row = loaded.rest.single()
+ assertNull(row.pendingApproval)
+ assertNull(row.roomAwaitingCreation)
+ }
+
+ private suspend fun load(
+ sessions: List,
+ pending: Map = emptyMap(),
+ roomAwaitingCreation: String? = null,
+ ): DkgRitualListUIState.Loaded {
+ val viewModel = DkgRitualListViewModel(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = me,
+ initialDkgRitualListUIState = DkgRitualListUIState.Loading,
+ chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
+ override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room()
+ },
+ dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
+ override fun observeSessionsForChatRoom(
+ chatRoomId: String
+ ): Flow> = flowOf(sessions)
+
+ override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
+ pending[session.id]
+
+ override suspend fun roomAwaitingCreation(session: DkgSession): String? =
+ roomAwaitingCreation
+ }
+ )
+
+ viewModel.initiate()
+
+ return withTimeout(5_000) {
+ viewModel.dkgRitualListUIState.first { it is DkgRitualListUIState.Loaded }
+ } as DkgRitualListUIState.Loaded
+ }
+
+ private fun room() = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = null,
+ ),
+ localParticipants = listOf(me to null, ada to "Ada").map { (publicKey, name) ->
+ LocalParticipant(
+ participant = Participant(
+ participantPublicKey = publicKey,
+ chatRoomId = chatRoomId,
+ relayHint = null,
+ ),
+ profile = name?.let {
+ Profile(
+ publicKey = publicKey,
+ displayName = it,
+ nostrEventId = "ee".repeat(32),
+ )
+ },
+ )
+ },
+ )
+
+ private fun session(id: String, stage: DkgRitualStage) = DkgSession(
+ id = id,
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = me,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = stage,
+ )
+}