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 Ngako
2026-10-03 01:27:47 +02:00
parent 0727a04bae
commit 2c3c949b86
15 changed files with 380 additions and 4 deletions

View File

@@ -32,6 +32,7 @@
<string name="add_chapter">Add chapter</string>
<string name="add_dialect">Add dialect</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="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>

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.width
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.PostAdd
import androidx.compose.material.icons.filled.Translate
@@ -35,7 +36,10 @@ import androidx.compose.ui.unit.dp
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.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.DetailRow
import androidx.compose.material.icons.filled.UploadFile
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.ChapterDetailRoute
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.widgets.LoadingDataIndicator
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.chapters
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_translations_yet
import mantra.composeapp.generated.resources.no_versions
@@ -81,6 +87,7 @@ fun ArtifactDetailScreen(
relayHint: String?,
initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading,
mantraRepository: MantraRepository,
chatRepository: ChatRepository,
onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
@@ -92,6 +99,7 @@ fun ArtifactDetailScreen(
relayHint = relayHint,
initialArtifactDetailUIState = initialArtifactDetailUIState,
mantraRepository = mantraRepository,
chatRepository = chatRepository,
)
)
@@ -291,6 +299,48 @@ 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
)
)
}
}
}
item { HorizontalDivider() }
// Details
item {
Text(
@@ -386,6 +436,7 @@ private fun ArtifactDetailScreenPreview() {
)
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateToRoute = {},
onNavigateBack = {}
)

View File

@@ -6,9 +6,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.LibraryBooks
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.Card
@@ -21,6 +23,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.details
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.library
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 org.jetbrains.compose.resources.stringResource
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository
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.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.DetailRow
@@ -228,6 +234,48 @@ 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
)
)
}
}
}
item { HorizontalDivider() }
item {
Text(
text = stringResource(Res.string.details),

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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.Article
import androidx.compose.material.icons.filled.PostAdd
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.created_by
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_translations_in_this_project
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 press.mantra.compose.database.model.Profile
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.MantraRepository
import press.mantra.compose.repository.NostrRepository
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.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.DetailRow
@@ -87,6 +92,7 @@ fun ProjectDetailScreen(
mantraRepository: MantraRepository,
nostrRepository: NostrRepository,
frostSigningRepository: FrostSigningRepository,
chatRepository: ChatRepository,
onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
@@ -100,6 +106,7 @@ fun ProjectDetailScreen(
mantraRepository = mantraRepository,
nostrRepository = nostrRepository,
frostSigningRepository = frostSigningRepository,
chatRepository = chatRepository,
)
)
@@ -258,6 +265,48 @@ 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
)
)
}
}
}
item { HorizontalDivider() }
// Who it belongs to
item {
Text(
@@ -359,6 +408,7 @@ private fun ProjectDetailScreenPreview() {
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateToRoute = {},
onNavigateBack = {},
)

View File

@@ -53,6 +53,7 @@ import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.managers.MarmotGroupName
import press.mantra.compose.repository.ChatRepository
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.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.ErrorState
@@ -85,6 +86,10 @@ fun SelectSubgroupAdminsScreen(
activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository,
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,
onNavigateBack: () -> Unit,
) {
@@ -94,6 +99,8 @@ fun SelectSubgroupAdminsScreen(
parentChatRoomId = parentChatRoomId,
initialUIState = initialUIState,
activeIdentityStateFlow = activeIdentityStateFlow,
suggestedName = suggestedName,
origin = origin,
chatRepository = chatRepository,
dkgRepository = dkgRepository
)

View File

@@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.Article
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material3.Card
@@ -32,12 +34,15 @@ import androidx.compose.ui.unit.dp
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.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
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.SelectSubgroupAdminsRoute
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.LoadingDataIndicator
import press.mantra.compose.ui.theme.MantraTheme
@@ -48,6 +53,7 @@ import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.chapters
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.propose
import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot
@@ -228,6 +234,48 @@ 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
)
)
}
}
}
item { HorizontalDivider() }
// Details
item {
Text(

View File

@@ -31,6 +31,7 @@ import press.mantra.compose.database.repository.DatabaseMarmotRepository
import press.mantra.compose.database.repository.DatabaseNostrRepository
import press.mantra.compose.database.repository.DatabaseSearchRepository
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.AddMemberToChatRoomConfirmationScreen
import press.mantra.compose.ui.composable.ChatRoomCreationScreen
@@ -605,6 +606,13 @@ fun MantraNavHost(
activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
chatRepository = databaseChatRepository,
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 ->
navController.navigate(route = nextRoute)
},
@@ -1318,6 +1326,7 @@ fun MantraNavHost(
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
@@ -1359,6 +1368,7 @@ fun MantraNavHost(
mantraRepository = databaseMantraRepository,
nostrRepository = databaseNostrRepository,
frostSigningRepository = databaseFrostSigningRepository,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute

View File

@@ -16,5 +16,29 @@ import kotlinx.serialization.Serializable
data class SelectSubgroupAdminsRoute(
val activeUserPublicKey: 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()

View File

@@ -13,6 +13,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
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.ui.view.state.ArtifactDetailUIState
@@ -23,6 +26,7 @@ class ArtifactDetailViewModel(
val relayHint: String?,
initialArtifactDetailUIState: ArtifactDetailUIState,
val mantraRepository: MantraRepository,
val chatRepository: ChatRepository,
): ViewModel() {
var artifactDetailUIState: ArtifactDetailUIState by mutableStateOf(initialArtifactDetailUIState)
@@ -43,11 +47,29 @@ class ArtifactDetailViewModel(
versions = mantraRepository.getArtifactVersions(artifactId),
chapters = mantraRepository.getChaptersForArtifact(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 {
private const val TAG = "ArtifactDetailViewModel"
@@ -58,6 +80,7 @@ class ArtifactDetailViewModel(
relayHint: String?,
initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading,
mantraRepository: MantraRepository,
chatRepository: ChatRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ArtifactDetailViewModel(
@@ -67,6 +90,7 @@ class ArtifactDetailViewModel(
relayHint = relayHint,
initialArtifactDetailUIState = initialArtifactDetailUIState,
mantraRepository = mantraRepository,
chatRepository = chatRepository,
)
}
}

View File

@@ -15,6 +15,9 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.Profile
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.NostrRepository
import press.mantra.compose.ui.view.state.ProjectDetailUIState
@@ -28,6 +31,7 @@ class ProjectDetailViewModel(
val mantraRepository: MantraRepository,
val nostrRepository: NostrRepository,
val frostSigningRepository: FrostSigningRepository,
val chatRepository: ChatRepository,
): ViewModel() {
var projectDetailUIState: ProjectDetailUIState by mutableStateOf(initialProjectDetailUIState)
@@ -54,11 +58,29 @@ class ProjectDetailViewModel(
project.ownerPublicKeySet() + project.creatorPublicKey
),
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> =
publicKeys.mapNotNull { publicKey ->
nostrRepository.getProfileWithPublicKey(publicKey)?.let { publicKey to it }
@@ -76,6 +98,7 @@ class ProjectDetailViewModel(
mantraRepository: MantraRepository,
nostrRepository: NostrRepository,
frostSigningRepository: FrostSigningRepository,
chatRepository: ChatRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ProjectDetailViewModel(
@@ -87,6 +110,7 @@ class ProjectDetailViewModel(
mantraRepository = mantraRepository,
nostrRepository = nostrRepository,
frostSigningRepository = frostSigningRepository,
chatRepository = chatRepository,
)
}
}

View File

@@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.managers.MarmotGroupCreation
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
@@ -53,6 +54,23 @@ class SelectSubgroupAdminsViewModel(
val activeIdentityStateFlow: StateFlow<Identity?>,
val chatRepository: ChatRepository,
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() {
var uiState: SelectSubgroupAdminsUIState by mutableStateOf(initialUIState)
@@ -73,8 +91,13 @@ class SelectSubgroupAdminsViewModel(
* 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
* 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)
@@ -246,6 +269,15 @@ class SelectSubgroupAdminsViewModel(
// being in it, and is not theirs to tick.
participantPublicKeys = selectedPublicKeys.toSet(),
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
@@ -293,7 +325,9 @@ class SelectSubgroupAdminsViewModel(
initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading,
activeIdentityStateFlow: StateFlow<Identity?>,
chatRepository: ChatRepository,
dkgRepository: DkgRepository
dkgRepository: DkgRepository,
suggestedName: String? = null,
origin: SubgroupOrigin.Ref? = null
): ViewModelProvider.Factory = viewModelFactory {
initializer {
SelectSubgroupAdminsViewModel(
@@ -302,7 +336,9 @@ class SelectSubgroupAdminsViewModel(
initialUIState = initialUIState,
activeIdentityStateFlow = activeIdentityStateFlow,
chatRepository = chatRepository,
dkgRepository = dkgRepository
dkgRepository = dkgRepository,
suggestedName = suggestedName,
origin = origin
)
}
}

View File

@@ -17,7 +17,9 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.MantraChapter
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.SubgroupOrigin
import press.mantra.compose.managers.TranslationScaffold
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
@@ -71,11 +73,29 @@ class TranslationArtifactVersionDetailViewModel(
.getChaptersForArtifactVersion(translation.artifactVersionId),
),
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.
*

View File

@@ -11,6 +11,22 @@ sealed interface ArtifactDetailUIState {
val versions: List<MantraArtifactVersion> = emptyList(),
val chapters: List<MantraChapter> = 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
data class Error(

View File

@@ -31,6 +31,15 @@ sealed interface ProjectDetailUIState {
* pressed.
*/
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
data class Error(

View File

@@ -38,6 +38,14 @@ sealed interface TranslationArtifactVersionDetailUIState {
/** Whether the group holds a shared key, without which nothing can be proposed. */
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
data class Error(