feat(dkg): every key ceremony a room holds, pending first
A room holds more than one ceremony as a matter of course, and has done since a subgroup's ChillDKG moved into its parent's Marmot room: the parent's own, long finished, plus one for every subgroup the group has ever made. Nothing in the app could name the second of them. `DkgRitualRoute` without a session id shows the room's *newest*, which is routinely the finished one with the subgroup's request buried underneath it. The group's details reached only that. The transcript could name one, by scrolling to the line it was about -- which for a subgroup means scrolling past whatever else the room has been used for, and is impossible for a ceremony whose line never arrived. **`DkgRitualListScreen`, deliberately the same shape as `ProposalListScreen`.** One row per ceremony off `observeSessionsForChatRoom`, split into "waiting for you" and "everything else". Split rather than sorted, for the reason `ProposalListUIState` gives: the rest of the list is what the room has done, these are what it is waiting on the reader for, and they are a different kind of thing to read. The finished ones are kept, because how this group came to hold the keys it holds is the other half of what the screen is for. **What "waiting on you" means is two things, and they are owed by different people.** An approval gate this member cannot delegate, and a finished ceremony whose room only its coordinator can create -- a ceremony reaching COMPLETE finishes nothing on its own, and nothing else in the app would tell the coordinator so. Both are asked of `DkgRepository` rather than re-derived, the same readings the transcript's standing notice and the ceremony screen make, so the three cannot disagree. An abandoned ceremony is asked neither: 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. **A row says what the key is for before it says where it has got to**, because a room holding two holds them for different groups and a row that only reported progress would leave a member guessing which. A subgroup is named by its proposal, so there is a third branch for the member who arrives before that name has -- it still says which of the two kinds of key this is. The quorum is on the row too: a ceremony fixes it and it cannot be changed afterwards. **Three entry points, two of which were gaps.** `ChatRoomDetailScreen` gets a "Key ceremonies" row in *every* room, including the Marmot ones the `mlsGroupState` check keeps "Shared key" out of. The two are different questions and both are kept: "Shared key" offers a room a key of its own, which only a NIP-17 room can want; this lists the ceremonies a room *holds*. A parent's Marmot room has never had a door to its subgroups' ceremonies at all. The transcript's `CeremoniesAwaitingYouNotice` says "3 key ceremonies need you" and then opened `ceremonies.first()` -- answering a different question from the one it asked, and leaving the other two exactly as hard to find as before. It now opens the list whenever it stands for more than one, which is what the signing notice beside it already did. A ceremony line in the transcript routes to the list when it cannot name a session -- a line written before chat rows carried one -- or when the ceremony it names is not the only one waiting on this member. `hidesOtherCeremonies` mirrors `hidesOtherDecisions` exactly, including its restraint: only for a ceremony that is itself waiting on them, because a line about a finished one is history and a reader who taps it is asking to see that, not to be handed a queue. **An empty list offers the way out of itself**, rather than ending in a blank page that sends a member back to the group's details to find the other door. It only offers to ask; the ceremony screen still decides whether the group is in a position to hold one. `DkgRitualListViewModel` is a `MutableStateFlow` from the start rather than a `by mutableStateOf`, for the reason the commit before this one found. **Tests, 6 cases in 2 files.** `DkgRitualListViewModelJvmTest` covers the three decisions nothing else can check: that a ceremony wanting something is at the top even when its date is not, that only the coordinator is told to finish setting up a finished one, and that an abandoned ceremony is waiting on nobody even when both lookups say it is. `DkgRitualListScreenJvmTest` renders it and checks the thing that is only true once drawn -- that a row opens the ceremony it names rather than the room's newest, which is the whole reason the screen exists -- plus the two headings and the empty state's offer. **Not done.** Nobody has looked at this screen running; the render tests prove it composes, sections and routes, which is not the same thing. 970 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e6ca0a080
commit
a14ea3d9d3
12 files changed
+1213
-7
No files matched your search
@@ -501,4 +501,17 @@
|
||||
<string name="accept_as_a_parent">Accept as a parent</string>
|
||||
<string name="a_group_is_a_subgroup_once_its_own_quorum_says_so">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.</string>
|
||||
<string name="asked_this_group_to_accept_that_parent">Asked this group to accept that parent</string>
|
||||
<!-- Every key ceremony a room holds, pending first: the list, and where each one has got to. -->
|
||||
<string name="key_ceremonies">Key ceremonies</string>
|
||||
<string name="this_group_has_held_no_key_ceremony">This group has not held a key ceremony.</string>
|
||||
<string name="the_groups_shared_key">The group's shared key</string>
|
||||
<string name="a_subgroups_key">A subgroup's key</string>
|
||||
<string name="a_subgroup_named_s">A subgroup: %1$s</string>
|
||||
<string name="waiting_for_members_to_join">Waiting for members to join.</string>
|
||||
<string name="waiting_for_everyone_to_commit_their_part">Waiting for everyone to commit their part.</string>
|
||||
<string name="waiting_for_everyone_to_confirm_the_result">Waiting for everyone to confirm the result.</string>
|
||||
<string name="the_group_has_a_key_from_this_ceremony">The group has a key from this ceremony.</string>
|
||||
<string name="abandoned_no_key_was_created">Abandoned. No key was created.</string>
|
||||
<string name="s_of_s_members_will_sign_with_it">%1$s of %2$s members will sign with it</string>
|
||||
<string name="set_up_a_shared_key">Set up a shared key</string>
|
||||
</resources>
|
||||
+35
@@ -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 --
|
||||
|
||||
+31
-5
@@ -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 ->
|
||||
|
||||
+504
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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<DkgRitualListRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<DkgRitualListRoute>()
|
||||
|
||||
DkgRitualListScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToRoute = { ceremonyRoute ->
|
||||
navController.navigate(route = ceremonyRoute)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<ProposalListRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ProposalListRoute>()
|
||||
|
||||
|
||||
+23
@@ -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()
|
||||
+7
-2
@@ -578,7 +578,8 @@ fun ChatTranscript(
|
||||
@Composable
|
||||
private fun CeremoniesAwaitingYouNotice(
|
||||
ceremonies: List<ChatMessageListViewModel.AwaitingCeremony>,
|
||||
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,
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
+140
@@ -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> =
|
||||
_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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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<Ceremony> = 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<Ceremony> = ceremonies.filter { it.awaitsYou }
|
||||
|
||||
val rest: List<Ceremony> = 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
|
||||
}
|
||||
+157
@@ -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",
|
||||
)
|
||||
}
|
||||
+198
@@ -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<DkgSession>,
|
||||
pending: Map<String, DkgApprovalStep> = 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<List<DkgSession>> = 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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user