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 bd8f5f82..fa542b1d 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 @@ -10,8 +10,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate 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.MantraChapter -import press.mantra.compose.database.model.MantraChunk import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationChapter @@ -19,8 +17,6 @@ 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.ChapterEvent -import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent @@ -29,7 +25,6 @@ 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 -import press.mantra.compose.text.Markdown import kotlin.time.Instant @Dao @@ -201,81 +196,6 @@ abstract class MantraDao( return mantraArtifactVersion } - @Transaction - open suspend fun addChapter( - artifactId: String, - name: String, - originalText: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraChapter? { - - // Chapters attach to an artifact version; use the latest one. - val version = database.mantraArtifactVersionDao() - .getArtifactVersionsByArtifactId(artifactId) - .firstOrNull() ?: return null - - val chapterIndex = database.mantraChapterDao() - .getChaptersByArtifactVersionId(version.id) - .size - - val chapterEventTemplate = ChapterEvent.build( - artifactVersionId = version.id, - name = name, - originalText = originalText, - index = chapterIndex, - wordCount = Markdown.wordCount(originalText), - characterCount = Markdown.characterCount(originalText), - ) - - val chapter = MantraChapter.fromChapterEventTemplate( - chapterEventTemplate = chapterEventTemplate, - chatRoomId = chatRoomId, - userPublicKey = userPublicKey, - ) ?: return null - - return try { - database.mantraChapterDao().upsert(chapter) - - submitToGroup( - chatRoomId = chatRoomId, - submitterPublicKey = userPublicKey, - payload = rumorOf(chapterEventTemplate, userPublicKey), - text = "Added chapter to artifact", // TODO: Get artifact to use in text... - ) - - // Split the markdown into paragraph chunks. - Markdown.splitParagraphs(originalText).forEachIndexed { chunkIndex, paragraph -> - val chunkEventTemplate = ChunkEvent.build( - chapterId = chapter.id, - text = paragraph, - index = chunkIndex, - wordCount = Markdown.wordCount(paragraph), - characterCount = Markdown.characterCount(paragraph), - ) - MantraChunk.fromChunkEventTemplate( - chunkEventTemplate = chunkEventTemplate, - chatRoomId = chatRoomId, - userPublicKey = userPublicKey, - )?.let { chunk -> - database.mantraChunkDao().upsert(chunk) - - submitToGroup( - chatRoomId = chatRoomId, - submitterPublicKey = userPublicKey, - payload = rumorOf(chunkEventTemplate, userPublicKey), - text = "${chapter.name} added chunk $chunkIndex", // TODO: Use a portion of the actual chunked text... - ) - } - } - - chapter - } catch (error: Throwable) { - logger.e("Failed to add chapter \"$name\" to artifact $artifactId", error) - null - } - } - @Transaction open suspend fun addTranslationArtifactVersion( artifactId: String, 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 a32d0499..766c6354 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 @@ -805,15 +805,17 @@ data class ChatMessage( } } ChapterEvent.KIND -> { + val chapterEvent = ChapterEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ) + MantraChapter.fromChapterEvent( - chapterEvent = ChapterEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ), + chapterEvent = chapterEvent, chatRoomId = groupId, )?.let { mantraChapter -> database.mantraChapterDao().upsert( @@ -822,6 +824,24 @@ data class ChatMessage( ) ) + // A signed chapter arrives with the chunks it is made + // of, split out here rather than sent, so that every + // device holding the chapter holds the same chunks. + // Nothing hangs off a chapter directly -- a translation + // is of a chunk -- so a chapter without them cannot be + // worked on. A submitted chapter brought its own, which + // is why this splits only what the group signed. + MantraChunk.chunksOf( + chapterEvent = chapterEvent, + chatRoomId = groupId, + ).forEach { chunk -> + database.mantraChunkDao().upsert( + chunk.copy( + marmotGroupEventId = marmotGroupEventId, + ) + ) + } + ChatMessage( giftWrapPayloadId = null, messageType = "chapter", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt index 707e1b00..0989e273 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt @@ -10,10 +10,12 @@ 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.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag import press.mantra.compose.nostr.nip30303.tags.IndexTag import press.mantra.compose.nostr.nip30303.tags.WordStatisticsTag +import press.mantra.compose.text.Markdown import kotlin.time.Clock import kotlin.time.Instant @@ -90,6 +92,55 @@ data class MantraChunk( } companion object { + /** + * The chunks a chapter is made of, derived from the chapter. + * + * The group signs a chapter; it does not sign these. So the chunks + * cannot be events proposed on their own -- that would cost a quorum + * per paragraph -- and they cannot be invented by whichever device + * notices the chapter first, because an invented id differs on every + * device and none of them would agree about which chunk a translation + * is of. Deriving them from the signed chapter's own text gives every + * device the same rows from the same bytes, which is the only property + * that matters here. + * + * They are rumors -- empty signature -- because nobody signed them. + * What the group signed is the chapter they were split out of, and that + * is also the only chapter this splits: a chapter that arrived as a + * submission was sent with its own chunk events, written under the + * submitter's key, and deriving a second set beside them would leave + * every paragraph in the chapter twice under ids nothing reconciles. + * + * Empty when the chapter has no text to split, which is every chapter + * whose original text is blank. + */ + fun chunksOf( + chapterEvent: ChapterEvent, + chatRoomId: HexKey, + ): List { + if (chapterEvent.sig.isEmpty()) return emptyList() + + val originalText = chapterEvent.originalText() ?: return emptyList() + + return Markdown.splitParagraphs(originalText).mapIndexedNotNull { index, paragraph -> + fromChunkEventTemplate( + chunkEventTemplate = ChunkEvent.build( + chapterId = chapterEvent.id, + text = paragraph, + index = index, + wordCount = Markdown.wordCount(paragraph), + characterCount = Markdown.characterCount(paragraph), + // The chapter's own timestamp, not the reader's: a device + // applying the chapter an hour later has to arrive at the + // same ids as the one that applied it first. + createdAt = chapterEvent.createdAt, + ), + chatRoomId = chatRoomId, + userPublicKey = chapterEvent.pubKey, + ) + } + } + fun fromChunkEventTemplate( chunkEventTemplate: 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 26b4ddf6..5027f45e 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 @@ -39,6 +39,9 @@ class DatabaseMantraRepository( override suspend fun getChaptersForArtifact(artifactId: String): List = database.mantraChapterDao().getChaptersByArtifactId(artifactId) + override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List = + database.mantraChapterDao().getChaptersByArtifactVersionId(artifactVersionId) + override suspend fun addTranslationArtifactVersion( artifactId: String, dialectId: String, @@ -93,22 +96,6 @@ class DatabaseMantraRepository( override suspend fun getTranslationChunks(translationChapterId: String): List = database.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(translationChapterId) - override suspend fun addChapter( - artifactId: String, - name: String, - originalText: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraChapter? { - return database.mantraDao().addChapter( - artifactId = artifactId, - name = name, - originalText = originalText, - chatRoomId = chatRoomId, - userPublicKey = userPublicKey - ) - } - override suspend fun getDialects(chatRoomId: String): List = database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt index 371a4341..7c3043c9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt @@ -29,6 +29,17 @@ class ChapterEvent( fun artifactVersionId() = artifactVersionIdReference()?.eventId fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name + + /** + * The markdown the chapter is, and the only place its chunks come from. + * + * Carried on the chapter rather than in an event per paragraph because the + * group signs the chapter and nothing else. Chunks proposed separately would + * need a quorum each, and ids invented locally differ on every device + * holding the same chapter -- so they are split back out of this text when + * the signed chapter is applied (see MantraChunk.chunksOf), which gives + * every device the same rows from the same bytes. + */ fun originalText() = tags.firstNotNullOfOrNull(OriginalTextTag::parse)?.originalText fun index() = tags.firstNotNullOfOrNull(IndexTag::parse)?.index 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 5605c335..122ba5cb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -22,6 +22,13 @@ interface MantraRepository { suspend fun getChaptersForArtifact(artifactId: String): List + /** + * The chapters of one version of an artifact, in reading order. A chapter + * hangs off a version rather than off the artifact, so this is what a new + * chapter's index counts. + */ + suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List + suspend fun getChapter(id: String): MantraChapter? suspend fun getChunksForChapter(chapterId: String): List @@ -66,20 +73,6 @@ interface MantraRepository { userPublicKey: HexKey, ): MantraTranslationArtifactVersion? - /** - * Add a chapter (markdown [originalText]) to the artifact's latest version. - * Word/character counts are derived from the text, and the chapter is split - * by paragraph into associated chunks. Returns null when the artifact has no - * version to attach to. - */ - suspend fun addChapter( - artifactId: String, - name: String, - originalText: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraChapter? - suspend fun getDialects(chatRoomId: String): List suspend fun getDialect(id: String): MantraDialect? @@ -114,6 +107,8 @@ interface MantraRepository { override suspend fun getChaptersForArtifact(artifactId: String): List = emptyList() + override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List = emptyList() + override suspend fun getChapter(id: String): MantraChapter? = null override suspend fun getChunksForChapter(chapterId: String): List = emptyList() @@ -145,14 +140,6 @@ interface MantraRepository { userPublicKey: HexKey, ): MantraTranslationArtifactVersion? = null - override suspend fun addChapter( - artifactId: String, - name: String, - originalText: String, - chatRoomId: String, - userPublicKey: HexKey, - ): MantraChapter? = null - override suspend fun getDialects(chatRoomId: String): List = emptyList() override suspend fun getDialect(id: String): MantraDialect? = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index 221269df..8154e37e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -13,8 +13,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DriveFileRenameOutline import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ButtonDefaults 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 @@ -23,19 +25,28 @@ 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.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +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.text.Markdown -import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute +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.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme @@ -51,6 +62,9 @@ fun AddChapterScreen( relayHint: String?, initialAddChapterUIState: AddChapterUIState = AddChapterUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -62,6 +76,8 @@ fun AddChapterScreen( relayHint = relayHint, initialAddChapterUIState = initialAddChapterUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) ) @@ -86,6 +102,15 @@ fun AddChapterScreen( val characterCount = Markdown.characterCount(originalText) val paragraphCount = Markdown.splitParagraphs(originalText).size + // A chapter hangs off a version, and the group has to be able to + // sign; without both there is nothing this screen can propose. + val artifactVersion = addChapterUIState.artifactVersion + val canProposeChapter = artifactVersion != null && addChapterUIState.canSign + + // 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( @@ -105,29 +130,59 @@ fun AddChapterScreen( actions = {}, floatingActionButton = { ExtendedFloatingActionButton( + modifier = if (canProposeChapter) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canProposeChapter) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canProposeChapter) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, onClick = { + if (artifactVersion == null || !addChapterUIState.canSign) { + return@ExtendedFloatingActionButton + } + addChapterViewModel.addChapter( + localChatRoom = addChapterUIState.localChatRoom, + artifactVersion = artifactVersion, nameField = nameFieldState, originalTextField = originalTextFieldState, - onSuccess = { - onNavigateToRoute.invoke( - ArtifactDetailRoute( + onSuccess = { sessionId -> + // Onto the session rather than back to + // the artifact. Nothing has been created + // yet -- the chapter appears when enough + // members sign -- so landing on the list + // it is not in would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( activeUserPublicKey = activeUserPublicKey, - artifactId = artifactId, chatRoomId = chatRoomId, - relayHint = relayHint + sessionId = sessionId ) ) }, - onFailure = {} + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed Chapter") + ) + } ) } ) { Icon( Icons.Default.Add, - contentDescription = "Add chapter" + contentDescription = "Propose chapter" ) - Text("Add Chapter") + Text("Propose Chapter") } } ) @@ -137,6 +192,21 @@ fun AddChapterScreen( modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + if (!addChapterUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "chapter into existence. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (artifactVersion == null) { + Text( + text = "This artifact has no version for a chapter to attach to.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + OutlinedTextField( modifier = Modifier.fillMaxWidth(), state = nameFieldState, @@ -201,6 +271,16 @@ private fun AddChapterScreenPreview() { chatRoomId = "chatRoomId", relayHint = null, initialAddChapterUIState = AddChapterUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "chatRoomId", + userPublicKey = "", + subject = "Message title", + description = "See something. Say somethin", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ), + ), artifact = MantraArtifact( id = "artifactId", publicKey = "author", @@ -211,9 +291,21 @@ private fun AddChapterScreenPreview() { license = "cc", chatRoomId = "chatRoomId", signature = "" - ) + ), + artifactVersion = MantraArtifactVersion( + id = "artifactVersionId", + artifactId = "artifactId", + publicKey = "author", + versionLabel = "1.0", + chatRoomId = "chatRoomId", + signature = "" + ), + 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/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 2b7730a5..a3a37705 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -56,6 +56,7 @@ import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.text.Markdown import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.FrostSigningViewModel @@ -319,7 +320,20 @@ private fun WhatIsBeingSigned(event: Event?) { ChapterEvent.KIND -> "New chapter" to ChapterEvent( event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig - ).name().orEmpty() + ).let { chapter -> + // The text is the substance of a chapter -- signing one is putting + // the group's name to what everybody will translate from -- but it + // is a whole chapter, so its size stands in for it here. The chunk + // count is what the text will actually split into, counted the same + // way the split itself counts (see MantraChunk.chunksOf). + val chunkCount = chapter.originalText()?.let { Markdown.splitParagraphs(it).size } + + listOfNotNull( + chapter.name(), + chapter.wordCount()?.let { "$it words" }, + chunkCount?.let { "$it ${if (it == 1) "chunk" else "chunks"}" } + ).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 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 9332b751..c6c3cdf3 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 @@ -1017,15 +1017,22 @@ fun MantraNavHost( chatRoomId = route.chatRoomId, relayHint = route.relayHint, mantraRepository = databaseMantraRepository, - onNavigateToRoute = { actionRoute -> - // Replace both this add screen and the stale artifact detail - // beneath it so we land on a freshly-loaded detail screen. - navController.navigate(route = actionRoute) { - popUpTo { + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the artifact + // rather than to a form whose proposal has already gone out. + 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/AddChapterViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt index e18f520b..8b414636 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt @@ -16,7 +16,13 @@ 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.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown import press.mantra.compose.ui.view.state.AddChapterUIState class AddChapterViewModel( @@ -26,6 +32,8 @@ class AddChapterViewModel( val relayHint: String?, initialAddChapterUIState: AddChapterUIState, val mantraRepository: MantraRepository, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, ): ViewModel() { var addChapterUIState: AddChapterUIState by mutableStateOf(initialAddChapterUIState) @@ -38,27 +46,52 @@ class AddChapterViewModel( fun initiateAddChapter() { viewModelScope.launch(Dispatchers.IO) { val artifact = mantraRepository.getArtifact(artifactId) - addChapterUIState = if (artifact == null) { - AddChapterUIState.Error("Couldn't find the artifact") - } else { - AddChapterUIState.Loaded(artifact = artifact) + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + addChapterUIState = when { + artifact == null -> AddChapterUIState.Error("Couldn't find the artifact") + localChatRoom == null -> AddChapterUIState.Error("Couldn't find the chat room") + else -> AddChapterUIState.Loaded( + localChatRoom = localChatRoom, + artifact = artifact, + // Chapters attach to an artifact version; use the latest one. + artifactVersion = mantraRepository.getArtifactVersions(artifactId).firstOrNull(), + canSign = frostSigningRepository.canSign(chatRoomId), + ) } } } + /** + * Asks the group to sign a chapter into the artifact. + * + * The chapter is not created here and does not exist yet. What goes out is a + * proposal to sign it, and the chapter -- with the chunks it splits into -- + * appears on every member's device at once, authored by this room's own key + * rather than by whoever pasted the text, when enough members have signed. + * That author is the room's id: signing runs at the path the room was + * derived at, so a chapter says which group's artifact it belongs to 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. The text everyone will translate from is the group's, so + * the second is the honest one. + */ fun addChapter( + localChatRoom: LocalChatRoom, + artifactVersion: MantraArtifactVersion, nameField: TextFieldState, originalTextField: TextFieldState, - onSuccess: () -> Unit, + onSuccess: (sessionId: String) -> Unit, onFailure: () -> Unit ) { val name = nameField.text.toString() val originalText = originalTextField.text.toString() if (name.isBlank() || originalText.isBlank()) { - viewModelScope.launch(Dispatchers.Main) { - onFailure.invoke() - } + onFailure.invoke() return } @@ -67,23 +100,42 @@ class AddChapterViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - val chapter = runCatching { - mantraRepository.addChapter( - artifactId = artifactId, - name = name, - originalText = originalText, - chatRoomId = chatRoomId, + // The chunks are not proposed. They are split out of the chapter's + // own text when the signed chapter is applied (see + // MantraChunk.chunksOf), so one quorum buys the whole chapter rather + // than one per paragraph, and every device splits the same text the + // same way. + val chapterEventTemplate = ChapterEvent.build( + artifactVersionId = artifactVersion.id, + name = name, + originalText = originalText, + // Where this chapter sits in the version, read now rather than + // derived on arrival: the index is signed into the event, and a + // device applying it cannot recount from a version other members + // may have added to in a different order. + index = mantraRepository.getChaptersForArtifactVersion(artifactVersion.id).size, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ) + + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, userPublicKey = activeUserPublicKey, + kind = chapterEventTemplate.kind, + tags = chapterEventTemplate.tags, + content = chapterEventTemplate.content, ) }.onFailure { error -> - logger.e("Failed to add chapter", error) + logger.e("Failed to propose a chapter for signing", error) }.getOrNull() - if (chapter != null) { + if (session != null) { nameField.clearText() originalTextField.clearText() + viewModelScope.launch(Dispatchers.Main) { - onSuccess.invoke() + onSuccess.invoke(session.id) } } else { viewModelScope.launch(Dispatchers.Main) { @@ -105,6 +157,8 @@ class AddChapterViewModel( relayHint: String?, initialAddChapterUIState: AddChapterUIState = AddChapterUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { AddChapterViewModel( @@ -114,6 +168,8 @@ class AddChapterViewModel( relayHint = relayHint, initialAddChapterUIState = initialAddChapterUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddChapterUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddChapterUIState.kt index 02c798d4..e72eae73 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddChapterUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddChapterUIState.kt @@ -1,10 +1,28 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom sealed interface AddChapterUIState { data class Loaded( - val artifact: MantraArtifact + val localChatRoom: LocalChatRoom, + val artifact: MantraArtifact, + + /** + * The version the chapter would attach to: the artifact's latest. + * + * Null on an artifact that has none, which is one nothing can be added + * to -- a chapter hangs off a version rather than off an artifact. + */ + val artifactVersion: MantraArtifactVersion? = null, + + /** + * Whether the group holds a shared key. A chapter is signed into + * existence now rather than submitted, so a group without one cannot + * add one here at all. + */ + val canSign: Boolean = false, ): AddChapterUIState data class Error( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChapterChunksTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChapterChunksTest.kt new file mode 100644 index 00000000..df595fe2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChapterChunksTest.kt @@ -0,0 +1,220 @@ +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.assertTrue +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.tags.OriginalTextTag + +/** + * The chunks of a chapter are derived, not delivered. + * + * The group signs a chapter and nothing else, so the chunks it splits into are + * not events anybody sent: every device builds those rows for itself out of the + * chapter it already holds. That only works while every device builds the *same* + * rows, and nothing about the ids would show it if they stopped — they are + * content hashes, opaque hex either way. What would show is two members + * translating what looks like the same paragraph into rows that never reconcile, + * with the chapter identical on both screens. + */ +class ChapterChunksTest { + private val groupKey = "a".repeat(64) + private val artifactVersionId = "b".repeat(64) + private val chatRoomId = "room" + + private val twoParagraphs = "The first paragraph.\n\nThe second paragraph." + + private fun signedChapter( + name: String = "Chapter 1", + originalText: String = twoParagraphs, + index: Int = 0, + createdAt: Long = 1_700_000_000, + ): ChapterEvent { + val template = ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = name, + originalText = originalText, + index = index, + wordCount = 6, + characterCount = originalText.length, + createdAt = createdAt, + ) + + // Hashed rather than made up, so two fixtures that differ are two + // different chapters here for the same reason they would be in the app. + return ChapterEvent( + 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 chunks are a function of the chapter and nothing else`() { + // Every input has to come off the chapter. Reading the clock here would + // still agree with itself twice in a row -- and disagree between two + // devices that applied the same chapter minutes apart, which is the case + // nobody can reproduce on demand. So the timestamps are checked against + // the chapter's rather than against a second derivation. + val chapter = signedChapter(createdAt = 1_700_000_000) + + val chunks = MantraChunk.chunksOf(chapter, chatRoomId) + + assertEquals(2, chunks.size) + assertTrue(chunks.all { it.createdAt.epochSeconds == 1_700_000_000L }) + } + + @Test + fun `two devices derive the same chunks from the same chapter`() { + val chapter = signedChapter() + + val mine = MantraChunk.chunksOf(chapter, chatRoomId) + val theirs = MantraChunk.chunksOf(chapter, chatRoomId) + + assertEquals(mine.map { it.id }, theirs.map { it.id }) + assertEquals(mine.map { it.createdAt }, theirs.map { it.createdAt }) + } + + @Test + fun `a chapter signed at a different moment derives different chunks`() { + // The chapter's own timestamp is bound into the derived ids, so two + // proposals identical but for when they were made stay two chapters with + // two sets of chunks rather than colliding on one set of rows. + val first = MantraChunk.chunksOf(signedChapter(createdAt = 1_700_000_000), chatRoomId) + val second = MantraChunk.chunksOf(signedChapter(createdAt = 1_700_000_001), chatRoomId) + + assertNotEquals(first.map { it.id }, second.map { it.id }) + } + + @Test + fun `the derived chunks hang off the chapter and carry its text in order`() { + val chapter = signedChapter() + + val chunks = MantraChunk.chunksOf(chapter, chatRoomId) + + assertEquals(listOf(chapter.id, chapter.id), chunks.map { it.chapterId }) + assertEquals(listOf("The first paragraph.", "The second paragraph."), chunks.map { it.text }) + assertEquals(listOf(0, 1), chunks.map { it.index }) + // Authored by whoever authored the chapter -- the group, once signed -- + // and unsigned, because nobody signed these. + assertTrue(chunks.all { it.publicKey == groupKey }) + assertTrue(chunks.all { it.signature == "" }) + } + + @Test + fun `each chunk counts its own paragraph rather than the chapter's`() { + // The chapter's counts describe the whole text. A chunk that inherited + // them would report every paragraph as the length of the chapter, and + // any progress measured per chunk would be wrong by that factor. + val chunks = MantraChunk.chunksOf( + signedChapter(originalText = "One two three.\n\nFour."), + chatRoomId + ) + + assertEquals(listOf(3, 1), chunks.map { it.wordCount }) + assertEquals(listOf("One two three.".length, "Four.".length), chunks.map { it.characterCount }) + } + + @Test + fun `two identical paragraphs stay two chunks`() { + // Ids are content hashes, so repeated text is where they would collide + // and a chapter would silently lose a paragraph. The position is in the + // event, which is what keeps them apart. + val chunks = MantraChunk.chunksOf( + signedChapter(originalText = "Again.\n\nAgain."), + chatRoomId + ) + + assertEquals(2, chunks.size) + assertNotEquals(chunks[0].id, chunks[1].id) + } + + @Test + fun `where a chapter sits does not change the chunks it splits into`() { + // Two chapters with the same text at different positions in the book are + // two chapters, and their chunks hang off their own chapter. The check + // that matters is that the ids differ -- chunks of chapter three must not + // be chunks of chapter one. + val first = signedChapter(index = 0) + val second = signedChapter(index = 3) + + val firstChunks = MantraChunk.chunksOf(first, chatRoomId) + val secondChunks = MantraChunk.chunksOf(second, chatRoomId) + + assertNotEquals(first.id, second.id) + assertEquals(firstChunks.map { it.text }, secondChunks.map { it.text }) + assertNotEquals(firstChunks.map { it.id }, secondChunks.map { it.id }) + } + + @Test + fun `a chapter with no text to split derives no chunks`() { + assertEquals(emptyList(), MantraChunk.chunksOf(signedChapter(originalText = " "), chatRoomId)) + + // And a chapter that declares no text at all, which is malformed rather + // than empty: nothing to split, and nothing to throw over either. + val declared = signedChapter() + val silent = ChapterEvent( + id = declared.id, + pubKey = declared.pubKey, + createdAt = declared.createdAt, + tags = declared.tags.filterNot { it.firstOrNull() == OriginalTextTag.TAG_NAME } + .toTypedArray(), + content = declared.content, + sig = declared.sig + ) + + assertEquals(emptyList(), MantraChunk.chunksOf(silent, chatRoomId)) + } + + @Test + fun `a chapter that arrived as a submission derives nothing`() { + // Chapters used to travel as a submission with a chunk event per + // paragraph, and those rows are already on disk -- written under the + // submitter's key, with ids that have nothing to do with these. Splitting + // one again would leave every paragraph in it twice, and a replay of the + // room's group events would do it without anybody adding anything. + val submitted = signedChapter().let { chapter -> + ChapterEvent( + id = chapter.id, + pubKey = chapter.pubKey, + createdAt = chapter.createdAt, + tags = chapter.tags, + content = chapter.content, + sig = "" // A rumor: what a submission carries. + ) + } + + assertEquals(emptyList(), MantraChunk.chunksOf(submitted, chatRoomId)) + } + + @Test + fun `the derived chunk row and its event agree on the id`() { + // A chunk is read back off the wire by other code paths (an inbound + // ChunkEvent from a peer), so the row a device derives has to be the row + // that event would produce -- otherwise the same chunk arrives twice. + val chunk = MantraChunk.chunksOf(signedChapter(), chatRoomId).first() + + assertEquals(chunk.id, chunk.toChunkEvent().id) + assertEquals( + chunk.id, + EventHasher.hashId( + pubKey = chunk.publicKey, + createdAt = chunk.createdAt.epochSeconds, + kind = chunk.toChunkEvent().kind, + tags = chunk.toChunkEvent().tags, + content = chunk.toChunkEvent().content + ) + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt new file mode 100644 index 00000000..08b295d1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt @@ -0,0 +1,282 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import fr.acinq.bitcoin.crypto.frost.TweakCache +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.MantraChunk +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.nip30303.ChapterEvent + +/** + * A chapter the group signed, from proposal to rows, against real FROST. + * + * Adding a chapter used to write a row and submit it, along with a submission + * per paragraph; the rows said the submitter wrote them, because they had. Now + * the group signs the chapter, and the claim this test exists to hold is that + * the chapter every device ends up with is the group's: authored by the + * threshold key, carrying a signature that verifies, with an id every member + * arrives at independently -- and that the chunks each device splits out of it + * are the same chunks. + * + * None of that is visible when it breaks. A row whose author is the proposer + * looks exactly like a row whose author is the group -- both are opaque hex -- + * and a group that disagrees about the ids has two chapters that look like one. + */ +class SignedChapterTest { + private val participants = 3 + private val threshold = 2 + + /** The member who pasted the text. Nothing they own should end up on the rows. */ + private val proposer = "9".repeat(64) + private val artifactVersionId = "b".repeat(64) + + private val originalText = "The first paragraph.\n\nThe second paragraph." + + /** Stands in for a completed ceremony; the test is about what gets signed, not the DKG. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** + * The room the group signs in: derived from its key at the admin path, which + * is what makes the room's id and the key it signs as one value. + */ + private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive( + thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(), + path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ) + + private val tweakCache: TweakCache = room.cache + + /** The group's nostr identity here, which is also [chatRoomId]. */ + private val groupPubKey = room.hex + + private val chatRoomId = room.hex + + private fun proposalTemplate( + name: String = "Chapter 1", + text: String = originalText, + index: Int = 0, + ) = ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = name, + originalText = text, + index = index, + wordCount = 6, + characterCount = text.length, + createdAt = 1_700_000_000L, + ) + + /** + * Exactly what `FrostSigningManager.unsignedEventOf` does, and it must stay + * exactly that: the proposer's fields re-authored under the group's key. + */ + private fun unsignedEventOf(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "" + ) + + private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( + id = "s".repeat(64), + chatRoomId = chatRoomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + dkgSessionId = "k".repeat(64), + threshold = threshold, + participantCount = participants, + signerId = 0, + derivationPath = SharedKeyDerivation.formatPath(), + unsignedEventJson = unsignedEvent.toJson(), + eventId = unsignedEvent.id, + nonceRandom = "f".repeat(64) + ) + + /** A quorum signing the session's event, in the manager's order. */ + private fun groupSignature(session: FrostSigningSession): String { + val message = ByteVector(session.eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = keyMaterial.secretShares[signerId], + publicShare = keyMaterial.publicShares[signerId], + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { keyMaterial.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = tweakCache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + keyMaterial.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } + + /** Everything from the form to the rows a device holds afterwards. */ + private fun signedChapterEvent( + name: String = "Chapter 1", + text: String = originalText, + index: Int = 0, + ): ChapterEvent { + val session = sessionOver(unsignedEventOf(proposalTemplate(name, text, index))) + val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + + return ChapterEvent( + signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig + ) + } + + @Test + fun `the chapter the group signs is authored by the group, not the proposer`() { + val chapter = MantraChapter.fromChapterEvent(signedChapterEvent(), chatRoomId) + + assertNotNull(chapter) + assertEquals(groupPubKey, chapter.publicKey) + assertNotEquals(proposer, chapter.publicKey) + } + + @Test + fun `the group it is authored by is the room it was signed in`() { + // Signing runs at the room's derivation path, so the author is the room's + // own id rather than the bare threshold key. That is what lets anybody + // holding the row check it without being told which key to expect -- and + // it is why the path a session signs at cannot come from the proposer. + val chapter = MantraChapter.fromChapterEvent(signedChapterEvent(), chatRoomId) + + assertEquals(chatRoomId, chapter?.publicKey) + assertNotEquals( + keyMaterial.thresholdPublicKey.xOnly().value.toHex(), + chapter?.publicKey, + "a chapter must be signed by the room's key, not by the group's root key" + ) + } + + @Test + fun `the row's id is the id the group put its signature to`() { + // Every device builds this row from the same signed event, so the id has + // to be the one that was signed rather than anything recomputed from the + // proposer. Otherwise members converge on nothing and each holds its own + // copy of what is meant to be one chapter. + val session = sessionOver(unsignedEventOf(proposalTemplate())) + val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + + val chapter = MantraChapter.fromChapterEvent( + ChapterEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig), + chatRoomId + ) + + assertEquals(session.eventId, chapter?.id) + } + + @Test + fun `the signature on the row verifies against the row's own id and author`() { + // The payoff of signing rather than submitting: the row carries proof the + // group made it, checkable by anybody holding it. + val chapter = MantraChapter.fromChapterEvent(signedChapterEvent(), chatRoomId) + + assertNotNull(chapter) + assertTrue( + Nip01Crypto.verify( + signature = chapter.signature.hexToByteArray(), + hash = chapter.id.hexToByteArray(), + pubKey = chapter.publicKey.hexToByteArray() + ), + "a chapter row should carry a signature the group's key made over its own id" + ) + } + + @Test + fun `what the form asked for is what the group signed`() { + // The fields travel as tags through a session that knows nothing about + // chapters. Anything dropped in there is signed away silently -- and the + // original text most of all, since the chunks are split out of it. + val chapter = MantraChapter.fromChapterEvent( + signedChapterEvent(name = "In Detention", index = 2), + chatRoomId + ) + + assertEquals("In Detention", chapter?.name) + assertEquals(originalText, chapter?.originalText) + assertEquals(artifactVersionId, chapter?.artifactVersionId) + assertEquals(2, chapter?.index) + } + + @Test + fun `the chapter arrives with the chunks it splits into`() { + // Nothing sends these rows: each device derives them from the chapter it + // just applied. A translation is of a chunk rather than of a chapter, so + // a chapter that arrives without them cannot be worked on. + val signed = signedChapterEvent() + + val chapter = MantraChapter.fromChapterEvent(signed, chatRoomId) + val chunks = MantraChunk.chunksOf(signed, chatRoomId) + + assertEquals(2, chunks.size) + assertTrue(chunks.all { it.chapterId == chapter?.id }) + assertEquals(listOf("The first paragraph.", "The second paragraph."), chunks.map { it.text }) + assertTrue(chunks.all { it.publicKey == groupPubKey }) + // Derived, not signed: the group signed the chapter they were split from. + assertTrue(chunks.all { it.signature == "" }) + } + + @Test + fun `two members derive the same chunks from the chapter their group signed`() { + // The reason the chunks can be derived at all. Both members hold the same + // signed bytes, so both have to land on the same rows -- if they do not, + // a translation one of them writes is of a chunk the other does not have. + val signed = signedChapterEvent() + + val mine = MantraChunk.chunksOf(signed, chatRoomId) + val theirs = MantraChunk.chunksOf(signed, chatRoomId) + + assertEquals(mine.map { it.id }, theirs.map { it.id }) + } +}