From 6bd59e05febd27708420c648ce6720627d88c141 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 23:47:29 +0200 Subject: [PATCH] feat(subgroups): the four-rung ladder, the picker, and the list a parent reads off its own signatures Phase 7 of docs/subgroups.md, and the first commit where a user can make a subgroup. Four pieces. **A subgroups section on the group detail screen**, above members, listing `SubgroupManager.subgroupsOf` -- so it shows a child this device holds no room for, which is the normal position of a member who is not in the subgroup and of everybody between the certificate being signed and the room being created. A row titles itself from the *room* where there is one and only otherwise from the certificate, because the certificate's name and `p` tags are the founding roster and a renamed or grown subgroup would otherwise be listed under a name nobody uses. The supporting line says which of the two absences it is: certified but not created, or created and you are not in it. Tapping opens the child where this device has it and the certificate where it does not, since that is the whole of what is known and it is checkable. **A parent row on the child's detail screen**, directly under the signing key, because between them they are what the room *is*: the identity it signs as and whose child it is. It comes off the verified `GroupKeyState.parentChatRoomId`, so a member welcomed in after the founding sees nothing there rather than an unverified guess. **`SelectSubgroupAdminsScreen`**, where the three things that cannot change later are settled. The pool is the parent's own members, admins and non-admins alike and marked rather than filtered -- the point of a subgroup is that it can be run by people the parent does not let run the parent. The coordinator is shown, ticked and locked, since they hold a share by construction and leaving them off the list would make "pick two more" read as a group of two. Key packages are resolved as the screen opens and a member without one is marked and unselectable. A key package is one-time-use, so every group a member joins burns one; `MarmotGroupCreation` would refuse to create the room for a missing one -- correctly, the address being permanent -- but only after a ChillDKG, a parent quorum and a child quorum had all completed, each needing every selected admin present. Finding out at the picker costs nothing and finding out at step 4 costs three ceremonies. The supporting line names the remedy rather than the diagnosis, because only its owner can publish another. The quorum stepper is here and nowhere else, and that is a protocol fact: ChillDKG hashes the threshold and the host keys into the session identity, so `t` is fixed the moment the proposal goes out. It is also the one value picked for other people, and consent survives it -- `t` rides on the proposal, `acceptProposal` re-checks it against `quorumRange`, and the host-key gate is where each invitee agrees to the `t`-of-`n` they can now see. **The ritual screen grows a rung rather than being cloned.** `DkgRitualRoute` takes an optional `parentChatRoomId`, and with one the ladder is four steps instead of three: key, certificate, key state, room. A parallel subgroup screen would have duplicated a progress ladder, a threshold picker, three approval gates and a key-state rung in order to insert one step, and the copies would drift within a release. The certificate rung is watched off the *parent's* signed events and sessions rather than this room's -- it is signed where the parent's key can sign it, which is never the ceremony's room. The key-state button stays shut until it is done, because a subgroup's state carries the certificate and `GroupKeyStateManager` refuses one without it; opening that session early would throw rather than fail. And `createAdminGroup` passes the verified parent through to the room, names a subgroup what the coordinator called it rather than "X (#admins)", and says so. No new approval UI. The certificate is a `FrostSigningEvents.PROPOSAL` in the parent's room and the key state one in the ceremony room; `ProposalListScreen` and `FrostSigningScreen` already show and approve both, on both transports. Six repository methods carry it: `subgroupsOf`, `parentOf`, `canSign` and `refuseSubgroup` on `ChatRepository`, and `proposeBirthCertificate`, `observeBirthCertificate` and `proposeSubgroupKeyState` on `DkgRepository`. The view models talk to repositories and the managers take the database, which is where the rest of the app has that line. `refuseSubgroup` returns a refusal when it cannot compute one, because a guard that fails open is not a guard. 25 new strings in the catalogue in sentence case; 397 common tests, 701 jvm tests, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 25 ++ .../repository/DatabaseChatRepository.kt | 46 +++ .../repository/DatabaseDkgRepository.kt | 75 ++++ .../compose/repository/ChatRepository.kt | 58 +++ .../compose/repository/DkgRepository.kt | 62 ++++ .../ui/composable/ChatRoomDetailScreen.kt | 189 ++++++++++ .../compose/ui/composable/DkgRitualScreen.kt | 102 +++++- .../composable/SelectSubgroupAdminsScreen.kt | 334 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 17 + .../navigation/routes/DkgRitualRoute.kt | 13 +- .../routes/SelectSubgroupAdminsRoute.kt | 20 ++ .../ui/view/model/ChatRoomDetailViewModel.kt | 4 + .../ui/view/model/DkgRitualViewModel.kt | 146 +++++++- .../model/SelectSubgroupAdminsViewModel.kt | 285 +++++++++++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 35 ++ .../compose/ui/view/state/DkgRitualUIState.kt | 19 + .../view/state/SelectSubgroupAdminsUIState.kt | 37 ++ 17 files changed, 1453 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectSubgroupAdminsUIState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 45d110c6..a65a7a5d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -365,4 +365,29 @@ Nothing matched that search. Key package published Key package rotated + Subgroups + Add subgroup + No subgroups have been made by this group. + Certified, not created yet + Created, you aren't a member + A subgroup + Parent group + The group this one is a subgroup of + Run by %1$s members + New subgroup + What is the subgroup called? + Who runs the subgroup? + A subgroup needs at least three admins, you included, so pick at least two more people. + Anyone in this group can run a subgroup, whether or not they administer this one. + Has no key package yet + Admin of this group + You coordinate this subgroup + Start the key ceremony + Checking who can be added… + The parent group has certified this subgroup + The parent group could not certify this subgroup + The parent group is certifying this subgroup — %1$s of %2$s admins have to sign + Before the subgroup exists, its parent signs for it. That signature is what lets anyone check where this group came from. + Ask the parent group to certify this subgroup + Create the subgroup diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 6ee41cc7..c4f36b34 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -5,8 +5,10 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.managers.ChronicleManager +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload @@ -101,6 +103,50 @@ class DatabaseChatRepository( null } + + override suspend fun subgroupsOf(chatRoomId: String): List = try { + SubgroupManager.subgroupsOf(database, chatRoomId) + } catch (e: Throwable) { + // A room that shows no subgroups is wrong and survivable; one that will + // not open because reading them threw is neither. + logger.e("Error reading the subgroups of $chatRoomId", e) + emptyList() + } + + override suspend fun parentOf(chatRoomId: String): HexKey? = try { + SubgroupManager.parentOf(database, chatRoomId) + } catch (e: Throwable) { + logger.e("Error reading the parent of $chatRoomId", e) + null + } + + override suspend fun canSign(chatRoomId: String): Boolean = try { + FrostSigningManager.canSign(database, chatRoomId) + } catch (e: Throwable) { + logger.e("Error reading whether $chatRoomId can sign", e) + false + } + + override suspend fun refuseSubgroup( + parentChatRoomId: String, + adminPublicKeys: Set, + coordinatorPublicKey: HexKey, + ): String? = try { + val parentRoom = database.chatRoomDao().findChatRoomById(parentChatRoomId) + ?: return "Couldn't load this group." + + SubgroupManager.refuseCeremonyRoom( + database = database, + parentRoom = parentRoom, + adminPublicKeys = adminPublicKeys, + coordinatorPublicKey = coordinatorPublicKey, + ) + } catch (e: Throwable) { + // A refusal that cannot be computed has to refuse. The alternative is + // opening a ceremony whose guards never ran. + logger.e("Error checking whether $parentChatRoomId can take a subgroup", e) + "Couldn't check this subgroup. Please try again." + } override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { return database.participantDao().findParticipantByPublicKey(publicKey) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index 4a1f1b26..89bac5fe 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -5,13 +5,16 @@ import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.managers.GroupKeyStateManager +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.repository.DkgRepository import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -108,6 +111,78 @@ class DatabaseDkgRepository( null } + override suspend fun proposeBirthCertificate( + parentChatRoomId: String, + userPublicKey: HexKey, + session: DkgSession, + adminPublicKeys: List, + name: String, + ): FrostSigningSession? = try { + val parentRoom = database.chatRoomDao().findChatRoomById(parentChatRoomId) + ?: throw IllegalStateException("No parent room $parentChatRoomId to certify from") + + SubgroupManager.proposeBirthCertificate( + database = database, + parentRoom = parentRoom, + userPublicKey = userPublicKey, + key = session, + adminPublicKeys = adminPublicKeys, + name = name, + ) + } catch (e: Throwable) { + // A device with no share of the parent's key cannot open this session, + // and a ceremony with no key yet is a caller that got ahead of itself. + // Neither is worth failing anything over: nothing was published, and the + // screen reports it by leaving the rung unfinished with the button there. + logger.e("Error proposing a birth certificate in $parentChatRoomId", e) + null + } + + override fun observeBirthCertificate( + ceremonyChatRoomId: String, + parentChatRoomId: String, + ): Flow = + 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() + .getLatestSessionForChatRoom(ceremonyChatRoomId) + ?.thresholdPublicKey + ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } + ?: return@map null + + signedEvents + .filter { + SubgroupBirthCertificateEvent.certifies( + event = it.toEvent(), + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + ) + } + .maxByOrNull { it.createdAt } + } + + override suspend fun proposeSubgroupKeyState( + ceremonyRoom: LocalChatRoom, + userPublicKey: HexKey, + session: DkgSession, + parentChatRoomId: HexKey, + ): FrostSigningSession? = try { + SubgroupManager.proposeSubgroupKeyState( + database = database, + ceremonyRoom = ceremonyRoom, + userPublicKey = userPublicKey, + key = session, + parentChatRoomId = parentChatRoomId, + ) + } catch (e: Throwable) { + logger.e("Error proposing the subgroup key state from ${ceremonyRoom.chatRoom.id}", e) + null + } + override suspend fun approve( localChatRoom: LocalChatRoom, sessionId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 150260b9..042a1d11 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -8,6 +8,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -58,6 +59,50 @@ interface ChatRepository { */ suspend fun signedGroupKeyStateEvent(chatRoomId: String): GroupSignedEvent? + /** + * Every subgroup this room has certified, as far as this device knows. + * + * Read off the parent's own signed certificates rather than off a table, so + * it includes children whose room this device does not have -- which is the + * normal position of a member who is not in the subgroup. See + * `SubgroupManager.subgroupsOf`. + */ + suspend fun subgroupsOf(chatRoomId: String): List + + /** + * The group this room is a subgroup of, verified, or null if it is nobody's + * child. + * + * Off the room's `GroupKeyState`, which only carries a parent whose + * certificate was checked. A member welcomed in after the founding holds no + * state and gets null here even when the room really is a subgroup. + */ + suspend fun parentOf(chatRoomId: String): HexKey? + + /** + * Whether this device holds a share of the group's key, and so could take + * part in signing for it. + * + * Read by the UI so nothing is offered that would throw: + * `FrostSigningManager.proposeSigningBatch` refuses outright for a device + * with no share, and a button that throws is not a button. + */ + suspend fun canSign(chatRoomId: String): Boolean + + /** + * Why these members cannot hold a subgroup ceremony together, or null when + * they can. + * + * Read by the picker so a subgroup that could only fail is never offered, and + * again on confirm, because a screen not drawing something is not a guard. + * See `SubgroupManager.refuseCeremonyRoom`. + */ + suspend fun refuseSubgroup( + parentChatRoomId: String, + adminPublicKeys: Set, + coordinatorPublicKey: HexKey, + ): String? + suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List @@ -210,6 +255,19 @@ interface ChatRepository { chatRoomId: String ): GroupSignedEvent? = null + override suspend fun subgroupsOf(chatRoomId: String): List = + emptyList() + + override suspend fun parentOf(chatRoomId: String): HexKey? = null + + override suspend fun canSign(chatRoomId: String): Boolean = false + + override suspend fun refuseSubgroup( + parentChatRoomId: String, + adminPublicKeys: Set, + coordinatorPublicKey: HexKey, + ): String? = "No database" + override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { return flow { } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index a947ef9e..c2d15dac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -4,6 +4,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession import press.mantra.compose.database.model.types.DkgApprovalStep @@ -96,6 +97,47 @@ interface DkgRepository { /** Files the state the group signed for a room that now exists. */ suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? + /** + * Asks the parent's quorum to certify the subgroup this ceremony produced. + * + * Runs in the **parent's** room, not the ceremony's, so what comes back is + * signed by the parent's key and authored by the parent's own room id -- the + * one thing a lineage can be checked against. See + * `SubgroupManager.proposeBirthCertificate`. + */ + suspend fun proposeBirthCertificate( + parentChatRoomId: String, + userPublicKey: HexKey, + session: DkgSession, + adminPublicKeys: List, + name: String, + ): FrostSigningSession? + + /** + * Whether the parent has certified the room this ceremony's key derives. + * + * Answerable before that room exists, which is the point: it is what gates + * the key state, which is itself signed before the room is created. + */ + fun observeBirthCertificate( + ceremonyChatRoomId: String, + parentChatRoomId: String, + ): Flow + + /** + * Asks the subgroup's own quorum to sign its key state, carrying the + * certificate. + * + * Refuses without one, because every device that receives the state runs the + * same check and drops it -- see `GroupKeyStateManager.stateFrom`. + */ + suspend fun proposeSubgroupKeyState( + ceremonyRoom: LocalChatRoom, + userPublicKey: HexKey, + session: DkgSession, + parentChatRoomId: HexKey, + ): FrostSigningSession? + companion object { val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository { override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow = flowOf(null) @@ -135,6 +177,26 @@ interface DkgRepository { flowOf(null) override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null + + override suspend fun proposeBirthCertificate( + parentChatRoomId: String, + userPublicKey: HexKey, + session: DkgSession, + adminPublicKeys: List, + name: String, + ): FrostSigningSession? = null + + override fun observeBirthCertificate( + ceremonyChatRoomId: String, + parentChatRoomId: String, + ): Flow = flowOf(null) + + override suspend fun proposeSubgroupKeyState( + ceremonyRoom: LocalChatRoom, + userPublicKey: HexKey, + session: DkgSession, + parentChatRoomId: HexKey, + ): FrostSigningSession? = null } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index de4e9e98..1edfffee 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -85,6 +85,18 @@ 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.ArtifactDetailRoute import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.run_by_members +import mantra.composeapp.generated.resources.parent_group +import mantra.composeapp.generated.resources.a_subgroup +import mantra.composeapp.generated.resources.created_you_are_not_a_member +import mantra.composeapp.generated.resources.certified_not_yet_created +import mantra.composeapp.generated.resources.no_subgroups_have_been_made_by_this_group +import mantra.composeapp.generated.resources.add_subgroup +import mantra.composeapp.generated.resources.subgroups +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute +import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute +import press.mantra.compose.managers.SubgroupManager import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.add_artifact_to_library @@ -237,6 +249,57 @@ fun ChatRoomDetailScreen( } } + // Directly under the signing key, because between them + // they are what the room *is*: the identity it signs as + // and whose child it is. Absent on a group that is + // nobody's child, and absent -- rather than guessed -- + // on a subgroup this device holds no key state for, + // since an unverified lineage is not one worth showing. + chatRoomDetailUIState.parentChatRoomId?.let { parentChatRoomId -> + item { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { + onNavigateToRoute.invoke( + ChatRoomDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = parentChatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative + ) + }, + headlineContent = { + Text(text = stringResource(Res.string.parent_group)) + }, + supportingContent = { + Text( + text = parentChatRoomId + .take(16) + .inComparableGroups(), + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + } + ) + } + } + } + item { chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { Text( @@ -444,6 +507,82 @@ fun ChatRoomDetailScreen( HorizontalDivider() } + item { + Text( + text = stringResource(Res.string.subgroups), + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.subgroups.isEmpty()) { + item { + Text(stringResource(Res.string.no_subgroups_have_been_made_by_this_group)) + } + } else { + items( + items = chatRoomDetailUIState.subgroups, + key = { subgroup -> subgroup.chatRoomId } + ) { subgroup -> + SubgroupCard( + subgroup = subgroup, + onOpen = { + // Into the child where this device has + // it; at the certificate where it does + // not, since that is the whole of what + // is known and it is checkable. + onNavigateToRoute.invoke( + subgroup.room?.let { + ChatRoomDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = it.chatRoom.id, + relayHint = relayHint + ) + } ?: NostrEventDetailRoute( + activeUserPublicKey = activeUserPublicKey, + nostrEventId = subgroup.certificate.id + ) + ) + } + ) + } + } + + // Offered only where it can work: this device holds a + // share of the group's key, so it can open the + // certificate session, and the room is a Marmot one, so + // there is a group with admins to be a parent. Hidden + // rather than disabled, the way the shared key entry is. + if (chatRoomDetailUIState.canAddSubgroup) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + SelectSubgroupAdminsRoute( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.add_subgroup)) + } + } + } + + item { + HorizontalDivider() + } + item { Text( text = stringResource(Res.string.members_2), @@ -911,6 +1050,56 @@ internal fun GroupKeyStateSheetContent( * signed event, which is not grouped -- so the display can be shaped for reading * without a paste losing the value. */ +/** + * One subgroup, as the parent's record and this device's rooms together have it. + * + * **The title comes from the room where there is one, and only otherwise from the + * certificate.** The certificate's name and `p` tags are what the parent approved + * at founding, frozen: the child may since have been renamed or have gained + * members, and none of that reaches a signature already made. So where this + * device holds the room, the room is the live answer; where it does not, the + * certificate is all there is and the supporting line says which of the two + * absences this is. + */ +@Composable +private fun SubgroupCard( + subgroup: SubgroupManager.Subgroup, + onOpen: () -> Unit +) { + Card(onClick = onOpen) { + ListItem( + leadingContent = { + Icon(Icons.Default.AccountTree, contentDescription = Decorative) + }, + trailingContent = { + Icon(Icons.Default.ChevronRight, contentDescription = Decorative) + }, + headlineContent = { + Text( + text = subgroup.room?.chatRoom?.subject + ?: subgroup.name + ?: stringResource(Res.string.a_subgroup) + ) + }, + supportingContent = { + Text( + text = when { + subgroup.isJoined -> stringResource( + Res.string.run_by_members, + subgroup.adminPublicKeys.size.toString() + ) + // The room does not exist anywhere yet: the parent has + // certified it and nobody has created it. Between steps 2 + // and 4 of docs/subgroups.md this is every member's view. + subgroup.keyState == null -> stringResource(Res.string.certified_not_yet_created) + else -> stringResource(Res.string.created_you_are_not_a_member) + } + ) + } + ) + } +} + private fun String.inComparableGroups(): String = chunked(8).joinToString(" ") /** One labelled value of a key state, whole and comparable. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index bfc6a964..7cba6bf3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -81,6 +81,13 @@ import press.mantra.compose.ui.composable.widgets.Decorative import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.create_the_admins_group +import androidx.compose.material.icons.filled.AccountTree +import mantra.composeapp.generated.resources.create_the_subgroup +import mantra.composeapp.generated.resources.ask_the_parent_group_to_certify +import mantra.composeapp.generated.resources.before_the_subgroup_exists_its_parent_signs +import mantra.composeapp.generated.resources.the_parent_group_is_certifying_this_subgroup +import mantra.composeapp.generated.resources.the_parent_group_could_not_certify_this_subgroup +import mantra.composeapp.generated.resources.the_parent_group_has_certified_this_subgroup import mantra.composeapp.generated.resources.how_many_members_will_it_take_to_sign import mantra.composeapp.generated.resources.key import mantra.composeapp.generated.resources.members @@ -125,6 +132,11 @@ import press.mantra.compose.ui.theme.ConformancePreviews fun DkgRitualScreen( activeUserPublicKey: HexKey, chatRoomId: String, + /** + * Set when this ceremony is making a subgroup, which grows the ladder by one + * rung: the parent's birth certificate, between the key and the key state. + */ + parentChatRoomId: String? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, activeWalletStateFlow: StateFlow, chatRepository: ChatRepository, @@ -135,6 +147,7 @@ fun DkgRitualScreen( factory = DkgRitualViewModel.factory( chatRoomId = chatRoomId, activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = parentChatRoomId, initialDkgRitualUIState = initialDkgRitualUIState, activeWalletStateFlow = activeWalletStateFlow, chatRepository = chatRepository, @@ -294,6 +307,10 @@ fun DkgRitualScreen( adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn, keyStateSession = dkgRitualUIState.keyStateSession, isKeyStateSigned = dkgRitualUIState.isKeyStateSigned, + parentChatRoomId = dkgRitualUIState.parentChatRoomId, + isCertified = dkgRitualUIState.isCertified, + certificateSession = dkgRitualUIState.certificateSession, + onProposeBirthCertificate = dkgRitualViewModel::proposeBirthCertificate, onProposeKeyState = dkgRitualViewModel::proposeKeyState, onCreateAdminGroup = { dkgRitualViewModel.createAdminGroup(onNavigateToRoute) @@ -343,6 +360,10 @@ private fun RitualProgress( adminGroupBlockedOn: List, keyStateSession: FrostSigningSession?, isKeyStateSigned: Boolean, + parentChatRoomId: String?, + isCertified: Boolean, + certificateSession: FrostSigningSession?, + onProposeBirthCertificate: () -> Unit, onProposeKeyState: () -> Unit, onCreateAdminGroup: () -> Unit, ) { @@ -500,6 +521,71 @@ private fun RitualProgress( // Shown to every member, not only the coordinator, because every // member has to sign it -- the request itself is a line in this // chat, which is where they answer it. + // A subgroup has one more thing to settle than a group does, and + // it comes first: the parent's signature over this room's id. The + // key state carries that certificate, so it cannot be proposed + // until this rung is done -- which is why the order on screen is + // the order the steps actually happen in rather than a layout + // choice. + if (parentChatRoomId != null) { + val certificateFailed = certificateSession?.stage == FrostSigningStage.FAILED + + when { + isCertified -> Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.CheckCircle, contentDescription = Decorative) + Text( + text = stringResource(Res.string.the_parent_group_has_certified_this_subgroup), + style = MaterialTheme.typography.labelMedium + ) + } + + certificateFailed -> Text( + text = stringResource(Res.string.the_parent_group_could_not_certify_this_subgroup), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error + ) + + certificateSession != null -> Text( + text = stringResource( + Res.string.the_parent_group_is_certifying_this_subgroup, + certificateSession.threshold, + certificateSession.participantCount + ), + style = MaterialTheme.typography.labelMedium + ) + + else -> Text( + text = stringResource(Res.string.before_the_subgroup_exists_its_parent_signs), + style = MaterialTheme.typography.labelMedium + ) + } + + // Offered to whoever holds a share of the parent's key, which + // the coordinator does by construction -- being a parent admin + // is how they got here. The other subgroup admins answer it in + // the parent's room, where the proposal lands, so there is + // nothing for them to press here. + if (session.isCoordinator() && !isCertified) { + Button( + onClick = { onProposeBirthCertificate() }, + enabled = !isActionPending && + (certificateSession == null || certificateFailed), + modifier = Modifier.fillMaxWidth() + ) { + if (isActionPending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Icon(Icons.Default.AccountTree, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) + Text(text = stringResource(Res.string.ask_the_parent_group_to_certify)) + } + } + } + } + val keyStateFailed = keyStateSession?.stage == FrostSigningStage.FAILED when { @@ -567,7 +653,13 @@ private fun RitualProgress( } else { Icon(Icons.Default.Groups, contentDescription = Decorative) Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) - Text(text = stringResource(Res.string.create_the_admins_group)) + Text( + text = if (parentChatRoomId != null) { + stringResource(Res.string.create_the_subgroup) + } else { + stringResource(Res.string.create_the_admins_group) + } + ) } } } else { @@ -579,7 +671,13 @@ private fun RitualProgress( // this is the safe retry by construction. Button( onClick = { onProposeKeyState() }, - enabled = !isActionPending && (keyStateSession == null || keyStateFailed), + // A subgroup's key state carries the certificate and + // is refused without one, so this stays shut until the + // rung above it is done rather than opening a session + // that would throw. + enabled = !isActionPending && + (keyStateSession == null || keyStateFailed) && + (parentChatRoomId == null || isCertified), modifier = Modifier.fillMaxWidth() ) { if (isActionPending) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt new file mode 100644 index 00000000..28932cde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -0,0 +1,334 @@ +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.ActiveWallet +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_subgroup_needs_at_least_three_admins +import mantra.composeapp.generated.resources.admin_of_this_group +import mantra.composeapp.generated.resources.anyone_in_this_group_can_run_a_subgroup +import mantra.composeapp.generated.resources.has_no_key_package_yet +import mantra.composeapp.generated.resources.how_many_admins_have_to_approve_a_change +import mantra.composeapp.generated.resources.new_subgroup +import mantra.composeapp.generated.resources.of +import mantra.composeapp.generated.resources.start_the_key_ceremony +import mantra.composeapp.generated.resources.what_is_the_subgroup_called +import mantra.composeapp.generated.resources.who_runs_the_subgroup +import mantra.composeapp.generated.resources.you_coordinate_this_subgroup +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.SelectSubgroupAdminsViewModel +import press.mantra.compose.ui.view.state.SelectSubgroupAdminsUIState + +/** + * Picks who runs a subgroup, what it is called, and on what quorum. + * + * The three things that cannot be changed later, gathered before anything is + * published -- see `SelectSubgroupAdminsViewModel` for why the threshold in + * particular has nowhere else to live. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun SelectSubgroupAdminsScreen( + activeUserPublicKey: HexKey, + parentChatRoomId: String, + initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, + activeWalletStateFlow: StateFlow, + chatRepository: ChatRepository, + dkgRepository: DkgRepository, + onNavigateToRoute: (Route) -> Unit +) { + val viewModel: SelectSubgroupAdminsViewModel = viewModel( + factory = SelectSubgroupAdminsViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = parentChatRoomId, + initialUIState = initialUIState, + activeWalletStateFlow = activeWalletStateFlow, + chatRepository = chatRepository, + dkgRepository = dkgRepository + ) + ) + + LaunchedEffect(parentChatRoomId) { viewModel.initiate() } + + val snackbarHostState = LocalSnackbarHostState.current + val scope = rememberCoroutineScope() + + ScreenStateTransition(viewModel.uiState) { uiState -> + when (val state = uiState) { + is SelectSubgroupAdminsUIState.Error -> ErrorState( + message = state.message, + // Retrying reloads the group, which is the thing that failed. + onRetry = { viewModel.initiate() } + ) + + SelectSubgroupAdminsUIState.Loading -> LoadingDataIndicator() + + is SelectSubgroupAdminsUIState.Loaded -> Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar(title = { Text(stringResource(Res.string.new_subgroup)) }) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + scope.launch { + // The refusal is recomputed rather than cached on + // the button: the key-package answer can go stale + // while the screen is open, and confirm checks + // again anyway. + val refusal = viewModel.refusal() + if (refusal != null) { + snackbarHostState.showSnackbar(refusal) + } else { + viewModel.confirm(onNavigateToRoute) + } + } + }, + icon = { Icon(Icons.Default.AccountTree, contentDescription = Decorative) }, + text = { Text(stringResource(Res.string.start_the_key_ceremony)) } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { + OutlinedTextField( + value = viewModel.name.value, + onValueChange = viewModel::setName, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(Res.string.what_is_the_subgroup_called)) } + ) + } + + item { + Text( + text = stringResource(Res.string.who_runs_the_subgroup), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + // Stated rather than discovered by tapping a disabled + // button. Three is the floor a quorum stops being a check + // below, and the coordinator is one of the three whether + // or not anybody ticks them. + Text( + text = stringResource(Res.string.a_subgroup_needs_at_least_three_admins), + style = MaterialTheme.typography.bodySmall + ) + } + + item { + Text( + text = stringResource(Res.string.anyone_in_this_group_can_run_a_subgroup), + style = MaterialTheme.typography.bodySmall + ) + } + + // The coordinator, shown and not offered. They are a ChillDKG + // participant by construction and hold a share whether or not + // anybody ticked them, so leaving them off the list entirely + // would make "pick two more" read as a group of two. + item { + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = activeUserPublicKey, + profile = null + ) + }, + headlineContent = { + Text(stringResource(Res.string.you_coordinate_this_subgroup)) + }, + trailingContent = { + Checkbox(checked = true, enabled = false, onCheckedChange = null) + } + ) + } + } + + items( + items = state.candidates, + key = { it.participant.participantPublicKey } + ) { candidate -> + val publicKey = candidate.participant.participantPublicKey + val isUnreachable = publicKey in state.withoutKeyPackage + + Card( + onClick = { viewModel.toggleMember(publicKey) }, + enabled = !isUnreachable + ) { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = publicKey, + profile = candidate.profile + ) + }, + headlineContent = { + Text( + text = candidate.profile?.humanReadableNameOrPubkey() + ?: publicKey, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + }, + supportingContent = { + // The remedy, not the diagnosis. A key package + // is one-time-use and only its owner can + // publish another, so the useful thing to say + // is whose door to knock on. + if (isUnreachable) { + Text(stringResource(Res.string.has_no_key_package_yet)) + } else if (candidate.participant.adminAt != null) { + Text(stringResource(Res.string.admin_of_this_group)) + } + }, + trailingContent = { + Checkbox( + checked = viewModel.isSelected(publicKey), + enabled = !isUnreachable, + onCheckedChange = { viewModel.toggleMember(publicKey) } + ) + } + ) + } + } + + item { + QuorumStepper( + quorum = viewModel.threshold.value, + adminCount = viewModel.adminCount(), + quorumRange = viewModel.quorumRange(), + onQuorumChange = viewModel::setThreshold + ) + } + } + } + } + } +} + +/** + * How many of the subgroup's admins have to sign for it. + * + * Stepped rather than typed, like the one on the group-creation screen: the range + * is small and both ends are bounded. Unlike that one it cannot be revisited -- + * ChillDKG hashes the threshold into the session identity, so this is the only + * moment it can be chosen at all. + */ +@Composable +private fun QuorumStepper( + quorum: Int, + adminCount: Int, + quorumRange: IntRange, + onQuorumChange: (Int) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.space50), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50) + ) { + Text( + text = stringResource(Res.string.how_many_admins_have_to_approve_a_change), + style = MaterialTheme.typography.titleSmall + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200), + verticalAlignment = Alignment.CenterVertically + ) { + FilledIconButton( + onClick = { onQuorumChange(quorum - 1) }, + enabled = quorum > quorumRange.first + ) { + Icon(Icons.Default.Remove, contentDescription = "Fewer approvals") + } + + Text( + text = stringResource(Res.string.of, quorum, adminCount), + style = MaterialTheme.typography.titleMedium + ) + + FilledIconButton( + onClick = { onQuorumChange(quorum + 1) }, + enabled = quorum < quorumRange.last + ) { + Icon(Icons.Default.Add, contentDescription = "More approvals") + } + } + } +} + +@ConformancePreviews +@Composable +private fun SelectSubgroupAdminsScreenPreview() { + TorchTheme { + Surface(modifier = Modifier.fillMaxSize()) { + SelectSubgroupAdminsScreen( + activeUserPublicKey = "a".repeat(64), + parentChatRoomId = "b".repeat(64), + activeWalletStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateToRoute = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 759d5942..8a1627c4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -87,6 +87,8 @@ import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToC import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomMembersRoute +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute +import press.mantra.compose.ui.composable.SelectSubgroupAdminsScreen import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute import press.mantra.compose.ui.composable.navigation.routes.SignInRoute @@ -530,12 +532,27 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SelectSubgroupAdminsScreen( + activeUserPublicKey = route.activeUserPublicKey, + parentChatRoomId = route.parentChatRoomId, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, + onNavigateToRoute = { nextRoute -> + navController.navigate(route = nextRoute) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() DkgRitualScreen( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, + parentChatRoomId = route.parentChatRoomId, activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualRoute.kt index 0f36ee15..e7541478 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualRoute.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualRoute.kt @@ -2,9 +2,18 @@ package press.mantra.compose.ui.composable.navigation.routes import kotlinx.serialization.Serializable -/** The shared-key ceremony for one NIP-17 group. */ +/** + * The shared-key ceremony for one NIP-17 group. + * + * [parentChatRoomId] is set when the ceremony is making a subgroup, and turns the + * screen from a three-step ladder into a four-step one -- the extra rung being the + * parent's birth certificate. It is a claim rather than evidence, in the route as + * on the wire: what makes a lineage real is the certificate the parent's quorum + * signs, two steps later. See `docs/subgroups.md`. + */ @Serializable data class DkgRitualRoute( val activeUserPublicKey: String, - val chatRoomId: String + val chatRoomId: String, + val parentChatRoomId: String? = null ): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt new file mode 100644 index 00000000..939abe8e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt @@ -0,0 +1,20 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * First step of making a subgroup: who runs it, what it is called, and on what + * quorum. + * + * All three have to be settled before anything is published. ChillDKG hashes the + * threshold and the host keys into the session identity, so `t` is fixed the + * moment the proposal goes out -- there is no later screen where it could be + * changed, and a group that disagrees about it gets no key at all rather than a + * weak one. + */ +@Serializable +data class SelectSubgroupAdminsRoute( + val activeUserPublicKey: String, + val parentChatRoomId: String, + val relayHint: String? = null +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index f1d0faad..3bfb3db2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -80,6 +80,10 @@ class ChatRoomDetailViewModel( dialects = mantraRepository.getDialects(chatRoomId), groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), + subgroups = chatRepository.subgroupsOf(chatRoomId), + parentChatRoomId = chatRepository.parentOf(chatRoomId), + canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.canSign(chatRoomId), ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 677dcc8c..01fd6639 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.Dispatchers.Main import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.view.state.DkgRitualUIState @@ -49,6 +50,11 @@ import kotlinx.coroutines.launch class DkgRitualViewModel( val chatRoomId: String, val activeUserPublicKey: HexKey, + /** + * The group this ceremony is making a subgroup of, or null for an ordinary + * one. Off the route, which got it off the proposal -- a claim either way. + */ + val parentChatRoomId: HexKey? = null, initialDkgRitualUIState: DkgRitualUIState, val activeWalletStateFlow: StateFlow, val chatRepository: ChatRepository, @@ -118,7 +124,10 @@ class DkgRitualViewModel( return@launch } - dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom) + dkgRitualUIState = DkgRitualUIState.Loaded( + localChatRoom = localChatRoom, + parentChatRoomId = parentChatRoomId + ) threshold.value = ChatRoomType .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) .coerceIn(quorumRange()) @@ -127,7 +136,10 @@ class DkgRitualViewModel( dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { session -> val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded) - ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom) + ?: DkgRitualUIState.Loaded( + localChatRoom = localChatRoom, + parentChatRoomId = parentChatRoomId + ) dkgRitualUIState = loaded.copy( session = session, @@ -206,6 +218,37 @@ class DkgRitualViewModel( dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null) } } + + // Only for a subgroup, and off the *parent's* signed events rather + // than this room's -- the certificate is made where the parent's key + // can sign it, which is never the ceremony's room. + parentChatRoomId?.let { parent -> + launch { + dkgRepository.observeBirthCertificate(chatRoomId, parent) + .collect { certificate -> + val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded + ?: return@collect + + dkgRitualUIState = loaded.copy(isCertified = certificate != null) + } + } + + launch { + dkgRepository.observeSigningSessions(parent).collect { sessions -> + val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect + + dkgRitualUIState = loaded.copy( + certificateSession = sessions.firstOrNull { local -> + local.items.any { item -> + runCatching { + Event.fromJson(item.unsignedEventJson).kind + }.getOrNull() == SubgroupBirthCertificateEvent.KIND + } + }?.session + ) + } + } + } } } @@ -236,11 +279,24 @@ class DkgRitualViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - val signing = dkgRepository.proposeGroupKeyState( - localChatRoom = loaded.localChatRoom, - userPublicKey = activeUserPublicKey, - session = session - ) + // A subgroup's state carries the parent's certificate, and refuses + // to be proposed without one -- every device that receives it runs + // the same check and drops it, so proposing one this device would not + // believe spends a quorum's attention on nothing. + val signing = if (parentChatRoomId != null) { + dkgRepository.proposeSubgroupKeyState( + ceremonyRoom = loaded.localChatRoom, + userPublicKey = activeUserPublicKey, + session = session, + parentChatRoomId = parentChatRoomId + ) + } else { + dkgRepository.proposeGroupKeyState( + localChatRoom = loaded.localChatRoom, + userPublicKey = activeUserPublicKey, + session = session + ) + } isActionPending.value = false @@ -255,6 +311,58 @@ class DkgRitualViewModel( } } + /** + * Asks the parent's quorum to certify the subgroup this ceremony produced. + * + * Step 2 of four, and the only one that runs somewhere else: the certificate + * is signed in the *parent's* room, because it is the parent's signature. + * Offered to whoever holds a share of the parent's key, which the coordinator + * does by construction -- they are a parent admin, that is how they got here. + * + * Nothing waits for the answer. The session runs on arriving messages like + * every other, and [observeKeyState] follows it. + */ + fun proposeBirthCertificate() { + if (isActionPending.value) return + + val parent = parentChatRoomId ?: return + val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val session = loaded.session ?: return + if (session.thresholdPublicKey == null) return + + // Nothing to ask for, and nobody to ask twice. A failed session is the + // one case a second proposal is right, and it has to be a new session + // rather than that one resumed. + if (loaded.isCertified) return + loaded.certificateSession?.let { if (it.stage != FrostSigningStage.FAILED) return } + + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val signing = dkgRepository.proposeBirthCertificate( + parentChatRoomId = parent, + userPublicKey = activeUserPublicKey, + session = session, + adminPublicKeys = ChillDkgRitualManager + .memberPublicKeys(loaded.localChatRoom) + .toList(), + // The ceremony room was created with the name the coordinator + // typed, and the proposal carried it to everyone else -- so this + // is the same name every admin has been looking at. + name = loaded.localChatRoom.chatRoom.subject ?: "Subgroup" + ) + + isActionPending.value = false + + if (signing == null) { + logger.e("Failed to propose a birth certificate in $parent") + dkgRitualUIState = DkgRitualUIState.Error( + "Couldn't ask the group to certify this subgroup. Please try again." + ) + } + } + } + /** Opens a ritual, making this device its coordinator, and publishes the proposal. */ fun startRitual() { if (isActionPending.value) return @@ -367,7 +475,15 @@ class DkgRitualViewModel( // admins: the whole point of the room is that the members who hold shares of // the key can all act in it. val members = ChillDkgRitualManager.memberPublicKeys(loaded.localChatRoom) - val name = "${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)" + + // A subgroup is called what the coordinator called it; an admin room is + // called after the group it administers, because it has no name of its + // own to be given. + val name = if (parentChatRoomId != null) { + loaded.localChatRoom.chatRoom.subject ?: "Subgroup" + } else { + "${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)" + } isActionPending.value = true dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList()) @@ -376,10 +492,18 @@ class DkgRitualViewModel( val outcome = chatRepository.createMarmotGroup( groupId = groupId, name = name, - purpose = "Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}.", + purpose = if (parentChatRoomId != null) { + "A subgroup of the group that certified it." + } else { + "Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}." + }, adminPublicKeys = members, userPublicKey = activeUserPublicKey, - keyPair = keyPair + keyPair = keyPair, + // Verified by the time it is written: the key state this room was + // gated on carries the certificate, and `stateFrom` refused it + // otherwise. + parentChatRoomId = parentChatRoomId ) isActionPending.value = false @@ -442,6 +566,7 @@ class DkgRitualViewModel( fun factory( chatRoomId: String, activeUserPublicKey: HexKey, + parentChatRoomId: HexKey? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, activeWalletStateFlow: StateFlow, chatRepository: ChatRepository, @@ -451,6 +576,7 @@ class DkgRitualViewModel( DkgRitualViewModel( chatRoomId = chatRoomId, activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = parentChatRoomId, initialDkgRitualUIState = initialDkgRitualUIState, activeWalletStateFlow = activeWalletStateFlow, chatRepository = chatRepository, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt new file mode 100644 index 00000000..566cb931 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -0,0 +1,285 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.ActiveWallet +import fr.acinq.phoenix.managers.nostrPrivateKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.database.model.types.ChatRoomType +import press.mantra.compose.managers.ChillDkgRitualManager +import press.mantra.compose.managers.MarmotGroupCreation +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.view.state.SelectSubgroupAdminsUIState + +/** + * Who runs a subgroup, what it is called, and on what quorum. + * + * All three are settled here and nowhere else. The name and the admin set are + * carried into the birth certificate, so they are what the parent's admins will + * be asked to approve; the threshold cannot move afterwards at all, because + * ChillDKG hashes it and the host keys into the session identity, so `t` is fixed + * the moment the proposal goes out and a group that disagrees about it gets no + * key rather than a weak one. + * + * It is also the one value picked *for* other people, and consent survives that: + * `t` rides on the proposal, every invitee's `acceptProposal` re-checks it against + * `quorumRange` and drops a proposal outside it, and the host-key approval gate is + * where each of them agrees to the `t`-of-`n` they can now see. + * + * Confirming opens the ceremony -- a NIP-17 room over the picked admins, and a + * ritual proposal in it -- and hands over to `DkgRitualViewModel`, which drives + * the remaining three steps. See `docs/subgroups.md`. + */ +class SelectSubgroupAdminsViewModel( + val activeUserPublicKey: HexKey, + val parentChatRoomId: String, + initialUIState: SelectSubgroupAdminsUIState, + val activeWalletStateFlow: StateFlow, + val chatRepository: ChatRepository, + val dkgRepository: DkgRepository, +): ViewModel() { + + var uiState: SelectSubgroupAdminsUIState by mutableStateOf(initialUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** Members ticked, in the order they were ticked. The coordinator is never here. */ + val selectedPublicKeys = mutableStateListOf() + + val name: MutableState = mutableStateOf("") + + val threshold: MutableState = mutableStateOf(ChatRoomType.MINIMUM_QUORUM) + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val parentRoom = chatRepository.getChatRoomByIdentifier(parentChatRoomId) + if (parentRoom == null) { + uiState = SelectSubgroupAdminsUIState.Error("Couldn't load this group") + return@launch + } + + // Everybody but this device, since the coordinator is a participant by + // construction and is not theirs to tick or untick. + val candidates = parentRoom.localParticipants + .distinctBy { it.participant.participantPublicKey } + .filterNot { it.participant.participantPublicKey == activeUserPublicKey } + + uiState = SelectSubgroupAdminsUIState.Loaded( + parentRoom = parentRoom, + candidates = candidates, + ) + + resolveKeyPackages(candidates.map { it.participant.participantPublicKey }) + } + } + + /** + * Asks every candidate's key package up front. + * + * The same move `SelectChatRoomMembersViewModel` makes for group creation, and + * here it is doing more than saving a round trip. A key package is + * one-time-use -- `NostrDao` marks the bundle consumed as the device processes + * its own Welcome -- so every group a member joins burns one, and a member who + * has not published since has none left. `MarmotGroupCreation` would refuse to + * create the room for that, correctly, but only after a ChillDKG, a parent + * quorum and a child quorum had all completed, each needing every selected + * admin present. Finding out here costs nothing and finding out there costs + * three ceremonies. + */ + private suspend fun resolveKeyPackages(publicKeys: List) { + val resolved = MarmotGroupCreation.keyPackagesFor(chatRepository, publicKeys) + val missing = resolved.filter { it.second == null }.map { it.first }.toSet() + + val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return + uiState = loaded.copy(withoutKeyPackage = missing, isResolvingKeyPackages = false) + + // A member who cannot be welcomed cannot be an admin of a room they are not + // in, so unpick them rather than leaving a selection that confirm will + // refuse. + selectedPublicKeys.removeAll(missing) + } + + fun toggleMember(publicKey: HexKey) { + if (isActionPending.value) return + + val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return + if (publicKey in loaded.withoutKeyPackage) return + + if (!selectedPublicKeys.remove(publicKey)) { + selectedPublicKeys.add(publicKey) + } + + // The range moves with the admin count, so a threshold picked at one size + // has to be pulled back into the range at another. + threshold.value = threshold.value.coerceIn(quorumRange()) + } + + fun isSelected(publicKey: HexKey): Boolean = selectedPublicKeys.contains(publicKey) + + fun setName(value: String) { + if (isActionPending.value) return + + name.value = value + } + + /** The subgroup's admins: everyone ticked, plus the coordinator. */ + fun adminCount(): Int = selectedPublicKeys.size + 1 + + fun quorumRange(): IntRange = + ChatRoomType.quorumRange(maxOf(adminCount(), ChatRoomType.MINIMUM_QUORUM)) + + fun setThreshold(value: Int) { + if (isActionPending.value) return + + threshold.value = value.coerceIn(quorumRange()) + } + + /** + * Why this cannot be confirmed yet, or null when it can. + * + * Every message names what to do rather than what is wrong, because each of + * these is something the user can fix from where they are standing. + */ + suspend fun refusal(): String? { + val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return "Still loading" + + if (name.value.isBlank()) return "Give the subgroup a name." + + if (loaded.isResolvingKeyPackages) return "Checking who can be added…" + + val unreachable = selectedPublicKeys.filter { it in loaded.withoutKeyPackage } + if (unreachable.isNotEmpty()) { + return "Some of the people picked have no key package. Ask them to " + + "publish one, then try again." + } + + return chatRepository.refuseSubgroup( + parentChatRoomId = parentChatRoomId, + adminPublicKeys = selectedPublicKeys.toSet(), + coordinatorPublicKey = activeUserPublicKey + ) + } + + /** + * Opens the ceremony and hands over to the ritual screen. + * + * The room is stood up first and the proposal is the first thing out of it, + * exactly as a robust group is created: standing up a NIP-17 room sends + * nothing to anybody, so the proposal is how the other admins hear of it at + * all -- which is why it carries the name and the parent. + */ + fun confirm(onNavigateToRoute: (Route) -> Unit) { + if (isActionPending.value) return + + val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return + + val nostrPrivateKey = activeWalletStateFlow.value + ?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + if (nostrPrivateKey == null) { + uiState = SelectSubgroupAdminsUIState.Error("Couldn't read your keys. Please try again.") + return + } + + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + // Checked again here rather than trusted from the button: a screen not + // drawing something is not a guard, and the key-package answer can go + // stale while the screen is open. + val refusal = refusal() + if (refusal != null) { + isActionPending.value = false + uiState = SelectSubgroupAdminsUIState.Error(refusal) + return@launch + } + + val ceremonyRoom = chatRepository.createNip17ChatRoom( + userPublicKey = activeUserPublicKey, + participantPublicKeys = selectedPublicKeys.toList(), + subject = name.value, + description = "Making ${name.value} a subgroup of " + + (loaded.parentRoom.chatRoom.subject ?: "this group") + "." + ) + + if (ceremonyRoom == null) { + isActionPending.value = false + uiState = SelectSubgroupAdminsUIState.Error( + "Couldn't start the subgroup's key ceremony. Please try again." + ) + return@launch + } + + val session = dkgRepository.proposeRitual( + localChatRoom = ceremonyRoom, + userPublicKey = activeUserPublicKey, + nostrPrivateKey = nostrPrivateKey.value.toByteArray(), + threshold = threshold.value, + parentChatRoomId = parentChatRoomId + ) + + isActionPending.value = false + + if (session == null) { + logger.e("Created ${ceremonyRoom.chatRoom.id} without a key ceremony") + uiState = SelectSubgroupAdminsUIState.Error( + "Couldn't start the subgroup's key ceremony. Please try again." + ) + return@launch + } + + withContext(Dispatchers.Main) { + onNavigateToRoute( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = ceremonyRoom.chatRoom.id, + parentChatRoomId = parentChatRoomId + ) + ) + } + } + } + + companion object { + private const val TAG = "SelectSubgroupAdminsViewModel" + + fun factory( + activeUserPublicKey: HexKey, + parentChatRoomId: String, + initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, + activeWalletStateFlow: StateFlow, + chatRepository: ChatRepository, + dkgRepository: DkgRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SelectSubgroupAdminsViewModel( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = parentChatRoomId, + initialUIState = initialUIState, + activeWalletStateFlow = activeWalletStateFlow, + chatRepository = chatRepository, + dkgRepository = dkgRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 881da4b1..80f08814 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -1,6 +1,8 @@ package press.mantra.compose.ui.view.state +import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect @@ -29,6 +31,39 @@ sealed interface ChatRoomDetailUIState { * checked. Null on a device that holds the reading and not the event. */ val signedGroupKeyStateEvent: GroupSignedEvent? = null, + /** + * The groups this one has certified as its children. + * + * Off the parent's own signed certificates, so it lists a subgroup this + * device holds no room for -- which is the normal position of a member + * who is not in it, and of everybody between the certificate being + * signed and the room being created. + */ + val subgroups: List = emptyList(), + /** + * The group this room is a subgroup of, or null when it is nobody's + * child. + * + * Verified: it comes off the room's key state, which only carries a + * parent whose certificate was checked. A member welcomed in after the + * founding holds no state and so sees nothing here, even in a room that + * really is a subgroup. + */ + val parentChatRoomId: HexKey? = null, + /** + * Whether this device could open a subgroup here at all. + * + * Two conditions and both are about capability rather than permission. + * The room has to be a Marmot one, since a NIP-17 room has no admins to + * be a parent's. And this device has to hold a share of the group's key, + * because certifying a child is a signature by the parent and + * `FrostSigningManager.proposeSigningBatch` throws for a device that + * cannot make one -- throwing at a button is not a UI. + * + * The rest of the refusals are the picker's, where they can name who is + * missing and what to do about it. + */ + val canAddSubgroup: Boolean = false, ): ChatRoomDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt index abad6a01..11dee98a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt @@ -63,6 +63,25 @@ sealed interface DkgRitualUIState { * signed first. It is what gates creating the room. */ val isKeyStateSigned: Boolean = false, + /** + * The group this ceremony is making a subgroup of, or null for an + * ordinary one. + * + * A claim off the route and the proposal, not evidence. It decides which + * ladder the screen draws and nothing else -- what makes a lineage real + * is [isCertified] below, which is a signature by that group's quorum. + */ + val parentChatRoomId: HexKey? = null, + /** Whether the parent has certified the room this ceremony's key derives. */ + val isCertified: Boolean = false, + /** + * The session asking the parent to certify, while it is running. + * + * Runs in the *parent's* room rather than this one, so it is watched + * separately from [keyStateSession] and cannot be read off this room's + * sessions at all. + */ + val certificateSession: FrostSigningSession? = null, ): DkgRitualUIState { val hostKeyCount: Int get() = hostKeyParticipants.size val round1Count: Int get() = round1Participants.size diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectSubgroupAdminsUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectSubgroupAdminsUIState.kt new file mode 100644 index 00000000..7b139447 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectSubgroupAdminsUIState.kt @@ -0,0 +1,37 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant + +sealed interface SelectSubgroupAdminsUIState { + data class Loaded( + val parentRoom: LocalChatRoom, + /** + * Who can be picked: the parent's own members, admins and non-admins + * alike. + * + * Not filtered to the parent's admins on purpose. The point of a subgroup + * is that it can be run by people the parent does not let run the parent, + * so a picker that only offered existing admins would offer the one thing + * subgroups are not for. + */ + val candidates: List = emptyList(), + /** + * Members with no unconsumed key package, who therefore cannot be + * welcomed into the room at step 4. + * + * Checked here rather than at creation because creation is three + * ceremonies later: a ChillDKG, a parent quorum and a child quorum all + * have to complete before `MarmotGroupCreation` would discover it, and + * each of those needed every selected admin to show up and approve. + */ + val withoutKeyPackage: Set = emptySet(), + /** Null while the lookup is still out; a set once it has settled. */ + val isResolvingKeyPackages: Boolean = true, + ): SelectSubgroupAdminsUIState + + data class Error(val message: String): SelectSubgroupAdminsUIState + + data object Loading: SelectSubgroupAdminsUIState +}