feat(subgroups): ask for a room from the thing that needs one

Four buttons, one per entity, each opening the existing picker with the name
already in it. This is the cheap version of the feature complete: a member
looking at a translation can give it a room without backing out to the group,
finding "Add subgroup" and retyping a name the app already knew.

`SelectSubgroupAdminsRoute` grows three nullable fields. The name is a
suggestion and stays editable -- a group renaming its own room is not a route's
business to prevent -- and it is seeded in the view model's constructor rather
than in `initiate`, because a field somebody may already be typing into must not
be overwritten by a load completing underneath them.

The origin is two flat fields rather than one type, because a route is
`@Serializable` and is rebuilt from a bundle. The nav host reassembles them and
requires both or neither: half a reference is a reference with the resolvable
part removed. The view model then only holds it. Riding it onto the ceremony
needs a `DkgSession` column and a tag on the proposal, which is Phase 5, and the
comment at the `proposeRitual` call says so rather than leaving a parameter that
looks forgotten.

The gate is `canAddSubgroup` on all four states and not `canSign`: a share of
the key *and* `mlsGroupState != null`, because a NIP-17 room has no admins to be
a parent's and these entities can live in one. Three states had to learn it.
`ProjectDetailUIState` and `TranslationArtifactVersionDetailUIState` already
carried `canSign`; `ArtifactDetailUIState` carried no capability flag at all, so
the artifact screen and its view model gained a `ChatRepository` as well -- the
one place this phase is more than a button.

Each flag is computed off `SubgroupOrigin.of(entity).parentChatRoomId` rather
than the route's `chatRoomId`. They are the same room in every path that exists,
and reading it through the function is what keeps the parent of a subgroup the
room the entity actually lives in if a route is ever built by hand.

Hidden rather than disabled where it cannot work, which is what the room's own
subgroup entry does and for the same reason: a device with no share cannot open
the certificate session at all, and `proposeSigningBatch` throws rather than
refusing.

Phase 3 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:27:47 +02:00
1 parent 0727a04bae
commit 2c3c949b86
15 files changed
+380 -4

No files matched your search

@@ -32,6 +32,7 @@
<string name="add_chapter">Add chapter</string> <string name="add_chapter">Add chapter</string>
<string name="add_dialect">Add dialect</string> <string name="add_dialect">Add dialect</string>
<string name="add_to_group">Add to group</string> <string name="add_to_group">Add to group</string>
<string name="give_this_a_room_of_its_own">Give this a room of its own</string>
<string name="add_translation">Add translation</string> <string name="add_translation">Add translation</string>
<string name="after_this_there_is_no_turning_back">After this there is no turning back.</string> <string name="after_this_there_is_no_turning_back">After this there is no turning back.</string>
<string name="all_broadcasts_are_queued_so_that_we_can">All broadcasts are queued so that we can manage data usage on metered connections.</string> <string name="all_broadcasts_are_queued_so_that_we_can">All broadcasts are queued so that we can manage data usage on metered connections.</string>
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.Article
import androidx.compose.material.icons.filled.PostAdd import androidx.compose.material.icons.filled.PostAdd
import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Translate
@@ -35,7 +36,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.DetailRow import press.mantra.compose.ui.composable.widgets.DetailRow
import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material.icons.filled.UploadFile
import mantra.composeapp.generated.resources.upload_chapters import mantra.composeapp.generated.resources.upload_chapters
@@ -44,6 +48,7 @@ import press.mantra.compose.ui.composable.navigation.routes.UploadChaptersRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.MantraTheme
@@ -57,6 +62,7 @@ import mantra.composeapp.generated.resources.add_translation
import mantra.composeapp.generated.resources.artifact_detail import mantra.composeapp.generated.resources.artifact_detail
import mantra.composeapp.generated.resources.chapters import mantra.composeapp.generated.resources.chapters
import mantra.composeapp.generated.resources.details import mantra.composeapp.generated.resources.details
import mantra.composeapp.generated.resources.give_this_a_room_of_its_own
import mantra.composeapp.generated.resources.no_chapters_yet import mantra.composeapp.generated.resources.no_chapters_yet
import mantra.composeapp.generated.resources.no_translations_yet import mantra.composeapp.generated.resources.no_translations_yet
import mantra.composeapp.generated.resources.no_versions import mantra.composeapp.generated.resources.no_versions
@@ -81,6 +87,7 @@ fun ArtifactDetailScreen(
relayHint: String?, relayHint: String?,
initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading, initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading,
mantraRepository: MantraRepository, mantraRepository: MantraRepository,
chatRepository: ChatRepository,
onNavigateToRoute: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
) { ) {
@@ -92,6 +99,7 @@ fun ArtifactDetailScreen(
relayHint = relayHint, relayHint = relayHint,
initialArtifactDetailUIState = initialArtifactDetailUIState, initialArtifactDetailUIState = initialArtifactDetailUIState,
mantraRepository = mantraRepository, mantraRepository = mantraRepository,
chatRepository = chatRepository,
) )
) )
@@ -291,6 +299,48 @@ fun ArtifactDetailScreen(
item { HorizontalDivider() } 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
)
)
}
}
}
item { HorizontalDivider() }
// Details // Details
item { item {
Text( Text(
@@ -386,6 +436,7 @@ private fun ArtifactDetailScreenPreview() {
) )
), ),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateToRoute = {}, onNavigateToRoute = {},
onNavigateBack = {} onNavigateBack = {}
) )
@@ -6,9 +6,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.LibraryBooks import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -21,6 +23,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -33,6 +36,7 @@ import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.country import mantra.composeapp.generated.resources.country
import mantra.composeapp.generated.resources.details import mantra.composeapp.generated.resources.details
import mantra.composeapp.generated.resources.dialect_detail import mantra.composeapp.generated.resources.dialect_detail
import mantra.composeapp.generated.resources.give_this_a_room_of_its_own
import mantra.composeapp.generated.resources.language import mantra.composeapp.generated.resources.language
import mantra.composeapp.generated.resources.library import mantra.composeapp.generated.resources.library
import mantra.composeapp.generated.resources.no_artifacts_are_written_in_this_dialect import mantra.composeapp.generated.resources.no_artifacts_are_written_in_this_dialect
@@ -40,10 +44,12 @@ import mantra.composeapp.generated.resources.no_translations_are_into_this_diale
import mantra.composeapp.generated.resources.translations import mantra.composeapp.generated.resources.translations
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.DetailRow import press.mantra.compose.ui.composable.widgets.DetailRow
@@ -228,6 +234,48 @@ fun DialectDetailScreen(
item { HorizontalDivider() } 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
)
)
}
}
}
item { HorizontalDivider() }
item { item {
Text( Text(
text = stringResource(Res.string.details), text = stringResource(Res.string.details),
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.Article
import androidx.compose.material.icons.filled.PostAdd import androidx.compose.material.icons.filled.PostAdd
import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Translate
@@ -38,6 +39,7 @@ import mantra.composeapp.generated.resources.add_source_version_to_project
import mantra.composeapp.generated.resources.add_translation_to_project import mantra.composeapp.generated.resources.add_translation_to_project
import mantra.composeapp.generated.resources.created_by import mantra.composeapp.generated.resources.created_by
import mantra.composeapp.generated.resources.details import mantra.composeapp.generated.resources.details
import mantra.composeapp.generated.resources.give_this_a_room_of_its_own
import mantra.composeapp.generated.resources.no_source_versions_in_this_project import mantra.composeapp.generated.resources.no_source_versions_in_this_project
import mantra.composeapp.generated.resources.no_translations_in_this_project import mantra.composeapp.generated.resources.no_translations_in_this_project
import mantra.composeapp.generated.resources.owned_by_this_group import mantra.composeapp.generated.resources.owned_by_this_group
@@ -49,11 +51,14 @@ import mantra.composeapp.generated.resources.version
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.Profile
import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.shortened
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.repository.NostrRepository import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.AddToProjectRoute import press.mantra.compose.ui.composable.navigation.routes.AddToProjectRoute
import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.DetailRow import press.mantra.compose.ui.composable.widgets.DetailRow
@@ -87,6 +92,7 @@ fun ProjectDetailScreen(
mantraRepository: MantraRepository, mantraRepository: MantraRepository,
nostrRepository: NostrRepository, nostrRepository: NostrRepository,
frostSigningRepository: FrostSigningRepository, frostSigningRepository: FrostSigningRepository,
chatRepository: ChatRepository,
onNavigateToRoute: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
) { ) {
@@ -100,6 +106,7 @@ fun ProjectDetailScreen(
mantraRepository = mantraRepository, mantraRepository = mantraRepository,
nostrRepository = nostrRepository, nostrRepository = nostrRepository,
frostSigningRepository = frostSigningRepository, frostSigningRepository = frostSigningRepository,
chatRepository = chatRepository,
) )
) )
@@ -258,6 +265,48 @@ fun ProjectDetailScreen(
item { HorizontalDivider() } 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
)
)
}
}
}
item { HorizontalDivider() }
// Who it belongs to // Who it belongs to
item { item {
Text( Text(
@@ -359,6 +408,7 @@ private fun ProjectDetailScreenPreview() {
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateToRoute = {}, onNavigateToRoute = {},
onNavigateBack = {}, onNavigateBack = {},
) )
@@ -53,6 +53,7 @@ import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.managers.MarmotGroupName import press.mantra.compose.managers.MarmotGroupName
import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.ErrorState
@@ -85,6 +86,10 @@ fun SelectSubgroupAdminsScreen(
activeIdentityStateFlow: StateFlow<Identity?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository, dkgRepository: DkgRepository,
/** A name to open the field with, where the caller knows one. */
suggestedName: String? = null,
/** What the subgroup is for, where it was asked for from an entity. */
origin: SubgroupOrigin.Ref? = null,
onNavigateToRoute: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
) { ) {
@@ -94,6 +99,8 @@ fun SelectSubgroupAdminsScreen(
parentChatRoomId = parentChatRoomId, parentChatRoomId = parentChatRoomId,
initialUIState = initialUIState, initialUIState = initialUIState,
activeIdentityStateFlow = activeIdentityStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
suggestedName = suggestedName,
origin = origin,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository dkgRepository = dkgRepository
) )
@@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.Article
import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -32,12 +34,15 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.DetailRow import press.mantra.compose.ui.composable.widgets.DetailRow
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.MantraTheme
@@ -48,6 +53,7 @@ import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.chapters import mantra.composeapp.generated.resources.chapters
import mantra.composeapp.generated.resources.details import mantra.composeapp.generated.resources.details
import mantra.composeapp.generated.resources.give_this_a_room_of_its_own
import mantra.composeapp.generated.resources.no_chapters import mantra.composeapp.generated.resources.no_chapters
import mantra.composeapp.generated.resources.propose import mantra.composeapp.generated.resources.propose
import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot
@@ -228,6 +234,48 @@ fun TranslationArtifactVersionDetailScreen(
item { HorizontalDivider() } 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
)
)
}
}
}
item { HorizontalDivider() }
// Details // Details
item { item {
Text( Text(
@@ -31,6 +31,7 @@ import press.mantra.compose.database.repository.DatabaseMarmotRepository
import press.mantra.compose.database.repository.DatabaseNostrRepository import press.mantra.compose.database.repository.DatabaseNostrRepository
import press.mantra.compose.database.repository.DatabaseSearchRepository import press.mantra.compose.database.repository.DatabaseSearchRepository
import press.mantra.compose.managers.AuxDatabaseManager import press.mantra.compose.managers.AuxDatabaseManager
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.ui.composable.ActiveProfileScreen import press.mantra.compose.ui.composable.ActiveProfileScreen
import press.mantra.compose.ui.composable.AddMemberToChatRoomConfirmationScreen import press.mantra.compose.ui.composable.AddMemberToChatRoomConfirmationScreen
import press.mantra.compose.ui.composable.ChatRoomCreationScreen import press.mantra.compose.ui.composable.ChatRoomCreationScreen
@@ -605,6 +606,13 @@ fun MantraNavHost(
activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
chatRepository = databaseChatRepository, chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository, dkgRepository = databaseDkgRepository,
suggestedName = route.name,
// Both or neither. A route carrying one half of a reference
// is a reference with the resolvable part missing, and
// reassembling it here is the one place that can say so.
origin = route.originKind?.let { kind ->
route.originId?.let { id -> SubgroupOrigin.Ref(kind, id) }
},
onNavigateToRoute = { nextRoute -> onNavigateToRoute = { nextRoute ->
navController.navigate(route = nextRoute) navController.navigate(route = nextRoute)
}, },
@@ -1318,6 +1326,7 @@ fun MantraNavHost(
chatRoomId = route.chatRoomId, chatRoomId = route.chatRoomId,
relayHint = route.relayHint, relayHint = route.relayHint,
mantraRepository = databaseMantraRepository, mantraRepository = databaseMantraRepository,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute -> onNavigateToRoute = { actionRoute ->
navController.navigate( navController.navigate(
route = actionRoute route = actionRoute
@@ -1359,6 +1368,7 @@ fun MantraNavHost(
mantraRepository = databaseMantraRepository, mantraRepository = databaseMantraRepository,
nostrRepository = databaseNostrRepository, nostrRepository = databaseNostrRepository,
frostSigningRepository = databaseFrostSigningRepository, frostSigningRepository = databaseFrostSigningRepository,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute -> onNavigateToRoute = { actionRoute ->
navController.navigate( navController.navigate(
route = actionRoute route = actionRoute
@@ -16,5 +16,29 @@ import kotlinx.serialization.Serializable
data class SelectSubgroupAdminsRoute( data class SelectSubgroupAdminsRoute(
val activeUserPublicKey: String, val activeUserPublicKey: String,
val parentChatRoomId: String, val parentChatRoomId: String,
val relayHint: String? = null val relayHint: String? = null,
/**
* A name to open the field with, where the caller knows one.
*
* Set when the subgroup was asked for from a dialect, an artifact, a
* translation or a project -- the entity's own name, bare, with no `#`; see
* `SubgroupOrigin`. It is a suggestion and stays editable: a group naming its
* own room something else is not this route's business to prevent.
*
* Null from the room's own "Add subgroup", which knows nothing to suggest.
*/
val name: String? = null,
/**
* The entity the subgroup is for, flattened: a kind and an event id.
*
* Two nullable fields rather than one type, because a route carries no
* domain types -- it is `@Serializable` and is reconstructed from a bundle.
* They are reassembled into a `SubgroupOrigin.Ref` by the view model, which
* requires both or neither: half a reference is a reference with the
* resolvable part removed.
*/
val originKind: Int? = null,
val originId: String? = null,
): Route() ): Route()
@@ -13,6 +13,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import press.mantra.compose.database.model.traits.SubgroupOriginEntity
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.view.state.ArtifactDetailUIState import press.mantra.compose.ui.view.state.ArtifactDetailUIState
@@ -23,6 +26,7 @@ class ArtifactDetailViewModel(
val relayHint: String?, val relayHint: String?,
initialArtifactDetailUIState: ArtifactDetailUIState, initialArtifactDetailUIState: ArtifactDetailUIState,
val mantraRepository: MantraRepository, val mantraRepository: MantraRepository,
val chatRepository: ChatRepository,
): ViewModel() { ): ViewModel() {
var artifactDetailUIState: ArtifactDetailUIState by mutableStateOf(initialArtifactDetailUIState) var artifactDetailUIState: ArtifactDetailUIState by mutableStateOf(initialArtifactDetailUIState)
@@ -43,11 +47,29 @@ class ArtifactDetailViewModel(
versions = mantraRepository.getArtifactVersions(artifactId), versions = mantraRepository.getArtifactVersions(artifactId),
chapters = mantraRepository.getChaptersForArtifact(artifactId), chapters = mantraRepository.getChaptersForArtifact(artifactId),
translations = mantraRepository.getTranslationsForArtifact(artifactId), translations = mantraRepository.getTranslationsForArtifact(artifactId),
canAddSubgroup = canAddSubgroupFor(artifact),
) )
} }
} }
} }
/**
* Whether a subgroup could be opened for [entity] at all.
*
* The two conditions `ChatRoomDetailUIState.canAddSubgroup` states, read off
* the entity's *own* room rather than the route's: the parent of a subgroup
* made here is the room the entity actually lives in, and
* `SubgroupOrigin.of` is the one place that says which room that is.
*/
private suspend fun canAddSubgroupFor(entity: SubgroupOriginEntity): Boolean {
val parentChatRoomId = SubgroupOrigin.of(entity).parentChatRoomId
val room = chatRepository.getChatRoomByIdentifier(parentChatRoomId)
return room?.chatRoom?.mlsGroupState != null &&
chatRepository.canSign(parentChatRoomId)
}
companion object { companion object {
private const val TAG = "ArtifactDetailViewModel" private const val TAG = "ArtifactDetailViewModel"
@@ -58,6 +80,7 @@ class ArtifactDetailViewModel(
relayHint: String?, relayHint: String?,
initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading, initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading,
mantraRepository: MantraRepository, mantraRepository: MantraRepository,
chatRepository: ChatRepository,
): ViewModelProvider.Factory = viewModelFactory { ): ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
ArtifactDetailViewModel( ArtifactDetailViewModel(
@@ -67,6 +90,7 @@ class ArtifactDetailViewModel(
relayHint = relayHint, relayHint = relayHint,
initialArtifactDetailUIState = initialArtifactDetailUIState, initialArtifactDetailUIState = initialArtifactDetailUIState,
mantraRepository = mantraRepository, mantraRepository = mantraRepository,
chatRepository = chatRepository,
) )
} }
} }
@@ -15,6 +15,9 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.Profile
import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.database.model.traits.SubgroupOriginEntity
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.repository.NostrRepository import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.view.state.ProjectDetailUIState import press.mantra.compose.ui.view.state.ProjectDetailUIState
@@ -28,6 +31,7 @@ class ProjectDetailViewModel(
val mantraRepository: MantraRepository, val mantraRepository: MantraRepository,
val nostrRepository: NostrRepository, val nostrRepository: NostrRepository,
val frostSigningRepository: FrostSigningRepository, val frostSigningRepository: FrostSigningRepository,
val chatRepository: ChatRepository,
): ViewModel() { ): ViewModel() {
var projectDetailUIState: ProjectDetailUIState by mutableStateOf(initialProjectDetailUIState) var projectDetailUIState: ProjectDetailUIState by mutableStateOf(initialProjectDetailUIState)
@@ -54,11 +58,29 @@ class ProjectDetailViewModel(
project.ownerPublicKeySet() + project.creatorPublicKey project.ownerPublicKeySet() + project.creatorPublicKey
), ),
canSign = frostSigningRepository.canSign(chatRoomId), canSign = frostSigningRepository.canSign(chatRoomId),
canAddSubgroup = canAddSubgroupFor(project),
) )
} }
} }
} }
/**
* Whether a subgroup could be opened for [entity] at all.
*
* The two conditions `ChatRoomDetailUIState.canAddSubgroup` states, read off
* the entity's *own* room rather than the route's: the parent of a subgroup
* made here is the room the entity actually lives in, and
* `SubgroupOrigin.of` is the one place that says which room that is.
*/
private suspend fun canAddSubgroupFor(entity: SubgroupOriginEntity): Boolean {
val parentChatRoomId = SubgroupOrigin.of(entity).parentChatRoomId
val room = chatRepository.getChatRoomByIdentifier(parentChatRoomId)
return room?.chatRoom?.mlsGroupState != null &&
chatRepository.canSign(parentChatRoomId)
}
private suspend fun peopleBehind(publicKeys: Set<HexKey>): Map<HexKey, Profile> = private suspend fun peopleBehind(publicKeys: Set<HexKey>): Map<HexKey, Profile> =
publicKeys.mapNotNull { publicKey -> publicKeys.mapNotNull { publicKey ->
nostrRepository.getProfileWithPublicKey(publicKey)?.let { publicKey to it } nostrRepository.getProfileWithPublicKey(publicKey)?.let { publicKey to it }
@@ -76,6 +98,7 @@ class ProjectDetailViewModel(
mantraRepository: MantraRepository, mantraRepository: MantraRepository,
nostrRepository: NostrRepository, nostrRepository: NostrRepository,
frostSigningRepository: FrostSigningRepository, frostSigningRepository: FrostSigningRepository,
chatRepository: ChatRepository,
): ViewModelProvider.Factory = viewModelFactory { ): ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
ProjectDetailViewModel( ProjectDetailViewModel(
@@ -87,6 +110,7 @@ class ProjectDetailViewModel(
mantraRepository = mantraRepository, mantraRepository = mantraRepository,
nostrRepository = nostrRepository, nostrRepository = nostrRepository,
frostSigningRepository = frostSigningRepository, frostSigningRepository = frostSigningRepository,
chatRepository = chatRepository,
) )
} }
} }
@@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.MarmotGroupCreation
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
@@ -53,6 +54,23 @@ class SelectSubgroupAdminsViewModel(
val activeIdentityStateFlow: StateFlow<Identity?>, val activeIdentityStateFlow: StateFlow<Identity?>,
val chatRepository: ChatRepository, val chatRepository: ChatRepository,
val dkgRepository: DkgRepository, val dkgRepository: DkgRepository,
/**
* A name to open the field with, or null when the caller has nothing to
* suggest.
*
* Bare, with no `#` -- see [name] for where the convention is applied.
*/
val suggestedName: String? = null,
/**
* What this subgroup is being made for, when it was asked for from a dialect,
* an artifact, a translation or a project.
*
* Held from here until [confirm] rides it onto the ceremony, which is the
* only thing this class does with it: the picker neither reads the entity nor
* checks that it exists. A room's own "Add subgroup" passes null and the
* subgroup is for nothing in particular, which is a normal subgroup.
*/
val origin: SubgroupOrigin.Ref? = null,
): ViewModel() { ): ViewModel() {
var uiState: SelectSubgroupAdminsUIState by mutableStateOf(initialUIState) var uiState: SelectSubgroupAdminsUIState by mutableStateOf(initialUIState)
@@ -73,8 +91,13 @@ class SelectSubgroupAdminsViewModel(
* halfway through a word. It is added by `MarmotGroupName.of` where the * halfway through a word. It is added by `MarmotGroupName.of` where the
* subgroup's name is actually used -- in the certificate the parent signs and * subgroup's name is actually used -- in the certificate the parent signs and
* on the room itself -- which is idempotent, so the two agree. * on the room itself -- which is idempotent, so the two agree.
*
* Seeded from [suggestedName] at construction rather than in [initiate],
* because a field the user may already be typing into must not be
* overwritten by a load completing underneath them. It stays editable after
* that: a pre-filled name is a suggestion, not a decision.
*/ */
val name: MutableState<String> = mutableStateOf("") val name: MutableState<String> = mutableStateOf(suggestedName?.trim().orEmpty())
val threshold: MutableState<Int> = mutableStateOf(ChatRoomType.MINIMUM_QUORUM) val threshold: MutableState<Int> = mutableStateOf(ChatRoomType.MINIMUM_QUORUM)
@@ -246,6 +269,15 @@ class SelectSubgroupAdminsViewModel(
// being in it, and is not theirs to tick. // being in it, and is not theirs to tick.
participantPublicKeys = selectedPublicKeys.toSet(), participantPublicKeys = selectedPublicKeys.toSet(),
subject = name.value.trim() subject = name.value.trim()
// [origin] is not on the ceremony yet. It will be, and for the
// same reason the name is: the certificate is proposed after the
// ceremony, possibly by an admin who was never on this screen, so
// anything it needs has to be on the stored session rather than
// in this lambda. That needs a `DkgSession` column and a tag on
// the proposal, which is Phase 5 of
// docs/subgroups-for-entities.md. Until then a subgroup made from
// an entity is named after it and nothing more, which is exactly
// the cheap version of this feature.
) )
isActionPending.value = false isActionPending.value = false
@@ -293,7 +325,9 @@ class SelectSubgroupAdminsViewModel(
initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading,
activeIdentityStateFlow: StateFlow<Identity?>, activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository, chatRepository: ChatRepository,
dkgRepository: DkgRepository dkgRepository: DkgRepository,
suggestedName: String? = null,
origin: SubgroupOrigin.Ref? = null
): ViewModelProvider.Factory = viewModelFactory { ): ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
SelectSubgroupAdminsViewModel( SelectSubgroupAdminsViewModel(
@@ -302,7 +336,9 @@ class SelectSubgroupAdminsViewModel(
initialUIState = initialUIState, initialUIState = initialUIState,
activeIdentityStateFlow = activeIdentityStateFlow, activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository, chatRepository = chatRepository,
dkgRepository = dkgRepository dkgRepository = dkgRepository,
suggestedName = suggestedName,
origin = origin
) )
} }
} }
@@ -17,7 +17,9 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.traits.SubgroupOriginEntity
import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.managers.TranslationScaffold import press.mantra.compose.managers.TranslationScaffold
import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.FrostSigningRepository
@@ -71,11 +73,29 @@ class TranslationArtifactVersionDetailViewModel(
.getChaptersForArtifactVersion(translation.artifactVersionId), .getChaptersForArtifactVersion(translation.artifactVersionId),
), ),
canSign = frostSigningRepository.canSign(chatRoomId), canSign = frostSigningRepository.canSign(chatRoomId),
canAddSubgroup = canAddSubgroupFor(translation),
) )
} }
} }
} }
/**
* Whether a subgroup could be opened for [entity] at all.
*
* The two conditions `ChatRoomDetailUIState.canAddSubgroup` states, read off
* the entity's *own* room rather than the route's: the parent of a subgroup
* made here is the room the entity actually lives in, and
* `SubgroupOrigin.of` is the one place that says which room that is.
*/
private suspend fun canAddSubgroupFor(entity: SubgroupOriginEntity): Boolean {
val parentChatRoomId = SubgroupOrigin.of(entity).parentChatRoomId
val room = chatRepository.getChatRoomByIdentifier(parentChatRoomId)
return room?.chatRoom?.mlsGroupState != null &&
chatRepository.canSign(parentChatRoomId)
}
/** /**
* Asks the group to put the chapters this translation is missing into it. * Asks the group to put the chapters this translation is missing into it.
* *
@@ -11,6 +11,22 @@ sealed interface ArtifactDetailUIState {
val versions: List<MantraArtifactVersion> = emptyList(), val versions: List<MantraArtifactVersion> = emptyList(),
val chapters: List<MantraChapter> = emptyList(), val chapters: List<MantraChapter> = emptyList(),
val translations: List<MantraTranslationArtifactVersion> = emptyList(), val translations: List<MantraTranslationArtifactVersion> = emptyList(),
/**
* Whether this device could open a subgroup for this artifact at all.
*
* The same two conditions `ChatRoomDetailUIState.canAddSubgroup` states,
* and both are about capability rather than permission: the room has to
* be a Marmot one, since a NIP-17 room has no admins to be a parent's,
* and this device has to hold a share of the group's key, because
* certifying a child is a signature by the parent and
* `FrostSigningManager.proposeSigningBatch` throws for a device that
* cannot make one.
*
* The first flag on this state, which is why it arrives with the
* subgroup button rather than before it.
*/
val canAddSubgroup: Boolean = false,
): ArtifactDetailUIState ): ArtifactDetailUIState
data class Error( data class Error(
@@ -31,6 +31,15 @@ sealed interface ProjectDetailUIState {
* pressed. * pressed.
*/ */
val canSign: Boolean = false, val canSign: Boolean = false,
/**
* Whether this device could open a subgroup for this project at all.
*
* [canSign] and a Marmot room, which is the same pair
* `ChatRoomDetailUIState.canAddSubgroup` states. Held separately rather
* than derived at the call site so there is one spelling of one rule.
*/
val canAddSubgroup: Boolean = false,
): ProjectDetailUIState ): ProjectDetailUIState
data class Error( data class Error(
@@ -38,6 +38,14 @@ sealed interface TranslationArtifactVersionDetailUIState {
/** Whether the group holds a shared key, without which nothing can be proposed. */ /** Whether the group holds a shared key, without which nothing can be proposed. */
val canSign: Boolean = false, val canSign: Boolean = false,
/**
* Whether this device could open a subgroup for this translation at all.
*
* [canSign] and a Marmot room, the same pair
* `ChatRoomDetailUIState.canAddSubgroup` states.
*/
val canAddSubgroup: Boolean = false,
): TranslationArtifactVersionDetailUIState ): TranslationArtifactVersionDetailUIState
data class Error( data class Error(