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