From 5f6811271726a8186fe6796f6eee2f36ef8dba1d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:55:21 +0200 Subject: [PATCH] feat(subgroups): and the other direction -- what has been made for this The phase that makes the link worth having. Until now a certificate could say what a room was for and nothing could ask the question backwards, so a member looking at a project had no way to find the room the group made for it. `subgroupsFor` is a filter over `subgroupsOf` rather than a query of its own, because the certificates are already the record and a second read could disagree with the first about which of them verify. The case that earns its test is a sibling subgroup for something else: a filter on the wrong field would include it and still read as a working screen. `ceremoniesFor` is the half that prevents a real race. Two admins open a project's screen, neither can see the other's ceremony, and the group gets two ceremonies and two rooms for one project -- the screen that invites the act is the screen that cannot otherwise see the act in progress. Finished and failed are both left out, for different reasons: a complete ceremony is a subgroup that was made and belongs in the list above, and a failed one is nothing to offer back. The stage filter is in Kotlin rather than in the `WHERE`, because `DkgRitualStage` goes through a converter and a SQL comparison would be the query deciding what the converter's output looks like. Nothing is refused. A group may want two rooms for one artifact -- two teams, two bodies of work -- and deciding otherwise would be this device deciding a group's structure. So the button changes its words to "give this another room" instead of disappearing, and the deliberate second room stays one tap away while the accidental one becomes hard to make by mistake. The section is one `LazyListScope` extension for all four screens rather than four copies, which also replaces the four button blocks Phase 3 added. Four copies of "has this been given a room of its own" would drift into four answers. It hides itself entirely when there is nothing made, nothing running and nothing this device could start, rather than leaving a heading over a gap. Phase 7 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 3 + .../compose/database/dao/DkgSessionDao.kt | 24 +++ .../repository/DatabaseChatRepository.kt | 13 ++ .../compose/managers/SubgroupManager.kt | 54 +++++ .../compose/repository/ChatRepository.kt | 31 +++ .../ui/composable/ArtifactDetailScreen.kt | 52 +---- .../ui/composable/DialectDetailScreen.kt | 52 +---- .../ui/composable/ProjectDetailScreen.kt | 52 +---- .../TranslationArtifactVersionDetailScreen.kt | 52 +---- .../composable/widgets/SubgroupsForEntity.kt | 191 ++++++++++++++++++ .../ui/view/model/ArtifactDetailViewModel.kt | 26 +++ .../ui/view/model/DialectDetailViewModel.kt | 28 +++ .../ui/view/model/ProjectDetailViewModel.kt | 26 +++ ...anslationArtifactVersionDetailViewModel.kt | 26 +++ .../ui/view/state/ArtifactDetailUIState.kt | 23 +++ .../ui/view/state/DialectDetailUIState.kt | 23 +++ .../ui/view/state/ProjectDetailUIState.kt | 23 +++ ...TranslationArtifactVersionDetailUIState.kt | 23 +++ .../managers/SubgroupManagerJvmTest.kt | 104 +++++++++- 19 files changed, 656 insertions(+), 170 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SubgroupsForEntity.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 29213b0d..9c097604 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -32,11 +32,13 @@ Add chapter Add dialect Add to group + A key ceremony for a room is already under way. Answer it in the group rather than starting another. For the artifact %1$s For the dialect %1$s For the project %1$s For the translation %1$s Give this a room of its own + Give this another room Add translation After this there is no turning back. All broadcasts are queued so that we can manage data usage on metered connections. @@ -191,6 +193,7 @@ No projects exists in this group. No artifacts are written in this dialect yet. Nothing has been translated into this dialect yet. + Rooms of its own No translations yet. No versions. Not now diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt index 4f542730..5df82bff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt @@ -120,6 +120,30 @@ interface DkgSessionDao { participantPublicKeys: String ): DkgSession? + /** + * Every ceremony in [chatRoomId] opened for one particular thing, newest + * first. + * + * Both halves of the origin are matched, because a row holding one of them is + * a row written by a version that disagreed with this one -- see + * `DkgSession.origin`. + * + * The stage is deliberately not in the `WHERE`. `DkgRitualStage` is stored + * through a converter, so a SQL comparison against it would be this query + * deciding what the converter's output looks like; the caller filters in + * Kotlin, where the enum is the enum. + */ + @Query( + "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " + + "originKind = :originKind AND originId = :originId " + + "ORDER BY createdAt DESC" + ) + suspend fun getSessionsForOrigin( + chatRoomId: String, + originKind: Int, + originId: String + ): List + /** * Every ceremony this room holds, newest first. * 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 70e05e89..5d9b647c 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 @@ -8,7 +8,9 @@ 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.database.model.DkgSession import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload @@ -102,6 +104,17 @@ class DatabaseChatRepository( } + override suspend fun subgroupsFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = + SubgroupManager.subgroupsFor(database, chatRoomId, origin) + + override suspend fun subgroupCeremoniesFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = SubgroupManager.ceremoniesFor(database, chatRoomId, origin) + override suspend fun subgroupsOf(chatRoomId: String): List = try { SubgroupManager.subgroupsOf(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 6f8ef1dd..ba97285c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -839,6 +839,60 @@ object SubgroupManager { } .sortedByDescending { it.certificate.createdAt } + /** + * The subgroups [parentChatRoomId] has made for one particular thing. + * + * The other direction of the certificate: [subgroupsOf] answers "what has + * this group made", and this answers "what has been made for this project". + * It is a filter over that list rather than a query of its own, because the + * certificates are already the record and a second read could disagree with + * the first about which of them verify. + * + * Empty is the ordinary answer and says only that this device holds no + * certificate naming that entity. A subgroup made for it by a parent whose + * room this device is not in is invisible here, as it is everywhere else. + */ + suspend fun subgroupsFor( + database: MantraDatabase, + parentChatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = + subgroupsOf(database, parentChatRoomId).filter { it.origin == origin } + + /** + * Ceremonies under way in [parentChatRoomId] for one particular thing. + * + * The race this exists for: two admins open a project's screen, both see no + * room, and the group gets two ceremonies and two rooms for one project. The + * screen that invites the act is the screen that cannot otherwise see the act + * in progress, so it is shown what is already running. + * + * Nothing is refused on the strength of it. A group may legitimately want two + * rooms for one artifact -- two teams, two bodies of work -- and deciding + * otherwise would be this device deciding a group's structure. What this + * prevents is the *accidental* second one, which is the only one anybody + * regrets. + * + * Finished and failed are both left out, and for different reasons. A + * complete ceremony is a subgroup that was made, and [subgroupsFor] is where + * it shows; a failed one is nothing, and offering it back would be offering a + * ceremony nobody can finish. + */ + suspend fun ceremoniesFor( + database: MantraDatabase, + parentChatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = + database.dkgSessionDao() + .getSessionsForOrigin( + chatRoomId = parentChatRoomId, + originKind = origin.kind, + originId = origin.id + ) + .filter { + it.stage != DkgRitualStage.FAILED && it.stage != DkgRitualStage.COMPLETE + } + /** * Every group that has certified a room as its child, verified, in canonical * order -- empty for a room that is nobody's child. 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 9dc7189f..4bb3d0f2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -3,12 +3,14 @@ package press.mantra.compose.repository import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.GroupSignedEvent 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.managers.SubgroupOrigin import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -69,6 +71,25 @@ interface ChatRepository { */ suspend fun subgroupsOf(chatRoomId: String): List + /** + * The subgroups a room has made for one particular dialect, artifact, + * translation or project, and the ceremonies under way for it. + * + * The other direction of a birth certificate. See + * `SubgroupManager.subgroupsFor` and `SubgroupManager.ceremoniesFor` -- the + * second is what stops two admins opening two ceremonies for one thing + * because neither screen could see the other's. + */ + suspend fun subgroupsFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List + + suspend fun subgroupCeremoniesFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List + /** * Every group that has certified this room as its child, verified, in * canonical order -- empty if it is nobody's child. @@ -330,6 +351,16 @@ interface ChatRepository { chatRoomId: String ): GroupSignedEvent? = null + override suspend fun subgroupsFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = emptyList() + + override suspend fun subgroupCeremoniesFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = emptyList() + override suspend fun subgroupsOf(chatRoomId: String): List = emptyList() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index f0150cb7..77a2426c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -37,6 +37,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.widgets.subgroupsForEntitySection import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.widgets.Decorative @@ -297,48 +298,15 @@ fun ArtifactDetailScreen( } } - item { HorizontalDivider() } - - item { - // Hidden rather than disabled where it cannot work, - // the way the room's own subgroup entry is: a device - // with no share of the key cannot open the - // certificate session at all, and a NIP-17 room has - // no admins to be a parent's. - if (artifactDetailUIState.canAddSubgroup) { - TextButton( - onClick = { - val candidate = SubgroupOrigin.of(artifact) - - onNavigateToRoute.invoke( - SelectSubgroupAdminsRoute( - activeUserPublicKey = activeUserPublicKey, - parentChatRoomId = candidate.parentChatRoomId, - relayHint = relayHint, - name = candidate.name, - originKind = candidate.origin.kind, - originId = candidate.origin.id, - ) - ) - } - ) { - Icon( - Icons.Default.AccountTree, - contentDescription = Decorative - ) - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.itemGap - ) - ) - Text( - stringResource( - Res.string.give_this_a_room_of_its_own - ) - ) - } - } - } + subgroupsForEntitySection( + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + candidate = SubgroupOrigin.of(artifact), + subgroups = artifactDetailUIState.subgroups, + ceremonies = artifactDetailUIState.subgroupCeremonies, + canAddSubgroup = artifactDetailUIState.canAddSubgroup, + onNavigateToRoute = onNavigateToRoute, + ) item { HorizontalDivider() } // Details diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt index c636103e..e1008dd7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt @@ -45,6 +45,7 @@ import mantra.composeapp.generated.resources.translations import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.widgets.subgroupsForEntitySection import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute @@ -232,48 +233,15 @@ fun DialectDetailScreen( } } - item { HorizontalDivider() } - - item { - // Hidden rather than disabled where it cannot work, - // the way the room's own subgroup entry is: a device - // with no share of the key cannot open the - // certificate session at all, and a NIP-17 room has - // no admins to be a parent's. - if (dialectDetailUIState.canAddSubgroup) { - TextButton( - onClick = { - val candidate = SubgroupOrigin.of(dialect) - - onNavigateToRoute.invoke( - SelectSubgroupAdminsRoute( - activeUserPublicKey = activeUserPublicKey, - parentChatRoomId = candidate.parentChatRoomId, - relayHint = relayHint, - name = candidate.name, - originKind = candidate.origin.kind, - originId = candidate.origin.id, - ) - ) - } - ) { - Icon( - Icons.Default.AccountTree, - contentDescription = Decorative - ) - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.itemGap - ) - ) - Text( - stringResource( - Res.string.give_this_a_room_of_its_own - ) - ) - } - } - } + subgroupsForEntitySection( + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + candidate = SubgroupOrigin.of(dialect), + subgroups = dialectDetailUIState.subgroups, + ceremonies = dialectDetailUIState.subgroupCeremonies, + canAddSubgroup = dialectDetailUIState.canAddSubgroup, + onNavigateToRoute = onNavigateToRoute, + ) item { HorizontalDivider() } item { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt index e39f5838..b69ccd05 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt @@ -52,6 +52,7 @@ import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.Profile import press.mantra.compose.extensions.shortened import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.widgets.subgroupsForEntitySection import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository @@ -263,48 +264,15 @@ fun ProjectDetailScreen( } } - item { HorizontalDivider() } - - item { - // Hidden rather than disabled where it cannot work, - // the way the room's own subgroup entry is: a device - // with no share of the key cannot open the - // certificate session at all, and a NIP-17 room has - // no admins to be a parent's. - if (projectDetailUIState.canAddSubgroup) { - TextButton( - onClick = { - val candidate = SubgroupOrigin.of(project) - - onNavigateToRoute.invoke( - SelectSubgroupAdminsRoute( - activeUserPublicKey = activeUserPublicKey, - parentChatRoomId = candidate.parentChatRoomId, - relayHint = relayHint, - name = candidate.name, - originKind = candidate.origin.kind, - originId = candidate.origin.id, - ) - ) - } - ) { - Icon( - Icons.Default.AccountTree, - contentDescription = Decorative - ) - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.itemGap - ) - ) - Text( - stringResource( - Res.string.give_this_a_room_of_its_own - ) - ) - } - } - } + subgroupsForEntitySection( + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + candidate = SubgroupOrigin.of(project), + subgroups = projectDetailUIState.subgroups, + ceremonies = projectDetailUIState.subgroupCeremonies, + canAddSubgroup = projectDetailUIState.canAddSubgroup, + onNavigateToRoute = onNavigateToRoute, + ) item { HorizontalDivider() } // Who it belongs to diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index 65ccf4c0..c6c4cc31 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.widgets.subgroupsForEntitySection import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository @@ -232,48 +233,15 @@ fun TranslationArtifactVersionDetailScreen( } } - item { HorizontalDivider() } - - item { - // Hidden rather than disabled where it cannot work, - // the way the room's own subgroup entry is: a device - // with no share of the key cannot open the - // certificate session at all, and a NIP-17 room has - // no admins to be a parent's. - if (translationDetailUIState.canAddSubgroup) { - TextButton( - onClick = { - val candidate = SubgroupOrigin.of(translation) - - onNavigateToRoute.invoke( - SelectSubgroupAdminsRoute( - activeUserPublicKey = activeUserPublicKey, - parentChatRoomId = candidate.parentChatRoomId, - relayHint = relayHint, - name = candidate.name, - originKind = candidate.origin.kind, - originId = candidate.origin.id, - ) - ) - } - ) { - Icon( - Icons.Default.AccountTree, - contentDescription = Decorative - ) - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.itemGap - ) - ) - Text( - stringResource( - Res.string.give_this_a_room_of_its_own - ) - ) - } - } - } + subgroupsForEntitySection( + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + candidate = SubgroupOrigin.of(translation), + subgroups = translationDetailUIState.subgroups, + ceremonies = translationDetailUIState.subgroupCeremonies, + canAddSubgroup = translationDetailUIState.canAddSubgroup, + onNavigateToRoute = onNavigateToRoute, + ) item { HorizontalDivider() } // Details diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SubgroupsForEntity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SubgroupsForEntity.kt new file mode 100644 index 00000000..5ab2f662 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SubgroupsForEntity.kt @@ -0,0 +1,191 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +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.HourglassTop +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.Modifier +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_key_ceremony_for_a_room_is_under_way +import mantra.composeapp.generated.resources.a_subgroup +import mantra.composeapp.generated.resources.certified_not_yet_created +import mantra.composeapp.generated.resources.created_you_are_not_a_member +import mantra.composeapp.generated.resources.give_this_a_room_of_its_own +import mantra.composeapp.generated.resources.give_this_another_room +import mantra.composeapp.generated.resources.rooms_of_its_own +import mantra.composeapp.generated.resources.run_by_members +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute +import press.mantra.compose.ui.theme.spacing + +/** + * The rooms a group has made for one thing, and the way to ask for another. + * + * Written once for four screens, which is the point: a dialect, an artifact, a + * translation and a project all answer "has this been given a room of its own" + * identically, and four copies of the answer would drift into four. It is a + * `LazyListScope` extension rather than a composable so each screen's own + * `LazyColumn` keeps one scroll and one set of keys. + * + * ### What is shown and what is not refused + * + * A group may legitimately want two rooms for one artifact -- two teams, two + * bodies of work -- so a second is never refused. What is prevented is the + * *accidental* second: two admins open this screen at the same time, both see + * nothing, and the group gets two ceremonies and two rooms for one project. + * [ceremonies] is what stops that, by making the act in progress visible on the + * screen that invites the act. The button changes its words rather than + * disappearing, so the deliberate second room is still one tap away. + * + * A subgroup with no room on this device is still listed. That is the normal + * position of a member who is not in the child, and of everybody between the + * parent certifying it and somebody creating it, so it opens at the certificate + * instead -- which is the whole of what is known and is checkable. + */ +fun LazyListScope.subgroupsForEntitySection( + activeUserPublicKey: HexKey, + relayHint: String?, + candidate: SubgroupOrigin.Candidate, + subgroups: List, + ceremonies: List, + canAddSubgroup: Boolean, + onNavigateToRoute: (Route) -> Unit, +) { + // Nothing made, nothing running and nothing this device could start: the + // section would be a heading over an empty space. Hidden entirely rather + // than shown empty, the way the room's own subgroup entry is -- a member who + // holds no share of the key cannot act on any of it. + if (subgroups.isEmpty() && ceremonies.isEmpty() && !canAddSubgroup) return + + item { HorizontalDivider() } + + item { + Text( + text = stringResource(Res.string.rooms_of_its_own), + style = MaterialTheme.typography.labelMedium + ) + } + + items( + items = subgroups, + key = { subgroup -> subgroup.chatRoomId } + ) { subgroup -> + Card( + onClick = { + onNavigateToRoute.invoke( + subgroup.room?.let { + ChatRoomDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = it.chatRoom.id, + relayHint = relayHint + ) + } ?: NostrEventDetailRoute( + activeUserPublicKey = activeUserPublicKey, + nostrEventId = subgroup.certificate.id + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon(Icons.Default.AccountTree, contentDescription = Decorative) + }, + // The room where this device holds one, and only otherwise the + // certificate's: the certificate's name is what the parent + // approved at founding, frozen, and the child may since have been + // renamed. Where the room is here it is the live answer. + 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() + ) + subgroup.keyState == null -> + stringResource(Res.string.certified_not_yet_created) + else -> stringResource(Res.string.created_you_are_not_a_member) + } + ) + } + ) + } + } + + // One line however many are running. Two ceremonies for one thing is already + // the thing this is warning about, and listing them separately would make + // the warning look like a feature. + if (ceremonies.isNotEmpty()) { + item { + Card { + ListItem( + leadingContent = { + Icon(Icons.Default.HourglassTop, contentDescription = Decorative) + }, + headlineContent = { + Text(stringResource(Res.string.a_key_ceremony_for_a_room_is_under_way)) + } + ) + } + } + } + + if (canAddSubgroup) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + SelectSubgroupAdminsRoute( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = candidate.parentChatRoomId, + relayHint = relayHint, + name = candidate.name, + originKind = candidate.origin.kind, + originId = candidate.origin.id, + ) + ) + } + ) { + Icon(Icons.Default.AccountTree, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + // "Another" once there is one, so the button is not inviting + // something that already exists. Changed rather than hidden, + // because a second room is a thing a group may genuinely want. + Text( + stringResource( + if (subgroups.isEmpty() && ceremonies.isEmpty()) { + Res.string.give_this_a_room_of_its_own + } else { + Res.string.give_this_another_room + } + ) + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt index 339f469a..35c02657 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.model +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.database.model.DkgSession import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -42,12 +44,18 @@ class ArtifactDetailViewModel( artifactDetailUIState = if (artifact == null) { ArtifactDetailUIState.Error("Couldn't find the artifact") } else { + // Read before the state is built so both halves come from one + // call, and so the room is resolved once rather than twice. + val (madeFor, ceremoniesFor) = subgroupsAndCeremoniesFor(artifact) + ArtifactDetailUIState.Loaded( artifact = artifact, versions = mantraRepository.getArtifactVersions(artifactId), chapters = mantraRepository.getChaptersForArtifact(artifactId), translations = mantraRepository.getTranslationsForArtifact(artifactId), canAddSubgroup = canAddSubgroupFor(artifact), + subgroups = madeFor, + subgroupCeremonies = ceremoniesFor, ) } } @@ -70,6 +78,24 @@ class ArtifactDetailViewModel( chatRepository.canSign(parentChatRoomId) } + + /** + * The rooms made for [entity] and the ceremonies under way for it. + * + * Both off the entity's own room, which `SubgroupOrigin.of` is the one + * authority on -- a subgroup made for this thing is a child of the room the + * thing lives in, and asking any other room would answer about somebody + * else's children. + */ + private suspend fun subgroupsAndCeremoniesFor( + entity: SubgroupOriginEntity + ): Pair, List> { + val candidate = SubgroupOrigin.of(entity) + + return chatRepository.subgroupsFor(candidate.parentChatRoomId, candidate.origin) to + chatRepository.subgroupCeremoniesFor(candidate.parentChatRoomId, candidate.origin) + } + companion object { private const val TAG = "ArtifactDetailViewModel" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt index c5e1b6b0..d4de4e47 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt @@ -1,5 +1,9 @@ package press.mantra.compose.ui.view.model +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.database.model.traits.SubgroupOriginEntity +import press.mantra.compose.database.model.DkgSession import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -60,17 +64,41 @@ class DialectDetailViewModel( // the dialect actually lives in. val room = chatRepository.getChatRoomByIdentifier(dialect.chatRoomId) + // Read before the state is built so both halves come from one + // call, and so the room is resolved once rather than twice. + val (madeFor, ceremoniesFor) = subgroupsAndCeremoniesFor(dialect) + DialectDetailUIState.Loaded( dialect = dialect, artifacts = mantraRepository.getArtifactsForDialect(dialectId), translations = mantraRepository.getTranslationsForDialect(dialectId), canAddSubgroup = room?.chatRoom?.mlsGroupState != null && chatRepository.canSign(dialect.chatRoomId), + subgroups = madeFor, + subgroupCeremonies = ceremoniesFor, ) } } } + + /** + * The rooms made for [entity] and the ceremonies under way for it. + * + * Both off the entity's own room, which `SubgroupOrigin.of` is the one + * authority on -- a subgroup made for this thing is a child of the room the + * thing lives in, and asking any other room would answer about somebody + * else's children. + */ + private suspend fun subgroupsAndCeremoniesFor( + entity: SubgroupOriginEntity + ): Pair, List> { + val candidate = SubgroupOrigin.of(entity) + + return chatRepository.subgroupsFor(candidate.parentChatRoomId, candidate.origin) to + chatRepository.subgroupCeremoniesFor(candidate.parentChatRoomId, candidate.origin) + } + companion object { private const val TAG = "DialectDetailViewModel" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt index f386d59f..ef6b571d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.model +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.database.model.DkgSession import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -47,6 +49,10 @@ class ProjectDetailViewModel( projectDetailUIState = if (project == null) { ProjectDetailUIState.Error("Couldn't find the project") } else { + // Read before the state is built so both halves come from one + // call, and so the room is resolved once rather than twice. + val (madeFor, ceremoniesFor) = subgroupsAndCeremoniesFor(project) + ProjectDetailUIState.Loaded( project = project, sourceVersions = mantraRepository.getProjectArtifactVersions(projectId), @@ -59,6 +65,8 @@ class ProjectDetailViewModel( ), canSign = frostSigningRepository.canSign(chatRoomId), canAddSubgroup = canAddSubgroupFor(project), + subgroups = madeFor, + subgroupCeremonies = ceremoniesFor, ) } } @@ -86,6 +94,24 @@ class ProjectDetailViewModel( nostrRepository.getProfileWithPublicKey(publicKey)?.let { publicKey to it } }.toMap() + + /** + * The rooms made for [entity] and the ceremonies under way for it. + * + * Both off the entity's own room, which `SubgroupOrigin.of` is the one + * authority on -- a subgroup made for this thing is a child of the room the + * thing lives in, and asking any other room would answer about somebody + * else's children. + */ + private suspend fun subgroupsAndCeremoniesFor( + entity: SubgroupOriginEntity + ): Pair, List> { + val candidate = SubgroupOrigin.of(entity) + + return chatRepository.subgroupsFor(candidate.parentChatRoomId, candidate.origin) to + chatRepository.subgroupCeremoniesFor(candidate.parentChatRoomId, candidate.origin) + } + companion object { private const val TAG = "ProjectDetailViewModel" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt index 293a0bda..89dad6da 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.model +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.database.model.DkgSession import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,6 +65,10 @@ class TranslationArtifactVersionDetailViewModel( ) } + // Read before the state is built so both halves come from one + // call, and so the room is resolved once rather than twice. + val (madeFor, ceremoniesFor) = subgroupsAndCeremoniesFor(translation) + TranslationArtifactVersionDetailUIState.Loaded( translation = translation, chapters = chapters, @@ -74,6 +80,8 @@ class TranslationArtifactVersionDetailViewModel( ), canSign = frostSigningRepository.canSign(chatRoomId), canAddSubgroup = canAddSubgroupFor(translation), + subgroups = madeFor, + subgroupCeremonies = ceremoniesFor, ) } } @@ -162,6 +170,24 @@ class TranslationArtifactVersionDetailViewModel( } } + + /** + * The rooms made for [entity] and the ceremonies under way for it. + * + * Both off the entity's own room, which `SubgroupOrigin.of` is the one + * authority on -- a subgroup made for this thing is a child of the room the + * thing lives in, and asking any other room would answer about somebody + * else's children. + */ + private suspend fun subgroupsAndCeremoniesFor( + entity: SubgroupOriginEntity + ): Pair, List> { + val candidate = SubgroupOrigin.of(entity) + + return chatRepository.subgroupsFor(candidate.parentChatRoomId, candidate.origin) to + chatRepository.subgroupCeremoniesFor(candidate.parentChatRoomId, candidate.origin) + } + companion object { private const val TAG = "TranslationArtifactVersionDetailViewModel" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt index 6fd75a0c..e2469e1e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter @@ -27,6 +29,27 @@ sealed interface ArtifactDetailUIState { * subgroup button rather than before it. */ val canAddSubgroup: Boolean = false, + /** + * The rooms this group has made for this artifact. + * + * The other direction of a birth certificate, and the reason the + * certificate carries an origin at all. Empty says only that this device + * holds no certificate naming it -- a room made for it by a group this + * device is not in is invisible here, as everywhere else. + */ + val subgroups: List = emptyList(), + + /** + * Key ceremonies already under way for this artifact. + * + * Shown rather than refused. Two admins opening this screen at the same + * time both see no room and both start one, which is the race the whole + * pair exists for -- the screen that invites the act is the screen that + * cannot otherwise see the act in progress. A group may still want two + * rooms for one artifact, and that is the group's business. + */ + val subgroupCeremonies: List = emptyList(), + ): ArtifactDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt index 0c5c3adf..09468ed4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.MantraTranslationArtifactVersion @@ -37,6 +39,27 @@ sealed interface DialectDetailUIState { * missing and what to do about it. */ val canAddSubgroup: Boolean = false, + /** + * The rooms this group has made for this dialect. + * + * The other direction of a birth certificate, and the reason the + * certificate carries an origin at all. Empty says only that this device + * holds no certificate naming it -- a room made for it by a group this + * device is not in is invisible here, as everywhere else. + */ + val subgroups: List = emptyList(), + + /** + * Key ceremonies already under way for this dialect. + * + * Shown rather than refused. Two admins opening this screen at the same + * time both see no room and both start one, which is the race the whole + * pair exists for -- the screen that invites the act is the screen that + * cannot otherwise see the act in progress. A group may still want two + * rooms for one dialect, and that is the group's business. + */ + val subgroupCeremonies: List = emptyList(), + ): DialectDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt index c2c228b8..cb3c0513 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraProject @@ -40,6 +42,27 @@ sealed interface ProjectDetailUIState { * than derived at the call site so there is one spelling of one rule. */ val canAddSubgroup: Boolean = false, + /** + * The rooms this group has made for this project. + * + * The other direction of a birth certificate, and the reason the + * certificate carries an origin at all. Empty says only that this device + * holds no certificate naming it -- a room made for it by a group this + * device is not in is invisible here, as everywhere else. + */ + val subgroups: List = emptyList(), + + /** + * Key ceremonies already under way for this project. + * + * Shown rather than refused. Two admins opening this screen at the same + * time both see no room and both start one, which is the race the whole + * pair exists for -- the screen that invites the act is the screen that + * cannot otherwise see the act in progress. A group may still want two + * rooms for one project, and that is the group's business. + */ + val subgroupCeremonies: List = emptyList(), + ): ProjectDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt index 05896461..14f3d4f6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationChapter @@ -46,6 +48,27 @@ sealed interface TranslationArtifactVersionDetailUIState { * `ChatRoomDetailUIState.canAddSubgroup` states. */ val canAddSubgroup: Boolean = false, + /** + * The rooms this group has made for this translation. + * + * The other direction of a birth certificate, and the reason the + * certificate carries an origin at all. Empty says only that this device + * holds no certificate naming it -- a room made for it by a group this + * device is not in is invisible here, as everywhere else. + */ + val subgroups: List = emptyList(), + + /** + * Key ceremonies already under way for this translation. + * + * Shown rather than refused. Two admins opening this screen at the same + * time both see no room and both start one, which is the race the whole + * pair exists for -- the screen that invites the act is the screen that + * cannot otherwise see the act in progress. A group may still want two + * rooms for one translation, and that is the group's business. + */ + val subgroupCeremonies: List = emptyList(), + ): TranslationArtifactVersionDetailUIState data class Error( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt index ca78097c..f210a2c8 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -1,5 +1,6 @@ package press.mantra.compose.managers +import press.mantra.compose.managers.SubgroupOrigin import androidx.room3.Room import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 @@ -133,6 +134,103 @@ class SubgroupManagerJvmTest { assertNull(subgroup.keyState) } + // --- the other direction: what was made for one thing --- + + /** + * The reverse index, which is the reason the certificate carries an origin. + * A room certifies every subgroup it ever makes, so asking "what has been + * made for this project" is a filter and not a lookup -- and the case that + * matters is a sibling subgroup for something else, which a filter on the + * wrong field would include. + */ + @Test + fun `a parent lists only the subgroups made for one thing`(): Unit = runBlocking { + seedRoom(parentRoomId) + + val theProject = SubgroupOrigin.Ref(kind = 30330, id = "a1".padEnd(64, '0')) + val theDialect = SubgroupOrigin.Ref(kind = 30304, id = "b2".padEnd(64, '0')) + + record(certificate(origin = theProject)) + record(certificate(subgroupMaterial = siblingMaterial, origin = theDialect)) + + assertEquals( + listOf(childRoomId), + SubgroupManager.subgroupsFor(db, parentRoomId, theProject).map { it.chatRoomId } + ) + assertEquals( + listOf(siblingRoomId), + SubgroupManager.subgroupsFor(db, parentRoomId, theDialect).map { it.chatRoomId } + ) + } + + /** + * A subgroup made for nothing in particular belongs to no entity's list. It + * is the group's own "Add subgroup", and every certificate signed before the + * origin tag existed is one. + */ + @Test + fun `a subgroup for nothing in particular is in no entity's list`(): Unit = runBlocking { + seedRoom(parentRoomId) + record(certificate()) + + assertEquals(1, SubgroupManager.subgroupsOf(db, parentRoomId).size) + assertTrue( + SubgroupManager.subgroupsFor( + db, + parentRoomId, + SubgroupOrigin.Ref(kind = 30330, id = "a1".padEnd(64, '0')) + ).isEmpty() + ) + } + + /** + * The race this pair exists for. Two admins open a project's screen, neither + * can see the other's ceremony, and the group gets two rooms for one project. + * A finished one is not in flight -- it is a subgroup that was made -- and a + * failed one is nothing to offer back. + */ + @Test + fun `only unfinished ceremonies for that thing count as in flight`(): Unit = runBlocking { + seedRoom(parentRoomId) + + val theProject = SubgroupOrigin.Ref(kind = 30330, id = "a1".padEnd(64, '0')) + val somethingElse = SubgroupOrigin.Ref(kind = 30304, id = "b2".padEnd(64, '0')) + + suspend fun ceremony(id: String, stage: DkgRitualStage, origin: SubgroupOrigin.Ref?) = + db.dkgSessionDao().upsert( + DkgSession( + id = id, + chatRoomId = parentRoomId, + coordinatorPublicKey = user, + userPublicKey = user, + threshold = 2, + participantCount = 3, + stage = stage, + hostPublicKey = user, + round1Random = "aa".repeat(32), + round2AuxRandom = "bb".repeat(32), + parentChatRoomId = parentRoomId, + originKind = origin?.kind, + originId = origin?.id, + ) + ) + + ceremony("running", DkgRitualStage.COLLECTING_HOST_KEYS, theProject) + ceremony("done", DkgRitualStage.COMPLETE, theProject) + ceremony("failed", DkgRitualStage.FAILED, theProject) + ceremony("elsewhere", DkgRitualStage.COLLECTING_HOST_KEYS, somethingElse) + ceremony("for-nothing", DkgRitualStage.COLLECTING_HOST_KEYS, null) + + assertEquals( + listOf("running"), + SubgroupManager.ceremoniesFor(db, parentRoomId, theProject).map { it.id } + ) + assertEquals( + listOf("elsewhere"), + SubgroupManager.ceremoniesFor(db, parentRoomId, somethingElse).map { it.id } + ) + } + @Test fun `a child whose room this device holds reports it`(): Unit = runBlocking { seedRoom(parentRoomId) @@ -1113,7 +1211,8 @@ class SubgroupManagerJvmTest { author: KeyMaterial = parentMaterial, signer: KeyMaterial = author, signature: String? = null, - parentChatRoomId: String = roomOf(author) + parentChatRoomId: String = roomOf(author), + origin: SubgroupOrigin.Ref? = null ): Event { val subgroupChatRoomId = roomOf(subgroupMaterial) val eventTags = tags ?: SubgroupBirthCertificateEvent.assembleTags( @@ -1122,7 +1221,8 @@ class SubgroupManagerJvmTest { thresholdPublicKey = subgroupMaterial.thresholdPublicKey.value.toHex(), adminPublicKeys = adminPublicKeys, name = name, - path = path + path = path, + origin = origin ) val pubKey = SharedKeyDerivation