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