Merge branch 'mantra' into claude/happy-gauss-dbe258

This commit is contained in:
Kgothatso Ngako
2026-09-06 15:06:13 +02:00
18 changed files with 858 additions and 377 deletions

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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(),

View File

@@ -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

View File

@@ -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:

View File

@@ -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 = {}
)

View File

@@ -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() }
}
}

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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,
)
}
}

View File

@@ -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(