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 9262005a..19815aba 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 @@ -859,22 +859,12 @@ data class ChatMessage( ) ) - // An artifact arrives with the version it starts life - // with, derived here rather than sent, so that every - // device holding the artifact holds the same first - // version. Nothing else can hang off an artifact until - // one exists -- a chapter attaches to a version, not to - // an artifact -- so an artifact without one is inert. - MantraArtifactVersion.initialVersionOf( - artifactEvent = artifactEvent, - chatRoomId = groupId, - )?.let { initialVersion -> - database.mantraArtifactVersionDao().upsert( - initialVersion.copy( - marmotGroupEventId = marmotGroupEventId, - ) - ) - } + // The version the artifact starts life with is its own + // event, signed in the same batch and applied after this + // one -- see AddArtifactViewModel.addArtifact. It used to + // be derived here instead; deriving it now as well would + // stand a second, unsigned version row against the same + // artifact, since the two hash differently. ChatMessage( giftWrapPayloadId = null, @@ -907,18 +897,15 @@ data class ChatMessage( ) ) - ChatMessage( - giftWrapPayloadId = null, - messageType = "artifactVersion", - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name... - ) + // No line of its own. A version arrives as the second + // item of the batch that carries its artifact, and the + // artifact has already said so -- the same reason a + // chunk writes no line beside its chapter. While the + // version was derived here rather than signed it wrote + // none either, so this is the transcript standing still + // rather than losing something. } + null } ChapterEvent.KIND -> { // The chunks the chapter splits into are their own events, @@ -1078,13 +1065,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/model/MantraArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt index 6cd5c209..f9d011f1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt @@ -10,10 +10,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity import press.mantra.compose.database.model.traits.TimestampedEntity -import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag -import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag import kotlin.time.Clock import kotlin.time.Instant @@ -84,42 +82,6 @@ data class MantraArtifactVersion( } companion object { - /** - * The version an artifact starts life with, derived from the artifact. - * - * The group signs an artifact; it does not sign this. So the first - * version cannot be an event proposed on its own -- that would cost a - * second quorum for one form -- and it cannot be invented by whichever - * device notices the artifact first, because an invented id differs on - * every device holding the same artifact and none of them would agree - * about which version a chapter hangs off. Deriving it from the signed - * artifact's own fields gives every device the same row from the same - * bytes, which is the only property that matters here. - * - * It is a rumor -- empty signature -- because nobody signed it. What the - * group signed is the artifact that declares it. - * - * Null when the artifact declares no version, which is every artifact - * written before it did. - */ - fun initialVersionOf( - artifactEvent: ArtifactEvent, - chatRoomId: HexKey, - ): MantraArtifactVersion? { - val versionLabel = artifactEvent.versionLabel() ?: return null - - return fromArtifactVersionEventTemplate( - artifactVersionEventTemplate = ArtifactVersionEvent.build( - content = versionLabel, - createdAt = artifactEvent.createdAt, - ) { - addUnique(ArtifactIdTag.assemble(artifactEvent.id)) - }, - chatRoomId = chatRoomId, - userPublicKey = artifactEvent.pubKey, - ) - } - fun fromArtifactVersionEventTemplate( artifactVersionEventTemplate: EventTemplate, chatRoomId: HexKey, 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/nostr/nip30303/ArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt index ca052f83..7268c87c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt @@ -32,6 +33,47 @@ class ArtifactVersionEvent( const val KIND = 30301 const val ALT_DESCRIPTION = "ArtifactVersion" + /** + * The first version [artifact] declares, ready to be signed with it. + * + * An artifact and the version it starts life with go to the group as one + * batch, so this is built from the artifact *after* it has been authored + * under the group's key -- [artifact] is the unsigned event the session + * will sign, which is where the id this carries comes from. Building it + * anywhere else would mean naming an artifact id before one exists. + * + * It takes the artifact's own timestamp, so the batch reads as one act + * rather than two events that happen to share a session. + * + * This used to be derived on arrival instead, from the label the + * artifact carries -- the same shape a chapter's chunks were in, and + * abandoned for the same reason. Deriving cost nothing while the + * alternative was a second quorum, and nothing is what it bought: a row + * naming the group as its author with no signature to show for it, which + * a chapter then hangs off. A batch is one quorum, so the version can + * carry the group's signature over its own label. + * + * Empty when the artifact declares no version -- every artifact written + * before it did -- which leaves a batch of one and an artifact with no + * version, exactly as before. + */ + fun initialVersionOf(artifact: Event): List> { + val versionLabel = ArtifactEvent( + id = artifact.id, + pubKey = artifact.pubKey, + createdAt = artifact.createdAt, + tags = artifact.tags, + content = artifact.content, + sig = artifact.sig + ).versionLabel() ?: return emptyList() + + return listOf( + build(content = versionLabel, createdAt = artifact.createdAt) { + addUnique(ArtifactIdTag.assemble(artifact.id)) + } + ) + } + fun build( content: String, createdAt: Long = TimeUtils.now(), 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 7d551150..e1bb1990 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -4,10 +4,12 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent 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. @@ -52,6 +54,15 @@ object ProposedEvent { } ) + // Signed alongside the artifact it belongs to rather than on its own, + // so this is almost always read as the second line of a batch of two. + // Named after the artifact rather than the label, because the label is + // the whole of the content and would otherwise be said twice. + ArtifactVersionEvent.KIND -> Summary( + label = "Version of the artifact", + detail = event.content.ifBlank { "Unlabelled" } + ) + ChapterEvent.KIND -> Summary( label = "New chapter", detail = ChapterEvent( @@ -96,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/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index b7e20a84..e576fd52 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.view.state.AddArtifactUIState @@ -75,6 +76,10 @@ class AddArtifactViewModel( * 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 library is the group's, so the second is the honest one. + * + * Two events, one session: the artifact and the version it starts life with, + * which a chapter later hangs off. All-or-nothing, which is right here -- + * an artifact with no version is inert, since nothing can attach to it. */ fun addArtifact( localChatRoom: LocalChatRoom, @@ -103,10 +108,12 @@ class AddArtifactViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - // The version label rides on the artifact rather than following it as - // a second event. The group signs the artifact; a first version - // proposed on its own would cost a second quorum for one form, and - // every device derives the same first version from what was signed. + // The label rides on the artifact and the version is signed beside + // it, in the same batch. A first version proposed on its own would + // cost a second quorum for one form, which is why it used to be + // derived on arrival instead; a batch costs one quorum, so the row a + // chapter hangs off can carry the group's signature rather than + // being rebuilt from the artifact by every device that holds it. val artifactEventTemplate = ArtifactEvent.build( name = name, url = url, @@ -117,12 +124,15 @@ class AddArtifactViewModel( ) val session = runCatching { - frostSigningRepository.proposeSigning( + frostSigningRepository.proposeSigningBatch( localChatRoom = localChatRoom, userPublicKey = activeUserPublicKey, - kind = artifactEventTemplate.kind, - tags = artifactEventTemplate.tags, - content = artifactEventTemplate.content, + lead = artifactEventTemplate, + // The version names the artifact it is of, 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 artifact comes back + // built, and the version is read off the label it declares. + dependents = ArtifactVersionEvent::initialVersionOf ) }.onFailure { error -> logger.e("Failed to propose an artifact for signing", error) 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/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt deleted file mode 100644 index c59e65ff..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt +++ /dev/null @@ -1,144 +0,0 @@ -package press.mantra.compose.database.model - -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import press.mantra.compose.nostr.nip30303.ArtifactEvent -import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag - -/** - * The first version of an artifact is derived, not delivered. - * - * The group signs an artifact and nothing else, so the version it starts life - * with is not an event anybody sent: every device builds the row for itself out - * of the artifact it already holds. That only works while every device builds - * the *same* row, and nothing about the ids would show it if they stopped — - * they are content hashes, opaque hex either way. What would show is a group - * that quietly disagrees about which version a chapter hangs off, with the - * artifact looking identical on every screen. - */ -class InitialArtifactVersionTest { - private val groupKey = "a".repeat(64) - private val dialectId = "b".repeat(64) - private val chatRoomId = "room" - - private fun signedArtifact( - name: String = "In Detention", - versionLabel: String = "1.0", - createdAt: Long = 1_700_000_000, - ): ArtifactEvent { - val template = ArtifactEvent.build( - name = name, - url = "example.com", - visibility = "private", - license = "cc", - dialectId = dialectId, - versionLabel = versionLabel, - createdAt = createdAt, - ) - - // Hashed rather than made up, so two fixtures that differ are two - // different artifacts here for the same reason they would be in the app. - return ArtifactEvent( - id = EventHasher.hashId( - pubKey = groupKey, - createdAt = template.createdAt, - kind = template.kind, - tags = template.tags, - content = template.content - ), - pubKey = groupKey, - createdAt = template.createdAt, - tags = template.tags, - content = template.content, - sig = "d".repeat(128) - ) - } - - @Test - fun `the derived version is a function of the artifact and nothing else`() { - // Every input has to come off the artifact. Reading the clock here would - // still agree with itself twice in a row -- and disagree between two - // devices that applied the same artifact minutes apart, which is the - // case nobody can reproduce on demand. So the timestamp is checked - // against the artifact's rather than against a second derivation. - val artifact = signedArtifact(createdAt = 1_700_000_000) - - val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) - - assertNotNull(version) - assertEquals(1_700_000_000, version.createdAt.epochSeconds) - } - - @Test - fun `two devices derive the same first version from the same artifact`() { - val artifact = signedArtifact() - - val mine = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) - val theirs = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) - - assertNotNull(mine) - assertEquals(mine.id, theirs?.id) - assertEquals(mine.createdAt, theirs?.createdAt) - } - - @Test - fun `an artifact signed at a different moment derives a different version`() { - // The artifact's own timestamp is bound into the derived id, so two - // proposals identical but for when they were made stay two artifacts - // with two first versions rather than colliding on one row. - val first = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_000), chatRoomId) - val second = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_001), chatRoomId) - - assertNotNull(first) - assertNotNull(second) - assertNotEquals(first.id, second.id) - } - - @Test - fun `the derived version hangs off the artifact and carries what it declared`() { - val artifact = signedArtifact(versionLabel = "First Edition") - - val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) - - assertEquals(artifact.id, version?.artifactId) - assertEquals("First Edition", version?.versionLabel) - // Authored by whoever authored the artifact -- the group, once signed -- - // and unsigned, because nobody signed this. - assertEquals(groupKey, version?.publicKey) - assertEquals("", version?.signature) - } - - @Test - fun `the label is bound into the id rather than hung beside it`() { - // Two artifacts alike but for the label must not derive one version - // between them: the id has to come from the whole event, or a group - // renaming a version would leave the row it replaces in place. - val first = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "1.0"), chatRoomId) - val second = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "2.0"), chatRoomId) - - assertNotNull(first) - assertNotNull(second) - assertNotEquals(first.id, second.id) - } - - @Test - fun `an artifact that declares no version derives none`() { - // Artifacts written before the artifact carried its first version. - val declared = signedArtifact() - val silent = ArtifactEvent( - id = declared.id, - pubKey = declared.pubKey, - createdAt = declared.createdAt, - tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } - .toTypedArray(), - content = declared.content, - sig = declared.sig - ) - - assertNull(MantraArtifactVersion.initialVersionOf(silent, chatRoomId)) - } -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt index 6aeea827..5cccb1dc 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -23,6 +23,7 @@ import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent /** * An artifact the group signed, from proposal to rows, against real FROST. @@ -227,21 +228,70 @@ class SignedArtifactTest { assertEquals(dialectId, artifact?.dialectId) } + /** The second item of the batch, through the same quorum as the first. */ + private fun signedInitialVersionEvent(versionLabel: String = "1.0"): ArtifactVersionEvent { + // Built from the unsigned lead, which is what `proposeSigningBatch` hands + // to `dependents` -- the artifact's id is settled at proposal, not at + // signature, which is the only reason a version can name it at all. + val lead = unsignedEventOf(proposalTemplate(versionLabel)) + val item = itemOver(unsignedEventOf(ArtifactVersionEvent.initialVersionOf(lead).single())) + val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) + + return ArtifactVersionEvent( + signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig + ) + } + @Test fun `the artifact arrives with the first version hanging off it`() { - // Nothing sends this row: each device derives it from the artifact it - // just applied. A chapter attaches to a version rather than to an - // artifact, so an artifact that arrives without one is inert. - val signed = signedArtifactEvent(versionLabel = "First Edition") - - val artifact = MantraArtifact.fromArtifactEvent(signed, chatRoomId) - val version = MantraArtifactVersion.initialVersionOf(signed, chatRoomId) + // A chapter attaches to a version rather than to an artifact, so an + // artifact that arrives without one is inert. Both come out of one + // batch, so neither can arrive without the other. + val artifact = MantraArtifact.fromArtifactEvent( + signedArtifactEvent(versionLabel = "First Edition"), chatRoomId + ) + val version = MantraArtifactVersion.fromArtifactVersionEvent( + signedInitialVersionEvent(versionLabel = "First Edition"), chatRoomId + ) assertNotNull(version) assertEquals(artifact?.id, version.artifactId) assertEquals("First Edition", version.versionLabel) assertEquals(groupPubKey, version.publicKey) - // Derived, not signed: the group signed the artifact that declares it. - assertEquals("", version.signature) + } + + @Test + fun `the first version carries the group's signature over its own id`() { + // The point of signing it rather than deriving it. A derived version was + // authored by the group and signed by nobody -- indistinguishable, on the + // row, from one somebody made up, and the parent of every chapter in it. + val version = MantraArtifactVersion.fromArtifactVersionEvent( + signedInitialVersionEvent(), chatRoomId + ) + + assertNotNull(version) + assertNotEquals("", version.signature) + assertTrue( + Nip01Crypto.verify( + signature = version.signature.hexToByteArray(), + hash = version.id.hexToByteArray(), + pubKey = version.publicKey.hexToByteArray() + ), + "a first version should carry a signature the group's key made over its own id" + ) + } + + @Test + fun `the version is a second message, signed under its own nonce`() { + // Two items of a batch are two independent FROST instances: k signatures + // over k ids, never one signature stretched over both. A version whose + // signature verified against the artifact's id would mean a nonce had + // been reused, which is how a share is extracted rather than a cosmetic + // mix-up. + val artifact = signedArtifactEvent() + val version = signedInitialVersionEvent() + + assertNotEquals(artifact.id, version.id) + assertNotEquals(artifact.sig, version.sig) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/InitialArtifactVersionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/InitialArtifactVersionTest.kt new file mode 100644 index 00000000..258c94a7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/InitialArtifactVersionTest.kt @@ -0,0 +1,160 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag + +/** + * The version an artifact starts life with, built to be signed beside it. + * + * It used to be derived on arrival from the label the artifact carries, because + * proposing it on its own would have cost a second quorum for one form. A batch + * costs one quorum, so it is signed now -- and a chapter hangs off this row, + * which is the reason to care: a version nobody signed is a parent vouched for + * by its own signed children rather than the other way round. + * + * What has to hold is that every input comes off the artifact. The id is not + * settled here -- the signing session hashes it under the group's key -- so an + * input read from anywhere else would not show up as a wrong row. It would show + * up as two devices proposing two different batches, which reads as a batch that + * simply never aggregates. + */ +class InitialArtifactVersionTest { + private val groupKey = "a".repeat(64) + private val dialectId = "b".repeat(64) + + /** + * The artifact as the session will sign it: the proposer's fields re-authored + * under the group's key. This is what `proposeSigningBatch` hands to + * `dependents`, so it is what the version has to be built from. + */ + private fun leadArtifact( + versionLabel: String = "1.0", + createdAt: Long = 1_700_000_000, + ): Event { + val template = ArtifactEvent.build( + name = "In Detention", + url = "example.com", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = createdAt, + ) + + return Event( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "" + ) + } + + /** The version item as the session would author it, so the accessors read. */ + private fun versionOf(artifact: Event): ArtifactVersionEvent? = + ArtifactVersionEvent.initialVersionOf(artifact).singleOrNull()?.let { template -> + ArtifactVersionEvent( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupKey, + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "" + ) + } + + @Test + fun `the version names the artifact it was built from`() { + // The whole reason this takes the lead event rather than a form's fields: + // the id it carries is a hash over the group's key at the room's path, so + // there is no artifact to name until the proposal has been authored. + val artifact = leadArtifact() + + assertEquals(artifact.id, versionOf(artifact)?.artifactId()) + } + + @Test + fun `it carries the label the artifact declared`() { + val artifact = leadArtifact(versionLabel = "First Edition") + + assertEquals("First Edition", versionOf(artifact)?.content) + } + + @Test + fun `it takes the artifact's own timestamp rather than the clock`() { + // So the batch reads as one act. Reading the clock here would agree with + // itself on one device and disagree between two that proposed the same + // artifact a second apart -- which is a batch nobody can aggregate, and + // the case nobody can reproduce on demand. + val artifact = leadArtifact(createdAt = 1_700_000_042) + + assertEquals(1_700_000_042, versionOf(artifact)?.createdAt) + } + + @Test + fun `an artifact declares exactly one first version`() { + // Item 0 is the artifact and item 1 is this, and a chapter hangs off + // whichever version it finds. Two would make that a coin toss. + assertEquals(1, ArtifactVersionEvent.initialVersionOf(leadArtifact()).size) + } + + @Test + fun `two devices build the same version from the same artifact`() { + // Every signer authors the batch itself and signs the ids it arrives at. + // Devices that disagree here produce partial signatures over different + // messages, and the session stalls rather than saying why. + val artifact = leadArtifact() + + assertEquals(versionOf(artifact)?.id, versionOf(artifact)?.id) + } + + @Test + fun `artifacts alike but for the label do not share a version`() { + // The label is bound into the id rather than hung beside it, so renaming + // a version cannot leave the row it replaces standing. + val first = versionOf(leadArtifact(versionLabel = "1.0")) + val second = versionOf(leadArtifact(versionLabel = "2.0")) + + assertNotEquals(first?.id, second?.id) + } + + @Test + fun `an artifact that declares no version proposes none`() { + // Artifacts written before the artifact carried its first version. The + // batch is then one item, which is what an artifact proposal used to be. + val declared = leadArtifact() + val silent = Event( + id = declared.id, + pubKey = declared.pubKey, + createdAt = declared.createdAt, + kind = declared.kind, + tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + .toTypedArray(), + content = declared.content, + sig = declared.sig + ) + + assertTrue(ArtifactVersionEvent.initialVersionOf(silent).isEmpty()) + assertNull(versionOf(silent)) + } +} 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 }, + ) + } +}