Merge branch 'mantra' into claude/new-member-archive-events-e3e074
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)
|
||||
}
|
||||
@@ -985,13 +985,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
|
||||
/**
|
||||
* An event a group is being asked to sign, said in words.
|
||||
@@ -106,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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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