diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b52173d7..cf280ebb 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -32,6 +32,7 @@ Add chapter Add dialect Add to group + Give this a room of its own Add translation After this there is no turning back. All broadcasts are queued so that we can manage data usage on metered connections. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index 06c128fd..f0150cb7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -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 = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt index b60f1557..c636103e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt @@ -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), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt index 6f03a595..e39f5838 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt @@ -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 = {}, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt index a104bc1e..2500436b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -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, 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 ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index 732a9f32..65ccf4c0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 9e1e5728..7ffcc252 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt index 939abe8e..533cce36 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt index bc1086a9..339f469a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt @@ -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, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt index 616a2cf3..f386d59f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt @@ -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): Map = 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, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt index e4ce6ff2..52a1b464 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -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, 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 = mutableStateOf("") + val name: MutableState = mutableStateOf(suggestedName?.trim().orEmpty()) val threshold: MutableState = 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, 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 ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt index d959e224..293a0bda 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt @@ -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. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt index f390abf9..6fd75a0c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt @@ -11,6 +11,22 @@ sealed interface ArtifactDetailUIState { val versions: List = emptyList(), val chapters: List = emptyList(), val translations: List = 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( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt index bb21e6c2..c2c228b8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt index af891f2c..05896461 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt @@ -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(