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 875bd8c4..d336122b 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,13 +11,11 @@ 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.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.TranslationChunkEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_VISIBILITY @@ -192,60 +190,6 @@ abstract class MantraDao( return mantraArtifactVersion } - @Transaction - open suspend fun saveTranslation( - translationChapterId: String, - chunkId: String, - text: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationChunk? { - // The translation chunk mirrors the source chunk's position. - val sourceChunk = database.mantraChunkDao().getChunkById(chunkId) ?: return null - - val translationChunkTemplate = TranslationChunkEvent.build( - translationChapterId = translationChapterId, - chunkId = chunkId, - index = sourceChunk.index, - text = text, - ) - val translationChunk = MantraTranslationChunk.fromTranslationChunkEventTemplate( - translationChunkEventTemplate = translationChunkTemplate, - chatRoomId = chatRoomId, - userPublicKey = userPublicKey, - ) ?: return null - - return try { - // Replace any existing translation chunk for this source chunk. Its id - // is derived from the (now changed) content, so it becomes a new row — - // drop the old one (and the submission carrying it) to keep one per - // source chunk. The submission is found by what it carries, since its - // own id is the envelope's rather than the chunk's. - database.mantraTranslationChunkDao() - .getTranslationChunksByTranslationChapterId(translationChapterId) - .filter { it.chunkId == chunkId && it.id != translationChunk.id } - .forEach { stale -> - database.mantraTranslationChunkDao().deleteById(stale.id) - database.marmotInnerEventDao().deleteById(stale.id) - database.marmotInnerEventDao().deleteByPayloadEventId(stale.id) - } - - database.mantraTranslationChunkDao().upsert(translationChunk) - - submitToGroup( - chatRoomId = chatRoomId, - submitterPublicKey = userPublicKey, - payload = rumorOf(translationChunkTemplate, userPublicKey), - text = "Translated chunk ${translationChunk.index}", // TODO: Use a portion of the translation and name the language - ) - - translationChunk - } catch (error: Throwable) { - logger.e("Failed to save translation for chunk $chunkId", error) - null - } - } - private suspend fun sendMarmotInnerEvent( chatRoomId: String, userPublicKey: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt index 52c1c7a3..8d110f83 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt @@ -15,6 +15,19 @@ interface MantraTranslationChunkDao { @Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC") suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List + /** + * Every translation this chapter holds of one source chunk. + * + * There should only ever be one, and this is how that is kept true: a + * retranslation is a new event with a new id rather than an edit, so the + * row it replaces has to be found and dropped. + */ + @Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId AND chunkId = :chunkId") + suspend fun getTranslationChunksByChunkId( + translationChapterId: String, + chunkId: String + ): List + @Query("DELETE FROM MantraTranslationChunk WHERE id = :id") suspend fun deleteById(id: String) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index baaaccff..656ccef6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -985,13 +985,42 @@ data class ChatMessage( ), chatRoomId = groupId, )?.let { mantraTranslationChunk -> - database.mantraTranslationChunkDao().upsert( - mantraTranslationChunk.copy( - marmotGroupEventId = marmotGroupEventId, + // One translation per source chunk. Retranslating changes + // the text and so the event's id, which makes it a new + // event rather than an edit of the old one -- so the one + // it supersedes is dropped here, or a passage would have + // two answers to what it says and the reader would be + // shown whichever the query happened to reach first. + // + // Newest wins by the timestamp the group signed at, not by + // when it arrived, with the id breaking a tie. Two devices + // catching up read the same events in whatever order the + // relay hands them over, and they have to end up holding + // the same translation either way. + val existing = database.mantraTranslationChunkDao() + .getTranslationChunksByChunkId( + translationChapterId = mantraTranslationChunk.translationChapterId, + chunkId = mantraTranslationChunk.chunkId, ) - ) + .filterNot { it.id == mantraTranslationChunk.id } + val isNewest = existing.none { + it.createdAt > mantraTranslationChunk.createdAt || + (it.createdAt == mantraTranslationChunk.createdAt && + it.id > mantraTranslationChunk.id) + } + if (isNewest) { + existing.forEach { + database.mantraTranslationChunkDao().deleteById(it.id) + } + + database.mantraTranslationChunkDao().upsert( + mantraTranslationChunk.copy( + marmotGroupEventId = marmotGroupEventId, + ) + ) + } } null } 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 39967f2e..74552762 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 @@ -14,7 +14,6 @@ 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.TranslationChunkEvent import press.mantra.compose.repository.MantraRepository class DatabaseMantraRepository( @@ -51,22 +50,6 @@ class DatabaseMantraRepository( override suspend fun getChunk(id: String): MantraChunk? = database.mantraChunkDao().getChunkById(id) - override suspend fun saveTranslationChunk( - translationChapterId: String, - chunkId: String, - text: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationChunk? { - return database.mantraDao().saveTranslation( - translationChapterId = translationChapterId, - chunkId = chunkId, - text = text, - chatRoomId = chatRoomId, - userPublicKey = userPublicKey - ) - } - override suspend fun getTranslationsForArtifact(artifactId: String): List = database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId) 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 1bf79271..677998d6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -35,20 +35,6 @@ interface MantraRepository { suspend fun getChunk(id: String): MantraChunk? - /** - * Create or replace the translation of a source chunk within a translation - * chapter. Any existing translation chunk for the same (translationChapterId, - * chunkId) is replaced. Returns the saved chunk, or null if the source chunk - * can't be found. - */ - suspend fun saveTranslationChunk( - translationChapterId: String, - chunkId: String, - text: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationChunk? - suspend fun getTranslationsForArtifact(artifactId: String): List suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? @@ -100,14 +86,6 @@ interface MantraRepository { override suspend fun getChunk(id: String): MantraChunk? = null - override suspend fun saveTranslationChunk( - translationChapterId: String, - chunkId: String, - text: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationChunk? = null - override suspend fun getTranslationsForArtifact(artifactId: String): List = emptyList() override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 4f25bc13..e1bb1990 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -9,6 +9,7 @@ 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.nostr.nip30303.TranslationChunkEvent /** * An event a group is being asked to sign, said in words. @@ -106,6 +107,23 @@ object ProposedEvent { } ) + TranslationChunkEvent.KIND -> Summary( + label = "Translated passage", + detail = TranslationChunkEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { chunk -> + // The translation itself is the whole of what is being decided -- + // a member signing this is agreeing that these words say what the + // original said -- so it is the detail rather than a count of it. + // Where in the chapter it sits comes first, since that is what + // says which passage to read it against. + listOfNotNull( + chunk.index()?.let { "Passage ${it + 1}" }, + event.content + ).joinToString(" · ") + } + ) + // 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/TranslateChunkScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt index 889ab152..b241f191 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt @@ -12,11 +12,13 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Add import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -25,19 +27,27 @@ 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.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 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.MantraChunk +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.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.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.TranslateChunkViewModel @@ -53,6 +63,9 @@ fun TranslateChunkScreen( relayHint: String?, initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -65,6 +78,8 @@ fun TranslateChunkScreen( relayHint = relayHint, initialTranslateChunkUIState = initialTranslateChunkUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) ) @@ -82,6 +97,10 @@ fun TranslateChunkScreen( is TranslateChunkUIState.Loaded -> { val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText) + // 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 = { TopAppBar( @@ -101,30 +120,59 @@ fun TranslateChunkScreen( actions = {}, floatingActionButton = { ExtendedFloatingActionButton( + modifier = if (translateChunkUIState.canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable: + // without this a screen reader still announces + // a button it is happy to press. + Modifier.semantics { disabled() } + }, + containerColor = if (translateChunkUIState.canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (translateChunkUIState.canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, onClick = { - translateChunkViewModel.saveTranslation( + if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton + + translateChunkViewModel.proposeTranslation( + localChatRoom = translateChunkUIState.localChatRoom, + originalChunk = translateChunkUIState.originalChunk, translationField = translationFieldState, - onSuccess = { - // Return to a freshly-loaded chapter table so the - // saved translation is reflected. - onNavigateToRoute.invoke( - TranslationChapterRoute( + onSuccess = { sessionId -> + // Onto the session rather than back to + // the chapter table. Nothing has been + // translated yet -- the chunk is + // translated when enough members sign -- + // so a table still showing it untranslated + // would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( activeUserPublicKey = activeUserPublicKey, - translationChapterId = translationChapterId, chatRoomId = chatRoomId, - relayHint = relayHint + sessionId = sessionId ) ) }, - onFailure = {} + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed Translation") + ) + } ) } ) { Icon( - Icons.Default.Save, - contentDescription = "Save translation" + Icons.Default.Add, + contentDescription = "Propose translation" ) - Text("Save") + Text("Propose Translation") } } ) @@ -138,6 +186,15 @@ fun TranslateChunkScreen( .padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + if (!translateChunkUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "translation. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + Text( text = "Original", style = MaterialTheme.typography.labelMedium @@ -201,6 +258,16 @@ private fun TranslateChunkScreenPreview() { chatRoomId = "chatRoomId", relayHint = null, initialTranslateChunkUIState = TranslateChunkUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "chatRoomId", + userPublicKey = "", + subject = "Message title", + description = "See something. Say somethin", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ), + ), originalChunk = MantraChunk( id = "chunkId", chapterId = "chapterId", @@ -212,9 +279,13 @@ private fun TranslateChunkScreenPreview() { signature = "", chatRoomId = "chatRoomId" ), - existingTranslationText = "" + existingTranslationText = "", + 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/TranslationChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt index 2a829f6b..8d7a5e18 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt @@ -1,5 +1,6 @@ package press.mantra.compose.ui.composable +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -25,7 +25,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -188,15 +187,13 @@ private fun ChunkTranslationRow( val translated = pair.translationChunk?.text?.takeIf { it.isNotBlank() } TableRow( left = { Text(pair.originalChunk.text) }, + // The whole translation cell opens the chunk translation editor. When + // there is no translation yet, the original text is shown greyed out as + // a placeholder. It stays plain text, laid out like the original cell + // beside it, rather than a button with its own shape and padding. + rightModifier = Modifier.clickable(onClick = onClick), right = { - // The translation cell is a button that opens the chunk translation - // editor. When there is no translation yet, the original text is - // shown greyed out as a placeholder. - TextButton( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(0.dp) - ) { + Row(verticalAlignment = Alignment.CenterVertically) { Text( modifier = Modifier.weight(1f), text = translated ?: pair.originalChunk.text, @@ -220,13 +217,14 @@ private fun ChunkTranslationRow( private fun TableRow( left: @Composable () -> Unit, right: @Composable () -> Unit, + rightModifier: Modifier = Modifier, ) { Row( modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min) ) { Box(modifier = Modifier.weight(1f).padding(12.dp)) { left() } VerticalDivider(modifier = Modifier.fillMaxHeight()) - Box(modifier = Modifier.weight(1f).padding(12.dp)) { right() } + Box(modifier = Modifier.weight(1f).then(rightModifier).padding(12.dp)) { right() } } } 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 6a847d32..c1e74835 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 @@ -991,15 +991,22 @@ fun MantraNavHost( chatRoomId = route.chatRoomId, relayHint = route.relayHint, mantraRepository = databaseMantraRepository, - onNavigateToRoute = { actionRoute -> - // Replace this editor and the stale chapter table beneath it so - // we land on a freshly-loaded table reflecting the saved translation. - navController.navigate(route = actionRoute) { - popUpTo { + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this editor so back returns to the chapter table + // rather than to a form whose proposal has already gone out. + // The table itself is left alone: nothing is translated until + // the group signs, so there is nothing new for it to show. + 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/TranslateChunkViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslateChunkViewModel.kt index e64ce9f5..223e7161 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslateChunkViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslateChunkViewModel.kt @@ -15,6 +15,11 @@ 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.MantraChunk +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.TranslationChunkEvent +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.TranslateChunkUIState @@ -26,6 +31,8 @@ class TranslateChunkViewModel( val relayHint: String?, initialTranslateChunkUIState: TranslateChunkUIState, val mantraRepository: MantraRepository, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, ): ViewModel() { var translateChunkUIState: TranslateChunkUIState by mutableStateOf(initialTranslateChunkUIState) @@ -38,51 +45,93 @@ class TranslateChunkViewModel( fun initiateTranslateChunk() { viewModelScope.launch(Dispatchers.IO) { val originalChunk = mantraRepository.getChunk(chunkId) - translateChunkUIState = if (originalChunk == null) { - TranslateChunkUIState.Error("Couldn't find the chunk") - } else { - val existing = mantraRepository.getTranslationChunks(translationChapterId) - .firstOrNull { it.chunkId == chunkId } - TranslateChunkUIState.Loaded( + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + translateChunkUIState = when { + originalChunk == null -> TranslateChunkUIState.Error("Couldn't find the chunk") + localChatRoom == null -> TranslateChunkUIState.Error("Couldn't find the chat room") + else -> TranslateChunkUIState.Loaded( + localChatRoom = localChatRoom, originalChunk = originalChunk, - existingTranslationText = existing?.text.orEmpty(), + // What the group has already signed for this chunk, so a + // retranslation starts from it rather than from nothing. + existingTranslationText = mantraRepository + .getTranslationChunks(translationChapterId) + .firstOrNull { it.chunkId == chunkId } + ?.text + .orEmpty(), + canSign = frostSigningRepository.canSign(chatRoomId), ) } } } - fun saveTranslation( + /** + * Asks the group to sign this chunk's translation. + * + * The translation is not saved here and does not exist yet. What goes out is + * a proposal to sign it, and the translated chunk appears -- on every + * member's device at once, authored by this room's own key rather than by + * whoever typed it -- when enough members have signed. That author is the + * room's id: signing runs at the path the room was derived at, so a + * translated chunk says which group rendered it 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. What a chunk means in another tongue is a claim the group + * is making about somebody else's words, so the second is the honest one. + * + * A retranslation goes the same way: the event carries a different text and + * so a different id, and it replaces the one the group signed before rather + * than editing it -- there is nothing here to edit, since the old text is + * already signed. + */ + fun proposeTranslation( + localChatRoom: LocalChatRoom, + originalChunk: MantraChunk, translationField: TextFieldState, - onSuccess: () -> Unit, + onSuccess: (sessionId: String) -> Unit, onFailure: () -> Unit ) { val text = translationField.text.toString() if (text.isBlank()) { - viewModelScope.launch(Dispatchers.Main) { - onFailure.invoke() - } + onFailure.invoke() return } + // Guard against double submits from repeated FAB taps. if (isActionPending.value) return isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - val saved = runCatching { - mantraRepository.saveTranslationChunk( - translationChapterId = translationChapterId, - chunkId = chunkId, - text = text, - chatRoomId = chatRoomId, + val translationChunkEventTemplate = TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = originalChunk.id, + // The translation sits where the chunk it translates sits. Read + // off the source rather than counted here: a chapter is + // translated a chunk at a time and in no particular order, so + // counting what is translated so far would number them by who + // got there first. + index = originalChunk.index, + text = text, + ) + + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, userPublicKey = activeUserPublicKey, + kind = translationChunkEventTemplate.kind, + tags = translationChunkEventTemplate.tags, + content = translationChunkEventTemplate.content, ) }.onFailure { error -> - logger.e("Failed to save translation", error) + logger.e("Failed to propose a translation for signing", error) }.getOrNull() - if (saved != null) { + if (session != null) { viewModelScope.launch(Dispatchers.Main) { - onSuccess.invoke() + onSuccess.invoke(session.id) } } else { viewModelScope.launch(Dispatchers.Main) { @@ -105,6 +154,8 @@ class TranslateChunkViewModel( relayHint: String?, initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { TranslateChunkViewModel( @@ -115,6 +166,8 @@ class TranslateChunkViewModel( relayHint = relayHint, initialTranslateChunkUIState = initialTranslateChunkUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslateChunkUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslateChunkUIState.kt index 027fa3be..55536981 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslateChunkUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslateChunkUIState.kt @@ -1,11 +1,20 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.MantraChunk +import press.mantra.compose.database.model.intermdiate.LocalChatRoom sealed interface TranslateChunkUIState { data class Loaded( + val localChatRoom: LocalChatRoom, val originalChunk: MantraChunk, val existingTranslationText: String = "", + + /** + * Whether the group holds a shared key. A translation is signed into + * existence now rather than saved, so a group without one cannot + * translate a chunk here at all. + */ + val canSign: Boolean = false, ): TranslateChunkUIState data class Error( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/TranslationChunkApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/TranslationChunkApplyJvmTest.kt new file mode 100644 index 00000000..a9123938 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/TranslationChunkApplyJvmTest.kt @@ -0,0 +1,301 @@ +package press.mantra.compose.database.model + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.nostr.nip30303.TranslationChunkEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * What happens when a chunk is translated twice. + * + * A translation chunk is not edited. Retranslating a passage means proposing a + * new event carrying new words, and an event's id is a hash over its content -- + * so the second translation arrives as a row of its own rather than as an + * overwrite of the first. Left alone, the chapter would hold two answers to + * what one passage says, and the table pairing chunks with their translations + * would show whichever the query reached first. + * + * The rule this asserts is that the newest one wins, decided by the timestamp + * the group signed at rather than by arrival: two devices catching up read the + * same events in whatever order the relay hands them over, and they have to end + * up holding the same translation either way. + */ +class TranslationChunkApplyJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val author = "a".repeat(64) + private val roomId = "b".repeat(64) + private val chunkId = "c".repeat(64) + private val translationChapterId = "d".repeat(64) + private val signature = "0".repeat(128) + + /** + * Everything a translation chunk hangs off, seeded as already signed: the + * room, the artifact it belongs to, the chapter and chunk it translates, and + * the translation chapter it goes into. None of it is what this test is + * about; they are the foreign keys in the way. + */ + private suspend fun seed() { + val nostrEventId = "e".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = author, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = signature, + ) + ) + db.profileDao().upsert(Profile(publicKey = author, nostrEventId = nostrEventId)) + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + userPublicKey = author, + subject = "a translation room", + description = null, + mlsGroupState = null, + ) + ) + + val dialectId = "1".repeat(64) + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Sesotho", + country = "ZA", + language = "st", + signature = signature, + chatRoomId = roomId, + ) + ) + + val artifactId = "2".repeat(64) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = signature, + ) + ) + + val artifactVersionId = "3".repeat(64) + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + signature = signature, + chatRoomId = roomId, + ) + ) + + val chapterId = "4".repeat(64) + db.mantraChapterDao().upsert( + MantraChapter( + id = chapterId, + artifactVersionId = artifactVersionId, + publicKey = roomId, + name = "Chapter 1", + originalText = "When he was nearly thirteen.", + index = 0, + wordCount = 5, + characterCount = 28, + signature = signature, + chatRoomId = roomId, + ) + ) + + db.mantraChunkDao().upsert( + MantraChunk( + id = chunkId, + chapterId = chapterId, + publicKey = roomId, + text = "When he was nearly thirteen.", + index = 0, + wordCount = 5, + characterCount = 28, + signature = signature, + chatRoomId = roomId, + ) + ) + + val translationId = "5".repeat(64) + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = translationId, + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Sesotho", + visibility = "private", + license = "cc", + signature = signature, + chatRoomId = roomId, + ) + ) + + db.mantraTranslationChapterDao().upsert( + MantraTranslationChapter( + id = translationChapterId, + publicKey = roomId, + chapterId = chapterId, + translationArtifactVersionId = translationId, + index = 0, + signature = signature, + chatRoomId = roomId, + ) + ) + } + + /** A translation of the seeded chunk, signed by the room as the group would. */ + private fun translation(text: String, createdAt: Long): Event { + val template = TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = chunkId, + index = 0, + text = text, + createdAt = createdAt, + ) + + return Event( + id = EventHasher.hashId( + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = signature, + ) + } + + private suspend fun apply(event: Event) = ChatMessage.applyInnerEvent( + database = db, + groupId = roomId, + event = event, + marmotGroupEventId = null, + marmotInnerEventId = null, + senderPublicKey = roomId, + isUserMessage = false, + createdAt = Instant.fromEpochSeconds(event.createdAt), + ) + + private suspend fun translations() = db.mantraTranslationChunkDao() + .getTranslationChunksByTranslationChapterId(translationChapterId) + + @Test + fun `a retranslation replaces the translation it supersedes`() = runBlocking { + seed() + + val signedAt = Clock.System.now().epochSeconds + apply(translation("Ha a le hantle lilemo li leshome le metso e meraro.", signedAt)) + apply(translation("Ha a batla ho ba lilemo li leshome le metso e meraro.", signedAt + 60)) + + // Both are valid signatures of the group's over the same passage, and + // the chapter has room for one of them. + val kept = translations().single() + assertEquals("Ha a batla ho ba lilemo li leshome le metso e meraro.", kept.text) + } + + @Test + fun `a translation arriving after the one that superseded it is dropped`() = runBlocking { + seed() + + val signedAt = Clock.System.now().epochSeconds + val first = translation("Ha a le hantle lilemo li leshome le metso e meraro.", signedAt) + val second = translation("Ha a batla ho ba lilemo li leshome le metso e meraro.", signedAt + 60) + + // The order the relay happened to hand them over in, which is not the + // order the group signed them in. A device catching up has to reach the + // same chapter as one that saw them the other way round. + apply(second) + apply(first) + + val kept = translations().single() + assertEquals(second.id, kept.id) + } + + @Test + fun `two chunks of the same chapter each keep their translation`() = runBlocking { + seed() + + // The rule is one translation per source chunk, not one per chapter. + // Matching on the chapter alone would leave a chapter that can only ever + // hold its most recently translated passage. + val otherChunkId = "f".repeat(64) + db.mantraChunkDao().upsert( + MantraChunk( + id = otherChunkId, + chapterId = "4".repeat(64), + publicKey = roomId, + text = "My brother Jem got his arm badly broken.", + index = 1, + wordCount = 8, + characterCount = 40, + signature = signature, + chatRoomId = roomId, + ) + ) + + val signedAt = Clock.System.now().epochSeconds + apply(translation("Ha a le hantle lilemo li leshome le metso e meraro.", signedAt)) + + val otherTemplate = TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = otherChunkId, + index = 1, + text = "Moholoane oa ka Jem o ile a robeha letsoho.", + createdAt = signedAt + 60, + ) + apply( + Event( + id = EventHasher.hashId( + pubKey = roomId, + createdAt = otherTemplate.createdAt, + kind = otherTemplate.kind, + tags = otherTemplate.tags, + content = otherTemplate.content, + ), + pubKey = roomId, + createdAt = otherTemplate.createdAt, + kind = otherTemplate.kind, + tags = otherTemplate.tags, + content = otherTemplate.content, + sig = signature, + ) + ) + + assertEquals( + listOf(chunkId, otherChunkId), + translations().map { it.chunkId }, + ) + } +}