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 7d551150..f7fba019 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -8,6 +8,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. @@ -96,6 +97,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/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(