From 54249e3c4f49032210807cc834f1eba6e7c09a95 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:42:28 +0200 Subject: [PATCH] feat(projects): a member can ask the group to sign a project into being Phase 7b of docs/project-feature.md -- the write path. The room's "Create project" button now opens a form instead of `ImplementationPendingRoute`. Nothing is created here. What goes out is a proposal, and the project appears on every member's device at once -- authored by the room's own key -- when enough members sign. That is the difference from submitting one: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social; a signature is the group saying it, and it takes a quorum to say. One session over the project and its contents. The memberships cannot be built by this screen, because each carries the project's id and that is a hash over the group's key at the room's derivation path -- it does not exist until the proposal is made. So the project leads and the memberships are derived from it, the same shape `AddArtifactViewModel` uses for an artifact and its first version. The creator is not a field: it is whoever is filling the form in, the view model already has it, and a picker would only let somebody credit the wrong person for something the group is about to sign. The owners are a field, pre-filled with this group. Most projects never touch it and that is why it is pre-filled rather than hidden -- a group making something for somebody else needs the question asked, and a group that is not needs to see the answer it is giving. Two guards, both because the alternative is silent: an entry that is not 64 characters is refused on the way in, since `ContributorTag.parse` would drop it and the owner would vanish between the form and the signature; and the last owner cannot be removed, since `ProjectEvent.build` refuses an empty set and a button that throws is worse than one that is off. A project with no contents is allowed, unlike an artifact with no version. An artifact with no version is inert because nothing can attach to it; an empty project is a name waiting for work. M3 budgets met. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 10 + .../compose/ui/composable/AddProjectScreen.kt | 503 ++++++++++++++++++ .../ui/composable/ChatRoomDetailScreen.kt | 26 + .../ui/composable/navigation/MantraNavHost.kt | 34 ++ .../navigation/routes/AddProjectRoute.kt | 10 + .../ui/view/model/AddProjectViewModel.kt | 206 +++++++ .../ui/view/state/AddProjectUIState.kt | 32 ++ 7 files changed, 821 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddProjectRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d58d615e..552fe3a7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -20,7 +20,9 @@ an em dash or a comma. The compose resources plugin reports that only as "XML file ... is not valid. Check the file content." --> + Add an owner Add project + An owner is a 64-character public key. Mantra Add a dialect the group can translate into Add artifact to library @@ -60,12 +62,19 @@ Create the admin room Agree the group's signing key Created by + What this project is + e.g. The four gospels into Sesotho + Name of project No source versions in this project yet. No translations in this project yet. This group + Owner public key Owners Project detail + Propose project + Remove owner Source versions + Ask the group to sign a project into being The group is agreeing what the admin room will sign with. It takes %1$s of %2$s members, and the request is in this chat. The group has agreed what the admin room will sign with. The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry. @@ -220,6 +229,7 @@ Shared key Signing key The key this room signs as, and the ceremony it came from. + This group has no library yet, so a project would have nothing in it. Add an artifact first. What the group signed This room signs as Derivation path diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt new file mode 100644 index 00000000..d93e4b2d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt @@ -0,0 +1,503 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.add_an_owner +import mantra.composeapp.generated.resources.an_owner_is_a_64_character_public_key +import mantra.composeapp.generated.resources.describe_the_project +import mantra.composeapp.generated.resources.eg_the_four_gospels_into_sesotho +import mantra.composeapp.generated.resources.name_of_project +import mantra.composeapp.generated.resources.owned_by_this_group +import mantra.composeapp.generated.resources.owner_public_key +import mantra.composeapp.generated.resources.owners +import mantra.composeapp.generated.resources.project_detail +import mantra.composeapp.generated.resources.propose_project +import mantra.composeapp.generated.resources.remove_owner +import mantra.composeapp.generated.resources.source_versions +import mantra.composeapp.generated.resources.start_a_project_in_this_group +import mantra.composeapp.generated.resources.this_group_has_no_library_to_build_a_project_from +import mantra.composeapp.generated.resources.translations +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.extensions.shortened +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.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.AddProjectViewModel +import press.mantra.compose.ui.view.state.AddProjectUIState + +/** + * Proposing a project: a name, what it is, who owns it, and what is in it. + * + * The creator is not a field. It is whoever is filling the form in, the view + * model already knows it, and a picker would only let somebody credit the wrong + * person for work the group is about to put its signature to. + * + * The owners are a field, pre-filled with this group. Most projects never touch + * it, and that is the point of pre-filling rather than hiding: a group making + * something for somebody else needs the question asked, and a group that is not + * needs to see the answer it is giving. + */ +@OptIn( + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class +) +@Composable +fun AddProjectScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddProjectUIState: AddProjectUIState = AddProjectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val addProjectViewModel: AddProjectViewModel = viewModel( + factory = AddProjectViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddProjectUIState = initialAddProjectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + activeUserPublicKey = activeUserPublicKey, + frostSigningRepository = frostSigningRepository, + mantraRepository = mantraRepository, + ) + ) + + ScreenStateTransition(addProjectViewModel.addProjectUIState) { uiState -> + when (val addProjectUIState = uiState) { + is AddProjectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addProjectUIState.message) + } + } + + is AddProjectUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val descriptionFieldState = rememberTextFieldState() + val ownerFieldState = rememberTextFieldState() + + // Pre-filled with the room, which is the common answer and a real + // value rather than an absence: "owned by us" and "owner unstated" + // must not be the same event. + val owners: SnapshotStateList = remember { + mutableStateListOf(chatRoomId) + } + val selectedSourceVersions = remember { mutableStateListOf() } + val selectedTranslations = remember { mutableStateListOf() } + + var nameEntered by remember { mutableStateOf(false) } + LaunchedEffect(nameFieldState.text) { + nameEntered = nameFieldState.text.isNotBlank() + } + + // A key that is not 64 hex characters is dropped by + // ContributorTag.parse, so it would vanish between this form and + // the signature. Caught on entry instead. + val typedOwner = ownerFieldState.text.toString().trim() + val canAddOwner = typedOwner.length == OWNER_KEY_LENGTH && + typedOwner !in owners + + val canAddProject = nameEntered && + owners.isNotEmpty() && + addProjectUIState.canSign + + // imePadding: this screen has text fields, and without it the + // software keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + addProjectUIState.localChatRoom.RenderChatRoomTitleText() + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFab( + label = stringResource(Res.string.propose_project), + icon = { + Icon(Icons.Default.Add, contentDescription = Decorative) + }, + enabled = canAddProject, + onClick = { + addProjectViewModel.addProject( + localChatRoom = addProjectUIState.localChatRoom, + nameField = nameFieldState, + descriptionField = descriptionFieldState, + ownerPublicKeys = owners.toSet(), + sourceVersionIds = selectedSourceVersions.toList(), + translationIds = selectedTranslations.toList(), + onSuccess = { sessionId -> + // Onto the session rather than to + // the project. Nothing exists yet + // -- it appears when enough members + // sign -- so a detail screen for a + // row that is not there would read + // as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed project") + ) + } + ) + } + ) + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.space125 + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.start_a_project_in_this_group)) + + if (!addProjectUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "project into being. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = nameFieldState, + colors = transparentOutline(), + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = Decorative + ) + }, + label = { Text(stringResource(Res.string.name_of_project)) }, + placeholder = { + Text(stringResource(Res.string.eg_the_four_gospels_into_sesotho)) + }, + ) + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = descriptionFieldState, + colors = transparentOutline(), + leadingIcon = { + Icon(Icons.Default.Notes, contentDescription = Decorative) + }, + label = { Text(stringResource(Res.string.describe_the_project)) }, + ) + + // --- owners --- + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.owners), + style = MaterialTheme.typography.labelMedium + ) + + val thisGroup = stringResource(Res.string.owned_by_this_group) + + owners.forEach { owner -> + ListItem( + headlineContent = { + Text( + if (owner == chatRoomId) thisGroup else owner.shortened() + ) + }, + trailingContent = { + // The last one cannot go: ProjectEvent.build + // refuses an empty set, and a button that + // throws is worse than one that is off. + IconButton( + enabled = owners.size > 1, + onClick = { owners.remove(owner) } + ) { + Icon( + Icons.Default.Close, + contentDescription = stringResource( + Res.string.remove_owner + ) + ) + } + } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier + .weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = ownerFieldState, + colors = transparentOutline(), + label = { Text(stringResource(Res.string.owner_public_key)) }, + isError = typedOwner.isNotEmpty() && !canAddOwner, + supportingText = { + if (typedOwner.isNotEmpty() && !canAddOwner) { + Text( + stringResource( + Res.string.an_owner_is_a_64_character_public_key + ) + ) + } + }, + ) + + IconButton( + enabled = canAddOwner, + onClick = { + owners.add(typedOwner) + ownerFieldState.clearText() + } + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(Res.string.add_an_owner) + ) + } + } + + // --- what is in it --- + + if (addProjectUIState.sourceVersions.isEmpty()) { + Text( + text = stringResource( + Res.string.this_group_has_no_library_to_build_a_project_from + ), + style = MaterialTheme.typography.bodySmall + ) + } else { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.source_versions), + style = MaterialTheme.typography.labelMedium + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ) + ) { + addProjectUIState.sourceVersions.forEach { version -> + FilterChip( + selected = version.id in selectedSourceVersions, + onClick = { + if (version.id in selectedSourceVersions) { + selectedSourceVersions.remove(version.id) + } else { + selectedSourceVersions.add(version.id) + } + }, + leadingIcon = { + Icon( + Icons.Default.Article, + contentDescription = Decorative + ) + }, + label = { Text(version.versionLabel) } + ) + } + } + } + + if (addProjectUIState.translations.isNotEmpty()) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.translations), + style = MaterialTheme.typography.labelMedium + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ) + ) { + addProjectUIState.translations.forEach { translation -> + FilterChip( + selected = translation.id in selectedTranslations, + onClick = { + if (translation.id in selectedTranslations) { + selectedTranslations.remove(translation.id) + } else { + selectedTranslations.add(translation.id) + } + }, + leadingIcon = { + Icon( + Icons.Default.Translate, + contentDescription = Decorative + ) + }, + label = { Text(translation.name) } + ) + } + } + } + } + } + } + + AddProjectUIState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.project_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + } + + LaunchedEffect(true) { + if (initialAddProjectUIState == AddProjectUIState.Loading) { + addProjectViewModel.initiateAddProject() + } + } +} + +/** What `ContributorTag.parse` demands of an owner's value. */ +private const val OWNER_KEY_LENGTH = 64 + +/** The borderless field the rest of this family's forms use. */ +@Composable +private fun transparentOutline() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent +) + +@ConformancePreviews +@Composable +private fun AddProjectScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddProjectScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = "b".repeat(64), + relayHint = null, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 1e489436..f04cd668 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -83,6 +83,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute +import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute @@ -522,6 +523,31 @@ fun ChatRoomDetailScreen( } } + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + AddProjectRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Schema, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.create_project)) + } + } + item { HorizontalDivider() } 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 99c08773..499dc57b 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 @@ -135,6 +135,7 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute +import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute @@ -147,6 +148,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute +import press.mantra.compose.ui.composable.AddProjectScreen import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.ProjectDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen @@ -1092,6 +1094,38 @@ fun MantraNavHost( }, ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddProjectScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the group rather + // than to a form whose proposal has already gone out. + navController.navigate( + route = signingRoute + ) { + popUpTo(route) { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddProjectRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddProjectRoute.kt new file mode 100644 index 00000000..080b33bf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddProjectRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class AddProjectRoute( + val chatRoomId: String, + val activeUserPublicKey: String, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt new file mode 100644 index 00000000..2f3e6488 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt @@ -0,0 +1,206 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +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.view.state.AddProjectUIState + +class AddProjectViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialAddProjectUIState: AddProjectUIState, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, + val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var addProjectUIState: AddProjectUIState by mutableStateOf(initialAddProjectUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddProject() { + logger.d("initiateAddProject: $chatRoomId") + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + addProjectUIState = if (localChatRoom == null) { + AddProjectUIState.Error("Couldn't find the chat room") + } else { + AddProjectUIState.Loaded( + localChatRoom = localChatRoom, + sourceVersions = sourceVersionsOf(localChatRoom.chatRoom.id), + translations = translationsOf(localChatRoom.chatRoom.id), + canSign = frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Every version in the room's library, walked down from its artifacts. + * + * There is no by-room query for versions -- a version hangs off an artifact, + * which is the same reason the chronicle walks the tree rather than querying + * per kind. A room's library is small and this runs once when the form + * opens. + */ + private suspend fun sourceVersionsOf(chatRoomId: String) = + mantraRepository.getArtifacts(chatRoomId) + .flatMap { mantraRepository.getArtifactVersions(it.id) } + + private suspend fun translationsOf(chatRoomId: String) = + sourceVersionsOf(chatRoomId) + .flatMap { mantraRepository.getTranslationsForArtifactVersion(it.id) } + + /** + * Asks the group to sign a project into being, with whatever it starts with. + * + * The project is not created here and does not exist yet, the way an + * artifact does not: what goes out is a proposal, and the project appears on + * every member's device at once -- authored by the room's own key -- when + * enough members have signed. + * + * One session over the project and its first contents. The memberships + * cannot be built by this screen because each carries the project's id, + * which is a hash over the group's key at the room's derivation path: it + * does not exist until the proposal is made. So the project leads and the + * memberships are derived from it, the same shape an artifact and its first + * version use. + * + * All-or-nothing, which is right. A project is a statement about which work + * belongs together, and half of that statement signed is not a smaller + * version of it. + * + * [creatorPublicKey] is this member and is not asked for. [ownerPublicKeys] + * is asked for and defaults to the room, and must not be empty -- the event + * builder refuses one, and the form keeps the last entry from being removed + * rather than letting it get that far. + */ + fun addProject( + localChatRoom: LocalChatRoom, + nameField: TextFieldState, + descriptionField: TextFieldState, + ownerPublicKeys: Set, + sourceVersionIds: List, + translationIds: List, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + val name = nameField.text.toString() + val description = descriptionField.text.toString() + + if (name.isBlank() || ownerPublicKeys.isEmpty()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated FAB taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val projectEventTemplate = ProjectEvent.build( + name = name, + description = description, + creatorPublicKey = activeUserPublicKey, + ownerPublicKeys = ownerPublicKeys, + ) + + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + lead = projectEventTemplate, + dependents = { project -> + buildList> { + addAll( + ProjectArtifactVersionEvent.membershipsOf( + project = project, + artifactVersionIds = sourceVersionIds, + ) + ) + addAll( + ProjectTranslationArtifactVersionEvent.membershipsOf( + project = project, + translationArtifactVersionIds = translationIds, + ) + ) + } + } + ) + }.onFailure { error -> + logger.e("Failed to propose a project for signing", error) + }.getOrNull() + + if (session != null) { + nameField.clearText() + descriptionField.clearText() + + viewModelScope.launch(Dispatchers.Main) { + onSuccess.invoke(session.id) + } + } else { + viewModelScope.launch(Dispatchers.Main) { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddProjectViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddProjectUIState: AddProjectUIState = AddProjectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddProjectViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddProjectUIState = initialAddProjectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt new file mode 100644 index 00000000..df06b932 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface AddProjectUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + + /** Everything in the room's library a project could be about. */ + val sourceVersions: List = emptyList(), + + /** And everything being translated. */ + val translations: List = emptyList(), + + /** + * Whether this device holds a share of the group's key. + * + * A project is proposed, not created, so a device that cannot sign + * cannot start one. Said on the form rather than discovered on the + * button. + */ + val canSign: Boolean = false, + ): AddProjectUIState + + data class Error( + val message: String + ): AddProjectUIState + + data object Loading: AddProjectUIState +}