feat: ask the group to sign a chunk's translation, not just save it
Everything else a group's library is made of -- the artifact, its dialects, its
chapters, the translations of it -- is signed into existence by a quorum. The
translation of a chunk was the last thing still being saved: `saveTranslation`
wrote the row on this device and queued a submission, and the group's only
recourse afterwards was social.
That is the wrong way round for this one in particular. An artifact is a link
and a name; a translated passage is a claim about what somebody else's words
mean, made in the group's name, and it is what every reader of that translation
reads instead of the original. If anything in the library deserves a quorum it
is this one.
So the screen proposes rather than saves, the way `AddArtifactScreen` does.
Nothing is written when the button is pressed. What goes out is a proposal to
sign a `TranslationChunkEvent`, and the translation appears on every member's
device at once -- authored by the room's own key rather than by whoever typed
it, since signing runs at the path the room was derived at -- when enough
members have signed.
**The form knows whether it can sign before it offers to.**
`TranslateChunkUIState.Loaded` now carries the room and
`frostSigningRepository.canSign`, so the view model has something to propose
with and the button has something to check. Greyed out with
`semantics { disabled() }` when the group holds no shared key, and the screen
says why: a group without one cannot translate here at all, and that is a dead
end to say up front rather than a proposal to be told about afterwards. The
disabled colours are borrowed from `ButtonDefaults` because M3 gives a FAB no
`enabled`, which is what the artifact and dialect forms already do.
**The index is read off the source chunk**, as the DAO did before it. A chapter
is translated a passage at a time and in no particular order, so counting what
is translated so far would number the translations by who got there first.
**Onto the session, not back to the chapter.** The editor is popped and replaced
by the signing screen: nothing has been translated yet, so a table still showing
the passage untranslated would read as a failure. Back from the session lands on
the chapter table, which is deliberately left alone -- the old flow popped and
reloaded it to reflect a save, and there is no longer a save to reflect.
**`ProposedEvent` learns kind 30309.** Without it, members would be asked to put
the group's name to "Event of kind 30309". A translated passage is summarised as
its position and then the translation itself: the words are the whole of what is
being decided -- signing this is agreeing they say what the original said -- and
the position is what tells the reader which passage to weigh them against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,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.
|
||||
@@ -96,6 +97,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 = {}
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user