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