diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt index 62fb0d05..84c89849 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt @@ -3,6 +3,7 @@ package press.mantra.compose.managers import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.nostr.nip30303.TranslationChapterEvent /** @@ -15,9 +16,10 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent * * Both ends of that join arrive on their own schedule -- a chapter is signed * into an artifact that already has translations, a translation is started on - * an artifact that already has chapters -- so the same scaffolding has to be - * built from either side. One function for both, because two copies of a cross - * product is two chances to disagree about what a translation covers. + * an artifact that already has chapters -- so the same scaffolding is built + * from either side and, when the two miss each other, afterwards. One function + * for all three, because three copies of a cross product is three chances to + * disagree about what a translation covers. */ object TranslationScaffold { @@ -62,13 +64,29 @@ object TranslationScaffold { TranslationChapterEvent.build( translationArtifactVersionId = translationArtifactVersionId, chapterId = chapter.id, - // The source chapter's own position, never this list's: - // a caller may hold a subset, and counting the list would - // renumber the chapters it happens to be holding. + // The source chapter's own position, never this list's: a + // catch-up proposes a subset, so counting the list would + // renumber the chapters it happens to be filling in. index = chapter.index, createdAt = createdAt, ) } } + /** + * The source chapters a translation has nothing for yet. + * + * Matched on the source chapter each translation chapter names rather than + * on how many there are of each. A translation that is missing its second + * chapter and has picked up its third is not a translation that is missing + * its last one, and a count would say it was. + */ + fun chaptersMissingFrom( + translationChapters: List, + sourceChapters: List, + ): List { + val covered = translationChapters.mapTo(mutableSetOf()) { it.chapterId } + + return sourceChapters.filterNot { it.id in covered } + } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index c93680c6..2bb9427b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -22,6 +23,7 @@ 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -33,7 +35,10 @@ 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.MantraTranslationArtifactVersion +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.Route import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.widgets.DetailRow @@ -51,6 +56,8 @@ fun TranslationArtifactVersionDetailScreen( relayHint: String?, initialTranslationArtifactVersionDetailUIState: TranslationArtifactVersionDetailUIState = TranslationArtifactVersionDetailUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -62,6 +69,8 @@ fun TranslationArtifactVersionDetailScreen( relayHint = relayHint, initialTranslationArtifactVersionDetailUIState = initialTranslationArtifactVersionDetailUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) ) @@ -97,6 +106,75 @@ fun TranslationArtifactVersionDetailScreen( modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + // Chapters the source version has and this translation + // does not. Above the list rather than after it: a chapter + // that is not here is invisible from a list of the ones + // that are. + val localChatRoom = translationDetailUIState.localChatRoom + val missingChapters = translationDetailUIState.missingChapters + + if (missingChapters.isNotEmpty()) { + item { + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.PlaylistAdd, + contentDescription = "Chapters missing from this translation" + ) + }, + headlineContent = { + Text( + "${missingChapters.size} " + + (if (missingChapters.size == 1) "chapter is" else "chapters are") + + " not in this translation yet" + ) + }, + supportingContent = { + Text( + if (!translationDetailUIState.canSign) { + "This group has no shared key, so it cannot sign them in." + } else { + "They were signed into the artifact after this " + + "translation. Ask the group to add them so they " + + "can be translated." + } + ) + }, + trailingContent = { + TextButton( + enabled = localChatRoom != null && + translationDetailUIState.canSign && + !translationDetailViewModel.isActionPending.value, + onClick = { + if (localChatRoom == null) return@TextButton + + translationDetailViewModel.catchUpMissingChapters( + localChatRoom = localChatRoom, + missingChapters = missingChapters, + onSuccess = { sessionId -> + onNavigateToRoute.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = {} + ) + } + ) { + Text("Propose") + } + } + ) + } + } + + item { HorizontalDivider() } + } + // Chapters with translation progress item { Text( @@ -212,6 +290,8 @@ private fun TranslationArtifactVersionDetailScreenPreview() { ) ), mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, 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 be061fcf..e0ada82a 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 @@ -932,6 +932,8 @@ fun MantraNavHost( chatRoomId = route.chatRoomId, relayHint = route.relayHint, mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, onNavigateToRoute = { actionRoute -> navController.navigate( route = actionRoute diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt index 3e0acde8..d959e224 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt @@ -1,5 +1,6 @@ package press.mantra.compose.ui.view.model +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -10,9 +11,16 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.managers.TranslationScaffold +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.TranslationChapterProgress import press.mantra.compose.ui.view.state.TranslationArtifactVersionDetailUIState @@ -24,6 +32,8 @@ class TranslationArtifactVersionDetailViewModel( val relayHint: String?, initialTranslationArtifactVersionDetailUIState: TranslationArtifactVersionDetailUIState, val mantraRepository: MantraRepository, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, ): ViewModel() { var translationDetailUIState: TranslationArtifactVersionDetailUIState by mutableStateOf(initialTranslationArtifactVersionDetailUIState) @@ -31,6 +41,8 @@ class TranslationArtifactVersionDetailViewModel( private val logger = Logger.withTag(TAG) + val isActionPending: MutableState = mutableStateOf(false) + fun initiateTranslationArtifactVersionDetail() { logger.d("initiateTranslationArtifactVersionDetail: $translationArtifactVersionId") viewModelScope.launch(Dispatchers.IO) { @@ -39,23 +51,97 @@ class TranslationArtifactVersionDetailViewModel( translationDetailUIState = if (translation == null) { TranslationArtifactVersionDetailUIState.Error("Couldn't find the translation") } else { - val chapters = mantraRepository.getTranslationChapters(translationArtifactVersionId) - .map { chapter -> - val chunks = mantraRepository.getTranslationChunks(chapter.id) - TranslationChapterProgress( - chapter = chapter, - totalChunks = chunks.size, - translatedChunks = chunks.count { it.text.isNotBlank() }, - ) - } + val translationChapters = mantraRepository.getTranslationChapters(translationArtifactVersionId) + val chapters = translationChapters.map { chapter -> + val chunks = mantraRepository.getTranslationChunks(chapter.id) + TranslationChapterProgress( + chapter = chapter, + totalChunks = chunks.size, + translatedChunks = chunks.count { it.text.isNotBlank() }, + ) + } + TranslationArtifactVersionDetailUIState.Loaded( translation = translation, chapters = chapters, + localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId), + missingChapters = TranslationScaffold.chaptersMissingFrom( + translationChapters = translationChapters, + sourceChapters = mantraRepository + .getChaptersForArtifactVersion(translation.artifactVersionId), + ), + canSign = frostSigningRepository.canSign(chatRoomId), ) } } } + /** + * Asks the group to put the chapters this translation is missing into it. + * + * The scaffolding a chapter is signed with covers the translations that + * existed when it was proposed, and the scaffolding a translation is signed + * with covers the chapters that existed when *it* was proposed. Two things + * proposed at the same moment see neither the other, and a session that + * fails to reach a quorum leaves nothing behind to try again with. Neither + * is a bug that can be designed out of a snapshot taken at proposal time -- + * so this is the pass that reconciles them, run when somebody notices. + * + * It proposes only what is missing, matched on the source chapter each + * translation chapter names, so running it on a translation that is already + * complete proposes nothing rather than a second copy of everything. + */ + fun catchUpMissingChapters( + localChatRoom: LocalChatRoom, + missingChapters: List, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + if (missingChapters.isEmpty()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + // One timestamp for the lot, so a catch-up reads as the one act it + // is rather than as chapters that happen to share a minute. + val createdAt = TimeUtils.now() + + val sessions = runCatching { + // One session per MAX_BATCH_SIZE chapters. A translation far + // enough behind to need more than a batch holds is not one to + // refuse; it is one the group answers for more than once. + missingChapters.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { batch -> + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = TranslationScaffold.chaptersOf( + translationArtifactVersionIds = listOf(translationArtifactVersionId), + chapters = batch.map(TranslationScaffold.SourceChapter::of), + createdAt = createdAt, + ) + ) + } + }.onFailure { error -> + logger.e("Failed to propose this translation's missing chapters", error) + }.getOrNull().orEmpty() + + // The first, because it is the one that opened; the rest are in the + // room's session list beside it. + val session = sessions.firstOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) onSuccess.invoke(session.id) else onFailure.invoke() + } + + isActionPending.value = false + } + } + companion object { private const val TAG = "TranslationArtifactVersionDetailViewModel" @@ -66,6 +152,8 @@ class TranslationArtifactVersionDetailViewModel( relayHint: String?, initialTranslationArtifactVersionDetailUIState: TranslationArtifactVersionDetailUIState = TranslationArtifactVersionDetailUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { TranslationArtifactVersionDetailViewModel( @@ -75,6 +163,8 @@ class TranslationArtifactVersionDetailViewModel( relayHint = relayHint, initialTranslationArtifactVersionDetailUIState = initialTranslationArtifactVersionDetailUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt index 94204fc8..af891f2c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt @@ -1,7 +1,9 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationChapter +import press.mantra.compose.database.model.intermdiate.LocalChatRoom /** A translation chapter with how many of its chunks have been translated. */ data class TranslationChapterProgress( @@ -14,6 +16,28 @@ sealed interface TranslationArtifactVersionDetailUIState { data class Loaded( val translation: MantraTranslationArtifactVersion, val chapters: List = emptyList(), + + /** + * The room this translation belongs to, which is who would sign the + * chapters it is missing. Null when it cannot be read, which is a + * translation nothing can be proposed for. + */ + val localChatRoom: LocalChatRoom? = null, + + /** + * Chapters of the source version this translation has nothing for. + * + * A translation covers the chapters that existed when it was proposed, + * and a chapter signed afterwards is scaffolded into it by a session of + * that chapter's own -- which the two can miss, by being proposed at the + * same moment or by one of them not reaching a quorum. This is what is + * left over when they do, and it is shown rather than inferred because + * a chapter nobody can translate is invisible from the translation. + */ + val missingChapters: List = emptyList(), + + /** Whether the group holds a shared key, without which nothing can be proposed. */ + val canSign: Boolean = false, ): TranslationArtifactVersionDetailUIState data class Error( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt index 83ef6e38..3beec9f7 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag import press.mantra.compose.nostr.nip30303.tags.IndexTag @@ -12,10 +13,11 @@ import press.mantra.compose.nostr.nip30303.tags.TranslationArtifactVersionIdTag /** * The join between a translation and the chapters it is of, as a pure function. * - * Both ends arrive on their own schedule, so this is built from two - * directions -- a translation being proposed, and a chapter being proposed. - * What it must not do is disagree with itself about what a translation covers - * depending on which direction it was built from. + * Both ends arrive on their own schedule, so this is built from three + * directions -- a translation being proposed, a chapter being proposed, and a + * catch-up filling in what the two missed. What it must not do is disagree with + * itself about what a translation covers depending on which direction it was + * built from. */ class TranslationScaffoldTest { @@ -35,6 +37,17 @@ class TranslationScaffoldTest { chatRoomId = "r".repeat(64), ) + private fun translationChapterFor(chapter: MantraChapter, translationId: String) = + MantraTranslationChapter( + id = "t${chapter.id}".take(64), + publicKey = "p".repeat(64), + chapterId = chapter.id, + translationArtifactVersionId = translationId, + index = chapter.index, + signature = "", + chatRoomId = "r".repeat(64), + ) + private fun parse(templates: List>) = templates.map { template -> Triple( @@ -90,10 +103,10 @@ class TranslationScaffoldTest { } @Test - fun `a chapter keeps its own position, not its position in the list`() { + fun `a catch-up keeps each chapter's own position, not the position in the gap`() { val chapters = (0..5).map(::sourceChapter) - // A caller may hold a subset, and the subset is not contiguous. + // A catch-up proposes a subset, and the subset is not contiguous. // Counting the list would renumber chapters 3 and 5 as 1 and 2, which // is a translation that reads in a different order than the work. val templates = TranslationScaffold.chaptersOf( @@ -136,4 +149,35 @@ class TranslationScaffoldTest { ).isEmpty() ) } + + @Test + fun `what is missing is matched on the chapter named, not on how many there are`() { + val chapters = (0..3).map(::sourceChapter) + + // Chapter 1 never landed but chapter 2 did, which a count would read as + // "the last one is missing" and scaffold the wrong chapter. + val missing = TranslationScaffold.chaptersMissingFrom( + translationChapters = listOf(chapters[0], chapters[2]).map { + translationChapterFor(it, translationA) + }, + sourceChapters = chapters, + ) + + assertEquals(listOf(chapters[1].id, chapters[3].id), missing.map { it.id }) + } + + @Test + fun `a translation that is already complete is missing nothing`() { + val chapters = (0..2).map(::sourceChapter) + + // So that running a catch-up twice proposes nothing the second time, + // rather than a second copy of every chapter under fresh ids. + assertEquals( + emptyList(), + TranslationScaffold.chaptersMissingFrom( + translationChapters = chapters.map { translationChapterFor(it, translationA) }, + sourceChapters = chapters, + ) + ) + } }