diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 552fe3a7..1d0e777a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -22,6 +22,8 @@ --> Add an owner Add project + Add source version + Add translation An owner is a 64-character public key. Mantra Add a dialect the group can translate into @@ -64,6 +66,8 @@ Created by What this project is e.g. The four gospels into Sesotho + Every translation in this group is already in this project. + Everything in this group's library is already in this project. Name of project No source versions in this project yet. No translations in this project yet. @@ -71,6 +75,7 @@ Owner public key Owners Project detail + Propose adding Propose project Remove owner Source versions diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt new file mode 100644 index 00000000..0d760299 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt @@ -0,0 +1,302 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.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.Add +import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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_source_version_to_project +import mantra.composeapp.generated.resources.add_translation_to_project +import mantra.composeapp.generated.resources.every_translation_is_already_in_this_project +import mantra.composeapp.generated.resources.everything_in_the_library_is_already_in_this_project +import mantra.composeapp.generated.resources.project_detail +import mantra.composeapp.generated.resources.propose_adding_to_the_project +import org.jetbrains.compose.resources.stringResource +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.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.AddToProjectViewModel +import press.mantra.compose.ui.view.state.AddToProjectUIState + +/** + * One more piece of work into a project that already exists. + * + * Unlike founding a project, this proposes a single membership -- so the whole + * of what the group is asked to agree to is two ids. That is why + * `FrostSigningViewModel` resolves names for them: without it the other members + * are shown a truncated hash and asked to sign it, which is not a question + * anybody can answer. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AddToProjectScreen( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + translations: Boolean, + relayHint: String?, + initialAddToProjectUIState: AddToProjectUIState = AddToProjectUIState.Loading, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val addToProjectViewModel: AddToProjectViewModel = viewModel( + factory = AddToProjectViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + translations = translations, + relayHint = relayHint, + initialAddToProjectUIState = initialAddToProjectUIState, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + ) + ) + + ScreenStateTransition(addToProjectViewModel.addToProjectUIState) { uiState -> + when (val addToProjectUIState = uiState) { + is AddToProjectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addToProjectUIState.message) + } + } + + is AddToProjectUIState.Loaded -> { + var selectedId: HexKey? by remember { mutableStateOf(null) } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + if (translations) { + stringResource(Res.string.add_translation_to_project) + } else { + stringResource(Res.string.add_source_version_to_project) + } + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFab( + label = stringResource( + Res.string.propose_adding_to_the_project + ), + icon = { + Icon(Icons.Default.Add, contentDescription = Decorative) + }, + enabled = selectedId != null && addToProjectUIState.canSign, + onClick = { + val chosen = selectedId ?: return@ExtendedFab + + addToProjectViewModel.addToProject( + localChatRoom = addToProjectUIState.localChatRoom, + selectedId = chosen, + onSuccess = { sessionId -> + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Failed project membership" + ) + ) + } + ) + } + ) + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { Text(addToProjectUIState.project.name) } + + if (!addToProjectUIState.canSign) { + item { + Text( + text = "This group has no shared key, so it cannot sign " + + "anything into this project. Run a shared key " + + "ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + + if (addToProjectUIState.candidates.isEmpty()) { + item { + Text( + if (translations) { + stringResource( + Res.string + .every_translation_is_already_in_this_project + ) + } else { + stringResource( + Res.string + .everything_in_the_library_is_already_in_this_project + ) + } + ) + } + } else { + items( + items = addToProjectUIState.candidates, + key = { candidate -> candidate.id } + ) { candidate -> + Card(onClick = { selectedId = candidate.id }) { + ListItem( + leadingContent = { + Icon( + if (translations) { + Icons.Default.Translate + } else { + Icons.Default.Article + }, + contentDescription = Decorative + ) + }, + trailingContent = { + // One at a time: the proposal is one + // event, and a quorum per thing added + // is the grain the group signs at. + RadioButton( + selected = selectedId == candidate.id, + onClick = { selectedId = candidate.id } + ) + }, + headlineContent = { Text(candidate.label) }, + supportingContent = candidate.detail?.let { detail -> + { Text(detail) } + } + ) + } + } + } + } + } + } + + AddToProjectUIState.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 (initialAddToProjectUIState == AddToProjectUIState.Loading) { + addToProjectViewModel.initiateAddToProject() + } + } +} + +@ConformancePreviews +@Composable +private fun AddToProjectScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddToProjectScreen( + activeUserPublicKey = "a".repeat(64), + projectId = "p".repeat(64), + chatRoomId = "b".repeat(64), + translations = false, + relayHint = null, + 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/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 9ab44345..44bf2b26 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -48,6 +48,8 @@ import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.managers.FrostSigningManager 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.text.ProposedEvent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.MantraTheme @@ -93,6 +95,8 @@ fun FrostSigningScreen( initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, onNavigateBack: () -> Unit, ) { val frostSigningViewModel: FrostSigningViewModel = viewModel( @@ -102,7 +106,9 @@ fun FrostSigningScreen( activeUserPublicKey = activeUserPublicKey, initialFrostSigningUIState = initialFrostSigningUIState, chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository + frostSigningRepository = frostSigningRepository, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository ) ) @@ -169,6 +175,7 @@ fun FrostSigningScreen( events = proposed, expected = state.items.size, chatRoomId = state.localChatRoom.chatRoom.id, + names = state.names, ) HorizontalDivider() @@ -339,7 +346,12 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { * sign something they cannot check. */ @Composable -private fun WhatIsBeingSigned(events: List, expected: Int, chatRoomId: String) { +private fun WhatIsBeingSigned( + events: List, + expected: Int, + chatRoomId: String, + names: Map, +) { if (expected == 0 || events.size != expected) { Text( text = if (expected <= 1) { @@ -365,7 +377,7 @@ private fun WhatIsBeingSigned(events: List, expected: Int, chatRoomId: St events.forEachIndexed { index, event -> if (index > 0) HorizontalDivider() - OneThingBeingSigned(event, chatRoomId) + OneThingBeingSigned(event, chatRoomId, names) } Text( @@ -380,9 +392,18 @@ private fun WhatIsBeingSigned(events: List, expected: Int, chatRoomId: St /** One event of the batch, described as the thing it is. */ @Composable -private fun OneThingBeingSigned(event: Event, chatRoomId: String) { - // The room, so a project owned by this group does not say so on every line. - val summary = ProposedEvent.summarize(event, chatRoomId = chatRoomId) +private fun OneThingBeingSigned( + event: Event, + chatRoomId: String, + names: Map, +) { + // The room, so a project owned by this group does not say so on every line; + // and the names, so what is being signed is a thing rather than a hash. + val summary = ProposedEvent.summarize( + event = event, + chatRoomId = chatRoomId, + resolve = names::get, + ) Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)) { Text(text = summary.label, style = MaterialTheme.typography.labelMedium) @@ -430,6 +451,8 @@ private fun FrostSigningScreenPreview() { ), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, onNavigateBack = {} ) } 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 7cef5aa1..6f03a595 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 @@ -5,12 +5,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.height 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.Article +import androidx.compose.material.icons.filled.PostAdd import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -22,6 +24,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 @@ -31,6 +34,8 @@ 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_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.no_source_versions_in_this_project @@ -47,6 +52,7 @@ import press.mantra.compose.extensions.shortened 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.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.widgets.Decorative @@ -166,6 +172,27 @@ fun ProjectDetailScreen( } } } + item { + TextButton( + enabled = projectDetailUIState.canSign, + onClick = { + onNavigateToRoute.invoke( + AddToProjectRoute( + activeUserPublicKey = activeUserPublicKey, + projectId = project.id, + chatRoomId = chatRoomId, + translations = false, + relayHint = relayHint, + ) + ) + } + ) { + Icon(Icons.Default.PostAdd, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_source_version_to_project)) + } + } + item { HorizontalDivider() } // Translations @@ -208,6 +235,27 @@ fun ProjectDetailScreen( } } } + item { + TextButton( + enabled = projectDetailUIState.canSign, + onClick = { + onNavigateToRoute.invoke( + AddToProjectRoute( + activeUserPublicKey = activeUserPublicKey, + projectId = project.id, + chatRoomId = chatRoomId, + translations = true, + relayHint = relayHint, + ) + ) + } + ) { + Icon(Icons.Default.Translate, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_translation_to_project)) + } + } + item { HorizontalDivider() } // Who it belongs to 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 499dc57b..64ebcde5 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 @@ -136,6 +136,7 @@ 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.AddToProjectRoute 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 @@ -149,6 +150,7 @@ 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.AddToProjectScreen import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.ProjectDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen @@ -1094,6 +1096,39 @@ fun MantraNavHost( }, ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddToProjectScreen( + activeUserPublicKey = route.activeUserPublicKey, + projectId = route.projectId, + chatRoomId = route.chatRoomId, + translations = route.translations, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this picker so back returns to the project + // rather than to a form whose proposal has 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() @@ -1215,6 +1250,8 @@ fun MantraNavHost( sessionId = route.sessionId, chatRepository = databaseChatRepository, frostSigningRepository = databaseFrostSigningRepository, + mantraRepository = databaseMantraRepository, + nostrRepository = databaseNostrRepository, onNavigateBack = { navController.popBackStack() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt new file mode 100644 index 00000000..d4e48a3b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt @@ -0,0 +1,20 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * [translations] picks which half of a project this adds to: its translations + * when true, its source versions when false. + * + * One route rather than two because the screen is the same screen -- a list to + * pick from and one proposal -- and the two differ only in which list and which + * kind. Two routes would be two view models with one body between them. + */ +@Serializable +data class AddToProjectRoute( + val activeUserPublicKey: String, + val projectId: String, + val chatRoomId: String, + val translations: Boolean, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt new file mode 100644 index 00000000..579e233f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt @@ -0,0 +1,190 @@ +package press.mantra.compose.ui.view.model + +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 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.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.view.state.AddToProjectUIState + +/** + * Adding one more piece of work to a project that already exists. + * + * The other half of `AddProjectViewModel`, and the simpler one: the project's + * id is known, so there is no batch and no lead -- one event, one session. + */ +class AddToProjectViewModel( + val projectId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val translations: Boolean, + val relayHint: String?, + initialAddToProjectUIState: AddToProjectUIState, + val chatRepository: ChatRepository, + val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var addToProjectUIState: AddToProjectUIState by mutableStateOf(initialAddToProjectUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddToProject() { + logger.d("initiateAddToProject: $projectId translations=$translations") + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + val project = mantraRepository.getProject(projectId) + + addToProjectUIState = when { + localChatRoom == null -> AddToProjectUIState.Error("Couldn't find the chat room") + project == null -> AddToProjectUIState.Error("Couldn't find the project") + else -> AddToProjectUIState.Loaded( + localChatRoom = localChatRoom, + project = project, + candidates = candidatesFor(localChatRoom.chatRoom.id), + canSign = frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * What the room has that the project does not already hold. + * + * Filtered rather than shown and refused: a duplicate membership is a + * second event with its own id, so nothing downstream would stop it -- it + * would just cost a quorum to say what the group has already said. + */ + private suspend fun candidatesFor(chatRoomId: String): List { + val versions = mantraRepository.getArtifacts(chatRoomId) + .flatMap { mantraRepository.getArtifactVersions(it.id) } + + return if (translations) { + val already = mantraRepository.getProjectTranslationArtifactVersions(projectId) + .map { it.id } + .toSet() + + versions + .flatMap { mantraRepository.getTranslationsForArtifactVersion(it.id) } + .filterNot { it.id in already } + .map { + AddToProjectUIState.Candidate( + id = it.id, + label = it.name, + detail = null, + ) + } + } else { + val already = mantraRepository.getProjectArtifactVersions(projectId) + .map { it.id } + .toSet() + + versions + .filterNot { it.id in already } + .map { version -> + AddToProjectUIState.Candidate( + id = version.id, + label = version.versionLabel, + // The artifact, because a version label on its own is + // "1.0" and a room has several of those. + detail = mantraRepository.getArtifact(version.artifactId)?.name, + ) + } + } + } + + /** + * Asks the group to sign one thing into the project. + * + * One event, so [FrostSigningRepository.proposeSigning]'s flat batch rather + * than the lead-and-dependents form: this screen knows the project's id + * because the project exists. + */ + fun addToProject( + localChatRoom: LocalChatRoom, + selectedId: HexKey, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val template = if (translations) { + ProjectTranslationArtifactVersionEvent.build( + projectId = projectId, + translationArtifactVersionId = selectedId, + ) + } else { + ProjectArtifactVersionEvent.build( + projectId = projectId, + artifactVersionId = selectedId, + ) + } + + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = listOf(template), + ) + }.onFailure { error -> + logger.e("Failed to propose adding to project $projectId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) onSuccess.invoke(session.id) else onFailure.invoke() + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddToProjectViewModel" + + fun factory( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + translations: Boolean, + relayHint: String?, + initialAddToProjectUIState: AddToProjectUIState = AddToProjectUIState.Loading, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddToProjectViewModel( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + translations = translations, + relayHint = relayHint, + initialAddToProjectUIState = initialAddToProjectUIState, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt index 3dc63459..e533a1e3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt @@ -19,8 +19,13 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.nostr.frost.FrostSigningEvents +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.FrostSigningUIState class FrostSigningViewModel( @@ -30,6 +35,8 @@ class FrostSigningViewModel( initialFrostSigningUIState: FrostSigningUIState, val chatRepository: ChatRepository, val frostSigningRepository: FrostSigningRepository, + val mantraRepository: MantraRepository, + val nostrRepository: NostrRepository, ): ViewModel() { var frostSigningUIState: FrostSigningUIState by mutableStateOf(initialFrostSigningUIState) @@ -72,12 +79,83 @@ class FrostSigningViewModel( signed = messages .filter { it.kind == FrostSigningEvents.PARTIAL_SIGNATURE } .map { it.signerPublicKey } - .toSet() + .toSet(), + names = namesIn(proposedEvents(items)) ) } } } + /** + * Whatever the proposed events refer to, as words. + * + * Only the kinds that need it, and only what this device already holds. A + * key with no profile and an id with no row are both left out, and + * `ProposedEvent.summarize` falls back to a short hash -- which is truthful + * rather than a gap: the device has not seen the thing. + * + * A project's own id resolves to nothing while it is being proposed, + * because nothing has applied it yet. That is why a membership says the + * version first. + */ + private suspend fun namesIn(events: List): Map = buildMap { + suspend fun person(publicKey: HexKey) { + if (containsKey(publicKey)) return + nostrRepository.getProfileWithPublicKey(publicKey)?.let { + put(publicKey, it.humanReadableNameOrPubkey()) + } + } + + suspend fun project(projectId: HexKey?) { + if (projectId == null || containsKey(projectId)) return + mantraRepository.getProject(projectId)?.let { put(projectId, it.name) } + } + + events.forEach { event -> + when (event.kind) { + ProjectEvent.KIND -> { + val proposed = ProjectEvent( + event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig + ) + proposed.creator()?.let { person(it) } + proposed.owners().forEach { person(it) } + } + + ProjectArtifactVersionEvent.KIND -> { + val membership = ProjectArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig + ) + membership.artifactVersionId()?.let { versionId -> + mantraRepository.getArtifactVersion(versionId)?.let { version -> + val artifact = mantraRepository.getArtifact(version.artifactId) + put( + versionId, + listOfNotNull(artifact?.name, version.versionLabel) + .joinToString(" ") + ) + } + } + project(membership.projectId()) + } + + ProjectTranslationArtifactVersionEvent.KIND -> { + val membership = ProjectTranslationArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig + ) + membership.translationArtifactVersionId()?.let { translationId -> + mantraRepository.getTranslation(translationId)?.let { + put(translationId, it.name) + } + } + project(membership.projectId()) + } + } + } + } + /** The events the group is being asked to sign, for showing them before they agree. */ fun proposedEvents(items: List): List = items.mapNotNull { Event.fromJsonOrNull(it.unsignedEventJson) } @@ -133,6 +211,8 @@ class FrostSigningViewModel( initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { FrostSigningViewModel( @@ -141,7 +221,9 @@ class FrostSigningViewModel( activeUserPublicKey = activeUserPublicKey, initialFrostSigningUIState = initialFrostSigningUIState, chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository + frostSigningRepository = frostSigningRepository, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt new file mode 100644 index 00000000..edb3862f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface AddToProjectUIState { + /** + * [candidates] is whatever is not already in the project: the room's source + * versions or its translations, depending on which half this screen is for. + * + * Already-included work is filtered out rather than shown and refused. A + * membership that duplicates one the group has already signed is a second + * event with a different id, so nothing would stop it -- it would simply + * cost a quorum to say what the group has already said. + */ + data class Loaded( + val localChatRoom: LocalChatRoom, + val project: MantraProject, + val candidates: List = emptyList(), + val canSign: Boolean = false, + ): AddToProjectUIState + + /** One thing that could be added, named the way the picker shows it. */ + data class Candidate(val id: HexKey, val label: String, val detail: String?) + + data class Error( + val message: String + ): AddToProjectUIState + + data object Loading: AddToProjectUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt index 515a603f..519d1efa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt @@ -23,6 +23,22 @@ sealed interface FrostSigningUIState { /** Who has signed their part. */ val signed: Set = emptySet(), + + /** + * Names for the keys and ids the proposed events refer to. + * + * `ProposedEvent.summarize` is a pure function with no database -- two + * call sites show these words and two copies of the mapping would drift + * -- so whatever can be named is resolved here and handed in as a + * lookup. + * + * It matters most for a membership, which carries two 64-character ids + * and nothing else: unresolved, a member is asked to agree to "sign + * a4f1be55d0c3". Empty is the honest default, and what a batch founding + * a project sees for the project's own id, since nothing has applied it + * yet. + */ + val names: Map = emptyMap(), ): FrostSigningUIState data class Error(