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(