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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 19:42:28 +02:00
parent 3912631b2e
commit 54249e3c4f
7 changed files with 821 additions and 0 deletions

View File

@@ -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."
-->
<string name="add_an_owner">Add an owner</string>
<string name="add_project">Add project</string>
<string name="an_owner_is_a_64_character_public_key">An owner is a 64-character public key.</string>
<string name="app_name">Mantra</string>
<string name="add_a_dialect_the_group_can_translate_into">Add a dialect the group can translate into</string>
<string name="add_artifact_to_library">Add artifact to library</string>
@@ -60,12 +62,19 @@
<string name="create_the_admin_room">Create the admin room</string>
<string name="agree_the_groups_signing_key">Agree the group's signing key</string>
<string name="created_by">Created by</string>
<string name="describe_the_project">What this project is</string>
<string name="eg_the_four_gospels_into_sesotho">e.g. The four gospels into Sesotho</string>
<string name="name_of_project">Name of project</string>
<string name="no_source_versions_in_this_project">No source versions in this project yet.</string>
<string name="no_translations_in_this_project">No translations in this project yet.</string>
<string name="owned_by_this_group">This group</string>
<string name="owner_public_key">Owner public key</string>
<string name="owners">Owners</string>
<string name="project_detail">Project detail</string>
<string name="propose_project">Propose project</string>
<string name="remove_owner">Remove owner</string>
<string name="source_versions">Source versions</string>
<string name="start_a_project_in_this_group">Ask the group to sign a project into being</string>
<string name="the_group_is_agreeing_what_the_admin_room">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.</string>
<string name="the_group_has_agreed_what_the_admin_room">The group has agreed what the admin room will sign with.</string>
<string name="the_group_could_not_agree_what_the_admin">The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry.</string>
@@ -220,6 +229,7 @@
<string name="shared_key">Shared key</string>
<string name="signing_key">Signing key</string>
<string name="the_key_this_room_signs_as">The key this room signs as, and the ceremony it came from.</string>
<string name="this_group_has_no_library_to_build_a_project_from">This group has no library yet, so a project would have nothing in it. Add an artifact first.</string>
<string name="what_the_group_signed">What the group signed</string>
<string name="this_room_signs_as">This room signs as</string>
<string name="derivation_path">Derivation path</string>

View File

@@ -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<HexKey> = remember {
mutableStateListOf(chatRoomId)
}
val selectedSourceVersions = remember { mutableStateListOf<HexKey>() }
val selectedTranslations = remember { mutableStateListOf<HexKey>() }
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 = {},
)
}
}
}

View File

@@ -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()
}

View File

@@ -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<AddProjectRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddProjectRoute>()
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<AddArtifactRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddArtifactRoute>()

View File

@@ -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()

View File

@@ -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<Boolean> = 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<HexKey>,
sourceVersionIds: List<HexKey>,
translationIds: List<HexKey>,
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<EventTemplate<*>> {
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
)
}
}
}
}

View File

@@ -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<MantraArtifactVersion> = emptyList(),
/** And everything being translated. */
val translations: List<MantraTranslationArtifactVersion> = 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
}