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
+}