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:
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>
|
||||
|
||||
+302
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-6
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
+48
@@ -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
|
||||
|
||||
+37
@@ -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()
|
||||
}
|
||||
|
||||
+20
@@ -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()
|
||||
+190
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -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
|
||||
}
|
||||
+16
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user