diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt index fa542b1d..875bd8c4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt @@ -11,16 +11,12 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraDialect -import press.mantra.compose.database.model.MantraTranslationArtifactVersion -import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.nostr.nip30303.SubmissionEvent -import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent -import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE @@ -196,113 +192,6 @@ abstract class MantraDao( return mantraArtifactVersion } - @Transaction - open suspend fun addTranslationArtifactVersion( - artifactId: String, - dialectId: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationArtifactVersion? { - val artifact = database.mantraArtifactDao().getArtifactById(artifactId) ?: return null - val version = database.mantraArtifactVersionDao() - .getArtifactVersionsByArtifactId(artifactId) - .firstOrNull() ?: return null - val dialect = database.mantraDialectDao().getDialectById(dialectId) ?: return null - - // The translation is named after its dialect, and inherits the source - // artifact's visibility/license. - val translationVersionTemplate = TranslationArtifactVersionEvent.build( - artifactVersionId = version.id, - dialectId = dialectId, - name = dialect.name, - visibility = artifact.visibility, - license = artifact.license, - ) - val translationVersion = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate( - translationArtifactVersionEventTemplate = translationVersionTemplate, - chatRoomId = chatRoomId, - userPublicKey = userPublicKey, - ) ?: return null - - return try { - database.mantraTranslationArtifactVersionDao().upsert(translationVersion) - - submitToGroup( - chatRoomId = chatRoomId, - submitterPublicKey = userPublicKey, - payload = rumorOf(translationVersionTemplate, userPublicKey), - text = "Added ${dialect.name} translation", - ) - - // Mirror the source structure: a translation chapter per chapter and - // an (untranslated) translation chunk per chunk. - val chapters = database.mantraChapterDao().getChaptersByArtifactVersionId(version.id) - chapters.forEach { chapter -> - val translationChapterTemplate = TranslationChapterEvent.build( - translationArtifactVersionId = translationVersion.id, - chapterId = chapter.id, - index = chapter.index, - ) - val translationChapter = MantraTranslationChapter.fromTranslationChapterEventTemplate( - translationChapterEventTemplate = translationChapterTemplate, - chatRoomId = chatRoomId, - userPublicKey = userPublicKey, - ) ?: return@forEach - - database.mantraTranslationChapterDao().upsert(translationChapter) - - submitToGroup( - chatRoomId = chatRoomId, - submitterPublicKey = userPublicKey, - payload = rumorOf(translationChapterTemplate, userPublicKey), - text = "Prepared ${dialect.name} translation of the chapter ${chapter.name}", - ) - - // TODO: Figure out if we seriously need the scaffolding -// val chunks = database.mantraChunkDao().getChunksByChapterId(chapter.id) -// chunks.forEach { chunk -> -// val translationChunkTemplate = TranslationChunkEvent.build( -// translationChapterId = translationChapter.id, -// chunkId = chunk.id, -// index = chunk.index, -// text = "", // Untranslated scaffold; filled in during authoring. -// ) -// val translationChunk = MantraTranslationChunk.fromTranslationChunkEventTemplate( -// translationChunkEventTemplate = translationChunkTemplate, -// chatRoomId = chatRoomId, -// userPublicKey = userPublicKey, -// ) ?: return@forEach -// -// database.mantraTranslationChunkDao().upsert(translationChunk) -// val translationChunkInnerEvent = MarmotInnerEvent( -// id = translationChunk.id, -// publicKey = translationChunk.publicKey, -// kind = TranslationChunkEvent.KIND, -// createdAt = translationChunk.createdAt, -// tags = translationChunkTemplate.tags, -// content = translationChunkTemplate.content, -// chatRoomId = translationChunk.chatRoomId, -// ) -// database.marmotInnerEventDao().upsert( -// translationChunkInnerEvent -// ) -// -// sendMarmotInnerEvent( -// chatRoomId = chatRoomId, -// userPublicKey = userPublicKey, -// text = "Prepared translation chunk ${chunk.index}", -// marmotInnerEvent = translationChunkInnerEvent -// ) -// } - } - - translationVersion - } catch (error: Throwable) { - logger.e("Failed to add translation for artifact $artifactId", error) - null - } - } - @Transaction open suspend fun saveTranslation( translationChapterId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index 5027f45e..e66a8db5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -42,20 +42,6 @@ class DatabaseMantraRepository( override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List = database.mantraChapterDao().getChaptersByArtifactVersionId(artifactVersionId) - override suspend fun addTranslationArtifactVersion( - artifactId: String, - dialectId: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationArtifactVersion? { - return database.mantraDao().addTranslationArtifactVersion( - artifactId = artifactId, - dialectId = dialectId, - chatRoomId = chatRoomId, - userPublicKey = userPublicKey - ) - } - override suspend fun getChapter(id: String): MantraChapter? = database.mantraChapterDao().getChapterById(id) @@ -102,22 +88,6 @@ class DatabaseMantraRepository( override suspend fun getDialect(id: String): MantraDialect? = database.mantraDialectDao().getDialectById(id) - override suspend fun addDialect( - localChatRoom: LocalChatRoom, - name: String, - country: String, - language: String, - userPublicKey: HexKey, - ): MantraDialect? { - return database.mantraDao().addDialect( - localChatRoom = localChatRoom, - name = name, - country = country, - language = language, - userPublicKey = userPublicKey - ) - } - override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 122ba5cb..8e841359 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -59,32 +59,10 @@ interface MantraRepository { suspend fun getTranslationChunks(translationChapterId: String): List - /** - * Start a translation of the artifact into the given dialect: creates a - * MantraTranslationArtifactVersion for the artifact's latest version, and - * scaffolds a MantraTranslationChapter for each existing chapter plus a - * MantraTranslationChunk (empty text) for each existing chunk. Returns null - * when the artifact has no version to translate. - */ - suspend fun addTranslationArtifactVersion( - artifactId: String, - dialectId: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationArtifactVersion? - suspend fun getDialects(chatRoomId: String): List suspend fun getDialect(id: String): MantraDialect? - suspend fun addDialect( - localChatRoom: LocalChatRoom, - name: String, - country: String, - language: String, - userPublicKey: HexKey, - ): MantraDialect? - suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, @@ -133,25 +111,10 @@ interface MantraRepository { override suspend fun getTranslationChunks(translationChapterId: String): List = emptyList() - override suspend fun addTranslationArtifactVersion( - artifactId: String, - dialectId: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationArtifactVersion? = null - override suspend fun getDialects(chatRoomId: String): List = emptyList() override suspend fun getDialect(id: String): MantraDialect? = null - override suspend fun addDialect( - localChatRoom: LocalChatRoom, - name: String, - country: String, - language: String, - userPublicKey: HexKey, - ): MantraDialect? = null - override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index 67ea9739..304d16a8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -9,25 +9,23 @@ 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.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Title import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -36,6 +34,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -43,10 +43,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom 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.ArtifactDetailRoute +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.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme @@ -63,6 +67,8 @@ fun AddTranslationArtifactVersionScreen( initialAddTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState = AddTranslationArtifactVersionUIState.Loading, mantraRepository: MantraRepository, chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -74,7 +80,8 @@ fun AddTranslationArtifactVersionScreen( relayHint = relayHint, initialAddTranslationArtifactVersionUIState = initialAddTranslationArtifactVersionUIState, mantraRepository = mantraRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) ) @@ -90,13 +97,32 @@ fun AddTranslationArtifactVersionScreen( } is AddTranslationArtifactVersionUIState.Loaded -> { - val dialectNameFieldState = rememberTextFieldState() - val dialectCountryFieldState = rememberTextFieldState() - val dialectLanguageFieldState = rememberTextFieldState() - - // null = "New dialect" (show the create fields); otherwise reuse an - // existing dialect by id. + // A translation is into a dialect the group already signed into + // existence. Nothing is chosen to begin with: picking the first one + // for somebody would be choosing the language of the work. var selectedDialectId: String? by remember { mutableStateOf(null) } + val selectedDialect = addTranslationUIState.dialects + .firstOrNull { dialect -> dialect.id == selectedDialectId } + + // A translation hangs off a version, and the group has to be able to + // sign; without both there is nothing this screen can propose. + val artifactVersion = addTranslationUIState.artifactVersion + val chapterCount = addTranslationUIState.chapters.size + + // The translation and a chapter apiece are signed in one session, + // and a session signs a bounded number of events. Past that the + // artifact cannot be translated in one go at all, which is worth + // saying here rather than after a failed propose. + val tooManyChapters = chapterCount > AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION + + val canProposeTranslation = artifactVersion != null && + selectedDialect != null && + addTranslationUIState.canSign && + !tooManyChapters + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() Scaffold( topBar = { @@ -117,32 +143,61 @@ fun AddTranslationArtifactVersionScreen( actions = {}, floatingActionButton = { ExtendedFloatingActionButton( + modifier = if (canProposeTranslation) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canProposeTranslation) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canProposeTranslation) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, onClick = { + if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) { + return@ExtendedFloatingActionButton + } + addTranslationArtifactVersionViewModel.addTranslation( localChatRoom = addTranslationUIState.localChatRoom, - existingDialectId = selectedDialectId, - dialectNameField = dialectNameFieldState, - dialectCountryField = dialectCountryFieldState, - dialectLanguageField = dialectLanguageFieldState, - onSuccess = { - onNavigateToRoute.invoke( - ArtifactDetailRoute( + artifact = addTranslationUIState.artifact, + artifactVersion = artifactVersion, + dialect = selectedDialect, + chapters = addTranslationUIState.chapters, + onSuccess = { sessionId -> + // Onto the session rather than back to + // the artifact. Nothing has been created + // yet -- the translation appears when + // enough members sign -- so landing on + // the list it is not in would read as a + // failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( activeUserPublicKey = activeUserPublicKey, - artifactId = artifactId, chatRoomId = chatRoomId, - relayHint = relayHint + sessionId = sessionId ) ) }, - onFailure = {} + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed Translation") + ) + } ) } ) { Icon( - Icons.Default.Add, - contentDescription = "Add translation" + Icons.Default.Translate, + contentDescription = "Propose translation" ) - Text("Add Translation") + Text("Propose Translation") } } ) @@ -152,8 +207,45 @@ fun AddTranslationArtifactVersionScreen( modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + if (!addTranslationUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "translation into existence. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (artifactVersion == null) { + Text( + text = "This artifact has no version for a translation to be of.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (tooManyChapters) { + Text( + text = "$chapterCount chapters is more than the group can sign in " + + "one go, so this artifact cannot be translated here. It signs " + + "at most " + + "${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " + + "chapters at a time.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + Text("Translate into which dialect?") + if (addTranslationUIState.dialects.isEmpty()) { + // The only way out of this screen, and it is not on it: + // a dialect is the group's too, and asking for one is + // its own quorum from the room's own detail screen. + Text( + text = "This group has no dialects yet. Add one from the group's " + + "details before translating anything into it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -165,64 +257,29 @@ fun AddTranslationArtifactVersionScreen( label = { Text(dialect.name) } ) } - FilterChip( - selected = selectedDialectId == null, - onClick = { selectedDialectId = null }, - leadingIcon = { - Icon( - Icons.Default.Add, - contentDescription = "Create a new dialect" - ) - }, - label = { Text("New dialect") } - ) - } - - // Only collect new-dialect details when not reusing an existing one. - if (selectedDialectId == null) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - state = dialectNameFieldState, - leadingIcon = { - Icon( - Icons.Default.Title, - contentDescription = "Name of the dialect" - ) - }, - label = { Text("Dialect Name") }, - placeholder = { Text("eg. Sesotho") }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - state = dialectCountryFieldState, - leadingIcon = { - Icon( - Icons.Default.Public, - contentDescription = "Country of the dialect" - ) - }, - label = { Text("Country") }, - placeholder = { Text("eg. Lesotho") }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - state = dialectLanguageFieldState, - leadingIcon = { - Icon( - Icons.Default.Translate, - contentDescription = "Language of the dialect" - ) - }, - label = { Text("Language") }, - placeholder = { Text("eg. st") }, - ) } Text( - text = "The artifact's chapters and chunks will be scaffolded for this translation.", - style = MaterialTheme.typography.labelMedium + // What the group is being asked to sign, counted before + // the tap rather than described after it: the chapters + // are the rest of the batch. The empty case is worth + // its own sentence, because a translation proposed now + // is not reached by chapters signed after it. + text = if (chapterCount == 0) { + "The artifact has no chapters yet, so the group would sign the " + + "translation on its own. Chapters added afterwards do not join it." + } else { + "The group signs the translation and " + + "$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"} " + + "of ${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " + + "together. Chunks are translated one at a time afterwards." + }, + style = MaterialTheme.typography.labelMedium, + color = if (tooManyChapters) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.typography.labelMedium.color + } ) } } @@ -284,10 +341,32 @@ private fun AddTranslationArtifactVersionScreenPreview() { license = "cc", chatRoomId = "chatRoomId", signature = "" - ) + ), + dialects = listOf( + MantraDialect( + id = "dialectId", + publicKey = "author", + name = "Sesotho", + country = "Lesotho", + language = "st", + chatRoomId = "chatRoomId", + signature = "" + ) + ), + artifactVersion = MantraArtifactVersion( + id = "artifactVersionId", + artifactId = "artifactId", + publicKey = "author", + versionLabel = "1.0", + chatRoomId = "chatRoomId", + signature = "" + ), + canSign = true, ), mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {}, onNavigateBack = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 69999110..86af594c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -54,6 +54,8 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.text.Markdown @@ -403,6 +405,24 @@ private fun OneThingBeingSigned(event: Event) { ).joinToString(" · ") } + TranslationArtifactVersionEvent.KIND -> "New translation" to TranslationArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { translation -> + // Named after the dialect it is into, which is the whole of what is + // being decided: everything else in the batch follows from it. + listOfNotNull(translation.name(), translation.visibility(), translation.license()) + .joinToString(" · ") + } + + TranslationChapterEvent.KIND -> "Chapter of the translation" to TranslationChapterEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { chapter -> + // A translation chapter is a place for translated chunks to hang + // off, so there is nothing in it to read -- only where in the work + // it sits. Counted from one, the way the chapter list reads. + chapter.index()?.let { "Chapter ${it + 1}" } ?: "Position unknown" + } + // The one thing a group signs that is about the group rather than about // its work, and the only one a member sees before the room has done // anything. Shown as the path and the ceremony rather than as the key: diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 7282a01c..be061fcf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -995,15 +995,21 @@ fun MantraNavHost( relayHint = route.relayHint, mantraRepository = databaseMantraRepository, chatRepository = databaseChatRepository, - onNavigateToRoute = { actionRoute -> - // Replace this screen and the stale artifact detail beneath it - // so we land on a freshly-loaded detail showing the new translation. - navController.navigate(route = actionRoute) { - popUpTo { + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this screen so back returns to the artifact rather + // than to a form whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { inclusive = true } } }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, onNavigateBack = { navController.popBackStack() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationArtifactVersionViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationArtifactVersionViewModel.kt index 02453a99..add39a01 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationArtifactVersionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationArtifactVersionViewModel.kt @@ -1,7 +1,5 @@ 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 @@ -12,12 +10,22 @@ 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.Event 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.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.TranslationChapterEvent 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.AddTranslationArtifactVersionUIState @@ -29,6 +37,7 @@ class AddTranslationArtifactVersionViewModel( initialAddTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState, val mantraRepository: MantraRepository, val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, ): ViewModel() { var addTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState by mutableStateOf(initialAddTranslationArtifactVersionUIState) @@ -42,6 +51,10 @@ class AddTranslationArtifactVersionViewModel( viewModelScope.launch(Dispatchers.IO) { val artifact = mantraRepository.getArtifact(artifactId) val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + // Translations attach to an artifact version; use the latest one. + val artifactVersion = mantraRepository.getArtifactVersions(artifactId).firstOrNull() + addTranslationArtifactVersionUIState = if (artifact == null || localChatRoom == null) { AddTranslationArtifactVersionUIState.Error("Couldn't find the artifact") } else { @@ -49,32 +62,55 @@ class AddTranslationArtifactVersionViewModel( artifact = artifact, localChatRoom = localChatRoom, dialects = mantraRepository.getDialects(chatRoomId), + artifactVersion = artifactVersion, + chapters = artifactVersion + ?.let { mantraRepository.getChaptersForArtifactVersion(it.id) } + .orEmpty(), + canSign = frostSigningRepository.canSign(chatRoomId), ) } } } + /** + * Asks the group to sign a translation of the artifact, and a translation + * chapter for every chapter it has, in one session. + * + * Nothing is created here. What goes out is a proposal to sign the lot, and + * the translation and its chapters appear on every member's device at once, + * authored by this room's own key rather than by whoever picked the dialect, + * when enough members have signed. That author is the room's id: signing + * runs at the path the room was derived at, so a translation says which + * group's artifact it belongs to simply by being signed. + * + * 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. A translation is what the group's readers will read the + * artifact as, so the second is the honest one. + * + * The chapters travel with it rather than being scaffolded on each device, + * for the same reason the chapter's chunks do: a translation chapter that + * carries the group's signature can be checked by anybody holding it, and a + * translation with no chapters is one nobody can start translating. What it + * costs is [MAX_CHAPTERS_PER_TRANSLATION]: the batch is capped, the + * translation takes one of its places, and an artifact with more chapters + * than the rest cannot be translated in one session at all. + */ fun addTranslation( localChatRoom: LocalChatRoom, - existingDialectId: HexKey?, - dialectNameField: TextFieldState, - dialectCountryField: TextFieldState, - dialectLanguageField: TextFieldState, - onSuccess: () -> Unit, + artifact: MantraArtifact, + artifactVersion: MantraArtifactVersion, + dialect: MantraDialect, + chapters: List, + onSuccess: (sessionId: String) -> Unit, onFailure: () -> Unit ) { - val dialectName = dialectNameField.text.toString() - val dialectCountry = dialectCountryField.text.toString() - val dialectLanguage = dialectLanguageField.text.toString() - - // When no existing dialect is selected, the new-dialect fields are required. - val creatingNewDialect = existingDialectId.isNullOrBlank() - val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank() - if (creatingNewDialect && newDialectIncomplete) { - viewModelScope.launch(Dispatchers.Main) { - onFailure.invoke() - } - + // The cap is the group's, not this screen's, and proposing past it + // throws rather than failing softly. The form says so before the tap; + // this is the check that has to hold when it does not. + if (chapters.size > MAX_CHAPTERS_PER_TRANSLATION) { + onFailure.invoke() return } @@ -83,43 +119,41 @@ class AddTranslationArtifactVersionViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - val translation = runCatching { - // Reuse the selected dialect, or create a new one for the translation. - val dialectId = if (creatingNewDialect) { - mantraRepository.addDialect( - localChatRoom = localChatRoom, - name = dialectName, - country = dialectCountry, - language = dialectLanguage, - userPublicKey = activeUserPublicKey, - )?.id ?: return@runCatching null - } else { - existingDialectId - } + val translationEventTemplate = TranslationArtifactVersionEvent.build( + artifactVersionId = artifactVersion.id, + dialectId = dialect.id, + // The translation is named after its dialect, and inherits the + // source artifact's visibility and license: it is the same work + // in another tongue, not a new one to license afresh. + name = dialect.name, + visibility = artifact.visibility, + license = artifact.license, + ) - mantraRepository.addTranslationArtifactVersion( - artifactId = artifactId, - dialectId = dialectId, - chatRoomId = chatRoomId, + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, userPublicKey = activeUserPublicKey, + lead = translationEventTemplate, + // A translation chapter names the translation it belongs to, + // and that id is a hash over the group's key at the room's + // path -- neither of which this screen knows or should. The + // translation comes back built, and the chapters are laid + // out against it. + dependents = { translation -> translationChaptersOf(translation, chapters) } ) }.onFailure { error -> - logger.e("Failed to add translation", error) + logger.e("Failed to propose a translation for signing", error) }.getOrNull() - if (translation != null) { - dialectNameField.clearText() - dialectCountryField.clearText() - dialectLanguageField.clearText() + if (session != null) { viewModelScope.launch(Dispatchers.Main) { - onSuccess.invoke() + onSuccess.invoke(session.id) } - } else { viewModelScope.launch(Dispatchers.Main) { onFailure.invoke() } - } isActionPending.value = false @@ -129,6 +163,49 @@ class AddTranslationArtifactVersionViewModel( companion object { private const val TAG = "AddTranslationViewModel" + /** + * A translation chapter for each of [chapters], ready to be signed with + * the translation they hang off. + * + * A translation and its chapters go to the group as one batch, so these + * are built from the translation *after* it has been authored under the + * group's key -- [translation] is the unsigned event the session will + * sign, which is where the id each chapter carries comes from. Laying + * them out anywhere else would mean naming a translation id before one + * exists. + * + * They take the translation's own timestamp, so the batch reads as one + * act rather than as events that happen to share a session. + * + * Empty when the artifact has no chapters, which is a translation with + * nothing yet to translate -- the chapters signed into the artifact + * afterwards do not reach a translation proposed before them. + */ + fun translationChaptersOf( + translation: Event, + chapters: List, + ): List> = chapters.map { chapter -> + TranslationChapterEvent.build( + translationArtifactVersionId = translation.id, + chapterId = chapter.id, + // The source chapter's own position, not this list's: the two + // agree today, and only one of them is what the group signed. + index = chapter.index, + createdAt = translation.createdAt, + ) + } + + /** + * The most chapters an artifact can be translated with in one session. + * + * The translation is signed together with a chapter apiece, and a + * session signs at most [FrostSigningManager.MAX_BATCH_SIZE] events. The + * translation is one of them, so the chapters get the rest. Unlike a + * chapter's paragraphs this is not something whoever is looking at the + * screen can shorten, so it is said plainly rather than as advice. + */ + const val MAX_CHAPTERS_PER_TRANSLATION: Int = FrostSigningManager.MAX_BATCH_SIZE - 1 + fun factory( activeUserPublicKey: HexKey, artifactId: String, @@ -136,7 +213,8 @@ class AddTranslationArtifactVersionViewModel( relayHint: String?, initialAddTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState = AddTranslationArtifactVersionUIState.Loading, mantraRepository: MantraRepository, - chatRepository: ChatRepository + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { AddTranslationArtifactVersionViewModel( @@ -146,7 +224,8 @@ class AddTranslationArtifactVersionViewModel( relayHint = relayHint, initialAddTranslationArtifactVersionUIState = initialAddTranslationArtifactVersionUIState, mantraRepository = mantraRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddTranslationArtifactVersionUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddTranslationArtifactVersionUIState.kt index 6534b6b3..18f395b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddTranslationArtifactVersionUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddTranslationArtifactVersionUIState.kt @@ -1,6 +1,8 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -8,7 +10,38 @@ sealed interface AddTranslationArtifactVersionUIState { data class Loaded( val localChatRoom: LocalChatRoom, val artifact: MantraArtifact, + + /** + * The dialects this room has signed into existence. A translation is + * into one of them and only one of them: an invented dialect is a + * dialect nobody agreed to, and the room already has a screen for + * asking the group for a new one. + */ val dialects: List = emptyList(), + + /** + * The version being translated: the artifact's latest. + * + * Null on an artifact that has none, which is one there is nothing to + * translate -- a translation is of a version rather than of an artifact. + */ + val artifactVersion: MantraArtifactVersion? = null, + + /** + * The chapters of that version, which the translation is laid out + * against: a translation chapter each, signed in the same session. + * + * Held here rather than re-read when proposing, so that the count the + * form shows is the count the group is asked to sign. + */ + val chapters: List = emptyList(), + + /** + * Whether the group holds a shared key. A translation is signed into + * existence now rather than submitted, so a group without one cannot + * start one here at all. + */ + val canSign: Boolean = false, ): AddTranslationArtifactVersionUIState data class Error( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt new file mode 100644 index 00000000..c2d99e16 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt @@ -0,0 +1,281 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgParticipantMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.TranslationChapterEvent +import press.mantra.compose.ui.view.model.AddTranslationArtifactVersionViewModel + +/** + * What a translation proposal actually puts in front of the group. + * + * The same sharp edge `ChapterBatchProposalJvmTest` covers for a chapter and + * its chunks, one level up: a translation chapter carries the id of the + * translation it belongs to, and that id does not exist until the proposal + * authors the translation under the group's key at the room's derivation path. + * Both are resolved inside `proposeSigningBatch` and neither is knowable to the + * caller, which is why the chapters are built from what it hands back. + * + * If that came apart, the translation would still be signed and every chapter + * would still verify -- against a translation id nobody has. They would fail a + * foreign key on the way in and the translation would simply arrive with no + * chapters, which is a translation nobody can start. + */ +class TranslationBatchProposalJvmTest { + private val participants = 3 + private val threshold = 2 + + /** Stands in for a completed ceremony; the test is about the proposal, not the DKG. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() + + /** The admin room: the group's key walked to the admin path, which is its id. */ + private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private val ceremonyId = "ceremony".padEnd(64, '0') + private val members = listOf("a", "b", "c").map { it.repeat(64) } + private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') } + + private val proposer = members.first() + private val artifactVersionId = "b".repeat(64) + private val dialectId = "d".repeat(64) + + private lateinit var db: MantraDatabase + private lateinit var room: LocalChatRoom + + @AfterTest + fun closeDatabase() { + if (::db.isInitialized) db.close() + } + + /** The proposer's device: a room it can sign in, and the share to sign with. */ + private suspend fun openDevice() { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + // Profile hangs off a nostr event, and a room off a profile. Neither is + // anything this test is about; they are the foreign keys in the way. + val nostrEventId = "e$proposer".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = proposer, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128) + ) + ) + db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId)) + + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = proposer, + subject = "#admins", + description = SharedKeyDerivation.describe("Admins of the group."), + mlsGroupState = null + ) + db.chatRoomDao().upsert(chatRoom) + + db.dkgSessionDao().upsert( + DkgSession( + id = ceremonyId, + chatRoomId = roomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + threshold = threshold, + participantCount = participants, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = hostKeys[0], + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + thresholdPublicKey = thresholdPublicKey, + secretShare = keyMaterial.secretShares[0].value.toHex(), + publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() } + ) + ) + + members.forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = ceremonyId, + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = hostKeys[index] + ) + ) + } + + room = LocalChatRoom(chatRoom = chatRoom) + } + + /** + * The source chapters, as the artifact already holds them. Only the id and + * the position travel into the translation, so nothing else is furnished. + */ + private fun sourceChapters(count: Int): List = (0 until count).map { index -> + MantraChapter( + id = "c$index".padEnd(64, 'f'), + artifactVersionId = artifactVersionId, + publicKey = roomId, + name = "Chapter ${index + 1}", + originalText = "Paragraph ${index + 1}.", + index = index, + wordCount = 2, + characterCount = 13, + signature = "0".repeat(128), + chatRoomId = roomId + ) + } + + private val translationTemplate = TranslationArtifactVersionEvent.build( + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Sesotho", + visibility = "private", + license = "cc", + ) + + /** Exactly what `AddTranslationArtifactVersionViewModel.addTranslation` proposes. */ + private suspend fun proposeTranslation(chapters: List) = + FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + lead = translationTemplate, + dependents = { translation -> + AddTranslationArtifactVersionViewModel.translationChaptersOf(translation, chapters) + } + ) + + private suspend fun itemEvents(sessionId: String): List = + db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) } + + @Test + fun `the translation leads the batch and its chapters follow`() = runBlocking { + openDevice() + + val events = itemEvents(proposeTranslation(sourceChapters(3)).id) + + // Item order is apply order, and a translation chapter row whose + // translation does not exist yet is a foreign key violation. The + // translation has to be first. + assertEquals(4, events.size) + assertEquals(TranslationArtifactVersionEvent.KIND, events.first().kind) + assertTrue(events.drop(1).all { it.kind == TranslationChapterEvent.KIND }) + } + + @Test + fun `every chapter names the translation as the group will author it`() = runBlocking { + openDevice() + + val events = itemEvents(proposeTranslation(sourceChapters(3)).id) + val translation = events.first() + + // The claim the whole `lead`/`dependents` form exists for. The id here is + // a hash over the group's key at the room's path; a caller that built the + // chapters before proposing could not have known it. + val chapters = events.drop(1).map { + TranslationChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + + assertTrue(chapters.all { it.translationArtifactVersionId() == translation.id }) + } + + @Test + fun `each chapter keeps the source chapter it stands in for, and its place`() = runBlocking { + openDevice() + + val source = sourceChapters(3) + val events = itemEvents(proposeTranslation(source).id) + val chapters = events.drop(1).map { + TranslationChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + + // A translation chapter is only ever a place to hang translated chunks + // off the right source chapter, in the right order. Lose either and the + // translation reads as some other book. + assertEquals(source.map { it.id }, chapters.map { it.chapterId() }) + assertEquals(source.map { it.index }, chapters.map { it.index() }) + } + + @Test + fun `the whole batch is stamped and authored as one act by the room`() = runBlocking { + openDevice() + + val events = itemEvents(proposeTranslation(sourceChapters(3)).id) + + // Signing runs at the path the room was derived at, so the author of + // every item is the room's own id -- chapters included, which is what + // lets one be checked without being told which key to expect. + assertTrue(events.all { it.pubKey == roomId }) + assertTrue(events.none { it.pubKey == proposer }) + + // And they carry the translation's timestamp rather than each reading + // the clock, so the batch is one act rather than events that happen to + // share a session. + assertEquals(listOf(events.first().createdAt), events.map { it.createdAt }.distinct()) + } + + @Test + fun `an artifact of the most chapters allowed still proposes`() = runBlocking { + openDevice() + + val events = itemEvents( + proposeTranslation(sourceChapters(FrostSigningManager.MAX_BATCH_SIZE - 1)).id + ) + + // The arithmetic the form is built on: the translation takes one of the + // batch's places and the chapters get the rest. If this stopped being + // true, the screen would offer a translation the session then refuses. + assertEquals( + FrostSigningManager.MAX_BATCH_SIZE, + AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION + 1 + ) + assertEquals(FrostSigningManager.MAX_BATCH_SIZE, events.size) + } + + @Test + fun `an artifact of one chapter too many is refused`() = runBlocking { + openDevice() + + // Refused here rather than truncated: a batch that quietly dropped its + // last chapters would sign a translation the group believes covers the + // whole work while the end of it can never be translated. The screen + // checks first so this is never what a member sees, but the screen is + // not what enforces it. + assertFailsWith { + proposeTranslation(sourceChapters(FrostSigningManager.MAX_BATCH_SIZE)) + } + + assertEquals(emptyList(), db.frostSigningSessionDao().getSessionsForChatRoom(roomId)) + } +}