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/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt
index 1353970d..fcb53e1f 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt
@@ -110,26 +110,27 @@ class DatabaseDkgRepository(
): Flow> =
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
+ /**
+ * By session id, not by room. A ceremony room holds more than one ceremony
+ * the moment a subgroup's admins are the whole group, and a lookup by room
+ * answered about whichever the room's rule picked -- for a room holding only
+ * a subgroup's ceremony, asking for the room's *own* found nothing at all,
+ * and a finished subgroup with a signed key state could never be created.
+ */
+ override suspend fun roomDerivedBy(dkgSessionId: String): String? =
+ database.dkgSessionDao()
+ .getSessionById(dkgSessionId)
+ ?.thresholdPublicKey
+ ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
+
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow =
database.groupSignedEventDao()
.observeByKind(GroupKeyStateEvent.KIND)
.map { signedEvents ->
- // The room this ceremony's key derives, resolved per emission
- // rather than once: the ceremony has no key until it finishes,
- // and this flow is running before it does.
- //
- // By session id, not by room. A ceremony room holds more than one
- // ceremony the moment a subgroup's admins are the whole group, and
- // a lookup by room answered about whichever the room's rule picked
- // -- for a room holding only a subgroup's ceremony, asking for the
- // room's *own* found nothing at all, and a finished subgroup with
- // a signed key state could never be created.
- val subjectRoomId = database.dkgSessionDao()
- .getSessionById(dkgSessionId)
- ?.thresholdPublicKey
- ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
-
- subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
+ // Resolved per emission rather than once: the ceremony has no key
+ // until it finishes, and this flow is running before it does.
+ roomDerivedBy(dkgSessionId)
+ ?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
}
override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try {
@@ -176,14 +177,9 @@ class DatabaseDkgRepository(
database.groupSignedEventDao()
.observeByKind(SubgroupBirthCertificateEvent.KIND)
.map { signedEvents ->
- // The room the ceremony's key derives, resolved per emission
- // rather than once: the ceremony has no key until it finishes,
- // and this flow is running before it does.
- val subgroupChatRoomId = database.dkgSessionDao()
- .getSessionById(dkgSessionId)
- ?.thresholdPublicKey
- ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
- ?: return@map null
+ // Resolved per emission rather than once: the ceremony has no key
+ // until it finishes, and this flow is running before it does.
+ val subgroupChatRoomId = roomDerivedBy(dkgSessionId) ?: return@map null
signedEvents
.filter {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt
index 9c1f8155..0554151d 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt
@@ -170,15 +170,7 @@ object SubgroupBirthCertificateEvent {
subgroupChatRoomId: String,
parentChatRoomId: String
): Boolean = runCatching {
- if (event.kind != KIND) return false
-
- if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false
-
- val addressed = parseSubgroupChatRoomId(event.tags) ?: return false
- if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false
-
- val named = parseParentChatRoomId(event.tags) ?: return false
- if (!named.equals(parentChatRoomId, ignoreCase = true)) return false
+ if (!addresses(event, subgroupChatRoomId, parentChatRoomId)) return false
val thresholdPublicKey = parseThresholdPublicKey(event.tags) ?: return false
val path = parsePath(event.tags) ?: return false
@@ -189,6 +181,38 @@ object SubgroupBirthCertificateEvent {
GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId)
}.getOrDefault(false)
+ /**
+ * Whether this event is *addressed to* that child by that parent -- the
+ * half of [certifies] that can be asked of a proposal nobody has signed.
+ *
+ * **Not a weaker [certifies].** It says who the event is about, not that
+ * the parent's quorum agreed to it: no derivation and no signature, so an
+ * event that merely claims the right pair passes. Nothing may decide a
+ * lineage on this.
+ *
+ * What it is for is telling one *in-flight* certificate signing from
+ * another in the same room. A parent certifies every subgroup it ever makes
+ * from one room, so the ceremony screen has several to choose between and
+ * no signature to choose by -- the proposal is what the quorum has not yet
+ * signed. The three values it reads are assembled together by
+ * [assembleTags], so they agree by construction.
+ */
+ fun addresses(
+ event: Event,
+ subgroupChatRoomId: String,
+ parentChatRoomId: String
+ ): Boolean = runCatching {
+ if (event.kind != KIND) return false
+
+ if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false
+
+ val addressed = parseSubgroupChatRoomId(event.tags) ?: return false
+ if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false
+
+ val named = parseParentChatRoomId(event.tags) ?: return false
+ named.equals(parentChatRoomId, ignoreCase = true)
+ }.getOrDefault(false)
+
/**
* The name tag. A bare two-element tag with no class of its own, because
* nothing but the proposal screen and the subgroup list ever reads it and
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
index 1e6c16d6..c61a9e85 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
@@ -65,6 +65,19 @@ interface DkgRepository {
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
+ /**
+ * The id of the room a ceremony's key derives, or null while it has no key.
+ *
+ * The one name a ceremony and the room it makes have in common, and the only
+ * way to tell two of a parent's subgroups apart in anything written about
+ * the room rather than about the ceremony -- a birth certificate names the
+ * child, not the ceremony that produced it.
+ *
+ * Null is the ordinary answer rather than a failure: a ceremony has no key
+ * until it finishes, and every caller here is watching from before that.
+ */
+ suspend fun roomDerivedBy(dkgSessionId: String): String?
+
/**
* Opens a ritual in [localChatRoom], making this device the coordinator.
*
@@ -213,6 +226,8 @@ interface DkgRepository {
override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null
+ override suspend fun roomDerivedBy(dkgSessionId: String): String? = null
+
override suspend fun proposeRitual(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
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/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt
index cc358aa5..d7513e87 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt
@@ -22,6 +22,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
@@ -100,7 +101,7 @@ internal fun DkgApprovalScaffold(
)
}
) { padding ->
- when (val state = dkgRitualViewModel.dkgRitualUIState) {
+ when (val state = dkgRitualViewModel.dkgRitualUIState.collectAsState().value) {
is DkgRitualUIState.Loading -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
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/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
index f669285c..3ea26e01 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
@@ -37,6 +37,7 @@ 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
@@ -154,7 +155,7 @@ fun DkgRitualScreen(
)
)
- ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState) { uiState ->
+ ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState.collectAsState().value) { uiState ->
when (val dkgRitualUIState = uiState) {
is DkgRitualUIState.Error -> {
Column(
@@ -168,7 +169,7 @@ fun DkgRitualScreen(
is DkgRitualUIState.Loaded -> {
val session = dkgRitualUIState.session
val participantCount = dkgRitualUIState.participantCount
- val canStartRitual = dkgRitualViewModel.canStartRitual()
+ val canStartRitual = dkgRitualViewModel.canStartRitual(dkgRitualUIState)
val isActionPending = dkgRitualViewModel.isActionPending.value
Scaffold(
@@ -286,7 +287,7 @@ fun DkgRitualScreen(
QuorumStepper(
threshold = dkgRitualViewModel.threshold.value,
participantCount = participantCount,
- quorumRange = dkgRitualViewModel.quorumRange(),
+ quorumRange = dkgRitualViewModel.quorumRange(dkgRitualUIState),
onThresholdChange = dkgRitualViewModel::setThreshold
)
} else {
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/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
index 76d61b2b..5f6858ac 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
@@ -1,9 +1,7 @@
package press.mantra.compose.ui.view.model
import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -36,7 +34,10 @@ import press.mantra.compose.identity.Identity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
@@ -60,8 +61,51 @@ class DkgRitualViewModel(
val dkgRepository: DkgRepository,
): ViewModel() {
- var dkgRitualUIState: DkgRitualUIState by mutableStateOf(initialDkgRitualUIState)
- private set
+ private val _dkgRitualUIState = MutableStateFlow(initialDkgRitualUIState)
+
+ /**
+ * What the screen draws -- a flow rather than a `mutableStateOf`, because
+ * seven flows write it.
+ *
+ * The ceremony, the room's other ceremonies, the ceremony's messages, the
+ * key state, whether the key state is signed, the certificate and the
+ * certificate's signing all fold into this one object, and every one of
+ * them emits on Room's own executor. Held
+ * as a `var ... by mutableStateOf`, each of those was a read, a `copy` and
+ * an assignment with a suspension point in the middle, so two emissions
+ * arriving together silently dropped one: whichever read first wrote last,
+ * over the top of the other's change.
+ *
+ * The change that went missing most visibly was [DkgRitualUIState.Loaded.session],
+ * copied back to null by a watcher that had read the state before the
+ * ceremony landed in it -- and a screen with no session is the screen that
+ * offers to start one. It stayed that way until some other table changed and
+ * the session flow emitted again, which for a ceremony waiting on an absent
+ * member is never.
+ *
+ * [MutableStateFlow.update] retries on a losing compare-and-set, so a change
+ * can only be folded into the state that is actually current. Every write
+ * below goes through it -- see [updateLoaded]. It is the same rule the
+ * wallet view models already keep, for the same reason.
+ */
+ val dkgRitualUIState: StateFlow = _dkgRitualUIState.asStateFlow()
+
+ /**
+ * Folds a change into the loaded state, atomically, and does nothing when
+ * the screen is not in it -- the `as? Loaded ?: return` that every one of
+ * these call sites used to write by hand.
+ *
+ * [block] is re-run when it loses the race, so it has to be a pure function
+ * of the state it is handed: read nothing else out of the view model inside
+ * it, and do the suspending work above the call.
+ */
+ private inline fun updateLoaded(
+ block: (DkgRitualUIState.Loaded) -> DkgRitualUIState.Loaded
+ ) {
+ _dkgRitualUIState.update { current ->
+ if (current is DkgRitualUIState.Loaded) block(current) else current
+ }
+ }
private val logger = Logger.withTag(TAG)
@@ -70,9 +114,6 @@ class DkgRitualViewModel(
/** Watches the current session's messages; replaced whenever the session changes. */
private var messageObserver: Job? = null
- /** Watches the room's signing sessions, which is where the key state is agreed. */
- private var keyStateObserver: Job? = null
-
/** Watches the parent certifying this subgroup; re-pointed as the purpose is learned. */
private var certificateObserver: Job? = null
@@ -101,9 +142,15 @@ class DkgRitualViewModel(
* Any member may: ChillDKG gives the coordinator no say in the outcome, and a
* NIP-17 room records no creator to reserve the job for. What rules it out is
* a ceremony already running, or a group too small to split a key across.
+ *
+ * [state] is passed in by the screen rather than read off the view model,
+ * because the state is a flow now and reading `.value` inside composition
+ * observes nothing -- the offer would be decided once and never revised. The
+ * screen hands in the state it is drawing, which is also the only state the
+ * answer should be about while a transition still holds the outgoing one.
*/
- fun canStartRitual(): Boolean {
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return false
+ fun canStartRitual(state: DkgRitualUIState = dkgRitualUIState.value): Boolean {
+ val loaded = state as? DkgRitualUIState.Loaded ?: return false
if (loaded.session != null && loaded.session.stage != DkgRitualStage.FAILED) return false
@@ -116,8 +163,8 @@ class DkgRitualViewModel(
return ChillDkgRitualManager.canRunRitual(loaded.localChatRoom)
}
- fun quorumRange(): IntRange {
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded
+ fun quorumRange(state: DkgRitualUIState = dkgRitualUIState.value): IntRange {
+ val loaded = state as? DkgRitualUIState.Loaded
?: return ChatRoomType.quorumRange(ChatRoomType.MINIMUM_QUORUM)
// Below the minimum the range runs backwards and is empty, which `coerceIn`
@@ -137,17 +184,10 @@ class DkgRitualViewModel(
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
if (localChatRoom == null) {
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't load this chat")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't load this chat")
return@launch
}
- dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
- threshold.value = ChatRoomType
- .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
- .coerceIn(quorumRange())
-
- observeKeyState()
-
// Room-scoped, deliberately. Only the member who opened a subgroup
// arrives here knowing it is one -- everybody else reaches this screen
// from the room, through the transcript's ritual notice or the group's
@@ -171,30 +211,63 @@ class DkgRitualViewModel(
// Whether the room has any ceremony at all, which is a different
// question from which one is being shown -- see
// `DkgRitualUIState.roomHasCeremony`.
- launch {
- dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { newest ->
- val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
+ val roomSessions = dkgRepository.observeLatestSessionForChatRoom(chatRoomId)
- dkgRitualUIState = current.copy(
- roomHasCeremony = newest != null && newest.stage != DkgRitualStage.FAILED
- )
+ // Both answers are read here, before anything is published, rather
+ // than awaited after a bare `Loaded` has gone up. A `Loaded` with no
+ // session and no ceremony *is* the screen that offers to start one,
+ // so publishing it first flashed "start key ceremony" over a ceremony
+ // already running -- for as long as the two queries took, which is
+ // long enough to read and long enough to tap. The flows below keep
+ // both current from here on; this only settles what the first frame
+ // says.
+ val shownSession = sessions.first()
+ val newestSession = roomSessions.first()
+
+ _dkgRitualUIState.value = DkgRitualUIState.Loaded(
+ localChatRoom = localChatRoom,
+ session = shownSession,
+ parentChatRoomId = shownSession?.parentChatRoomId,
+ pendingApproval = shownSession?.let { dkgRepository.pendingApproval(it) },
+ roomHasCeremony = newestSession != null &&
+ newestSession.stage != DkgRitualStage.FAILED
+ )
+
+ threshold.value = ChatRoomType
+ .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
+ .coerceIn(quorumRange())
+
+ launch {
+ roomSessions.collect { newest ->
+ updateLoaded { current ->
+ current.copy(
+ roomHasCeremony = newest != null &&
+ newest.stage != DkgRitualStage.FAILED
+ )
+ }
}
}
sessions.collect { session ->
- val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
- ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
-
// The session is the only thing that knows what the ceremony is
// for. The route used to carry it too, which was right for the one
// member who opened the subgroup and null for everybody else.
val parent = session?.parentChatRoomId
- dkgRitualUIState = loaded.copy(
- session = session,
- parentChatRoomId = parent,
- pendingApproval = session?.let { dkgRepository.pendingApproval(it) }
- )
+ // Above the update, not inside it: `update` re-runs its block when
+ // it loses the race, and a block that re-runs cannot suspend.
+ val pending = session?.let { dkgRepository.pendingApproval(it) }
+
+ _dkgRitualUIState.update { current ->
+ val loaded = current as? DkgRitualUIState.Loaded
+ ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
+
+ loaded.copy(
+ session = session,
+ parentChatRoomId = parent,
+ pendingApproval = pending
+ )
+ }
// Re-point the per-ceremony watchers whenever the ceremony or its
// purpose changes, which for a member arriving cold is once.
@@ -212,23 +285,32 @@ class DkgRitualViewModel(
if (session != null) {
messageObserver = viewModelScope.launch(Dispatchers.IO) {
dkgRepository.observeMessages(session.id).collect { messages ->
- val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
-
fun senders(kind: Kind) = messages
.filter { it.kind == kind }
.map { it.participantPublicKey }
.toSet()
- dkgRitualUIState = current.copy(
- hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY),
- round1Participants = senders(DkgRitualEvents.ROUND_1),
- round2Participants = senders(DkgRitualEvents.ROUND_2),
- // Recomputed here as well as on the session, because
- // whether round 1 can be asked for depends on how many
- // host keys have arrived, which only this flow sees.
- pendingApproval = current.session
- ?.let { dkgRepository.pendingApproval(it) }
- )
+ // Recomputed here as well as on the session, because
+ // whether round 1 can be asked for depends on how many
+ // host keys have arrived, which only this flow sees.
+ val pending = dkgRepository.pendingApproval(session)
+
+ updateLoaded { current ->
+ // This watcher belongs to one ceremony, and
+ // cancelling it does not reach into a body already
+ // running -- so say which ceremony these counts are
+ // for rather than letting them land on the next.
+ if (current.session?.id != session.id) {
+ return@updateLoaded current
+ }
+
+ current.copy(
+ hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY),
+ round1Participants = senders(DkgRitualEvents.ROUND_1),
+ round2Participants = senders(DkgRitualEvents.ROUND_2),
+ pendingApproval = pending
+ )
+ }
}
}
}
@@ -236,45 +318,6 @@ class DkgRitualViewModel(
}
}
- /**
- * Follows the group agreeing what its admin room will sign with.
- *
- * Nothing here watches a [GroupKeyState] row, because there is no room yet
- * for one to belong to -- being able to follow this before the room exists
- * is the point. What is watched instead is the session, for how the signing
- * is going, and the signed event, for whether it finished.
- *
- * The room a ceremony runs in signs nothing else, so the newest session in
- * it is the key state's -- but it is matched on the kind it carries anyway,
- * so a room that one day signs something else does not confuse the two.
- */
- private fun observeKeyState() {
- keyStateObserver?.cancel()
- keyStateObserver = viewModelScope.launch(Dispatchers.IO) {
- // Two flows because they answer two questions off two tables. How
- // the signing is going is the session row; whether it finished is
- // the signed event, and only that second one can be asked at all
- // before there is a room -- which is the whole point of asking it.
- launch {
- dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
-
- dkgRitualUIState = loaded.copy(
- keyStateSession = sessions.firstOrNull { local ->
- local.items.any { item ->
- runCatching {
- Event.fromJson(item.unsignedEventJson).kind
- }.getOrNull() == GroupKeyStateEvent.KIND
- }
- }?.session
- )
- }
- }
-
-
- }
- }
-
/**
* Follows one ceremony: whether its key state has been signed, and -- for a
* subgroup -- whether its parent has certified it.
@@ -293,54 +336,113 @@ class DkgRitualViewModel(
certificateObserver?.cancel()
if (dkgSessionId == null) {
- dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded)
- ?.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null)
- ?: dkgRitualUIState
+ updateLoaded {
+ it.copy(
+ isKeyStateSigned = false,
+ keyStateSession = null,
+ isCertified = false,
+ certificateSession = null
+ )
+ }
return
}
certificateObserver = viewModelScope.launch(Dispatchers.IO) {
+ // How the agreement is going, as opposed to whether it finished --
+ // two questions off two tables, and only the second can be asked
+ // before there is a room at all, which is the point of asking it.
+ //
+ // By the ceremony the proposal names, not by kind in this room. The
+ // room a ceremony runs in used to sign nothing else, and that is
+ // what this read assumed; a parent's Marmot room now hosts the
+ // ceremony of every subgroup the group makes, so it holds one of
+ // these per subgroup. Matching on kind alone handed the screen the
+ // newest of them -- which is some other subgroup's -- and with it a
+ // progress bar for a signing this ceremony is not in and a button
+ // disabled on the strength of it. Permanently, once that other one
+ // completed: the guard only reopens on a failure.
+ //
+ // `FrostKeyTag` is on the proposal from the moment it is made, so
+ // this works before the ceremony has a key and before anybody signs.
+ launch {
+ dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
+ val keyState = sessions.firstOrNull { local ->
+ local.items.any { item ->
+ val event = runCatching {
+ Event.fromJson(item.unsignedEventJson)
+ }.getOrNull() ?: return@any false
+
+ event.kind == GroupKeyStateEvent.KIND &&
+ GroupKeyStateEvent.parseDkgSessionId(event.tags) == dkgSessionId
+ }
+ }?.session
+
+ updateLoaded { it.copy(keyStateSession = keyState) }
+ }
+ }
+
// Whether the group has agreed what the room this ceremony's key
// derives will sign with. Asked by ceremony rather than by room: the
// room holds however many ceremonies it holds, and the answer is
// about this one's key.
launch {
dkgRepository.observeSignedGroupKeyState(dkgSessionId).collect { state ->
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
-
- dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null)
+ updateLoaded { it.copy(isKeyStateSigned = state != null) }
}
}
if (parentChatRoomId == null) {
- dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded)
- ?.copy(isCertified = false, certificateSession = null)
- ?: dkgRitualUIState
+ updateLoaded { it.copy(isCertified = false, certificateSession = null) }
return@launch
}
launch {
dkgRepository.observeBirthCertificate(dkgSessionId, parentChatRoomId)
.collect { certificate ->
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
-
- dkgRitualUIState = loaded.copy(isCertified = certificate != null)
+ updateLoaded { it.copy(isCertified = certificate != null) }
}
}
launch {
+ // The child this ceremony is making, which is what a certificate
+ // names. Resolved per emission rather than once, because the
+ // ceremony has no key until it finishes and this flow is running
+ // before it does -- the same reason `observeBirthCertificate`
+ // resolves it per emission.
+ //
+ // Scoped to that child, not to the kind. A parent certifies every
+ // subgroup it ever makes from the same room, so by kind alone this
+ // found the newest certificate signing in the parent -- some other
+ // child's -- and read its progress as this one's. Worse, the rung
+ // disables "ask the group to certify" while a certificate session
+ // is running and un-failed, so once a parent had certified one
+ // subgroup it could never be asked to certify another.
dkgRepository.observeSigningSessions(parentChatRoomId).collect { sessions ->
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
+ val subgroupChatRoomId = dkgRepository.roomDerivedBy(dkgSessionId)
- dkgRitualUIState = loaded.copy(
- certificateSession = sessions.firstOrNull { local ->
+ val certificate = subgroupChatRoomId?.let { child ->
+ sessions.firstOrNull { local ->
local.items.any { item ->
- runCatching {
- Event.fromJson(item.unsignedEventJson).kind
- }.getOrNull() == SubgroupBirthCertificateEvent.KIND
+ val event = runCatching {
+ Event.fromJson(item.unsignedEventJson)
+ }.getOrNull() ?: return@any false
+
+ // `addresses`, not `certifies`: the thing being
+ // matched is a proposal, and `certifies` ends on
+ // a signature by the parent's quorum, which is
+ // precisely what has not happened yet. Whether
+ // the parent actually agreed is `isCertified`,
+ // which does check it.
+ SubgroupBirthCertificateEvent.addresses(
+ event = event,
+ subgroupChatRoomId = child,
+ parentChatRoomId = parentChatRoomId,
+ )
}
}?.session
- )
+ }
+
+ updateLoaded { it.copy(certificateSession = certificate) }
}
}
}
@@ -359,7 +461,7 @@ class DkgRitualViewModel(
fun proposeKeyState() {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return
if (session.thresholdPublicKey == null) return
@@ -398,7 +500,7 @@ class DkgRitualViewModel(
if (signing == null) {
logger.e("Failed to propose the key state for $chatRoomId")
- dkgRitualUIState = DkgRitualUIState.Error(
+ _dkgRitualUIState.value = DkgRitualUIState.Error(
"Couldn't ask the group to agree its signing key. Please try again."
)
}
@@ -421,7 +523,7 @@ class DkgRitualViewModel(
fun proposeBirthCertificate() {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
// The state's, not the route's. Every step after the ceremony is open to
// any of the subgroup's admins -- see `SubgroupManager` -- and they reach
// this screen from the room, with no route to have told them anything.
@@ -459,7 +561,7 @@ class DkgRitualViewModel(
if (signing == null) {
logger.e("Failed to propose a birth certificate in $parent")
- dkgRitualUIState = DkgRitualUIState.Error(
+ _dkgRitualUIState.value = DkgRitualUIState.Error(
"Couldn't ask the group to certify this subgroup. Please try again."
)
}
@@ -470,11 +572,11 @@ class DkgRitualViewModel(
fun startRitual() {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) {
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return
}
@@ -492,7 +594,7 @@ class DkgRitualViewModel(
if (session == null) {
logger.e("Failed to start DKG ritual for $chatRoomId")
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.")
}
// On success the session flow above delivers the new row; no need to
// set it here and risk racing the observer.
@@ -509,12 +611,12 @@ class DkgRitualViewModel(
fun approve(step: DkgApprovalStep) {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return
val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) {
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return
}
@@ -558,7 +660,7 @@ class DkgRitualViewModel(
fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return
val thresholdPublicKey = session.thresholdPublicKey ?: return
@@ -568,7 +670,7 @@ class DkgRitualViewModel(
val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) {
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return
}
@@ -594,7 +696,7 @@ class DkgRitualViewModel(
?: if (subgroupParent != null) "Subgroup" else "Group"
isActionPending.value = true
- dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList())
+ updateLoaded { it.copy(adminGroupBlockedOn = emptyList()) }
viewModelScope.launch(Dispatchers.IO) {
val outcome = chatRepository.createMarmotGroup(
@@ -617,19 +719,21 @@ class DkgRitualViewModel(
// Named rather than counted. A member cannot publish a key
// package on somebody else's behalf, so the only useful thing
// to say is whose door to knock on.
- dkgRitualUIState = loaded.copy(
- adminGroupBlockedOn = outcome.missing.map { publicKey ->
- loaded.ritualMembers
- .firstOrNull { it.participant.participantPublicKey == publicKey }
- ?.profile
- ?.humanReadableNameOrPubkey()
- ?: publicKey.take(12)
- }
- )
+ updateLoaded { current ->
+ current.copy(
+ adminGroupBlockedOn = outcome.missing.map { publicKey ->
+ current.ritualMembers
+ .firstOrNull { it.participant.participantPublicKey == publicKey }
+ ?.profile
+ ?.humanReadableNameOrPubkey()
+ ?: publicKey.take(12)
+ }
+ )
+ }
}
is MarmotGroupCreation.Outcome.Failed -> {
- dkgRitualUIState = DkgRitualUIState.Error(outcome.reason)
+ _dkgRitualUIState.value = DkgRitualUIState.Error(outcome.reason)
}
is MarmotGroupCreation.Outcome.Created -> withContext(Main) {
@@ -657,7 +761,6 @@ class DkgRitualViewModel(
override fun onCleared() {
messageObserver?.cancel()
- keyStateObserver?.cancel()
certificateObserver?.cancel()
super.onCleared()
}
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/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt
index a625bb39..ada04fb2 100644
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt
@@ -436,6 +436,152 @@ class SubgroupBirthCertificateEventTest {
assertNull(SubgroupManager.classify(subgroupChatRoomId, aboutSomebodyElse))
}
+ // ---- `addresses`: the half that can be asked of a proposal ------------
+ //
+ // The ceremony screen has to tell one *in-flight* certificate signing from
+ // another in the parent's room, and a parent certifies every subgroup it
+ // ever makes from that one room. `certifies` cannot answer it: it ends on a
+ // signature by the parent's quorum, which is exactly what the screen is
+ // waiting for. `addresses` is the structural half, and the only thing worth
+ // asserting about it is where it stops.
+
+ @Test
+ fun `an unsigned proposal addresses its child, and certifies nothing`() {
+ // What the ceremony screen is actually holding: the event as it was
+ // assembled, before anybody signed it. Any string in `sig` serves --
+ // the point is that no quorum has put one there.
+ val proposal = certificate(signature = "0".repeat(128))
+
+ assertTrue(
+ SubgroupBirthCertificateEvent.addresses(
+ event = proposal,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ ),
+ "the screen could not tell which subgroup its own proposal is about"
+ )
+
+ assertFalse(
+ SubgroupBirthCertificateEvent.certifies(
+ event = proposal,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ ),
+ "an unsigned proposal is being read as a parent's certificate"
+ )
+ }
+
+ @Test
+ fun `a forgery by another group addresses the pair it names, and certifies nothing`() {
+ // The reason `addresses` must never decide a lineage: anybody can author
+ // an event naming any pair, and this says yes to it. The guard is that
+ // nothing downstream of a lineage asks `addresses` -- only the screen
+ // deciding which of its own room's signings to draw does.
+ val forged = certificate(author = childMaterial)
+
+ assertTrue(
+ SubgroupBirthCertificateEvent.addresses(
+ event = forged,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+
+ assertFalse(
+ SubgroupBirthCertificateEvent.certifies(
+ event = forged,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ }
+
+ @Test
+ fun `a sibling's proposal is not addressed to this child`() {
+ // Two subgroups of one parent, which is the case the screen exists to
+ // tell apart. The sibling's proposal is a real, well-formed certificate
+ // request; it is simply about somebody else.
+ val siblingRoomId = SharedKeyDerivation
+ .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path)
+
+ val sibling = certificate(
+ content = siblingRoomId,
+ tags = SubgroupBirthCertificateEvent.assembleTags(
+ subgroupChatRoomId = siblingRoomId,
+ parentChatRoomId = parentChatRoomId,
+ thresholdPublicKey = strangerMaterial.thresholdPublicKey.value.toHex(),
+ adminPublicKeys = admins,
+ name = "Another team",
+ path = path
+ )
+ )
+
+ assertFalse(
+ SubgroupBirthCertificateEvent.addresses(
+ event = sibling,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ ),
+ "one subgroup's rung is following a sibling's proposal"
+ )
+
+ // And it is addressed to the sibling, so the sibling's own screen finds
+ // it -- the two are told apart rather than both being refused.
+ assertTrue(
+ SubgroupBirthCertificateEvent.addresses(
+ event = sibling,
+ subgroupChatRoomId = siblingRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ }
+
+ @Test
+ fun `a proposal naming another parent is not addressed by this one`() {
+ val fromElsewhere = certificate(
+ tags = SubgroupBirthCertificateEvent.assembleTags(
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = SharedKeyDerivation
+ .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path),
+ thresholdPublicKey = childKey,
+ adminPublicKeys = admins,
+ name = name,
+ path = path
+ )
+ )
+
+ assertFalse(
+ SubgroupBirthCertificateEvent.addresses(
+ event = fromElsewhere,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ }
+
+ @Test
+ fun `everything certifies addresses, so the weaker one cannot be the stricter`() {
+ // `certifies` calls `addresses` and adds to it, and this is what keeps
+ // that true: a change that let one pass where the other refuses would
+ // mean two different answers to "which child is this about".
+ val valid = certificate()
+
+ assertTrue(
+ SubgroupBirthCertificateEvent.certifies(
+ event = valid,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ assertTrue(
+ SubgroupBirthCertificateEvent.addresses(
+ event = valid,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ }
+
private fun certificate(
content: String = subgroupChatRoomId,
tags: Array> = SubgroupBirthCertificateEvent.assembleTags(
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/CeremonyQueueRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt
new file mode 100644
index 00000000..f6c876cb
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt
@@ -0,0 +1,216 @@
+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.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 kotlinx.coroutines.delay
+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.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.repository.FrostSigningRepository
+import press.mantra.compose.repository.NostrRepository
+import press.mantra.compose.ui.view.state.ChatMessageListUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * When tapping one ceremony in the transcript is answering the whole question,
+ * and when it is answering a third of it.
+ *
+ * The transcript names one ceremony per line and its standing notice stands for
+ * however many are owed. Opening the named one is right while it is the only
+ * thing being asked; it stops being right the moment there are two, because the
+ * second is not on screen beside the first -- it may be pages up, or have
+ * arrived while the member was reading -- so answering the one they tapped and
+ * leaving would look exactly like being done.
+ *
+ * `hidesOtherDecisions` has made this call for proposals since before subgroups
+ * existed. [ChatMessageListViewModel.hidesOtherCeremonies] is its counterpart,
+ * and a room holds several ceremonies as a matter of course now that a
+ * subgroup's ChillDKG runs in its parent's room.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class CeremonyQueueRoutingJvmTest {
+ private val chatRoomId = "aa".repeat(32)
+ private val me = "11".repeat(32)
+ private val peer = "22".repeat(32)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `one ceremony owed is answered by opening it`() = runBlocking {
+ val viewModel = awaiting(
+ sessions = listOf(session("only", DkgRitualStage.COLLECTING_ROUND_1)),
+ pending = mapOf("only" to DkgApprovalStep.ROUND_1),
+ expected = 1
+ )
+
+ assertFalse(
+ viewModel.hidesOtherCeremonies("only"),
+ "the one ceremony owed was sent to a queue of itself"
+ )
+ }
+
+ @Test
+ fun `two ceremonies owed send the member to the list instead`() = runBlocking {
+ val viewModel = awaiting(
+ sessions = listOf(
+ session("ours", DkgRitualStage.COLLECTING_ROUND_1),
+ session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS)
+ .copy(parentChatRoomId = chatRoomId, subject = "Translators"),
+ ),
+ pending = mapOf(
+ "ours" to DkgApprovalStep.ROUND_1,
+ "subgroup" to DkgApprovalStep.HOST_KEY
+ ),
+ expected = 2
+ )
+
+ // Either tap: the queue is the honest answer to both, because neither is
+ // the whole of what the room is waiting on this member for.
+ assertTrue(viewModel.hidesOtherCeremonies("ours"))
+ assertTrue(viewModel.hidesOtherCeremonies("subgroup"))
+ }
+
+ /**
+ * The restraint, and the reason this is not just "does the room owe two".
+ * 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 a queue of other
+ * people's business.
+ */
+ @Test
+ fun `a finished ceremony is opened on its own even while others are owed`() = runBlocking {
+ val viewModel = awaiting(
+ sessions = listOf(
+ session("done", DkgRitualStage.COMPLETE),
+ session("ours", DkgRitualStage.COLLECTING_ROUND_1),
+ session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS),
+ ),
+ pending = mapOf(
+ "ours" to DkgApprovalStep.ROUND_1,
+ "subgroup" to DkgApprovalStep.HOST_KEY
+ ),
+ expected = 2
+ )
+
+ assertFalse(
+ viewModel.hidesOtherCeremonies("done"),
+ "a finished ceremony's line was answered with somebody else's queue"
+ )
+ }
+
+ /**
+ * An abandoned one is not owed either, which matters because the room's
+ * watcher filters on it and a row that slipped through would put a dead
+ * ceremony at the top of the notice.
+ */
+ @Test
+ fun `an abandoned ceremony is never counted as owed`() = runBlocking {
+ val viewModel = awaiting(
+ sessions = listOf(session("abandoned", DkgRitualStage.FAILED)),
+ // Says yes, and has to be ignored on a failure.
+ pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1),
+ expected = 0
+ )
+
+ assertTrue(viewModel.ceremoniesAwaitingYou.isEmpty())
+ assertFalse(viewModel.hidesOtherCeremonies("abandoned"))
+ }
+
+ /** Builds the view model and waits until its watcher has settled on [expected]. */
+ private suspend fun awaiting(
+ sessions: List,
+ pending: Map,
+ expected: Int,
+ ): ChatMessageListViewModel {
+ val viewModel = ChatMessageListViewModel(
+ initialChatMessageListUIState = ChatMessageListUIState.Loading,
+ localChatRoom = room(),
+ nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
+ 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]
+ }
+ )
+
+ viewModel.initiate()
+
+ // The watcher writes once, so an empty expectation cannot be waited for
+ // by its own value -- it is indistinguishable from not having run. Wait
+ // for a non-empty one, and give an empty one a moment to be wrong in.
+ withTimeout(5_000) {
+ if (expected == 0) {
+ delay(200)
+ } else {
+ while (viewModel.ceremoniesAwaitingYou.size != expected) delay(5)
+ }
+ }
+
+ assertEquals(expected, viewModel.ceremoniesAwaitingYou.size)
+ return viewModel
+ }
+
+ private fun room() = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = null,
+ ),
+ localParticipants = listOf(me, peer).map { publicKey ->
+ LocalParticipant(
+ participant = Participant(
+ participantPublicKey = publicKey,
+ chatRoomId = chatRoomId,
+ relayHint = null,
+ ),
+ profile = null,
+ )
+ },
+ )
+
+ private fun session(id: String, stage: DkgRitualStage) = DkgSession(
+ id = id,
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = peer,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = peer,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = stage,
+ )
+}
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,
+ )
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt
new file mode 100644
index 00000000..84815488
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt
@@ -0,0 +1,322 @@
+package press.mantra.compose.ui.view.model
+
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.toHexKey
+import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+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.FrostSigningItem
+import press.mantra.compose.database.model.FrostSigningSession
+import press.mantra.compose.database.model.Participant
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
+import press.mantra.compose.database.model.intermdiate.LocalParticipant
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.database.model.types.FrostSigningStage
+import press.mantra.compose.nostr.frost.GroupKeyStateEvent
+import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.ui.view.state.DkgRitualUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+/**
+ * A parent's room holds every one of its subgroups' signings, and the ceremony
+ * screen has to pick out the one it is showing.
+ *
+ * Since a subgroup's ChillDKG moved into its parent's Marmot room, both of the
+ * rungs after the ceremony run there too: the birth certificate is the parent's
+ * signature, and the key state is signed over the parent's room because the
+ * subgroup's own room does not exist yet. So a parent making a second subgroup
+ * has two of each in one room, told apart by nothing but the ceremony they
+ * belong to.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class DkgRitualSecondSubgroupJvmTest {
+ private val parentChatRoomId = "aa".repeat(32)
+ private val me = "11".repeat(32)
+ private val peer = "22".repeat(32)
+
+ // Real points, in the compressed form `SharedKeyDerivation` reads: it derives
+ // a room id off the curve, and a made-up 32 bytes is not on it.
+ private val firstKey = "02" + KeyPair().pubKey.toHexKey()
+ private val secondKey = "02" + KeyPair().pubKey.toHexKey()
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `the certificate shown is this subgroup's, not the parent's newest`() = runBlocking {
+ val loaded = load(
+ signingSessions = listOf(
+ // Newest first, the way the query returns them.
+ certificateSigning("forSecond", subgroupOf(secondKey)),
+ certificateSigning("forFirst", subgroupOf(firstKey)),
+ ),
+ until = { it.certificateSession != null }
+ )
+
+ assertEquals(
+ "forFirst",
+ loaded.certificateSession?.id,
+ "the screen for one subgroup is following another subgroup's certificate"
+ )
+ }
+
+ @Test
+ fun `the key state shown is this ceremony's, not the room's newest`() = runBlocking {
+ val loaded = load(
+ signingSessions = listOf(
+ keyStateSigning("forSecond", dkgSessionId = "secondCeremony"),
+ keyStateSigning("forFirst", dkgSessionId = "firstCeremony"),
+ ),
+ until = { it.keyStateSession != null }
+ )
+
+ assertEquals(
+ "forFirst",
+ loaded.keyStateSession?.id,
+ "the screen for one ceremony is following another ceremony's key state"
+ )
+ }
+
+ /**
+ * The damaging half, and the reason this is a block rather than a cosmetic
+ * mix-up. Both rungs disable their button while a signing of that kind is
+ * running and un-failed, and a *completed* signing is un-failed forever --
+ * so by kind alone, a parent that had certified one subgroup could never be
+ * asked to certify another, and the rung sat there disabled with no way out.
+ */
+ @Test
+ fun `a sibling's finished signings leave this ceremony's rungs open`() = runBlocking {
+ // Driven in two emissions rather than one, so the assertion is about a
+ // watcher that demonstrably ran. Asserting "still null" against a single
+ // emission passes before the watcher has started, which is how a version
+ // of this test passed against the bug it was written for.
+ val signingSessions = MutableStateFlow(
+ listOf(
+ certificateSigning("mine", subgroupOf(firstKey)),
+ keyStateSigning("myKeyState", dkgSessionId = "firstCeremony"),
+ )
+ )
+
+ val viewModel = viewModel(signingSessions)
+ viewModel.initiate()
+
+ awaitLoaded(viewModel) {
+ it.certificateSession?.id == "mine" && it.keyStateSession?.id == "myKeyState"
+ }
+
+ // Now the room holds only a sibling's, both finished -- which is every
+ // parent that has already made one subgroup and is making its second.
+ signingSessions.value = listOf(
+ certificateSigning(
+ "siblingCertificate",
+ subgroupOf(secondKey),
+ stage = FrostSigningStage.COMPLETE
+ ),
+ // A key state naming another ceremony: the sibling's, or this parent's
+ // own from an adoption, which is signed in this room too.
+ keyStateSigning(
+ "siblingKeyState",
+ dkgSessionId = "secondCeremony",
+ stage = FrostSigningStage.COMPLETE
+ ),
+ )
+
+ val loaded = awaitLoaded(viewModel) {
+ it.certificateSession == null && it.keyStateSession == null
+ }
+
+ // Both rungs gate on these being null or failed, and a completed signing
+ // is never failed -- so a sibling left in either of them shuts the rung
+ // for good.
+ assertNull(loaded.certificateSession)
+ assertNull(loaded.keyStateSession)
+ }
+
+ private suspend fun load(
+ signingSessions: List,
+ until: (DkgRitualUIState.Loaded) -> Boolean,
+ ): DkgRitualUIState.Loaded {
+ val viewModel = viewModel(MutableStateFlow(signingSessions))
+ viewModel.initiate()
+ return awaitLoaded(viewModel, until)
+ }
+
+ private suspend fun awaitLoaded(
+ viewModel: DkgRitualViewModel,
+ until: (DkgRitualUIState.Loaded) -> Boolean,
+ ): DkgRitualUIState.Loaded = withTimeout(5_000) {
+ viewModel.dkgRitualUIState.first {
+ (it as? DkgRitualUIState.Loaded)?.let(until) == true
+ }
+ } as DkgRitualUIState.Loaded
+
+ /** The screen is always showing the FIRST subgroup's ceremony. */
+ private fun viewModel(
+ signingSessions: MutableStateFlow>
+ ): DkgRitualViewModel {
+ val ceremony = DkgSession(
+ id = "firstCeremony",
+ chatRoomId = parentChatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = me,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = DkgRitualStage.COMPLETE,
+ thresholdPublicKey = firstKey,
+ parentChatRoomId = parentChatRoomId,
+ subject = "Translators",
+ )
+
+ return DkgRitualViewModel(
+ chatRoomId = parentChatRoomId,
+ activeUserPublicKey = me,
+ dkgSessionId = ceremony.id,
+ initialDkgRitualUIState = DkgRitualUIState.Loading,
+ activeIdentityStateFlow = MutableStateFlow(null),
+ 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 suspend fun roomDerivedBy(dkgSessionId: String): String? =
+ if (dkgSessionId == ceremony.id) subgroupOf(firstKey) else null
+
+ override fun observeSessionById(sessionId: String): Flow =
+ flowOf(ceremony)
+
+ override fun observeLatestSessionForChatRoom(
+ chatRoomId: String,
+ parentChatRoomId: String?,
+ ): Flow = flowOf(ceremony)
+
+ override fun observeSigningSessions(
+ chatRoomId: String
+ ): Flow> = signingSessions
+ }
+ )
+ }
+
+ /** The room id a ceremony's key derives, which is what a certificate names. */
+ private fun subgroupOf(thresholdPublicKey: HexKey): String =
+ press.mantra.compose.managers.SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
+
+ private fun certificateSigning(
+ id: String,
+ subgroupChatRoomId: String,
+ stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
+ ) = signing(
+ id = id,
+ stage = stage,
+ event = Event(
+ id = "ee".repeat(32),
+ pubKey = me,
+ createdAt = 0,
+ kind = SubgroupBirthCertificateEvent.KIND,
+ tags = SubgroupBirthCertificateEvent.assembleTags(
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId,
+ thresholdPublicKey = firstKey,
+ adminPublicKeys = listOf(me, peer),
+ name = "Translators",
+ ),
+ content = subgroupChatRoomId,
+ sig = "ff".repeat(64),
+ )
+ )
+
+ private fun keyStateSigning(
+ id: String,
+ dkgSessionId: String,
+ stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
+ ) = signing(
+ id = id,
+ stage = stage,
+ event = Event(
+ id = "ee".repeat(32),
+ pubKey = me,
+ createdAt = 0,
+ kind = GroupKeyStateEvent.KIND,
+ tags = GroupKeyStateEvent.assembleTags(
+ chatRoomId = subgroupOf(firstKey),
+ dkgSessionId = dkgSessionId,
+ ),
+ content = firstKey,
+ sig = "ff".repeat(64),
+ )
+ )
+
+ private fun signing(
+ id: String,
+ event: Event,
+ stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
+ ) = LocalFrostSigningSession(
+ session = FrostSigningSession(
+ id = id,
+ stage = stage,
+ chatRoomId = parentChatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ dkgSessionId = "firstCeremony",
+ threshold = 2,
+ participantCount = 2,
+ signerId = 0,
+ ),
+ unorderedItems = listOf(
+ FrostSigningItem(
+ sessionId = id,
+ itemIndex = 0,
+ unsignedEventJson = event.toJson(),
+ eventId = event.id,
+ nonceRandom = "55".repeat(32),
+ )
+ ),
+ )
+
+ private fun room() = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = parentChatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = "state",
+ ),
+ localParticipants = listOf(me, peer).map { publicKey ->
+ LocalParticipant(
+ participant = Participant(
+ participantPublicKey = publicKey,
+ chatRoomId = parentChatRoomId,
+ relayHint = null,
+ ),
+ profile = null,
+ )
+ },
+ )
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt
new file mode 100644
index 00000000..e811acb0
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt
@@ -0,0 +1,260 @@
+package press.mantra.compose.ui.view.model
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+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.FrostSigningItem
+import press.mantra.compose.database.model.FrostSigningSession
+import press.mantra.compose.database.model.Participant
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
+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.DkgRitualUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+
+/**
+ * The two ways the key-ceremony screen came to offer to start the ceremony it
+ * was already showing.
+ *
+ * "Start key ceremony" is the whole no-ceremony screen -- the explainer, the
+ * quorum picker and the button -- so anything that leaves the state saying
+ * there is no ceremony puts that offer in front of a member watching one run.
+ * Both of these did, and both are invisible to a test that only asks the view
+ * model what it settles on: the state was right before and right after, and
+ * wrong in between for long enough to read and to tap.
+ *
+ * Neither is about ChillDKG. They are about one `DkgRitualUIState` being
+ * assembled by seven flows at once, which is why they are tested here rather
+ * than against the ritual.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class DkgRitualStartOfferJvmTest {
+ private val chatRoomId = "a1b2c3d4".repeat(8)
+ private val me = "11".repeat(32)
+ private val peer = "22".repeat(32)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ /**
+ * The first loaded state already knows the room is running a ceremony.
+ *
+ * The screen used to publish a bare `Loaded` and then wait for the rows,
+ * and a `Loaded` with no session and no ceremony *is* the offer to start
+ * one. On a cold open -- the only time the queries are slow -- that is
+ * exactly when it was shown.
+ */
+ @Test
+ fun `the offer to start is not published while the rows are still being read`() = runBlocking {
+ val session = session(DkgRitualStage.COLLECTING_HOST_KEYS)
+
+ val viewModel = viewModel(
+ object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
+ // Slow on purpose: a Room query answers in microseconds and
+ // would hide the window rather than prove it is gone.
+ override fun observeLatestSessionForChatRoom(
+ chatRoomId: String,
+ parentChatRoomId: String?,
+ ): Flow = flow {
+ delay(50)
+ emit(session)
+ }
+ }
+ )
+
+ viewModel.initiate()
+
+ val firstLoaded = withTimeout(5_000) {
+ viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded }
+ } as DkgRitualUIState.Loaded
+
+ assertEquals(session.id, firstLoaded.session?.id, "the first loaded state missed the ceremony")
+ assertFalse(
+ viewModel.canStartRitual(firstLoaded),
+ "offered to start a ceremony on a room already running one"
+ )
+ }
+
+ /**
+ * A write that suspends between reading the state and writing it back does
+ * not carry the stale copy over somebody else's change.
+ *
+ * Every flow folding into this state used to read it, build a `copy` and
+ * assign -- and the session watcher asks the database what this member owes
+ * in the middle of that. Whoever wrote during that pause had their change
+ * thrown away by the assignment that followed. The same pause in the other
+ * direction is what put `session` back to null and offered to start the
+ * ceremony on screen; this is the half of it that can be forced to happen
+ * rather than raced into.
+ *
+ * The pair used here is the session watcher against the room watcher, which
+ * are the two that are genuinely concurrent: both are collecting before
+ * either has written, and neither waits on the other. `roomHasCeremony` is
+ * the field they fight over, and it is the one that decides the offer.
+ */
+ @Test
+ fun `a change written during a suspension survives the write that follows it`() = runBlocking {
+ val live = session(DkgRitualStage.COLLECTING_ROUND_1)
+ val abandoned = session(DkgRitualStage.FAILED)
+
+ val roomSessions = MutableStateFlow(live)
+
+ // Opened once the test has had the room watcher write. The seeding call
+ // runs before anything else is collecting, so it is let through; the
+ // session watcher's call is the one that waits, and `parked` says when
+ // it is in there.
+ val gate = CompletableDeferred()
+ val parked = CompletableDeferred()
+ var calls = 0
+
+ val viewModel = viewModel(
+ object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
+ override fun observeLatestSessionForChatRoom(
+ chatRoomId: String,
+ parentChatRoomId: String?,
+ ): Flow = roomSessions
+
+ override fun observeSessionById(sessionId: String): Flow =
+ flowOf(live)
+
+ override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? {
+ calls += 1
+ if (calls == 1) return null
+
+ parked.complete(Unit)
+ gate.await()
+ return DkgApprovalStep.ROUND_1
+ }
+ }
+ )
+
+ viewModel.initiate()
+
+ awaitLoaded(viewModel) { it.roomHasCeremony }
+
+ // The session watcher is now inside `pendingApproval`, holding -- in the
+ // shape this used to be written in -- a copy of the state as it was
+ // before the next two lines.
+ withTimeout(5_000) { parked.await() }
+ roomSessions.value = abandoned
+ awaitLoaded(viewModel) { !it.roomHasCeremony }
+
+ gate.complete(Unit)
+
+ val settled = awaitLoaded(viewModel) {
+ it.pendingApproval == DkgApprovalStep.ROUND_1
+ }
+
+ assertFalse(
+ settled.roomHasCeremony,
+ "the session watcher wrote back a copy taken before the room watcher's change"
+ )
+ assertEquals(live.id, settled.session?.id)
+ }
+
+ private suspend fun awaitLoaded(
+ viewModel: DkgRitualViewModel,
+ until: (DkgRitualUIState.Loaded) -> Boolean,
+ ): DkgRitualUIState.Loaded = withTimeout(5_000) {
+ viewModel.dkgRitualUIState.first {
+ (it as? DkgRitualUIState.Loaded)?.let(until) == true
+ }
+ } as DkgRitualUIState.Loaded
+
+ private fun viewModel(dkgRepository: DkgRepository) = DkgRitualViewModel(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = me,
+ initialDkgRitualUIState = DkgRitualUIState.Loading,
+ activeIdentityStateFlow = MutableStateFlow(null),
+ chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
+ override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room()
+ },
+ dkgRepository = dkgRepository
+ )
+
+ private fun room() = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = null,
+ ),
+ localParticipants = listOf(me, peer).map { publicKey ->
+ LocalParticipant(
+ participant = Participant(
+ participantPublicKey = publicKey,
+ chatRoomId = chatRoomId,
+ relayHint = null,
+ ),
+ profile = null,
+ )
+ },
+ )
+
+ private fun session(stage: DkgRitualStage) = DkgSession(
+ id = "ceremony",
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = me,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = stage,
+ )
+
+ private fun keyStateSigningSession() = LocalFrostSigningSession(
+ session = FrostSigningSession(
+ id = "signing",
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ dkgSessionId = "ceremony",
+ threshold = 2,
+ participantCount = 2,
+ signerId = 0,
+ ),
+ unorderedItems = listOf(
+ FrostSigningItem(
+ sessionId = "signing",
+ itemIndex = 0,
+ unsignedEventJson = """{"id":"${"33".repeat(32)}","pubkey":"$me",""" +
+ """"created_at":0,"kind":30326,"tags":[],"content":"","sig":"${"44".repeat(64)}"}""",
+ eventId = "33".repeat(32),
+ nonceRandom = "55".repeat(32),
+ )
+ ),
+ )
+}