feat(projects): add to an existing project, and names instead of hashes

Phase 7c of docs/project-feature.md, and the last of the phases this plan
builds. 8 and 9 -- removal, and projects that span rooms -- stay deferred.

The resolver comes first because the screen depends on it. A membership event
carries two 64-character ids and nothing else, so proposing one on its own
means every other member is shown "sign a4f1be55d0c3" and asked to agree. That
is not a question anybody can answer, and the plan said not to ship this screen
without the fix.

So `FrostSigningViewModel` resolves what the proposed events refer to -- the
profile behind a creator or an owner, the artifact and label behind a version,
the name behind a project -- and hands `summarize` a lookup. Synchronous, because
two of the four call sites are composables and cannot await a database;
pre-resolved in the view model for the same reason. Only what this device
already holds: a key with no profile and an id with no row are left out and fall
back to a short hash, which is truthful rather than a gap.

A project's own id still resolves to nothing while it is being proposed, since
nothing has applied it yet. That is why a membership's summary says the version
first.

`AddToProjectScreen` is the simpler half of founding a project: the project
exists, so its id is known and there is no lead and no batch -- one event, one
session. Candidates are filtered to what the project does not already hold
rather than shown and refused, because a duplicate membership is a second event
with its own id and nothing downstream would stop it; it would just cost a
quorum to say what the group has already said.

One at a time, by radio button rather than checkboxes. A quorum per thing added
is the grain this app signs at, and a multi-select here would quietly be a batch
whose items a member has to weigh separately anyway.

M3 budgets met. 1403 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-02 19:49:57 +02:00
1 parent 54249e3c4f
commit 3668b39575
10 files changed
+763 -8

No files matched your search

@@ -22,6 +22,8 @@
-->
<string name="add_an_owner">Add an owner</string>
<string name="add_project">Add project</string>
<string name="add_source_version_to_project">Add source version</string>
<string name="add_translation_to_project">Add translation</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>
@@ -64,6 +66,8 @@
<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="every_translation_is_already_in_this_project">Every translation in this group is already in this project.</string>
<string name="everything_in_the_library_is_already_in_this_project">Everything in this group's library is already in this project.</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>
@@ -71,6 +75,7 @@
<string name="owner_public_key">Owner public key</string>
<string name="owners">Owners</string>
<string name="project_detail">Project detail</string>
<string name="propose_adding_to_the_project">Propose adding</string>
<string name="propose_project">Propose project</string>
<string name="remove_owner">Remove owner</string>
<string name="source_versions">Source versions</string>
@@ -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 = {},
)
}
}
}
@@ -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<Event>, expected: Int, chatRoomId: String) {
private fun WhatIsBeingSigned(
events: List<Event>,
expected: Int,
chatRoomId: String,
names: Map<HexKey, String>,
) {
if (expected == 0 || events.size != expected) {
Text(
text = if (expected <= 1) {
@@ -365,7 +377,7 @@ private fun WhatIsBeingSigned(events: List<Event>, 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<Event>, 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<HexKey, String>,
) {
// 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 = {}
)
}
@@ -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
@@ -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<AddToProjectRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddToProjectRoute>()
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<AddProjectRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddProjectRoute>()
@@ -1215,6 +1250,8 @@ fun MantraNavHost(
sessionId = route.sessionId,
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
mantraRepository = databaseMantraRepository,
nostrRepository = databaseNostrRepository,
onNavigateBack = {
navController.popBackStack()
}
@@ -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()
@@ -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<Boolean> = 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<AddToProjectUIState.Candidate> {
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,
)
}
}
}
}
@@ -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<Event>): Map<HexKey, String> = 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<FrostSigningItem>): List<Event> =
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
)
}
}
@@ -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<Candidate> = 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
}
@@ -23,6 +23,22 @@ sealed interface FrostSigningUIState {
/** Who has signed their part. */
val signed: Set<HexKey> = 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<HexKey, String> = emptyMap(),
): FrostSigningUIState
data class Error(