From 8a23a28e542626105f8cf9d56f07a11413c24536 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 13:06:28 +0200 Subject: [PATCH 1/5] fix: let the untranslated side read as text, not as a button The translation cell was a `TextButton` with its content padding zeroed, which took care of the padding and left everything else a button brings: Material's pill shape, a 40dp minimum height, and a ripple rounded to match. So a chapter's two columns -- the same text, one side not yet translated -- did not read as two columns of one table. One was prose and the other was a control, and the thing being offered is not a control, it is the text with an invitation to write it. It is a plain `Row` now, laid out like the original cell beside it. The click moves up onto the cell's `Box`, before the 12dp padding rather than inside it, so the tap target is the whole cell rather than a button indented within it and the ripple is the rectangle the cell already was. `TableRow` grows a `rightModifier` to carry that, which is where a modifier for that cell belongs. Same greyed-out placeholder, same chevron, same destination. Co-Authored-By: Claude Opus 5 --- .../ui/composable/TranslationChapterScreen.kt | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) 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() } } } From 76b4a78581225153ca0f85772e4032316c3739ac Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 13:13:50 +0200 Subject: [PATCH 2/5] fix: keep one translation per source chunk, however it arrives A translation chunk is never edited. Retranslating a passage means a new event carrying new words, and an event's id is a hash over its content -- so the second translation is a row of its own rather than an overwrite of the first. `MantraDao.saveTranslation` knew that and dropped the row it superseded, but it only ever saw this device's own retranslations. Everything arriving from the group went through `ChatMessage.applyInnerEvent`, which upserted and nothing else. A member retranslating a passage somebody else had already translated left two rows behind, and `TranslationChapterViewModel` pairs chunks with their translations by `associateBy { it.chunkId }` -- one of the two wins, and which one is whatever order SQLite happened to return them in for a query ordered on a column they share. So the rule moves to where the row is actually made, and now covers the group's signatures and the relay's deliveries alike. **Newest wins by the timestamp the group signed at, not by arrival.** Two devices catching up read the same events in whatever order their relays hand them over, and they have to end up holding the same translation either way. An older translation arriving after the one that superseded it is dropped rather than allowed to overwrite it. Ties break on the event id -- arbitrary, but the same arbitrary on every device, which is the whole requirement. **Matched on the source chunk, not on the chapter.** A chapter holds one translation per passage, not one translation; matching on the chapter alone would leave a chapter that could only ever show its most recently translated paragraph. `getTranslationChunksByChunkId` is the query that says so. **Tests.** `TranslationChunkApplyJvmTest` covers the three cases against a real database: a retranslation replaces what it supersedes, a translation arriving after the one that superseded it is dropped, and two chunks of one chapter each keep their own. The first two fail against the plain upsert this replaces; the third is what stops the fix from over-deleting. Co-Authored-By: Claude Opus 5 --- .../database/dao/MantraTranslationChunkDao.kt | 13 + .../compose/database/model/ChatMessage.kt | 37 ++- .../model/TranslationChunkApplyJvmTest.kt | 301 ++++++++++++++++++ 3 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/TranslationChunkApplyJvmTest.kt 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 7aba6e26..9217697c 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 @@ -998,13 +998,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/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 }, + ) + } +} From abbb84bb29ba60efb13ee2ce69b940a3b537a17a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 13:14:06 +0200 Subject: [PATCH 3/5] feat: ask the group to sign a chunk's translation, not just save it Everything else a group's library is made of -- the artifact, its dialects, its chapters, the translations of it -- is signed into existence by a quorum. The translation of a chunk was the last thing still being saved: `saveTranslation` wrote the row on this device and queued a submission, and the group's only recourse afterwards was social. That is the wrong way round for this one in particular. An artifact is a link and a name; a translated passage is a claim about what somebody else's words mean, made in the group's name, and it is what every reader of that translation reads instead of the original. If anything in the library deserves a quorum it is this one. So the screen proposes rather than saves, the way `AddArtifactScreen` does. Nothing is written when the button is pressed. What goes out is a proposal to sign a `TranslationChunkEvent`, and the translation appears on every member's device at once -- authored by the room's own key rather than by whoever typed it, since signing runs at the path the room was derived at -- when enough members have signed. **The form knows whether it can sign before it offers to.** `TranslateChunkUIState.Loaded` now carries the room and `frostSigningRepository.canSign`, so the view model has something to propose with and the button has something to check. Greyed out with `semantics { disabled() }` when the group holds no shared key, and the screen says why: a group without one cannot translate here at all, and that is a dead end to say up front rather than a proposal to be told about afterwards. The disabled colours are borrowed from `ButtonDefaults` because M3 gives a FAB no `enabled`, which is what the artifact and dialect forms already do. **The index is read off the source chunk**, as the DAO did before it. A chapter is translated a passage at a time and in no particular order, so counting what is translated so far would number the translations by who got there first. **Onto the session, not back to the chapter.** The editor is popped and replaced by the signing screen: nothing has been translated yet, so a table still showing the passage untranslated would read as a failure. Back from the session lands on the chapter table, which is deliberately left alone -- the old flow popped and reloaded it to reflect a save, and there is no longer a save to reflect. **`ProposedEvent` learns kind 30309.** Without it, members would be asked to put the group's name to "Event of kind 30309". A translated passage is summarised as its position and then the translation itself: the words are the whole of what is being decided -- signing this is agreeing they say what the original said -- and the position is what tells the reader which passage to weigh them against. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/text/ProposedEvent.kt | 18 ++++ .../ui/composable/TranslateChunkScreen.kt | 101 +++++++++++++++--- .../ui/composable/navigation/MantraNavHost.kt | 17 ++- .../ui/view/model/TranslateChunkViewModel.kt | 95 ++++++++++++---- .../ui/view/state/TranslateChunkUIState.kt | 9 ++ 5 files changed, 199 insertions(+), 41 deletions(-) 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( From 90c6db7827baa6b1804807e1c35649fe60e077da Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 13:14:17 +0200 Subject: [PATCH 4/5] refactor: drop the local path a chunk's translation no longer takes The translation editor was the only caller of `saveTranslationChunk`, and it stopped calling it when it started proposing. What is left behind is dead: the method on `MantraRepository`, its no-op for previews, its implementation in `DatabaseMantraRepository`, and `MantraDao.saveTranslation` underneath them. Deleting it rather than leaving it is the point. Two ways to create a translation chunk, one of which bypasses the quorum, is one too many -- the next screen wanting one would find it and take it, and the group would end up with a translation in its name that nobody signed. The rule it enforced does not go with it. Keeping one translation per source chunk moved to `ChatMessage.applyInnerEvent`, where the row is now made, in the commit before this one -- and covers more there than it ever did here, since the group's other members were always able to leave a duplicate behind. `MarmotInnerEventDao.deleteByPayloadEventId` loses its only production caller here and stays. It is a DAO query rather than a private helper, the invariant behind it is still true and still tested -- a submission's id is the envelope's, so a superseded payload cannot be un-queued by its own id -- and `MantraDao`'s remaining `addDialect` and `addArtifactVersion` are in the same position: reachable now only from `MantraDaoJvmTest`, and a decision about the whole submit-to-group path rather than about this screen. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/MantraDao.kt | 56 ------------------- .../repository/DatabaseMantraRepository.kt | 17 ------ .../compose/repository/MantraRepository.kt | 22 -------- 3 files changed, 95 deletions(-) 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/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index 39967f2e..74552762 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -14,7 +14,6 @@ import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.nip30303.TranslationChunkEvent import press.mantra.compose.repository.MantraRepository class DatabaseMantraRepository( @@ -51,22 +50,6 @@ class DatabaseMantraRepository( override suspend fun getChunk(id: String): MantraChunk? = database.mantraChunkDao().getChunkById(id) - override suspend fun saveTranslationChunk( - translationChapterId: String, - chunkId: String, - text: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationChunk? { - return database.mantraDao().saveTranslation( - translationChapterId = translationChapterId, - chunkId = chunkId, - text = text, - chatRoomId = chatRoomId, - userPublicKey = userPublicKey - ) - } - override suspend fun getTranslationsForArtifact(artifactId: String): List = database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 1bf79271..677998d6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -35,20 +35,6 @@ interface MantraRepository { suspend fun getChunk(id: String): MantraChunk? - /** - * Create or replace the translation of a source chunk within a translation - * chapter. Any existing translation chunk for the same (translationChapterId, - * chunkId) is replaced. Returns the saved chunk, or null if the source chunk - * can't be found. - */ - suspend fun saveTranslationChunk( - translationChapterId: String, - chunkId: String, - text: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationChunk? - suspend fun getTranslationsForArtifact(artifactId: String): List suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? @@ -100,14 +86,6 @@ interface MantraRepository { override suspend fun getChunk(id: String): MantraChunk? = null - override suspend fun saveTranslationChunk( - translationChapterId: String, - chunkId: String, - text: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraTranslationChunk? = null - override suspend fun getTranslationsForArtifact(artifactId: String): List = emptyList() override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null From c8b62e606e4e341b4226486b31ed1e836a169256 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 13:44:28 +0200 Subject: [PATCH 5/5] feat: sign an artifact's first version with it, not derive it after A chapter attaches to a version rather than to an artifact, so the first version is the parent of everything a group later translates. It was not signed. Every device rebuilt it from the artifact on arrival, which put a row on disk naming the group as its author and carrying no signature to show for it -- a parent vouched for by its own signed children rather than the other way round. The reason was written into both ends: a first version proposed on its own would cost a second quorum for one form. That is an argument against a second session, and it stopped being an argument at all once a batch existed. `proposeSigningBatch` is one ceremony, one approval and one transcript whatever k is. The same reasoning was already overturned once, for the same shape. A chapter's chunks were briefly derived from the signed chapter's text for exactly this reason, and they carry their own signatures now. The artifact version is the case that was left behind, and it needs the same form: `ArtifactVersionEvent` names the artifact it is of, and that id is a hash over the group's key at the room's path, so it cannot be known until the proposal is authored. `initialVersionOf` takes the lead the session built, mirroring `ChunkEvent.splitOf`, and the artifact is item 0 because a version row whose artifact does not exist yet is a foreign key violation. Two things had to move with it, and both would have been silent. `ChatMessage.applyInnerEvent` no longer derives a version under an artifact. The derived row and the signed one hash differently -- different author, different timestamp -- so keeping both would have stood two versions against one artifact and let a chapter hang off whichever it found. The `ArtifactVersionEvent` arm no longer writes a chat line. It never used to reach one: a derived version wrote nothing. Signed, it would have put "Added 1.0 to artifact versions" under every "Added In Detention to artifacts", which is the noise the chunk arm already declines to make beside a chapter. An artifact signed before this keeps a version label nothing turns into a row, so its version does not appear. That is what the chapter's chunks cost too. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 41 ++--- .../database/model/MantraArtifactVersion.kt | 38 ----- .../nostr/nip30303/ArtifactVersionEvent.kt | 42 +++++ .../mantra/compose/text/ProposedEvent.kt | 10 ++ .../ui/view/model/AddArtifactViewModel.kt | 26 ++- .../model/InitialArtifactVersionTest.kt | 144 ---------------- .../compose/managers/SignedArtifactTest.kt | 68 +++++++- .../nip30303/InitialArtifactVersionTest.kt | 160 ++++++++++++++++++ 8 files changed, 303 insertions(+), 226 deletions(-) delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/InitialArtifactVersionTest.kt 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 7aba6e26..baaaccff 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 @@ -779,22 +779,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, @@ -827,18 +817,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, 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/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/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 7d551150..4f25bc13 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -4,6 +4,7 @@ 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 @@ -52,6 +53,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( 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/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)) + } +}