feat: sign a translation into the artifact instead of submitting one
Starting a translation no longer creates one. It opens a signing session over a TranslationArtifactVersionEvent and a TranslationChapterEvent per chapter, and the translation appears -- on every member's device at once, authored by the room's shared key rather than by whoever picked the dialect -- when enough members have signed. The same trade the dialects, artifacts and chapters made: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social, while a signature is the group saying it and it takes a quorum to say. A translation is what the group's readers will read the work as, so the second is the honest one. **The chapters, in the same batch.** A translation with no chapters is one nobody can start: a translated chunk hangs off a translation chapter, which hangs off the translation. They travel as their own signed events for the same reason the chapter's chunks do since2e133dd-- a row that carries the group's signature over its own id can be checked by anybody holding it, rather than only by whoever re-derives it. That makes this the second caller of `proposeSigningBatch`'s lead/dependents form, and for exactly the reason the form exists. A translation chapter carries the id of the translation it belongs to, and that id is a hash over the group's key at the room's derivation path -- neither resolved until the proposal runs. A caller computing it would be recomputing `signingPath`, the one input in this protocol that must never come from a proposer. So the translation is built first and handed to `AddTranslationArtifactVersionViewModel.translationChaptersOf`, which lays the chapters out against it. An item naming a translation nobody signed is not a mistake that can be made rather than one to be tested for. The lead is item 0 and items apply in `itemIndex` order, which is what MantraTranslationChapter's foreign key to MantraTranslationArtifactVersion needs: what is referenced is signed first as well as named first. **Only existing dialects.** The form's "New dialect" chip and its three fields are gone, and with them the path that created a dialect on the way to using it. A dialect is the group's too -- AddDialectScreen has proposed one for signing since the dialects made this trade -- so minting one as a side effect of translating would have put a dialect nobody agreed to underneath a translation the group did. Nothing is selected to begin with, because picking a default would be choosing the language of the work; the room's own detail screen is where a missing dialect is asked for, and the form says so when there are none. **The cost, in front of whoever is looking.** MAX_BATCH_SIZE is 64 and the translation takes one place, so MAX_CHAPTERS_PER_TRANSLATION is 63. Unlike a chapter's paragraphs this is not something the person at the screen can shorten by splitting anything, so it is stated rather than advised: the count is shown against the cap as chapters load, coloured when past it, and the form will not propose -- because the alternative is an IllegalArgumentException after the fact. The view model refuses independently; the screen is not what enforces it. **What a chapter signed later does not reach.** A translation covers the chapters that existed when it was proposed, and nothing scaffolds a translation chapter for one signed into the artifact afterwards. That is not new -- the DAO this replaces scaffolded once too -- but it is now said where somebody can act on it, in the form's own line, rather than discovered as a chapter that cannot be translated. Fixing it properly is its own change. **What went away.** MantraDao.addTranslationArtifactVersion and its way up through the repository, including the commented-out chunk scaffolding it had been carrying. Nothing called it once the screen proposed instead, and leaving a path that authors a translation under a member's key while the UI insists on a quorum is the trapeb34c8eremoved for chapters. MantraRepository.addDialect and DatabaseMantraRepository.addDialect went with it: the new-dialect path was their last caller, so what remained was a member-authored dialect reachable from any screen that holds a MantraRepository. MantraDao.addDialect stays, because MantraDaoJvmTest uses it as the worked example for the `rumorOf` seam that addArtifact, addArtifactVersion and saveTranslation still run through. **The screens.** AddTranslationArtifactVersionScreen loads the artifact's latest version, its chapters and canSign up front and disables the FAB when any of them is missing, the way the dialect, artifact and chapter screens do; on success it lands on the session rather than on an artifact the translation is not in yet. FrostSigningScreen described both new kinds as "Event of kind 30306" and a run of "Event of kind 30308", which is a member being asked to sign a translation they cannot read; it now reads the dialect name, visibility and licence for the translation and the position for each chapter. **Tests.** TranslationBatchProposalJvmTest runs the real proposal against a real database over a real ceremony, which is where the sharp edge is: item order, every chapter naming the translation as the group will author it, the source chapter and position each stands in for, one timestamp across the batch, and both ends of the cap -- 63 chapters proposes, 64 is refused and leaves no session behind. Checked against broken implementations rather than only against a working one: naming the artifact version instead of the signed translation, taking the index from list position, and stamping the chapters off their own clock are each caught. If the first of those came apart the translation would still be signed and every chapter would still verify -- against a translation id nobody has. They would fail a foreign key on the way in and the translation would simply arrive empty, which is the failure worth a database to catch. 392 jvmTest and 244 testDebugUnitTest pass, none of the existing ones edited. Not covered: applyInnerEvent's upserts, which need a database no test here stands up, and addTranslation itself, which is plumbing across two dispatchers over a template and a builder the tests already pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,16 +11,12 @@ 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.MantraTranslationArtifactVersion
|
||||
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.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE
|
||||
@@ -196,113 +192,6 @@ abstract class MantraDao(
|
||||
return mantraArtifactVersion
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun addTranslationArtifactVersion(
|
||||
artifactId: String,
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationArtifactVersion? {
|
||||
val artifact = database.mantraArtifactDao().getArtifactById(artifactId) ?: return null
|
||||
val version = database.mantraArtifactVersionDao()
|
||||
.getArtifactVersionsByArtifactId(artifactId)
|
||||
.firstOrNull() ?: return null
|
||||
val dialect = database.mantraDialectDao().getDialectById(dialectId) ?: return null
|
||||
|
||||
// The translation is named after its dialect, and inherits the source
|
||||
// artifact's visibility/license.
|
||||
val translationVersionTemplate = TranslationArtifactVersionEvent.build(
|
||||
artifactVersionId = version.id,
|
||||
dialectId = dialectId,
|
||||
name = dialect.name,
|
||||
visibility = artifact.visibility,
|
||||
license = artifact.license,
|
||||
)
|
||||
val translationVersion = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
|
||||
translationArtifactVersionEventTemplate = translationVersionTemplate,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
) ?: return null
|
||||
|
||||
return try {
|
||||
database.mantraTranslationArtifactVersionDao().upsert(translationVersion)
|
||||
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(translationVersionTemplate, userPublicKey),
|
||||
text = "Added ${dialect.name} translation",
|
||||
)
|
||||
|
||||
// Mirror the source structure: a translation chapter per chapter and
|
||||
// an (untranslated) translation chunk per chunk.
|
||||
val chapters = database.mantraChapterDao().getChaptersByArtifactVersionId(version.id)
|
||||
chapters.forEach { chapter ->
|
||||
val translationChapterTemplate = TranslationChapterEvent.build(
|
||||
translationArtifactVersionId = translationVersion.id,
|
||||
chapterId = chapter.id,
|
||||
index = chapter.index,
|
||||
)
|
||||
val translationChapter = MantraTranslationChapter.fromTranslationChapterEventTemplate(
|
||||
translationChapterEventTemplate = translationChapterTemplate,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
) ?: return@forEach
|
||||
|
||||
database.mantraTranslationChapterDao().upsert(translationChapter)
|
||||
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(translationChapterTemplate, userPublicKey),
|
||||
text = "Prepared ${dialect.name} translation of the chapter ${chapter.name}",
|
||||
)
|
||||
|
||||
// TODO: Figure out if we seriously need the scaffolding
|
||||
// val chunks = database.mantraChunkDao().getChunksByChapterId(chapter.id)
|
||||
// chunks.forEach { chunk ->
|
||||
// val translationChunkTemplate = TranslationChunkEvent.build(
|
||||
// translationChapterId = translationChapter.id,
|
||||
// chunkId = chunk.id,
|
||||
// index = chunk.index,
|
||||
// text = "", // Untranslated scaffold; filled in during authoring.
|
||||
// )
|
||||
// val translationChunk = MantraTranslationChunk.fromTranslationChunkEventTemplate(
|
||||
// translationChunkEventTemplate = translationChunkTemplate,
|
||||
// chatRoomId = chatRoomId,
|
||||
// userPublicKey = userPublicKey,
|
||||
// ) ?: return@forEach
|
||||
//
|
||||
// database.mantraTranslationChunkDao().upsert(translationChunk)
|
||||
// val translationChunkInnerEvent = MarmotInnerEvent(
|
||||
// id = translationChunk.id,
|
||||
// publicKey = translationChunk.publicKey,
|
||||
// kind = TranslationChunkEvent.KIND,
|
||||
// createdAt = translationChunk.createdAt,
|
||||
// tags = translationChunkTemplate.tags,
|
||||
// content = translationChunkTemplate.content,
|
||||
// chatRoomId = translationChunk.chatRoomId,
|
||||
// )
|
||||
// database.marmotInnerEventDao().upsert(
|
||||
// translationChunkInnerEvent
|
||||
// )
|
||||
//
|
||||
// sendMarmotInnerEvent(
|
||||
// chatRoomId = chatRoomId,
|
||||
// userPublicKey = userPublicKey,
|
||||
// text = "Prepared translation chunk ${chunk.index}",
|
||||
// marmotInnerEvent = translationChunkInnerEvent
|
||||
// )
|
||||
// }
|
||||
}
|
||||
|
||||
translationVersion
|
||||
} catch (error: Throwable) {
|
||||
logger.e("Failed to add translation for artifact $artifactId", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun saveTranslation(
|
||||
translationChapterId: String,
|
||||
|
||||
@@ -42,20 +42,6 @@ class DatabaseMantraRepository(
|
||||
override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List<MantraChapter> =
|
||||
database.mantraChapterDao().getChaptersByArtifactVersionId(artifactVersionId)
|
||||
|
||||
override suspend fun addTranslationArtifactVersion(
|
||||
artifactId: String,
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationArtifactVersion? {
|
||||
return database.mantraDao().addTranslationArtifactVersion(
|
||||
artifactId = artifactId,
|
||||
dialectId = dialectId,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getChapter(id: String): MantraChapter? =
|
||||
database.mantraChapterDao().getChapterById(id)
|
||||
|
||||
@@ -102,22 +88,6 @@ class DatabaseMantraRepository(
|
||||
override suspend fun getDialect(id: String): MantraDialect? =
|
||||
database.mantraDialectDao().getDialectById(id)
|
||||
|
||||
override suspend fun addDialect(
|
||||
localChatRoom: LocalChatRoom,
|
||||
name: String,
|
||||
country: String,
|
||||
language: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraDialect? {
|
||||
return database.mantraDao().addDialect(
|
||||
localChatRoom = localChatRoom,
|
||||
name = name,
|
||||
country = country,
|
||||
language = language,
|
||||
userPublicKey = userPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactId: HexKey,
|
||||
|
||||
@@ -59,32 +59,10 @@ interface MantraRepository {
|
||||
|
||||
suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk>
|
||||
|
||||
/**
|
||||
* Start a translation of the artifact into the given dialect: creates a
|
||||
* MantraTranslationArtifactVersion for the artifact's latest version, and
|
||||
* scaffolds a MantraTranslationChapter for each existing chapter plus a
|
||||
* MantraTranslationChunk (empty text) for each existing chunk. Returns null
|
||||
* when the artifact has no version to translate.
|
||||
*/
|
||||
suspend fun addTranslationArtifactVersion(
|
||||
artifactId: String,
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationArtifactVersion?
|
||||
|
||||
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
|
||||
|
||||
suspend fun getDialect(id: String): MantraDialect?
|
||||
|
||||
suspend fun addDialect(
|
||||
localChatRoom: LocalChatRoom,
|
||||
name: String,
|
||||
country: String,
|
||||
language: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraDialect?
|
||||
|
||||
suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactId: HexKey,
|
||||
@@ -133,25 +111,10 @@ interface MantraRepository {
|
||||
|
||||
override suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk> = emptyList()
|
||||
|
||||
override suspend fun addTranslationArtifactVersion(
|
||||
artifactId: String,
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationArtifactVersion? = null
|
||||
|
||||
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
|
||||
|
||||
override suspend fun getDialect(id: String): MantraDialect? = null
|
||||
|
||||
override suspend fun addDialect(
|
||||
localChatRoom: LocalChatRoom,
|
||||
name: String,
|
||||
country: String,
|
||||
language: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraDialect? = null
|
||||
|
||||
override suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactId: HexKey,
|
||||
|
||||
@@ -9,25 +9,23 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Title
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.runtime.getValue
|
||||
@@ -36,6 +34,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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
|
||||
@@ -43,10 +43,14 @@ 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.MantraDialect
|
||||
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.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
|
||||
@@ -63,6 +67,8 @@ fun AddTranslationArtifactVersionScreen(
|
||||
initialAddTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState = AddTranslationArtifactVersionUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
@@ -74,7 +80,8 @@ fun AddTranslationArtifactVersionScreen(
|
||||
relayHint = relayHint,
|
||||
initialAddTranslationArtifactVersionUIState = initialAddTranslationArtifactVersionUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -90,13 +97,32 @@ fun AddTranslationArtifactVersionScreen(
|
||||
}
|
||||
|
||||
is AddTranslationArtifactVersionUIState.Loaded -> {
|
||||
val dialectNameFieldState = rememberTextFieldState()
|
||||
val dialectCountryFieldState = rememberTextFieldState()
|
||||
val dialectLanguageFieldState = rememberTextFieldState()
|
||||
|
||||
// null = "New dialect" (show the create fields); otherwise reuse an
|
||||
// existing dialect by id.
|
||||
// A translation is into a dialect the group already signed into
|
||||
// existence. Nothing is chosen to begin with: picking the first one
|
||||
// for somebody would be choosing the language of the work.
|
||||
var selectedDialectId: String? by remember { mutableStateOf(null) }
|
||||
val selectedDialect = addTranslationUIState.dialects
|
||||
.firstOrNull { dialect -> dialect.id == selectedDialectId }
|
||||
|
||||
// A translation hangs off a version, and the group has to be able to
|
||||
// sign; without both there is nothing this screen can propose.
|
||||
val artifactVersion = addTranslationUIState.artifactVersion
|
||||
val chapterCount = addTranslationUIState.chapters.size
|
||||
|
||||
// The translation and a chapter apiece are signed in one session,
|
||||
// and a session signs a bounded number of events. Past that the
|
||||
// artifact cannot be translated in one go at all, which is worth
|
||||
// saying here rather than after a failed propose.
|
||||
val tooManyChapters = chapterCount > AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION
|
||||
|
||||
val canProposeTranslation = artifactVersion != null &&
|
||||
selectedDialect != null &&
|
||||
addTranslationUIState.canSign &&
|
||||
!tooManyChapters
|
||||
|
||||
// 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 = {
|
||||
@@ -117,32 +143,61 @@ fun AddTranslationArtifactVersionScreen(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
modifier = if (canProposeTranslation) {
|
||||
Modifier
|
||||
} else {
|
||||
// Looking unavailable is not being unavailable.
|
||||
Modifier.semantics { disabled() }
|
||||
},
|
||||
containerColor = if (canProposeTranslation) {
|
||||
FloatingActionButtonDefaults.containerColor
|
||||
} else {
|
||||
buttonColors.disabledContainerColor
|
||||
},
|
||||
contentColor = if (canProposeTranslation) {
|
||||
contentColorFor(FloatingActionButtonDefaults.containerColor)
|
||||
} else {
|
||||
buttonColors.disabledContentColor
|
||||
},
|
||||
onClick = {
|
||||
if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) {
|
||||
return@ExtendedFloatingActionButton
|
||||
}
|
||||
|
||||
addTranslationArtifactVersionViewModel.addTranslation(
|
||||
localChatRoom = addTranslationUIState.localChatRoom,
|
||||
existingDialectId = selectedDialectId,
|
||||
dialectNameField = dialectNameFieldState,
|
||||
dialectCountryField = dialectCountryFieldState,
|
||||
dialectLanguageField = dialectLanguageFieldState,
|
||||
onSuccess = {
|
||||
onNavigateToRoute.invoke(
|
||||
ArtifactDetailRoute(
|
||||
artifact = addTranslationUIState.artifact,
|
||||
artifactVersion = artifactVersion,
|
||||
dialect = selectedDialect,
|
||||
chapters = addTranslationUIState.chapters,
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than back to
|
||||
// the artifact. Nothing has been created
|
||||
// yet -- the translation 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 Translation")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Add translation"
|
||||
Icons.Default.Translate,
|
||||
contentDescription = "Propose translation"
|
||||
)
|
||||
Text("Add Translation")
|
||||
Text("Propose Translation")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -152,8 +207,45 @@ fun AddTranslationArtifactVersionScreen(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (!addTranslationUIState.canSign) {
|
||||
Text(
|
||||
text = "This group has no shared key, so it cannot sign a " +
|
||||
"translation 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 translation to be of.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
} else if (tooManyChapters) {
|
||||
Text(
|
||||
text = "$chapterCount chapters is more than the group can sign in " +
|
||||
"one go, so this artifact cannot be translated here. It signs " +
|
||||
"at most " +
|
||||
"${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " +
|
||||
"chapters at a time.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Text("Translate into which dialect?")
|
||||
|
||||
if (addTranslationUIState.dialects.isEmpty()) {
|
||||
// The only way out of this screen, and it is not on it:
|
||||
// a dialect is the group's too, and asking for one is
|
||||
// its own quorum from the room's own detail screen.
|
||||
Text(
|
||||
text = "This group has no dialects yet. Add one from the group's " +
|
||||
"details before translating anything into it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
@@ -165,64 +257,29 @@ fun AddTranslationArtifactVersionScreen(
|
||||
label = { Text(dialect.name) }
|
||||
)
|
||||
}
|
||||
FilterChip(
|
||||
selected = selectedDialectId == null,
|
||||
onClick = { selectedDialectId = null },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Create a new dialect"
|
||||
)
|
||||
},
|
||||
label = { Text("New dialect") }
|
||||
)
|
||||
}
|
||||
|
||||
// Only collect new-dialect details when not reusing an existing one.
|
||||
if (selectedDialectId == null) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = dialectNameFieldState,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Title,
|
||||
contentDescription = "Name of the dialect"
|
||||
)
|
||||
},
|
||||
label = { Text("Dialect Name") },
|
||||
placeholder = { Text("eg. Sesotho") },
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = dialectCountryFieldState,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Public,
|
||||
contentDescription = "Country of the dialect"
|
||||
)
|
||||
},
|
||||
label = { Text("Country") },
|
||||
placeholder = { Text("eg. Lesotho") },
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = dialectLanguageFieldState,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Translate,
|
||||
contentDescription = "Language of the dialect"
|
||||
)
|
||||
},
|
||||
label = { Text("Language") },
|
||||
placeholder = { Text("eg. st") },
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "The artifact's chapters and chunks will be scaffolded for this translation.",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
// What the group is being asked to sign, counted before
|
||||
// the tap rather than described after it: the chapters
|
||||
// are the rest of the batch. The empty case is worth
|
||||
// its own sentence, because a translation proposed now
|
||||
// is not reached by chapters signed after it.
|
||||
text = if (chapterCount == 0) {
|
||||
"The artifact has no chapters yet, so the group would sign the " +
|
||||
"translation on its own. Chapters added afterwards do not join it."
|
||||
} else {
|
||||
"The group signs the translation and " +
|
||||
"$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"} " +
|
||||
"of ${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " +
|
||||
"together. Chunks are translated one at a time afterwards."
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (tooManyChapters) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium.color
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -284,10 +341,32 @@ private fun AddTranslationArtifactVersionScreenPreview() {
|
||||
license = "cc",
|
||||
chatRoomId = "chatRoomId",
|
||||
signature = ""
|
||||
)
|
||||
),
|
||||
dialects = listOf(
|
||||
MantraDialect(
|
||||
id = "dialectId",
|
||||
publicKey = "author",
|
||||
name = "Sesotho",
|
||||
country = "Lesotho",
|
||||
language = "st",
|
||||
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 = {}
|
||||
)
|
||||
|
||||
@@ -54,6 +54,8 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
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.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.text.Markdown
|
||||
@@ -403,6 +405,24 @@ private fun OneThingBeingSigned(event: Event) {
|
||||
).joinToString(" · ")
|
||||
}
|
||||
|
||||
TranslationArtifactVersionEvent.KIND -> "New translation" to TranslationArtifactVersionEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
).let { translation ->
|
||||
// Named after the dialect it is into, which is the whole of what is
|
||||
// being decided: everything else in the batch follows from it.
|
||||
listOfNotNull(translation.name(), translation.visibility(), translation.license())
|
||||
.joinToString(" · ")
|
||||
}
|
||||
|
||||
TranslationChapterEvent.KIND -> "Chapter of the translation" to TranslationChapterEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
).let { chapter ->
|
||||
// A translation chapter is a place for translated chunks to hang
|
||||
// off, so there is nothing in it to read -- only where in the work
|
||||
// it sits. Counted from one, the way the chapter list reads.
|
||||
chapter.index()?.let { "Chapter ${it + 1}" } ?: "Position unknown"
|
||||
}
|
||||
|
||||
// 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:
|
||||
|
||||
@@ -995,15 +995,21 @@ fun MantraNavHost(
|
||||
relayHint = route.relayHint,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
// Replace this screen and the stale artifact detail beneath it
|
||||
// so we land on a freshly-loaded detail showing the new translation.
|
||||
navController.navigate(route = actionRoute) {
|
||||
popUpTo<ArtifactDetailRoute> {
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace this screen so back returns to the artifact rather
|
||||
// than to a form whose proposal has already gone out.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<AddTranslationRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(
|
||||
route = actionRoute
|
||||
)
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -12,12 +10,22 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraChapter
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
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.AddTranslationArtifactVersionUIState
|
||||
|
||||
@@ -29,6 +37,7 @@ class AddTranslationArtifactVersionViewModel(
|
||||
initialAddTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState,
|
||||
val mantraRepository: MantraRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var addTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState by mutableStateOf(initialAddTranslationArtifactVersionUIState)
|
||||
@@ -42,6 +51,10 @@ class AddTranslationArtifactVersionViewModel(
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val artifact = mantraRepository.getArtifact(artifactId)
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
|
||||
// Translations attach to an artifact version; use the latest one.
|
||||
val artifactVersion = mantraRepository.getArtifactVersions(artifactId).firstOrNull()
|
||||
|
||||
addTranslationArtifactVersionUIState = if (artifact == null || localChatRoom == null) {
|
||||
AddTranslationArtifactVersionUIState.Error("Couldn't find the artifact")
|
||||
} else {
|
||||
@@ -49,32 +62,55 @@ class AddTranslationArtifactVersionViewModel(
|
||||
artifact = artifact,
|
||||
localChatRoom = localChatRoom,
|
||||
dialects = mantraRepository.getDialects(chatRoomId),
|
||||
artifactVersion = artifactVersion,
|
||||
chapters = artifactVersion
|
||||
?.let { mantraRepository.getChaptersForArtifactVersion(it.id) }
|
||||
.orEmpty(),
|
||||
canSign = frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to sign a translation of the artifact, and a translation
|
||||
* chapter for every chapter it has, in one session.
|
||||
*
|
||||
* Nothing is created here. What goes out is a proposal to sign the lot, and
|
||||
* the translation and its chapters appear on every member's device at once,
|
||||
* authored by this room's own key rather than by whoever picked the dialect,
|
||||
* when enough members have signed. That author is the room's id: signing
|
||||
* runs at the path the room was derived at, so a translation 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. A translation is what the group's readers will read the
|
||||
* artifact as, so the second is the honest one.
|
||||
*
|
||||
* The chapters travel with it rather than being scaffolded on each device,
|
||||
* for the same reason the chapter's chunks do: a translation chapter that
|
||||
* carries the group's signature can be checked by anybody holding it, and a
|
||||
* translation with no chapters is one nobody can start translating. What it
|
||||
* costs is [MAX_CHAPTERS_PER_TRANSLATION]: the batch is capped, the
|
||||
* translation takes one of its places, and an artifact with more chapters
|
||||
* than the rest cannot be translated in one session at all.
|
||||
*/
|
||||
fun addTranslation(
|
||||
localChatRoom: LocalChatRoom,
|
||||
existingDialectId: HexKey?,
|
||||
dialectNameField: TextFieldState,
|
||||
dialectCountryField: TextFieldState,
|
||||
dialectLanguageField: TextFieldState,
|
||||
onSuccess: () -> Unit,
|
||||
artifact: MantraArtifact,
|
||||
artifactVersion: MantraArtifactVersion,
|
||||
dialect: MantraDialect,
|
||||
chapters: List<MantraChapter>,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
val dialectName = dialectNameField.text.toString()
|
||||
val dialectCountry = dialectCountryField.text.toString()
|
||||
val dialectLanguage = dialectLanguageField.text.toString()
|
||||
|
||||
// When no existing dialect is selected, the new-dialect fields are required.
|
||||
val creatingNewDialect = existingDialectId.isNullOrBlank()
|
||||
val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank()
|
||||
if (creatingNewDialect && newDialectIncomplete) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onFailure.invoke()
|
||||
}
|
||||
|
||||
// The cap is the group's, not this screen's, and proposing past it
|
||||
// throws rather than failing softly. The form says so before the tap;
|
||||
// this is the check that has to hold when it does not.
|
||||
if (chapters.size > MAX_CHAPTERS_PER_TRANSLATION) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,43 +119,41 @@ class AddTranslationArtifactVersionViewModel(
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val translation = runCatching {
|
||||
// Reuse the selected dialect, or create a new one for the translation.
|
||||
val dialectId = if (creatingNewDialect) {
|
||||
mantraRepository.addDialect(
|
||||
localChatRoom = localChatRoom,
|
||||
name = dialectName,
|
||||
country = dialectCountry,
|
||||
language = dialectLanguage,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
)?.id ?: return@runCatching null
|
||||
} else {
|
||||
existingDialectId
|
||||
}
|
||||
val translationEventTemplate = TranslationArtifactVersionEvent.build(
|
||||
artifactVersionId = artifactVersion.id,
|
||||
dialectId = dialect.id,
|
||||
// The translation is named after its dialect, and inherits the
|
||||
// source artifact's visibility and license: it is the same work
|
||||
// in another tongue, not a new one to license afresh.
|
||||
name = dialect.name,
|
||||
visibility = artifact.visibility,
|
||||
license = artifact.license,
|
||||
)
|
||||
|
||||
mantraRepository.addTranslationArtifactVersion(
|
||||
artifactId = artifactId,
|
||||
dialectId = dialectId,
|
||||
chatRoomId = chatRoomId,
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigningBatch(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
lead = translationEventTemplate,
|
||||
// A translation chapter names the translation it belongs to,
|
||||
// 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
|
||||
// translation comes back built, and the chapters are laid
|
||||
// out against it.
|
||||
dependents = { translation -> translationChaptersOf(translation, chapters) }
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to add translation", error)
|
||||
logger.e("Failed to propose a translation for signing", error)
|
||||
}.getOrNull()
|
||||
|
||||
if (translation != null) {
|
||||
dialectNameField.clearText()
|
||||
dialectCountryField.clearText()
|
||||
dialectLanguageField.clearText()
|
||||
if (session != null) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onSuccess.invoke()
|
||||
onSuccess.invoke(session.id)
|
||||
}
|
||||
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onFailure.invoke()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
isActionPending.value = false
|
||||
@@ -129,6 +163,49 @@ class AddTranslationArtifactVersionViewModel(
|
||||
companion object {
|
||||
private const val TAG = "AddTranslationViewModel"
|
||||
|
||||
/**
|
||||
* A translation chapter for each of [chapters], ready to be signed with
|
||||
* the translation they hang off.
|
||||
*
|
||||
* A translation and its chapters go to the group as one batch, so these
|
||||
* are built from the translation *after* it has been authored under the
|
||||
* group's key -- [translation] is the unsigned event the session will
|
||||
* sign, which is where the id each chapter carries comes from. Laying
|
||||
* them out anywhere else would mean naming a translation id before one
|
||||
* exists.
|
||||
*
|
||||
* They take the translation's own timestamp, so the batch reads as one
|
||||
* act rather than as events that happen to share a session.
|
||||
*
|
||||
* Empty when the artifact has no chapters, which is a translation with
|
||||
* nothing yet to translate -- the chapters signed into the artifact
|
||||
* afterwards do not reach a translation proposed before them.
|
||||
*/
|
||||
fun translationChaptersOf(
|
||||
translation: Event,
|
||||
chapters: List<MantraChapter>,
|
||||
): List<EventTemplate<TranslationChapterEvent>> = chapters.map { chapter ->
|
||||
TranslationChapterEvent.build(
|
||||
translationArtifactVersionId = translation.id,
|
||||
chapterId = chapter.id,
|
||||
// The source chapter's own position, not this list's: the two
|
||||
// agree today, and only one of them is what the group signed.
|
||||
index = chapter.index,
|
||||
createdAt = translation.createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The most chapters an artifact can be translated with in one session.
|
||||
*
|
||||
* The translation is signed together with a chapter apiece, and a
|
||||
* session signs at most [FrostSigningManager.MAX_BATCH_SIZE] events. The
|
||||
* translation is one of them, so the chapters get the rest. Unlike a
|
||||
* chapter's paragraphs this is not something whoever is looking at the
|
||||
* screen can shorten, so it is said plainly rather than as advice.
|
||||
*/
|
||||
const val MAX_CHAPTERS_PER_TRANSLATION: Int = FrostSigningManager.MAX_BATCH_SIZE - 1
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
artifactId: String,
|
||||
@@ -136,7 +213,8 @@ class AddTranslationArtifactVersionViewModel(
|
||||
relayHint: String?,
|
||||
initialAddTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState = AddTranslationArtifactVersionUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
AddTranslationArtifactVersionViewModel(
|
||||
@@ -146,7 +224,8 @@ class AddTranslationArtifactVersionViewModel(
|
||||
relayHint = relayHint,
|
||||
initialAddTranslationArtifactVersionUIState = initialAddTranslationArtifactVersionUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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.MantraChapter
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
@@ -8,7 +10,38 @@ sealed interface AddTranslationArtifactVersionUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
val artifact: MantraArtifact,
|
||||
|
||||
/**
|
||||
* The dialects this room has signed into existence. A translation is
|
||||
* into one of them and only one of them: an invented dialect is a
|
||||
* dialect nobody agreed to, and the room already has a screen for
|
||||
* asking the group for a new one.
|
||||
*/
|
||||
val dialects: List<MantraDialect> = emptyList(),
|
||||
|
||||
/**
|
||||
* The version being translated: the artifact's latest.
|
||||
*
|
||||
* Null on an artifact that has none, which is one there is nothing to
|
||||
* translate -- a translation is of a version rather than of an artifact.
|
||||
*/
|
||||
val artifactVersion: MantraArtifactVersion? = null,
|
||||
|
||||
/**
|
||||
* The chapters of that version, which the translation is laid out
|
||||
* against: a translation chapter each, signed in the same session.
|
||||
*
|
||||
* Held here rather than re-read when proposing, so that the count the
|
||||
* form shows is the count the group is asked to sign.
|
||||
*/
|
||||
val chapters: List<MantraChapter> = emptyList(),
|
||||
|
||||
/**
|
||||
* Whether the group holds a shared key. A translation is signed into
|
||||
* existence now rather than submitted, so a group without one cannot
|
||||
* start one here at all.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): AddTranslationArtifactVersionUIState
|
||||
|
||||
data class Error(
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.MantraChapter
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
import press.mantra.compose.ui.view.model.AddTranslationArtifactVersionViewModel
|
||||
|
||||
/**
|
||||
* What a translation proposal actually puts in front of the group.
|
||||
*
|
||||
* The same sharp edge `ChapterBatchProposalJvmTest` covers for a chapter and
|
||||
* its chunks, one level up: a translation chapter carries the id of the
|
||||
* translation it belongs to, and that id does not exist until the proposal
|
||||
* authors the translation under the group's key at the room's derivation path.
|
||||
* Both are resolved inside `proposeSigningBatch` and neither is knowable to the
|
||||
* caller, which is why the chapters are built from what it hands back.
|
||||
*
|
||||
* If that came apart, the translation would still be signed and every chapter
|
||||
* would still verify -- against a translation id nobody has. They would fail a
|
||||
* foreign key on the way in and the translation would simply arrive with no
|
||||
* chapters, which is a translation nobody can start.
|
||||
*/
|
||||
class TranslationBatchProposalJvmTest {
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
|
||||
/** Stands in for a completed ceremony; the test is about the proposal, not the DKG. */
|
||||
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
||||
),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex()
|
||||
|
||||
/** The admin room: the group's key walked to the admin path, which is its id. */
|
||||
private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
|
||||
|
||||
private val ceremonyId = "ceremony".padEnd(64, '0')
|
||||
private val members = listOf("a", "b", "c").map { it.repeat(64) }
|
||||
private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') }
|
||||
|
||||
private val proposer = members.first()
|
||||
private val artifactVersionId = "b".repeat(64)
|
||||
private val dialectId = "d".repeat(64)
|
||||
|
||||
private lateinit var db: MantraDatabase
|
||||
private lateinit var room: LocalChatRoom
|
||||
|
||||
@AfterTest
|
||||
fun closeDatabase() {
|
||||
if (::db.isInitialized) db.close()
|
||||
}
|
||||
|
||||
/** The proposer's device: a room it can sign in, and the share to sign with. */
|
||||
private suspend fun openDevice() {
|
||||
db = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
|
||||
|
||||
// Profile hangs off a nostr event, and a room off a profile. Neither is
|
||||
// anything this test is about; they are the foreign keys in the way.
|
||||
val nostrEventId = "e$proposer".take(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = proposer,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128)
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId))
|
||||
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = proposer,
|
||||
subject = "#admins",
|
||||
description = SharedKeyDerivation.describe("Admins of the group."),
|
||||
mlsGroupState = null
|
||||
)
|
||||
db.chatRoomDao().upsert(chatRoom)
|
||||
|
||||
db.dkgSessionDao().upsert(
|
||||
DkgSession(
|
||||
id = ceremonyId,
|
||||
chatRoomId = roomId,
|
||||
coordinatorPublicKey = proposer,
|
||||
userPublicKey = proposer,
|
||||
threshold = threshold,
|
||||
participantCount = participants,
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
hostPublicKey = hostKeys[0],
|
||||
round1Random = "1".repeat(64),
|
||||
round2AuxRandom = "2".repeat(64),
|
||||
thresholdPublicKey = thresholdPublicKey,
|
||||
secretShare = keyMaterial.secretShares[0].value.toHex(),
|
||||
publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() }
|
||||
)
|
||||
)
|
||||
|
||||
members.forEachIndexed { index, member ->
|
||||
db.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = ceremonyId,
|
||||
participantPublicKey = member,
|
||||
kind = DkgRitualEvents.HOST_KEY,
|
||||
payload = hostKeys[index]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
room = LocalChatRoom(chatRoom = chatRoom)
|
||||
}
|
||||
|
||||
/**
|
||||
* The source chapters, as the artifact already holds them. Only the id and
|
||||
* the position travel into the translation, so nothing else is furnished.
|
||||
*/
|
||||
private fun sourceChapters(count: Int): List<MantraChapter> = (0 until count).map { index ->
|
||||
MantraChapter(
|
||||
id = "c$index".padEnd(64, 'f'),
|
||||
artifactVersionId = artifactVersionId,
|
||||
publicKey = roomId,
|
||||
name = "Chapter ${index + 1}",
|
||||
originalText = "Paragraph ${index + 1}.",
|
||||
index = index,
|
||||
wordCount = 2,
|
||||
characterCount = 13,
|
||||
signature = "0".repeat(128),
|
||||
chatRoomId = roomId
|
||||
)
|
||||
}
|
||||
|
||||
private val translationTemplate = TranslationArtifactVersionEvent.build(
|
||||
artifactVersionId = artifactVersionId,
|
||||
dialectId = dialectId,
|
||||
name = "Sesotho",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
)
|
||||
|
||||
/** Exactly what `AddTranslationArtifactVersionViewModel.addTranslation` proposes. */
|
||||
private suspend fun proposeTranslation(chapters: List<MantraChapter>) =
|
||||
FrostSigningManager.proposeSigningBatch(
|
||||
database = db,
|
||||
localChatRoom = room,
|
||||
userPublicKey = proposer,
|
||||
lead = translationTemplate,
|
||||
dependents = { translation ->
|
||||
AddTranslationArtifactVersionViewModel.translationChaptersOf(translation, chapters)
|
||||
}
|
||||
)
|
||||
|
||||
private suspend fun itemEvents(sessionId: String): List<Event> =
|
||||
db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) }
|
||||
|
||||
@Test
|
||||
fun `the translation leads the batch and its chapters follow`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val events = itemEvents(proposeTranslation(sourceChapters(3)).id)
|
||||
|
||||
// Item order is apply order, and a translation chapter row whose
|
||||
// translation does not exist yet is a foreign key violation. The
|
||||
// translation has to be first.
|
||||
assertEquals(4, events.size)
|
||||
assertEquals(TranslationArtifactVersionEvent.KIND, events.first().kind)
|
||||
assertTrue(events.drop(1).all { it.kind == TranslationChapterEvent.KIND })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every chapter names the translation as the group will author it`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val events = itemEvents(proposeTranslation(sourceChapters(3)).id)
|
||||
val translation = events.first()
|
||||
|
||||
// The claim the whole `lead`/`dependents` form exists for. The id here is
|
||||
// a hash over the group's key at the room's path; a caller that built the
|
||||
// chapters before proposing could not have known it.
|
||||
val chapters = events.drop(1).map {
|
||||
TranslationChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig)
|
||||
}
|
||||
|
||||
assertTrue(chapters.all { it.translationArtifactVersionId() == translation.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each chapter keeps the source chapter it stands in for, and its place`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val source = sourceChapters(3)
|
||||
val events = itemEvents(proposeTranslation(source).id)
|
||||
val chapters = events.drop(1).map {
|
||||
TranslationChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig)
|
||||
}
|
||||
|
||||
// A translation chapter is only ever a place to hang translated chunks
|
||||
// off the right source chapter, in the right order. Lose either and the
|
||||
// translation reads as some other book.
|
||||
assertEquals(source.map { it.id }, chapters.map { it.chapterId() })
|
||||
assertEquals(source.map { it.index }, chapters.map { it.index() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole batch is stamped and authored as one act by the room`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val events = itemEvents(proposeTranslation(sourceChapters(3)).id)
|
||||
|
||||
// Signing runs at the path the room was derived at, so the author of
|
||||
// every item is the room's own id -- chapters included, which is what
|
||||
// lets one be checked without being told which key to expect.
|
||||
assertTrue(events.all { it.pubKey == roomId })
|
||||
assertTrue(events.none { it.pubKey == proposer })
|
||||
|
||||
// And they carry the translation's timestamp rather than each reading
|
||||
// the clock, so the batch is one act rather than events that happen to
|
||||
// share a session.
|
||||
assertEquals(listOf(events.first().createdAt), events.map { it.createdAt }.distinct())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact of the most chapters allowed still proposes`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val events = itemEvents(
|
||||
proposeTranslation(sourceChapters(FrostSigningManager.MAX_BATCH_SIZE - 1)).id
|
||||
)
|
||||
|
||||
// The arithmetic the form is built on: the translation takes one of the
|
||||
// batch's places and the chapters get the rest. If this stopped being
|
||||
// true, the screen would offer a translation the session then refuses.
|
||||
assertEquals(
|
||||
FrostSigningManager.MAX_BATCH_SIZE,
|
||||
AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION + 1
|
||||
)
|
||||
assertEquals(FrostSigningManager.MAX_BATCH_SIZE, events.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact of one chapter too many is refused`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
// Refused here rather than truncated: a batch that quietly dropped its
|
||||
// last chapters would sign a translation the group believes covers the
|
||||
// whole work while the end of it can never be translated. The screen
|
||||
// checks first so this is never what a member sees, but the screen is
|
||||
// not what enforces it.
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
proposeTranslation(sourceChapters(FrostSigningManager.MAX_BATCH_SIZE))
|
||||
}
|
||||
|
||||
assertEquals(emptyList(), db.frostSigningSessionDao().getSessionsForChatRoom(roomId))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user