Merge branch 'mantra' into claude/subgroups-multiple-parents-053be4

This commit is contained in:
Kgothatso Ngako committed 2026-10-02 21:06:30 +02:00
commit 1a6034dadb
22 files changed
+2460 -170

No files matched your search

@@ -110,26 +110,27 @@ class DatabaseDkgRepository(
): Flow<List<LocalFrostSigningSession>> =
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<LocalGroupKeyState?> =
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 {
@@ -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
@@ -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,
@@ -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 --
@@ -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 ->
@@ -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
@@ -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 = {}
)
}
}
}
@@ -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 {
@@ -38,6 +38,7 @@ import press.mantra.compose.ui.composable.ChatRoomDetailScreen
import press.mantra.compose.ui.composable.ChatRoomMessagingScreen
import press.mantra.compose.ui.composable.CreateProfileScreen
import press.mantra.compose.ui.composable.DkgJoinApprovalScreen
import press.mantra.compose.ui.composable.DkgRitualListScreen
import press.mantra.compose.ui.composable.DkgRitualScreen
import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen
import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
@@ -140,6 +141,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
@@ -1198,6 +1200,22 @@ fun MantraNavHost(
}
)
}
composable<DkgRitualListRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DkgRitualListRoute>()
DkgRitualListScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToRoute = { ceremonyRoute ->
navController.navigate(route = ceremonyRoute)
}
)
}
composable<ProposalListRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ProposalListRoute>()
@@ -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()
@@ -578,7 +578,8 @@ fun ChatTranscript(
@Composable
private fun CeremoniesAwaitingYouNotice(
ceremonies: List<ChatMessageListViewModel.AwaitingCeremony>,
onClick: (dkgSessionId: String) -> Unit,
/** Null where the notice stands for more than one, which no single screen can show. */
onClick: (dkgSessionId: String?) -> Unit,
) {
val single = ceremonies.singleOrNull()
@@ -602,7 +603,11 @@ private fun CeremoniesAwaitingYouNotice(
}
Card(
onClick = { onClick(ceremonies.first().sessionId) },
// The one it names when it names one, and the list otherwise. A notice
// reading "3 key ceremonies need you" that opened the first of them --
// which is what this did -- answered a different question from the one
// it asked, and left the other two exactly as hard to find as before.
onClick = { onClick(single?.sessionId) },
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
@@ -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
@@ -0,0 +1,140 @@
package press.mantra.compose.ui.view.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.extensions.memberName
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.view.state.DkgRitualListUIState
/**
* Every key ceremony a room holds.
*
* A room routinely holds more than one: a parent's Marmot room hosts the
* ceremony of every subgroup the group ever makes, alongside whatever it holds
* of its own. Watching the whole list rather than the newest is the point --
* which of them is waiting on the reader is a fact about each one, and the
* newest is routinely the finished one.
*
* The counterpart to [ProposalListViewModel], and deliberately the same shape.
*/
class DkgRitualListViewModel(
val chatRoomId: String,
val activeUserPublicKey: HexKey,
initialDkgRitualListUIState: DkgRitualListUIState,
val chatRepository: ChatRepository,
val dkgRepository: DkgRepository,
): ViewModel() {
private val _dkgRitualListUIState = MutableStateFlow(initialDkgRitualListUIState)
/**
* A flow rather than a `mutableStateOf`: this is written from
* `Dispatchers.IO`, and a `mutableStateOf` created in a view model's
* constructor silently drops a write that lands during the composition that
* created it. See [DkgRitualViewModel.dkgRitualUIState].
*/
val dkgRitualListUIState: StateFlow<DkgRitualListUIState> =
_dkgRitualListUIState.asStateFlow()
private val logger = Logger.withTag(TAG)
/**
* Loads the room, then watches its ceremonies for as long as the screen
* lives.
*
* Every one of them moves on messages arriving from other members, so a
* screen that read the list once would go stale the moment somebody else
* took their step -- which is exactly when a member is likely to be looking
* at it.
*/
fun initiate() {
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
if (localChatRoom == null) {
_dkgRitualListUIState.value =
DkgRitualListUIState.Error("Couldn't find the group")
return@launch
}
dkgRepository.observeSessionsForChatRoom(chatRoomId).collect { sessions ->
logger.d("${sessions.size} key ceremonies in $chatRoomId")
_dkgRitualListUIState.value = DkgRitualListUIState.Loaded(
localChatRoom = localChatRoom,
ceremonies = sessions.map { asCeremony(it, localChatRoom) }
)
}
}
}
/**
* One ceremony as a row.
*
* What it wants is asked of the repository rather than worked out here, so
* this list, the transcript's standing notice and the ceremony screen cannot
* disagree about which ceremonies still have something in them -- the same
* rule the proposal list keeps.
*
* An abandoned ceremony is asked nothing. It can want nothing: there is no
* step left to approve and no key to build a room from, and a row claiming
* otherwise would send a member to a screen with nothing on it.
*/
private suspend fun asCeremony(
session: DkgSession,
localChatRoom: LocalChatRoom
): DkgRitualListUIState.Ceremony {
val live = session.takeIf { it.stage != DkgRitualStage.FAILED }
return DkgRitualListUIState.Ceremony(
session = session,
coordinatorName = session.coordinatorPublicKey.memberName(
members = localChatRoom.localParticipants,
activeUserPublicKey = activeUserPublicKey
),
pendingApproval = live?.let { dkgRepository.pendingApproval(it) },
// Only of the member who opened it: creating the room is the
// coordinator's job, and a row telling everybody else to do it would
// be telling them to do something they cannot.
roomAwaitingCreation = live
?.takeIf { it.isCoordinator() }
?.let { dkgRepository.roomAwaitingCreation(it) }
)
}
companion object {
private const val TAG = "DkgRitualListViewModel"
fun factory(
chatRoomId: String,
activeUserPublicKey: HexKey,
initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading,
chatRepository: ChatRepository,
dkgRepository: DkgRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
DkgRitualListViewModel(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
initialDkgRitualListUIState = initialDkgRitualListUIState,
chatRepository = chatRepository,
dkgRepository = dkgRepository
)
}
}
}
}
@@ -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> = _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()
}
@@ -0,0 +1,68 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgApprovalStep
sealed interface DkgRitualListUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
/** Every ceremony the room holds, newest first. */
val ceremonies: List<Ceremony> = emptyList(),
): DkgRitualListUIState {
/**
* The ones that cannot move until this member acts.
*
* Split out rather than sorted to the top, the way the proposal list
* splits its own -- the rest of the list is what the room has done,
* these are what it is waiting on the reader for. A room can have two
* at once the moment a subgroup's admins are the whole group, and
* burying the second under the first one's history is the whole reason
* this screen exists.
*/
val waitingForYou: List<Ceremony> = ceremonies.filter { it.awaitsYou }
val rest: List<Ceremony> = ceremonies.filterNot { it.awaitsYou }
}
/**
* One ceremony, as a row.
*
* Both reasons a ceremony can want something are carried separately rather
* than folded into one flag, because they are owed by different people and
* read differently: an approval is this member's gate and nobody else can
* pass it, while finishing the setup is work the coordinator alone can come
* back to. The row says which.
*/
data class Ceremony(
val session: DkgSession,
/** Whoever opened it, as a name to read. */
val coordinatorName: String,
/** What the ceremony is waiting on this member for, if anything. */
val pendingApproval: DkgApprovalStep? = null,
/**
* The room this finished ceremony's key derives, when it does not exist
* yet and this member is the one who would make it.
*
* Reaching COMPLETE finishes nothing on its own: the group has a key and
* somebody still has to get its state signed and create the room. The
* same reading the transcript's standing notice makes.
*/
val roomAwaitingCreation: String? = null,
) {
/** Whether this ceremony is making a subgroup rather than the room's own key. */
val isSubgroup: Boolean get() = session.parentChatRoomId != null
val awaitsYou: Boolean get() = pendingApproval != null || roomAwaitingCreation != null
}
data class Error(
val message: String
): DkgRitualListUIState
data object Loading: DkgRitualListUIState
}