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(