feat(subgroups): and the other direction -- what has been made for this

The phase that makes the link worth having. Until now a certificate could say
what a room was for and nothing could ask the question backwards, so a member
looking at a project had no way to find the room the group made for it.

`subgroupsFor` is a filter over `subgroupsOf` rather than a query of its own,
because the certificates are already the record and a second read could disagree
with the first about which of them verify. The case that earns its test is a
sibling subgroup for something else: a filter on the wrong field would include
it and still read as a working screen.

`ceremoniesFor` is the half that prevents a real race. Two admins open a
project's screen, neither can see the other's ceremony, and the group gets two
ceremonies and two rooms for one project -- the screen that invites the act is
the screen that cannot otherwise see the act in progress. Finished and failed
are both left out, for different reasons: a complete ceremony is a subgroup that
was made and belongs in the list above, and a failed one is nothing to offer
back. The stage filter is in Kotlin rather than in the `WHERE`, because
`DkgRitualStage` goes through a converter and a SQL comparison would be the
query deciding what the converter's output looks like.

Nothing is refused. A group may want two rooms for one artifact -- two teams,
two bodies of work -- and deciding otherwise would be this device deciding a
group's structure. So the button changes its words to "give this another room"
instead of disappearing, and the deliberate second room stays one tap away while
the accidental one becomes hard to make by mistake.

The section is one `LazyListScope` extension for all four screens rather than
four copies, which also replaces the four button blocks Phase 3 added. Four
copies of "has this been given a room of its own" would drift into four
answers. It hides itself entirely when there is nothing made, nothing running
and nothing this device could start, rather than leaving a heading over a gap.

Phase 7 of docs/subgroups-for-entities.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-03 01:55:21 +02:00
1 parent 7640461345
commit 5f68112717
19 files changed
+656 -170

No files matched your search

@@ -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<DkgSession>
/**
* Every ceremony this room holds, newest first.
*
@@ -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.Subgroup> =
SubgroupManager.subgroupsFor(database, chatRoomId, origin)
override suspend fun subgroupCeremoniesFor(
chatRoomId: String,
origin: SubgroupOrigin.Ref
): List<DkgSession> = SubgroupManager.ceremoniesFor(database, chatRoomId, origin)
override suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup> = try {
SubgroupManager.subgroupsOf(database, chatRoomId)
} catch (e: Throwable) {
@@ -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<Subgroup> =
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<DkgSession> =
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.
@@ -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<SubgroupManager.Subgroup>
/**
* 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<SubgroupManager.Subgroup>
suspend fun subgroupCeremoniesFor(
chatRoomId: String,
origin: SubgroupOrigin.Ref
): List<DkgSession>
/**
* 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<SubgroupManager.Subgroup> = emptyList()
override suspend fun subgroupCeremoniesFor(
chatRoomId: String,
origin: SubgroupOrigin.Ref
): List<DkgSession> = emptyList()
override suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup> =
emptyList()
@@ -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
@@ -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 {
@@ -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
@@ -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
@@ -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<SubgroupManager.Subgroup>,
ceremonies: List<DkgSession>,
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
}
)
)
}
}
}
}
@@ -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<SubgroupManager.Subgroup>, List<DkgSession>> {
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"
@@ -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<SubgroupManager.Subgroup>, List<DkgSession>> {
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"
@@ -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<SubgroupManager.Subgroup>, List<DkgSession>> {
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"
@@ -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<SubgroupManager.Subgroup>, List<DkgSession>> {
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"
@@ -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<SubgroupManager.Subgroup> = 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<DkgSession> = emptyList(),
): ArtifactDetailUIState
data class Error(
@@ -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<SubgroupManager.Subgroup> = 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<DkgSession> = emptyList(),
): DialectDetailUIState
data class Error(
@@ -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<SubgroupManager.Subgroup> = 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<DkgSession> = emptyList(),
): ProjectDetailUIState
data class Error(
@@ -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<SubgroupManager.Subgroup> = 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<DkgSession> = emptyList(),
): TranslationArtifactVersionDetailUIState
data class Error(