Merge branch 'mantra' into claude/happy-gauss-dbe258
This commit is contained in:
@@ -11,13 +11,11 @@ import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.MantraTranslationChunk
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE
|
||||
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_VISIBILITY
|
||||
@@ -192,60 +190,6 @@ abstract class MantraDao(
|
||||
return mantraArtifactVersion
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun saveTranslation(
|
||||
translationChapterId: String,
|
||||
chunkId: String,
|
||||
text: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationChunk? {
|
||||
// The translation chunk mirrors the source chunk's position.
|
||||
val sourceChunk = database.mantraChunkDao().getChunkById(chunkId) ?: return null
|
||||
|
||||
val translationChunkTemplate = TranslationChunkEvent.build(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = chunkId,
|
||||
index = sourceChunk.index,
|
||||
text = text,
|
||||
)
|
||||
val translationChunk = MantraTranslationChunk.fromTranslationChunkEventTemplate(
|
||||
translationChunkEventTemplate = translationChunkTemplate,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
) ?: return null
|
||||
|
||||
return try {
|
||||
// Replace any existing translation chunk for this source chunk. Its id
|
||||
// is derived from the (now changed) content, so it becomes a new row —
|
||||
// drop the old one (and the submission carrying it) to keep one per
|
||||
// source chunk. The submission is found by what it carries, since its
|
||||
// own id is the envelope's rather than the chunk's.
|
||||
database.mantraTranslationChunkDao()
|
||||
.getTranslationChunksByTranslationChapterId(translationChapterId)
|
||||
.filter { it.chunkId == chunkId && it.id != translationChunk.id }
|
||||
.forEach { stale ->
|
||||
database.mantraTranslationChunkDao().deleteById(stale.id)
|
||||
database.marmotInnerEventDao().deleteById(stale.id)
|
||||
database.marmotInnerEventDao().deleteByPayloadEventId(stale.id)
|
||||
}
|
||||
|
||||
database.mantraTranslationChunkDao().upsert(translationChunk)
|
||||
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(translationChunkTemplate, userPublicKey),
|
||||
text = "Translated chunk ${translationChunk.index}", // TODO: Use a portion of the translation and name the language
|
||||
)
|
||||
|
||||
translationChunk
|
||||
} catch (error: Throwable) {
|
||||
logger.e("Failed to save translation for chunk $chunkId", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendMarmotInnerEvent(
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
|
||||
@@ -15,6 +15,19 @@ interface MantraTranslationChunkDao {
|
||||
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC")
|
||||
suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List<MantraTranslationChunk>
|
||||
|
||||
/**
|
||||
* Every translation this chapter holds of one source chunk.
|
||||
*
|
||||
* There should only ever be one, and this is how that is kept true: a
|
||||
* retranslation is a new event with a new id rather than an edit, so the
|
||||
* row it replaces has to be found and dropped.
|
||||
*/
|
||||
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId AND chunkId = :chunkId")
|
||||
suspend fun getTranslationChunksByChunkId(
|
||||
translationChapterId: String,
|
||||
chunkId: String
|
||||
): List<MantraTranslationChunk>
|
||||
|
||||
@Query("DELETE FROM MantraTranslationChunk WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
}
|
||||
@@ -859,22 +859,12 @@ data class ChatMessage(
|
||||
)
|
||||
)
|
||||
|
||||
// An artifact arrives with the version it starts life
|
||||
// with, derived here rather than sent, so that every
|
||||
// device holding the artifact holds the same first
|
||||
// version. Nothing else can hang off an artifact until
|
||||
// one exists -- a chapter attaches to a version, not to
|
||||
// an artifact -- so an artifact without one is inert.
|
||||
MantraArtifactVersion.initialVersionOf(
|
||||
artifactEvent = artifactEvent,
|
||||
chatRoomId = groupId,
|
||||
)?.let { initialVersion ->
|
||||
database.mantraArtifactVersionDao().upsert(
|
||||
initialVersion.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
// The version the artifact starts life with is its own
|
||||
// event, signed in the same batch and applied after this
|
||||
// one -- see AddArtifactViewModel.addArtifact. It used to
|
||||
// be derived here instead; deriving it now as well would
|
||||
// stand a second, unsigned version row against the same
|
||||
// artifact, since the two hash differently.
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
@@ -907,18 +897,15 @@ data class ChatMessage(
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "artifactVersion",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
|
||||
)
|
||||
// No line of its own. A version arrives as the second
|
||||
// item of the batch that carries its artifact, and the
|
||||
// artifact has already said so -- the same reason a
|
||||
// chunk writes no line beside its chapter. While the
|
||||
// version was derived here rather than signed it wrote
|
||||
// none either, so this is the transcript standing still
|
||||
// rather than losing something.
|
||||
}
|
||||
null
|
||||
}
|
||||
ChapterEvent.KIND -> {
|
||||
// The chunks the chapter splits into are their own events,
|
||||
@@ -1078,13 +1065,42 @@ data class ChatMessage(
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraTranslationChunk ->
|
||||
database.mantraTranslationChunkDao().upsert(
|
||||
mantraTranslationChunk.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
// One translation per source chunk. Retranslating changes
|
||||
// the text and so the event's id, which makes it a new
|
||||
// event rather than an edit of the old one -- so the one
|
||||
// it supersedes is dropped here, or a passage would have
|
||||
// two answers to what it says and the reader would be
|
||||
// shown whichever the query happened to reach first.
|
||||
//
|
||||
// Newest wins by the timestamp the group signed at, not by
|
||||
// when it arrived, with the id breaking a tie. Two devices
|
||||
// catching up read the same events in whatever order the
|
||||
// relay hands them over, and they have to end up holding
|
||||
// the same translation either way.
|
||||
val existing = database.mantraTranslationChunkDao()
|
||||
.getTranslationChunksByChunkId(
|
||||
translationChapterId = mantraTranslationChunk.translationChapterId,
|
||||
chunkId = mantraTranslationChunk.chunkId,
|
||||
)
|
||||
)
|
||||
.filterNot { it.id == mantraTranslationChunk.id }
|
||||
|
||||
val isNewest = existing.none {
|
||||
it.createdAt > mantraTranslationChunk.createdAt ||
|
||||
(it.createdAt == mantraTranslationChunk.createdAt &&
|
||||
it.id > mantraTranslationChunk.id)
|
||||
}
|
||||
|
||||
if (isNewest) {
|
||||
existing.forEach {
|
||||
database.mantraTranslationChunkDao().deleteById(it.id)
|
||||
}
|
||||
|
||||
database.mantraTranslationChunkDao().upsert(
|
||||
mantraTranslationChunk.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
@@ -10,10 +10,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -84,42 +82,6 @@ data class MantraArtifactVersion(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The version an artifact starts life with, derived from the artifact.
|
||||
*
|
||||
* The group signs an artifact; it does not sign this. So the first
|
||||
* version cannot be an event proposed on its own -- that would cost a
|
||||
* second quorum for one form -- and it cannot be invented by whichever
|
||||
* device notices the artifact first, because an invented id differs on
|
||||
* every device holding the same artifact and none of them would agree
|
||||
* about which version a chapter hangs off. Deriving it from the signed
|
||||
* artifact's own fields gives every device the same row from the same
|
||||
* bytes, which is the only property that matters here.
|
||||
*
|
||||
* It is a rumor -- empty signature -- because nobody signed it. What the
|
||||
* group signed is the artifact that declares it.
|
||||
*
|
||||
* Null when the artifact declares no version, which is every artifact
|
||||
* written before it did.
|
||||
*/
|
||||
fun initialVersionOf(
|
||||
artifactEvent: ArtifactEvent,
|
||||
chatRoomId: HexKey,
|
||||
): MantraArtifactVersion? {
|
||||
val versionLabel = artifactEvent.versionLabel() ?: return null
|
||||
|
||||
return fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate = ArtifactVersionEvent.build(
|
||||
content = versionLabel,
|
||||
createdAt = artifactEvent.createdAt,
|
||||
) {
|
||||
addUnique(ArtifactIdTag.assemble(artifactEvent.id))
|
||||
},
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = artifactEvent.pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
fun fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate: EventTemplate<ArtifactVersionEvent>,
|
||||
chatRoomId: HexKey,
|
||||
|
||||
@@ -14,7 +14,6 @@ import press.mantra.compose.database.model.MantraTranslationChapter
|
||||
import press.mantra.compose.database.model.MantraTranslationChunk
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
|
||||
class DatabaseMantraRepository(
|
||||
@@ -51,22 +50,6 @@ class DatabaseMantraRepository(
|
||||
override suspend fun getChunk(id: String): MantraChunk? =
|
||||
database.mantraChunkDao().getChunkById(id)
|
||||
|
||||
override suspend fun saveTranslationChunk(
|
||||
translationChapterId: String,
|
||||
chunkId: String,
|
||||
text: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationChunk? {
|
||||
return database.mantraDao().saveTranslation(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = chunkId,
|
||||
text = text,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> =
|
||||
database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
@@ -32,6 +33,47 @@ class ArtifactVersionEvent(
|
||||
const val KIND = 30301
|
||||
const val ALT_DESCRIPTION = "ArtifactVersion"
|
||||
|
||||
/**
|
||||
* The first version [artifact] declares, ready to be signed with it.
|
||||
*
|
||||
* An artifact and the version it starts life with go to the group as one
|
||||
* batch, so this is built from the artifact *after* it has been authored
|
||||
* under the group's key -- [artifact] is the unsigned event the session
|
||||
* will sign, which is where the id this carries comes from. Building it
|
||||
* anywhere else would mean naming an artifact id before one exists.
|
||||
*
|
||||
* It takes the artifact's own timestamp, so the batch reads as one act
|
||||
* rather than two events that happen to share a session.
|
||||
*
|
||||
* This used to be derived on arrival instead, from the label the
|
||||
* artifact carries -- the same shape a chapter's chunks were in, and
|
||||
* abandoned for the same reason. Deriving cost nothing while the
|
||||
* alternative was a second quorum, and nothing is what it bought: a row
|
||||
* naming the group as its author with no signature to show for it, which
|
||||
* a chapter then hangs off. A batch is one quorum, so the version can
|
||||
* carry the group's signature over its own label.
|
||||
*
|
||||
* Empty when the artifact declares no version -- every artifact written
|
||||
* before it did -- which leaves a batch of one and an artifact with no
|
||||
* version, exactly as before.
|
||||
*/
|
||||
fun initialVersionOf(artifact: Event): List<EventTemplate<ArtifactVersionEvent>> {
|
||||
val versionLabel = ArtifactEvent(
|
||||
id = artifact.id,
|
||||
pubKey = artifact.pubKey,
|
||||
createdAt = artifact.createdAt,
|
||||
tags = artifact.tags,
|
||||
content = artifact.content,
|
||||
sig = artifact.sig
|
||||
).versionLabel() ?: return emptyList()
|
||||
|
||||
return listOf(
|
||||
build(content = versionLabel, createdAt = artifact.createdAt) {
|
||||
addUnique(ArtifactIdTag.assemble(artifact.id))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun build(
|
||||
content: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
|
||||
@@ -35,20 +35,6 @@ interface MantraRepository {
|
||||
|
||||
suspend fun getChunk(id: String): MantraChunk?
|
||||
|
||||
/**
|
||||
* Create or replace the translation of a source chunk within a translation
|
||||
* chapter. Any existing translation chunk for the same (translationChapterId,
|
||||
* chunkId) is replaced. Returns the saved chunk, or null if the source chunk
|
||||
* can't be found.
|
||||
*/
|
||||
suspend fun saveTranslationChunk(
|
||||
translationChapterId: String,
|
||||
chunkId: String,
|
||||
text: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationChunk?
|
||||
|
||||
suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion>
|
||||
|
||||
suspend fun getTranslation(id: String): MantraTranslationArtifactVersion?
|
||||
@@ -100,14 +86,6 @@ interface MantraRepository {
|
||||
|
||||
override suspend fun getChunk(id: String): MantraChunk? = null
|
||||
|
||||
override suspend fun saveTranslationChunk(
|
||||
translationChapterId: String,
|
||||
chunkId: String,
|
||||
text: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationChunk? = null
|
||||
|
||||
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> = emptyList()
|
||||
|
||||
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null
|
||||
|
||||
@@ -4,10 +4,12 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
|
||||
/**
|
||||
* An event a group is being asked to sign, said in words.
|
||||
@@ -52,6 +54,15 @@ object ProposedEvent {
|
||||
}
|
||||
)
|
||||
|
||||
// Signed alongside the artifact it belongs to rather than on its own,
|
||||
// so this is almost always read as the second line of a batch of two.
|
||||
// Named after the artifact rather than the label, because the label is
|
||||
// the whole of the content and would otherwise be said twice.
|
||||
ArtifactVersionEvent.KIND -> Summary(
|
||||
label = "Version of the artifact",
|
||||
detail = event.content.ifBlank { "Unlabelled" }
|
||||
)
|
||||
|
||||
ChapterEvent.KIND -> Summary(
|
||||
label = "New chapter",
|
||||
detail = ChapterEvent(
|
||||
@@ -96,6 +107,23 @@ object ProposedEvent {
|
||||
}
|
||||
)
|
||||
|
||||
TranslationChunkEvent.KIND -> Summary(
|
||||
label = "Translated passage",
|
||||
detail = TranslationChunkEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
).let { chunk ->
|
||||
// The translation itself is the whole of what is being decided --
|
||||
// a member signing this is agreeing that these words say what the
|
||||
// original said -- so it is the detail rather than a count of it.
|
||||
// Where in the chapter it sits comes first, since that is what
|
||||
// says which passage to read it against.
|
||||
listOfNotNull(
|
||||
chunk.index()?.let { "Passage ${it + 1}" },
|
||||
event.content
|
||||
).joinToString(" · ")
|
||||
}
|
||||
)
|
||||
|
||||
// The one thing a group signs that is about the group rather than about
|
||||
// its work, and the only one a member sees before the room has done
|
||||
// anything. Shown as the path and the ceremony rather than as the key:
|
||||
|
||||
@@ -12,11 +12,13 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -25,19 +27,27 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.MantraChunk
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import press.mantra.compose.ui.view.model.TranslateChunkViewModel
|
||||
@@ -53,6 +63,9 @@ fun TranslateChunkScreen(
|
||||
relayHint: String?,
|
||||
initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
@@ -65,6 +78,8 @@ fun TranslateChunkScreen(
|
||||
relayHint = relayHint,
|
||||
initialTranslateChunkUIState = initialTranslateChunkUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -82,6 +97,10 @@ fun TranslateChunkScreen(
|
||||
is TranslateChunkUIState.Loaded -> {
|
||||
val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText)
|
||||
|
||||
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
|
||||
// other button in the app uses rather than inventing a shade here.
|
||||
val buttonColors = ButtonDefaults.buttonColors()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -101,30 +120,59 @@ fun TranslateChunkScreen(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
modifier = if (translateChunkUIState.canSign) {
|
||||
Modifier
|
||||
} else {
|
||||
// Looking unavailable is not being unavailable:
|
||||
// without this a screen reader still announces
|
||||
// a button it is happy to press.
|
||||
Modifier.semantics { disabled() }
|
||||
},
|
||||
containerColor = if (translateChunkUIState.canSign) {
|
||||
FloatingActionButtonDefaults.containerColor
|
||||
} else {
|
||||
buttonColors.disabledContainerColor
|
||||
},
|
||||
contentColor = if (translateChunkUIState.canSign) {
|
||||
contentColorFor(FloatingActionButtonDefaults.containerColor)
|
||||
} else {
|
||||
buttonColors.disabledContentColor
|
||||
},
|
||||
onClick = {
|
||||
translateChunkViewModel.saveTranslation(
|
||||
if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton
|
||||
|
||||
translateChunkViewModel.proposeTranslation(
|
||||
localChatRoom = translateChunkUIState.localChatRoom,
|
||||
originalChunk = translateChunkUIState.originalChunk,
|
||||
translationField = translationFieldState,
|
||||
onSuccess = {
|
||||
// Return to a freshly-loaded chapter table so the
|
||||
// saved translation is reflected.
|
||||
onNavigateToRoute.invoke(
|
||||
TranslationChapterRoute(
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than back to
|
||||
// the chapter table. Nothing has been
|
||||
// translated yet -- the chunk is
|
||||
// translated when enough members sign --
|
||||
// so a table still showing it untranslated
|
||||
// would read as a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
translationChapterId = translationChapterId,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = {}
|
||||
onFailure = {
|
||||
onNavigateToRoute.invoke(
|
||||
ImplementationPendingRoute("Failed Translation")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = "Save translation"
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Propose translation"
|
||||
)
|
||||
Text("Save")
|
||||
Text("Propose Translation")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -138,6 +186,15 @@ fun TranslateChunkScreen(
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (!translateChunkUIState.canSign) {
|
||||
Text(
|
||||
text = "This group has no shared key, so it cannot sign a " +
|
||||
"translation. Run a shared key ceremony first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Original",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
@@ -201,6 +258,16 @@ private fun TranslateChunkScreenPreview() {
|
||||
chatRoomId = "chatRoomId",
|
||||
relayHint = null,
|
||||
initialTranslateChunkUIState = TranslateChunkUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = "chatRoomId",
|
||||
userPublicKey = "",
|
||||
subject = "Message title",
|
||||
description = "See something. Say somethin",
|
||||
initialGiftWrapPayloadId = "sdfaer",
|
||||
mlsGroupState = null
|
||||
),
|
||||
),
|
||||
originalChunk = MantraChunk(
|
||||
id = "chunkId",
|
||||
chapterId = "chapterId",
|
||||
@@ -212,9 +279,13 @@ private fun TranslateChunkScreenPreview() {
|
||||
signature = "",
|
||||
chatRoomId = "chatRoomId"
|
||||
),
|
||||
existingTranslationText = ""
|
||||
existingTranslationText = "",
|
||||
canSign = true
|
||||
),
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -10,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -188,15 +187,13 @@ private fun ChunkTranslationRow(
|
||||
val translated = pair.translationChunk?.text?.takeIf { it.isNotBlank() }
|
||||
TableRow(
|
||||
left = { Text(pair.originalChunk.text) },
|
||||
// The whole translation cell opens the chunk translation editor. When
|
||||
// there is no translation yet, the original text is shown greyed out as
|
||||
// a placeholder. It stays plain text, laid out like the original cell
|
||||
// beside it, rather than a button with its own shape and padding.
|
||||
rightModifier = Modifier.clickable(onClick = onClick),
|
||||
right = {
|
||||
// The translation cell is a button that opens the chunk translation
|
||||
// editor. When there is no translation yet, the original text is
|
||||
// shown greyed out as a placeholder.
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = translated ?: pair.originalChunk.text,
|
||||
@@ -220,13 +217,14 @@ private fun ChunkTranslationRow(
|
||||
private fun TableRow(
|
||||
left: @Composable () -> Unit,
|
||||
right: @Composable () -> Unit,
|
||||
rightModifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f).padding(12.dp)) { left() }
|
||||
VerticalDivider(modifier = Modifier.fillMaxHeight())
|
||||
Box(modifier = Modifier.weight(1f).padding(12.dp)) { right() }
|
||||
Box(modifier = Modifier.weight(1f).then(rightModifier).padding(12.dp)) { right() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -991,15 +991,22 @@ fun MantraNavHost(
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
// Replace this editor and the stale chapter table beneath it so
|
||||
// we land on a freshly-loaded table reflecting the saved translation.
|
||||
navController.navigate(route = actionRoute) {
|
||||
popUpTo<TranslationChapterRoute> {
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace this editor so back returns to the chapter table
|
||||
// rather than to a form whose proposal has already gone out.
|
||||
// The table itself is left alone: nothing is translated until
|
||||
// the group signs, so there is nothing new for it to show.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<TranslateChunkRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(route = actionRoute)
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.view.state.AddArtifactUIState
|
||||
@@ -75,6 +76,10 @@ class AddArtifactViewModel(
|
||||
* putting this in front of the group" and the group's only recourse
|
||||
* afterwards is social. A signature is the group saying it, and it takes a
|
||||
* quorum to say. A library is the group's, so the second is the honest one.
|
||||
*
|
||||
* Two events, one session: the artifact and the version it starts life with,
|
||||
* which a chapter later hangs off. All-or-nothing, which is right here --
|
||||
* an artifact with no version is inert, since nothing can attach to it.
|
||||
*/
|
||||
fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
@@ -103,10 +108,12 @@ class AddArtifactViewModel(
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// The version label rides on the artifact rather than following it as
|
||||
// a second event. The group signs the artifact; a first version
|
||||
// proposed on its own would cost a second quorum for one form, and
|
||||
// every device derives the same first version from what was signed.
|
||||
// The label rides on the artifact and the version is signed beside
|
||||
// it, in the same batch. A first version proposed on its own would
|
||||
// cost a second quorum for one form, which is why it used to be
|
||||
// derived on arrival instead; a batch costs one quorum, so the row a
|
||||
// chapter hangs off can carry the group's signature rather than
|
||||
// being rebuilt from the artifact by every device that holds it.
|
||||
val artifactEventTemplate = ArtifactEvent.build(
|
||||
name = name,
|
||||
url = url,
|
||||
@@ -117,12 +124,15 @@ class AddArtifactViewModel(
|
||||
)
|
||||
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
frostSigningRepository.proposeSigningBatch(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = artifactEventTemplate.kind,
|
||||
tags = artifactEventTemplate.tags,
|
||||
content = artifactEventTemplate.content,
|
||||
lead = artifactEventTemplate,
|
||||
// The version names the artifact it is of, and that id is a
|
||||
// hash over the group's key at the room's path -- neither of
|
||||
// which this screen knows or should. The artifact comes back
|
||||
// built, and the version is read off the label it declares.
|
||||
dependents = ArtifactVersionEvent::initialVersionOf
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose an artifact for signing", error)
|
||||
|
||||
@@ -15,6 +15,11 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.MantraChunk
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.view.state.TranslateChunkUIState
|
||||
|
||||
@@ -26,6 +31,8 @@ class TranslateChunkViewModel(
|
||||
val relayHint: String?,
|
||||
initialTranslateChunkUIState: TranslateChunkUIState,
|
||||
val mantraRepository: MantraRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var translateChunkUIState: TranslateChunkUIState by mutableStateOf(initialTranslateChunkUIState)
|
||||
@@ -38,51 +45,93 @@ class TranslateChunkViewModel(
|
||||
fun initiateTranslateChunk() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val originalChunk = mantraRepository.getChunk(chunkId)
|
||||
translateChunkUIState = if (originalChunk == null) {
|
||||
TranslateChunkUIState.Error("Couldn't find the chunk")
|
||||
} else {
|
||||
val existing = mantraRepository.getTranslationChunks(translationChapterId)
|
||||
.firstOrNull { it.chunkId == chunkId }
|
||||
TranslateChunkUIState.Loaded(
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
|
||||
translateChunkUIState = when {
|
||||
originalChunk == null -> TranslateChunkUIState.Error("Couldn't find the chunk")
|
||||
localChatRoom == null -> TranslateChunkUIState.Error("Couldn't find the chat room")
|
||||
else -> TranslateChunkUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
originalChunk = originalChunk,
|
||||
existingTranslationText = existing?.text.orEmpty(),
|
||||
// What the group has already signed for this chunk, so a
|
||||
// retranslation starts from it rather than from nothing.
|
||||
existingTranslationText = mantraRepository
|
||||
.getTranslationChunks(translationChapterId)
|
||||
.firstOrNull { it.chunkId == chunkId }
|
||||
?.text
|
||||
.orEmpty(),
|
||||
canSign = frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveTranslation(
|
||||
/**
|
||||
* Asks the group to sign this chunk's translation.
|
||||
*
|
||||
* The translation is not saved here and does not exist yet. What goes out is
|
||||
* a proposal to sign it, and the translated chunk appears -- on every
|
||||
* member's device at once, authored by this room's own key rather than by
|
||||
* whoever typed it -- when enough members have signed. That author is the
|
||||
* room's id: signing runs at the path the room was derived at, so a
|
||||
* translated chunk says which group rendered it simply by being signed.
|
||||
*
|
||||
* That is the difference from submitting one. A submission says "I am
|
||||
* putting this in front of the group" and the group's only recourse
|
||||
* afterwards is social. A signature is the group saying it, and it takes a
|
||||
* quorum to say. What a chunk means in another tongue is a claim the group
|
||||
* is making about somebody else's words, so the second is the honest one.
|
||||
*
|
||||
* A retranslation goes the same way: the event carries a different text and
|
||||
* so a different id, and it replaces the one the group signed before rather
|
||||
* than editing it -- there is nothing here to edit, since the old text is
|
||||
* already signed.
|
||||
*/
|
||||
fun proposeTranslation(
|
||||
localChatRoom: LocalChatRoom,
|
||||
originalChunk: MantraChunk,
|
||||
translationField: TextFieldState,
|
||||
onSuccess: () -> Unit,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
val text = translationField.text.toString()
|
||||
if (text.isBlank()) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onFailure.invoke()
|
||||
}
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against double submits from repeated FAB taps.
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val saved = runCatching {
|
||||
mantraRepository.saveTranslationChunk(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = chunkId,
|
||||
text = text,
|
||||
chatRoomId = chatRoomId,
|
||||
val translationChunkEventTemplate = TranslationChunkEvent.build(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = originalChunk.id,
|
||||
// The translation sits where the chunk it translates sits. Read
|
||||
// off the source rather than counted here: a chapter is
|
||||
// translated a chunk at a time and in no particular order, so
|
||||
// counting what is translated so far would number them by who
|
||||
// got there first.
|
||||
index = originalChunk.index,
|
||||
text = text,
|
||||
)
|
||||
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = translationChunkEventTemplate.kind,
|
||||
tags = translationChunkEventTemplate.tags,
|
||||
content = translationChunkEventTemplate.content,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to save translation", error)
|
||||
logger.e("Failed to propose a translation for signing", error)
|
||||
}.getOrNull()
|
||||
|
||||
if (saved != null) {
|
||||
if (session != null) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onSuccess.invoke()
|
||||
onSuccess.invoke(session.id)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
@@ -105,6 +154,8 @@ class TranslateChunkViewModel(
|
||||
relayHint: String?,
|
||||
initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
TranslateChunkViewModel(
|
||||
@@ -115,6 +166,8 @@ class TranslateChunkViewModel(
|
||||
relayHint = relayHint,
|
||||
initialTranslateChunkUIState = initialTranslateChunkUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.MantraChunk
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
sealed interface TranslateChunkUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
val originalChunk: MantraChunk,
|
||||
val existingTranslationText: String = "",
|
||||
|
||||
/**
|
||||
* Whether the group holds a shared key. A translation is signed into
|
||||
* existence now rather than saved, so a group without one cannot
|
||||
* translate a chunk here at all.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): TranslateChunkUIState
|
||||
|
||||
data class Error(
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
|
||||
/**
|
||||
* The first version of an artifact is derived, not delivered.
|
||||
*
|
||||
* The group signs an artifact and nothing else, so the version it starts life
|
||||
* with is not an event anybody sent: every device builds the row for itself out
|
||||
* of the artifact it already holds. That only works while every device builds
|
||||
* the *same* row, and nothing about the ids would show it if they stopped —
|
||||
* they are content hashes, opaque hex either way. What would show is a group
|
||||
* that quietly disagrees about which version a chapter hangs off, with the
|
||||
* artifact looking identical on every screen.
|
||||
*/
|
||||
class InitialArtifactVersionTest {
|
||||
private val groupKey = "a".repeat(64)
|
||||
private val dialectId = "b".repeat(64)
|
||||
private val chatRoomId = "room"
|
||||
|
||||
private fun signedArtifact(
|
||||
name: String = "In Detention",
|
||||
versionLabel: String = "1.0",
|
||||
createdAt: Long = 1_700_000_000,
|
||||
): ArtifactEvent {
|
||||
val template = ArtifactEvent.build(
|
||||
name = name,
|
||||
url = "example.com",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = versionLabel,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
// Hashed rather than made up, so two fixtures that differ are two
|
||||
// different artifacts here for the same reason they would be in the app.
|
||||
return ArtifactEvent(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "d".repeat(128)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived version is a function of the artifact and nothing else`() {
|
||||
// Every input has to come off the artifact. Reading the clock here would
|
||||
// still agree with itself twice in a row -- and disagree between two
|
||||
// devices that applied the same artifact minutes apart, which is the
|
||||
// case nobody can reproduce on demand. So the timestamp is checked
|
||||
// against the artifact's rather than against a second derivation.
|
||||
val artifact = signedArtifact(createdAt = 1_700_000_000)
|
||||
|
||||
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
|
||||
assertNotNull(version)
|
||||
assertEquals(1_700_000_000, version.createdAt.epochSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two devices derive the same first version from the same artifact`() {
|
||||
val artifact = signedArtifact()
|
||||
|
||||
val mine = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
val theirs = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
|
||||
assertNotNull(mine)
|
||||
assertEquals(mine.id, theirs?.id)
|
||||
assertEquals(mine.createdAt, theirs?.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact signed at a different moment derives a different version`() {
|
||||
// The artifact's own timestamp is bound into the derived id, so two
|
||||
// proposals identical but for when they were made stay two artifacts
|
||||
// with two first versions rather than colliding on one row.
|
||||
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_000), chatRoomId)
|
||||
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_001), chatRoomId)
|
||||
|
||||
assertNotNull(first)
|
||||
assertNotNull(second)
|
||||
assertNotEquals(first.id, second.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived version hangs off the artifact and carries what it declared`() {
|
||||
val artifact = signedArtifact(versionLabel = "First Edition")
|
||||
|
||||
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
|
||||
assertEquals(artifact.id, version?.artifactId)
|
||||
assertEquals("First Edition", version?.versionLabel)
|
||||
// Authored by whoever authored the artifact -- the group, once signed --
|
||||
// and unsigned, because nobody signed this.
|
||||
assertEquals(groupKey, version?.publicKey)
|
||||
assertEquals("", version?.signature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the label is bound into the id rather than hung beside it`() {
|
||||
// Two artifacts alike but for the label must not derive one version
|
||||
// between them: the id has to come from the whole event, or a group
|
||||
// renaming a version would leave the row it replaces in place.
|
||||
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "1.0"), chatRoomId)
|
||||
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "2.0"), chatRoomId)
|
||||
|
||||
assertNotNull(first)
|
||||
assertNotNull(second)
|
||||
assertNotEquals(first.id, second.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact that declares no version derives none`() {
|
||||
// Artifacts written before the artifact carried its first version.
|
||||
val declared = signedArtifact()
|
||||
val silent = ArtifactEvent(
|
||||
id = declared.id,
|
||||
pubKey = declared.pubKey,
|
||||
createdAt = declared.createdAt,
|
||||
tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
|
||||
.toTypedArray(),
|
||||
content = declared.content,
|
||||
sig = declared.sig
|
||||
)
|
||||
|
||||
assertNull(MantraArtifactVersion.initialVersionOf(silent, chatRoomId))
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
|
||||
/**
|
||||
* An artifact the group signed, from proposal to rows, against real FROST.
|
||||
@@ -227,21 +228,70 @@ class SignedArtifactTest {
|
||||
assertEquals(dialectId, artifact?.dialectId)
|
||||
}
|
||||
|
||||
/** The second item of the batch, through the same quorum as the first. */
|
||||
private fun signedInitialVersionEvent(versionLabel: String = "1.0"): ArtifactVersionEvent {
|
||||
// Built from the unsigned lead, which is what `proposeSigningBatch` hands
|
||||
// to `dependents` -- the artifact's id is settled at proposal, not at
|
||||
// signature, which is the only reason a version can name it at all.
|
||||
val lead = unsignedEventOf(proposalTemplate(versionLabel))
|
||||
val item = itemOver(unsignedEventOf(ArtifactVersionEvent.initialVersionOf(lead).single()))
|
||||
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
|
||||
|
||||
return ArtifactVersionEvent(
|
||||
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the artifact arrives with the first version hanging off it`() {
|
||||
// Nothing sends this row: each device derives it from the artifact it
|
||||
// just applied. A chapter attaches to a version rather than to an
|
||||
// artifact, so an artifact that arrives without one is inert.
|
||||
val signed = signedArtifactEvent(versionLabel = "First Edition")
|
||||
|
||||
val artifact = MantraArtifact.fromArtifactEvent(signed, chatRoomId)
|
||||
val version = MantraArtifactVersion.initialVersionOf(signed, chatRoomId)
|
||||
// A chapter attaches to a version rather than to an artifact, so an
|
||||
// artifact that arrives without one is inert. Both come out of one
|
||||
// batch, so neither can arrive without the other.
|
||||
val artifact = MantraArtifact.fromArtifactEvent(
|
||||
signedArtifactEvent(versionLabel = "First Edition"), chatRoomId
|
||||
)
|
||||
val version = MantraArtifactVersion.fromArtifactVersionEvent(
|
||||
signedInitialVersionEvent(versionLabel = "First Edition"), chatRoomId
|
||||
)
|
||||
|
||||
assertNotNull(version)
|
||||
assertEquals(artifact?.id, version.artifactId)
|
||||
assertEquals("First Edition", version.versionLabel)
|
||||
assertEquals(groupPubKey, version.publicKey)
|
||||
// Derived, not signed: the group signed the artifact that declares it.
|
||||
assertEquals("", version.signature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first version carries the group's signature over its own id`() {
|
||||
// The point of signing it rather than deriving it. A derived version was
|
||||
// authored by the group and signed by nobody -- indistinguishable, on the
|
||||
// row, from one somebody made up, and the parent of every chapter in it.
|
||||
val version = MantraArtifactVersion.fromArtifactVersionEvent(
|
||||
signedInitialVersionEvent(), chatRoomId
|
||||
)
|
||||
|
||||
assertNotNull(version)
|
||||
assertNotEquals("", version.signature)
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = version.signature.hexToByteArray(),
|
||||
hash = version.id.hexToByteArray(),
|
||||
pubKey = version.publicKey.hexToByteArray()
|
||||
),
|
||||
"a first version should carry a signature the group's key made over its own id"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the version is a second message, signed under its own nonce`() {
|
||||
// Two items of a batch are two independent FROST instances: k signatures
|
||||
// over k ids, never one signature stretched over both. A version whose
|
||||
// signature verified against the artifact's id would mean a nonce had
|
||||
// been reused, which is how a share is extracted rather than a cosmetic
|
||||
// mix-up.
|
||||
val artifact = signedArtifactEvent()
|
||||
val version = signedInitialVersionEvent()
|
||||
|
||||
assertNotEquals(artifact.id, version.id)
|
||||
assertNotEquals(artifact.sig, version.sig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package press.mantra.compose.nostr.nip30303
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
|
||||
/**
|
||||
* The version an artifact starts life with, built to be signed beside it.
|
||||
*
|
||||
* It used to be derived on arrival from the label the artifact carries, because
|
||||
* proposing it on its own would have cost a second quorum for one form. A batch
|
||||
* costs one quorum, so it is signed now -- and a chapter hangs off this row,
|
||||
* which is the reason to care: a version nobody signed is a parent vouched for
|
||||
* by its own signed children rather than the other way round.
|
||||
*
|
||||
* What has to hold is that every input comes off the artifact. The id is not
|
||||
* settled here -- the signing session hashes it under the group's key -- so an
|
||||
* input read from anywhere else would not show up as a wrong row. It would show
|
||||
* up as two devices proposing two different batches, which reads as a batch that
|
||||
* simply never aggregates.
|
||||
*/
|
||||
class InitialArtifactVersionTest {
|
||||
private val groupKey = "a".repeat(64)
|
||||
private val dialectId = "b".repeat(64)
|
||||
|
||||
/**
|
||||
* The artifact as the session will sign it: the proposer's fields re-authored
|
||||
* under the group's key. This is what `proposeSigningBatch` hands to
|
||||
* `dependents`, so it is what the version has to be built from.
|
||||
*/
|
||||
private fun leadArtifact(
|
||||
versionLabel: String = "1.0",
|
||||
createdAt: Long = 1_700_000_000,
|
||||
): Event {
|
||||
val template = ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "example.com",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = versionLabel,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
return Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = ""
|
||||
)
|
||||
}
|
||||
|
||||
/** The version item as the session would author it, so the accessors read. */
|
||||
private fun versionOf(artifact: Event): ArtifactVersionEvent? =
|
||||
ArtifactVersionEvent.initialVersionOf(artifact).singleOrNull()?.let { template ->
|
||||
ArtifactVersionEvent(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = ""
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the version names the artifact it was built from`() {
|
||||
// The whole reason this takes the lead event rather than a form's fields:
|
||||
// the id it carries is a hash over the group's key at the room's path, so
|
||||
// there is no artifact to name until the proposal has been authored.
|
||||
val artifact = leadArtifact()
|
||||
|
||||
assertEquals(artifact.id, versionOf(artifact)?.artifactId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it carries the label the artifact declared`() {
|
||||
val artifact = leadArtifact(versionLabel = "First Edition")
|
||||
|
||||
assertEquals("First Edition", versionOf(artifact)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it takes the artifact's own timestamp rather than the clock`() {
|
||||
// So the batch reads as one act. Reading the clock here would agree with
|
||||
// itself on one device and disagree between two that proposed the same
|
||||
// artifact a second apart -- which is a batch nobody can aggregate, and
|
||||
// the case nobody can reproduce on demand.
|
||||
val artifact = leadArtifact(createdAt = 1_700_000_042)
|
||||
|
||||
assertEquals(1_700_000_042, versionOf(artifact)?.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact declares exactly one first version`() {
|
||||
// Item 0 is the artifact and item 1 is this, and a chapter hangs off
|
||||
// whichever version it finds. Two would make that a coin toss.
|
||||
assertEquals(1, ArtifactVersionEvent.initialVersionOf(leadArtifact()).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two devices build the same version from the same artifact`() {
|
||||
// Every signer authors the batch itself and signs the ids it arrives at.
|
||||
// Devices that disagree here produce partial signatures over different
|
||||
// messages, and the session stalls rather than saying why.
|
||||
val artifact = leadArtifact()
|
||||
|
||||
assertEquals(versionOf(artifact)?.id, versionOf(artifact)?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `artifacts alike but for the label do not share a version`() {
|
||||
// The label is bound into the id rather than hung beside it, so renaming
|
||||
// a version cannot leave the row it replaces standing.
|
||||
val first = versionOf(leadArtifact(versionLabel = "1.0"))
|
||||
val second = versionOf(leadArtifact(versionLabel = "2.0"))
|
||||
|
||||
assertNotEquals(first?.id, second?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact that declares no version proposes none`() {
|
||||
// Artifacts written before the artifact carried its first version. The
|
||||
// batch is then one item, which is what an artifact proposal used to be.
|
||||
val declared = leadArtifact()
|
||||
val silent = Event(
|
||||
id = declared.id,
|
||||
pubKey = declared.pubKey,
|
||||
createdAt = declared.createdAt,
|
||||
kind = declared.kind,
|
||||
tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
|
||||
.toTypedArray(),
|
||||
content = declared.content,
|
||||
sig = declared.sig
|
||||
)
|
||||
|
||||
assertTrue(ArtifactVersionEvent.initialVersionOf(silent).isEmpty())
|
||||
assertNull(versionOf(silent))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What happens when a chunk is translated twice.
|
||||
*
|
||||
* A translation chunk is not edited. Retranslating a passage means proposing a
|
||||
* new event carrying new words, and an event's id is a hash over its content --
|
||||
* so the second translation arrives as a row of its own rather than as an
|
||||
* overwrite of the first. Left alone, the chapter would hold two answers to
|
||||
* what one passage says, and the table pairing chunks with their translations
|
||||
* would show whichever the query reached first.
|
||||
*
|
||||
* The rule this asserts is that the newest one wins, decided by the timestamp
|
||||
* the group signed at rather than by arrival: two devices catching up read the
|
||||
* same events in whatever order the relay hands them over, and they have to end
|
||||
* up holding the same translation either way.
|
||||
*/
|
||||
class TranslationChunkApplyJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val author = "a".repeat(64)
|
||||
private val roomId = "b".repeat(64)
|
||||
private val chunkId = "c".repeat(64)
|
||||
private val translationChapterId = "d".repeat(64)
|
||||
private val signature = "0".repeat(128)
|
||||
|
||||
/**
|
||||
* Everything a translation chunk hangs off, seeded as already signed: the
|
||||
* room, the artifact it belongs to, the chapter and chunk it translates, and
|
||||
* the translation chapter it goes into. None of it is what this test is
|
||||
* about; they are the foreign keys in the way.
|
||||
*/
|
||||
private suspend fun seed() {
|
||||
val nostrEventId = "e".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = author,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = signature,
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = author, nostrEventId = nostrEventId))
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = author,
|
||||
subject = "a translation room",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
|
||||
val dialectId = "1".repeat(64)
|
||||
db.mantraDialectDao().upsert(
|
||||
MantraDialect(
|
||||
id = dialectId,
|
||||
publicKey = roomId,
|
||||
name = "Sesotho",
|
||||
country = "ZA",
|
||||
language = "st",
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
val artifactId = "2".repeat(64)
|
||||
db.mantraArtifactDao().upsert(
|
||||
MantraArtifact(
|
||||
id = artifactId,
|
||||
publicKey = roomId,
|
||||
name = "To Kill a Mockingbird",
|
||||
url = "https://harper.com/2-kill-Bird",
|
||||
visibility = "private",
|
||||
dialectId = dialectId,
|
||||
license = "cc",
|
||||
chatRoomId = roomId,
|
||||
signature = signature,
|
||||
)
|
||||
)
|
||||
|
||||
val artifactVersionId = "3".repeat(64)
|
||||
db.mantraArtifactVersionDao().upsert(
|
||||
MantraArtifactVersion(
|
||||
id = artifactVersionId,
|
||||
artifactId = artifactId,
|
||||
publicKey = roomId,
|
||||
versionLabel = "1.0",
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
val chapterId = "4".repeat(64)
|
||||
db.mantraChapterDao().upsert(
|
||||
MantraChapter(
|
||||
id = chapterId,
|
||||
artifactVersionId = artifactVersionId,
|
||||
publicKey = roomId,
|
||||
name = "Chapter 1",
|
||||
originalText = "When he was nearly thirteen.",
|
||||
index = 0,
|
||||
wordCount = 5,
|
||||
characterCount = 28,
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
db.mantraChunkDao().upsert(
|
||||
MantraChunk(
|
||||
id = chunkId,
|
||||
chapterId = chapterId,
|
||||
publicKey = roomId,
|
||||
text = "When he was nearly thirteen.",
|
||||
index = 0,
|
||||
wordCount = 5,
|
||||
characterCount = 28,
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
val translationId = "5".repeat(64)
|
||||
db.mantraTranslationArtifactVersionDao().upsert(
|
||||
MantraTranslationArtifactVersion(
|
||||
id = translationId,
|
||||
publicKey = roomId,
|
||||
artifactVersionId = artifactVersionId,
|
||||
dialectId = dialectId,
|
||||
name = "Sesotho",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
db.mantraTranslationChapterDao().upsert(
|
||||
MantraTranslationChapter(
|
||||
id = translationChapterId,
|
||||
publicKey = roomId,
|
||||
chapterId = chapterId,
|
||||
translationArtifactVersionId = translationId,
|
||||
index = 0,
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** A translation of the seeded chunk, signed by the room as the group would. */
|
||||
private fun translation(text: String, createdAt: Long): Event {
|
||||
val template = TranslationChunkEvent.build(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = chunkId,
|
||||
index = 0,
|
||||
text = text,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
return Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = roomId,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = roomId,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = signature,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun apply(event: Event) = ChatMessage.applyInnerEvent(
|
||||
database = db,
|
||||
groupId = roomId,
|
||||
event = event,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null,
|
||||
senderPublicKey = roomId,
|
||||
isUserMessage = false,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
)
|
||||
|
||||
private suspend fun translations() = db.mantraTranslationChunkDao()
|
||||
.getTranslationChunksByTranslationChapterId(translationChapterId)
|
||||
|
||||
@Test
|
||||
fun `a retranslation replaces the translation it supersedes`() = runBlocking {
|
||||
seed()
|
||||
|
||||
val signedAt = Clock.System.now().epochSeconds
|
||||
apply(translation("Ha a le hantle lilemo li leshome le metso e meraro.", signedAt))
|
||||
apply(translation("Ha a batla ho ba lilemo li leshome le metso e meraro.", signedAt + 60))
|
||||
|
||||
// Both are valid signatures of the group's over the same passage, and
|
||||
// the chapter has room for one of them.
|
||||
val kept = translations().single()
|
||||
assertEquals("Ha a batla ho ba lilemo li leshome le metso e meraro.", kept.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a translation arriving after the one that superseded it is dropped`() = runBlocking {
|
||||
seed()
|
||||
|
||||
val signedAt = Clock.System.now().epochSeconds
|
||||
val first = translation("Ha a le hantle lilemo li leshome le metso e meraro.", signedAt)
|
||||
val second = translation("Ha a batla ho ba lilemo li leshome le metso e meraro.", signedAt + 60)
|
||||
|
||||
// The order the relay happened to hand them over in, which is not the
|
||||
// order the group signed them in. A device catching up has to reach the
|
||||
// same chapter as one that saw them the other way round.
|
||||
apply(second)
|
||||
apply(first)
|
||||
|
||||
val kept = translations().single()
|
||||
assertEquals(second.id, kept.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two chunks of the same chapter each keep their translation`() = runBlocking {
|
||||
seed()
|
||||
|
||||
// The rule is one translation per source chunk, not one per chapter.
|
||||
// Matching on the chapter alone would leave a chapter that can only ever
|
||||
// hold its most recently translated passage.
|
||||
val otherChunkId = "f".repeat(64)
|
||||
db.mantraChunkDao().upsert(
|
||||
MantraChunk(
|
||||
id = otherChunkId,
|
||||
chapterId = "4".repeat(64),
|
||||
publicKey = roomId,
|
||||
text = "My brother Jem got his arm badly broken.",
|
||||
index = 1,
|
||||
wordCount = 8,
|
||||
characterCount = 40,
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
val signedAt = Clock.System.now().epochSeconds
|
||||
apply(translation("Ha a le hantle lilemo li leshome le metso e meraro.", signedAt))
|
||||
|
||||
val otherTemplate = TranslationChunkEvent.build(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = otherChunkId,
|
||||
index = 1,
|
||||
text = "Moholoane oa ka Jem o ile a robeha letsoho.",
|
||||
createdAt = signedAt + 60,
|
||||
)
|
||||
apply(
|
||||
Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = roomId,
|
||||
createdAt = otherTemplate.createdAt,
|
||||
kind = otherTemplate.kind,
|
||||
tags = otherTemplate.tags,
|
||||
content = otherTemplate.content,
|
||||
),
|
||||
pubKey = roomId,
|
||||
createdAt = otherTemplate.createdAt,
|
||||
kind = otherTemplate.kind,
|
||||
tags = otherTemplate.tags,
|
||||
content = otherTemplate.content,
|
||||
sig = signature,
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(chunkId, otherChunkId),
|
||||
translations().map { it.chunkId },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user