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