Merge branch 'mantra' into claude/happy-gauss-dbe258
This commit is contained in:
@@ -11,13 +11,11 @@ import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.MantraTranslationChunk
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE
|
||||
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_VISIBILITY
|
||||
@@ -192,60 +190,6 @@ abstract class MantraDao(
|
||||
return mantraArtifactVersion
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun saveTranslation(
|
||||
translationChapterId: String,
|
||||
chunkId: String,
|
||||
text: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationChunk? {
|
||||
// The translation chunk mirrors the source chunk's position.
|
||||
val sourceChunk = database.mantraChunkDao().getChunkById(chunkId) ?: return null
|
||||
|
||||
val translationChunkTemplate = TranslationChunkEvent.build(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = chunkId,
|
||||
index = sourceChunk.index,
|
||||
text = text,
|
||||
)
|
||||
val translationChunk = MantraTranslationChunk.fromTranslationChunkEventTemplate(
|
||||
translationChunkEventTemplate = translationChunkTemplate,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
) ?: return null
|
||||
|
||||
return try {
|
||||
// Replace any existing translation chunk for this source chunk. Its id
|
||||
// is derived from the (now changed) content, so it becomes a new row —
|
||||
// drop the old one (and the submission carrying it) to keep one per
|
||||
// source chunk. The submission is found by what it carries, since its
|
||||
// own id is the envelope's rather than the chunk's.
|
||||
database.mantraTranslationChunkDao()
|
||||
.getTranslationChunksByTranslationChapterId(translationChapterId)
|
||||
.filter { it.chunkId == chunkId && it.id != translationChunk.id }
|
||||
.forEach { stale ->
|
||||
database.mantraTranslationChunkDao().deleteById(stale.id)
|
||||
database.marmotInnerEventDao().deleteById(stale.id)
|
||||
database.marmotInnerEventDao().deleteByPayloadEventId(stale.id)
|
||||
}
|
||||
|
||||
database.mantraTranslationChunkDao().upsert(translationChunk)
|
||||
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(translationChunkTemplate, userPublicKey),
|
||||
text = "Translated chunk ${translationChunk.index}", // TODO: Use a portion of the translation and name the language
|
||||
)
|
||||
|
||||
translationChunk
|
||||
} catch (error: Throwable) {
|
||||
logger.e("Failed to save translation for chunk $chunkId", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendMarmotInnerEvent(
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
|
||||
@@ -15,6 +15,19 @@ interface MantraTranslationChunkDao {
|
||||
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC")
|
||||
suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List<MantraTranslationChunk>
|
||||
|
||||
/**
|
||||
* Every translation this chapter holds of one source chunk.
|
||||
*
|
||||
* There should only ever be one, and this is how that is kept true: a
|
||||
* retranslation is a new event with a new id rather than an edit, so the
|
||||
* row it replaces has to be found and dropped.
|
||||
*/
|
||||
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId AND chunkId = :chunkId")
|
||||
suspend fun getTranslationChunksByChunkId(
|
||||
translationChapterId: String,
|
||||
chunkId: String
|
||||
): List<MantraTranslationChunk>
|
||||
|
||||
@Query("DELETE FROM MantraTranslationChunk WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
}
|
||||
@@ -859,22 +859,12 @@ data class ChatMessage(
|
||||
)
|
||||
)
|
||||
|
||||
// An artifact arrives with the version it starts life
|
||||
// with, derived here rather than sent, so that every
|
||||
// device holding the artifact holds the same first
|
||||
// version. Nothing else can hang off an artifact until
|
||||
// one exists -- a chapter attaches to a version, not to
|
||||
// an artifact -- so an artifact without one is inert.
|
||||
MantraArtifactVersion.initialVersionOf(
|
||||
artifactEvent = artifactEvent,
|
||||
chatRoomId = groupId,
|
||||
)?.let { initialVersion ->
|
||||
database.mantraArtifactVersionDao().upsert(
|
||||
initialVersion.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
// The version the artifact starts life with is its own
|
||||
// event, signed in the same batch and applied after this
|
||||
// one -- see AddArtifactViewModel.addArtifact. It used to
|
||||
// be derived here instead; deriving it now as well would
|
||||
// stand a second, unsigned version row against the same
|
||||
// artifact, since the two hash differently.
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
@@ -907,18 +897,15 @@ data class ChatMessage(
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "artifactVersion",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
|
||||
)
|
||||
// No line of its own. A version arrives as the second
|
||||
// item of the batch that carries its artifact, and the
|
||||
// artifact has already said so -- the same reason a
|
||||
// chunk writes no line beside its chapter. While the
|
||||
// version was derived here rather than signed it wrote
|
||||
// none either, so this is the transcript standing still
|
||||
// rather than losing something.
|
||||
}
|
||||
null
|
||||
}
|
||||
ChapterEvent.KIND -> {
|
||||
// The chunks the chapter splits into are their own events,
|
||||
@@ -1078,13 +1065,42 @@ data class ChatMessage(
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraTranslationChunk ->
|
||||
database.mantraTranslationChunkDao().upsert(
|
||||
mantraTranslationChunk.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
// One translation per source chunk. Retranslating changes
|
||||
// the text and so the event's id, which makes it a new
|
||||
// event rather than an edit of the old one -- so the one
|
||||
// it supersedes is dropped here, or a passage would have
|
||||
// two answers to what it says and the reader would be
|
||||
// shown whichever the query happened to reach first.
|
||||
//
|
||||
// Newest wins by the timestamp the group signed at, not by
|
||||
// when it arrived, with the id breaking a tie. Two devices
|
||||
// catching up read the same events in whatever order the
|
||||
// relay hands them over, and they have to end up holding
|
||||
// the same translation either way.
|
||||
val existing = database.mantraTranslationChunkDao()
|
||||
.getTranslationChunksByChunkId(
|
||||
translationChapterId = mantraTranslationChunk.translationChapterId,
|
||||
chunkId = mantraTranslationChunk.chunkId,
|
||||
)
|
||||
)
|
||||
.filterNot { it.id == mantraTranslationChunk.id }
|
||||
|
||||
val isNewest = existing.none {
|
||||
it.createdAt > mantraTranslationChunk.createdAt ||
|
||||
(it.createdAt == mantraTranslationChunk.createdAt &&
|
||||
it.id > mantraTranslationChunk.id)
|
||||
}
|
||||
|
||||
if (isNewest) {
|
||||
existing.forEach {
|
||||
database.mantraTranslationChunkDao().deleteById(it.id)
|
||||
}
|
||||
|
||||
database.mantraTranslationChunkDao().upsert(
|
||||
mantraTranslationChunk.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
@@ -10,10 +10,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -84,42 +82,6 @@ data class MantraArtifactVersion(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The version an artifact starts life with, derived from the artifact.
|
||||
*
|
||||
* The group signs an artifact; it does not sign this. So the first
|
||||
* version cannot be an event proposed on its own -- that would cost a
|
||||
* second quorum for one form -- and it cannot be invented by whichever
|
||||
* device notices the artifact first, because an invented id differs on
|
||||
* every device holding the same artifact and none of them would agree
|
||||
* about which version a chapter hangs off. Deriving it from the signed
|
||||
* artifact's own fields gives every device the same row from the same
|
||||
* bytes, which is the only property that matters here.
|
||||
*
|
||||
* It is a rumor -- empty signature -- because nobody signed it. What the
|
||||
* group signed is the artifact that declares it.
|
||||
*
|
||||
* Null when the artifact declares no version, which is every artifact
|
||||
* written before it did.
|
||||
*/
|
||||
fun initialVersionOf(
|
||||
artifactEvent: ArtifactEvent,
|
||||
chatRoomId: HexKey,
|
||||
): MantraArtifactVersion? {
|
||||
val versionLabel = artifactEvent.versionLabel() ?: return null
|
||||
|
||||
return fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate = ArtifactVersionEvent.build(
|
||||
content = versionLabel,
|
||||
createdAt = artifactEvent.createdAt,
|
||||
) {
|
||||
addUnique(ArtifactIdTag.assemble(artifactEvent.id))
|
||||
},
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = artifactEvent.pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
fun fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate: EventTemplate<ArtifactVersionEvent>,
|
||||
chatRoomId: HexKey,
|
||||
|
||||
@@ -14,7 +14,6 @@ import press.mantra.compose.database.model.MantraTranslationChapter
|
||||
import press.mantra.compose.database.model.MantraTranslationChunk
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
|
||||
class DatabaseMantraRepository(
|
||||
@@ -51,22 +50,6 @@ class DatabaseMantraRepository(
|
||||
override suspend fun getChunk(id: String): MantraChunk? =
|
||||
database.mantraChunkDao().getChunkById(id)
|
||||
|
||||
override suspend fun saveTranslationChunk(
|
||||
translationChapterId: String,
|
||||
chunkId: String,
|
||||
text: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationChunk? {
|
||||
return database.mantraDao().saveTranslation(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = chunkId,
|
||||
text = text,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> =
|
||||
database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
@@ -32,6 +33,47 @@ class ArtifactVersionEvent(
|
||||
const val KIND = 30301
|
||||
const val ALT_DESCRIPTION = "ArtifactVersion"
|
||||
|
||||
/**
|
||||
* The first version [artifact] declares, ready to be signed with it.
|
||||
*
|
||||
* An artifact and the version it starts life with go to the group as one
|
||||
* batch, so this is built from the artifact *after* it has been authored
|
||||
* under the group's key -- [artifact] is the unsigned event the session
|
||||
* will sign, which is where the id this carries comes from. Building it
|
||||
* anywhere else would mean naming an artifact id before one exists.
|
||||
*
|
||||
* It takes the artifact's own timestamp, so the batch reads as one act
|
||||
* rather than two events that happen to share a session.
|
||||
*
|
||||
* This used to be derived on arrival instead, from the label the
|
||||
* artifact carries -- the same shape a chapter's chunks were in, and
|
||||
* abandoned for the same reason. Deriving cost nothing while the
|
||||
* alternative was a second quorum, and nothing is what it bought: a row
|
||||
* naming the group as its author with no signature to show for it, which
|
||||
* a chapter then hangs off. A batch is one quorum, so the version can
|
||||
* carry the group's signature over its own label.
|
||||
*
|
||||
* Empty when the artifact declares no version -- every artifact written
|
||||
* before it did -- which leaves a batch of one and an artifact with no
|
||||
* version, exactly as before.
|
||||
*/
|
||||
fun initialVersionOf(artifact: Event): List<EventTemplate<ArtifactVersionEvent>> {
|
||||
val versionLabel = ArtifactEvent(
|
||||
id = artifact.id,
|
||||
pubKey = artifact.pubKey,
|
||||
createdAt = artifact.createdAt,
|
||||
tags = artifact.tags,
|
||||
content = artifact.content,
|
||||
sig = artifact.sig
|
||||
).versionLabel() ?: return emptyList()
|
||||
|
||||
return listOf(
|
||||
build(content = versionLabel, createdAt = artifact.createdAt) {
|
||||
addUnique(ArtifactIdTag.assemble(artifact.id))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun build(
|
||||
content: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
|
||||
@@ -35,20 +35,6 @@ interface MantraRepository {
|
||||
|
||||
suspend fun getChunk(id: String): MantraChunk?
|
||||
|
||||
/**
|
||||
* Create or replace the translation of a source chunk within a translation
|
||||
* chapter. Any existing translation chunk for the same (translationChapterId,
|
||||
* chunkId) is replaced. Returns the saved chunk, or null if the source chunk
|
||||
* can't be found.
|
||||
*/
|
||||
suspend fun saveTranslationChunk(
|
||||
translationChapterId: String,
|
||||
chunkId: String,
|
||||
text: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationChunk?
|
||||
|
||||
suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion>
|
||||
|
||||
suspend fun getTranslation(id: String): MantraTranslationArtifactVersion?
|
||||
@@ -100,14 +86,6 @@ interface MantraRepository {
|
||||
|
||||
override suspend fun getChunk(id: String): MantraChunk? = null
|
||||
|
||||
override suspend fun saveTranslationChunk(
|
||||
translationChapterId: String,
|
||||
chunkId: String,
|
||||
text: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationChunk? = null
|
||||
|
||||
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> = emptyList()
|
||||
|
||||
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null
|
||||
|
||||
@@ -4,10 +4,12 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
|
||||
/**
|
||||
* An event a group is being asked to sign, said in words.
|
||||
@@ -52,6 +54,15 @@ object ProposedEvent {
|
||||
}
|
||||
)
|
||||
|
||||
// Signed alongside the artifact it belongs to rather than on its own,
|
||||
// so this is almost always read as the second line of a batch of two.
|
||||
// Named after the artifact rather than the label, because the label is
|
||||
// the whole of the content and would otherwise be said twice.
|
||||
ArtifactVersionEvent.KIND -> Summary(
|
||||
label = "Version of the artifact",
|
||||
detail = event.content.ifBlank { "Unlabelled" }
|
||||
)
|
||||
|
||||
ChapterEvent.KIND -> Summary(
|
||||
label = "New chapter",
|
||||
detail = ChapterEvent(
|
||||
@@ -96,6 +107,23 @@ object ProposedEvent {
|
||||
}
|
||||
)
|
||||
|
||||
TranslationChunkEvent.KIND -> Summary(
|
||||
label = "Translated passage",
|
||||
detail = TranslationChunkEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
).let { chunk ->
|
||||
// The translation itself is the whole of what is being decided --
|
||||
// a member signing this is agreeing that these words say what the
|
||||
// original said -- so it is the detail rather than a count of it.
|
||||
// Where in the chapter it sits comes first, since that is what
|
||||
// says which passage to read it against.
|
||||
listOfNotNull(
|
||||
chunk.index()?.let { "Passage ${it + 1}" },
|
||||
event.content
|
||||
).joinToString(" · ")
|
||||
}
|
||||
)
|
||||
|
||||
// The one thing a group signs that is about the group rather than about
|
||||
// its work, and the only one a member sees before the room has done
|
||||
// anything. Shown as the path and the ceremony rather than as the key:
|
||||
|
||||
@@ -12,11 +12,13 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -25,19 +27,27 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.MantraChunk
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import press.mantra.compose.ui.view.model.TranslateChunkViewModel
|
||||
@@ -53,6 +63,9 @@ fun TranslateChunkScreen(
|
||||
relayHint: String?,
|
||||
initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
@@ -65,6 +78,8 @@ fun TranslateChunkScreen(
|
||||
relayHint = relayHint,
|
||||
initialTranslateChunkUIState = initialTranslateChunkUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -82,6 +97,10 @@ fun TranslateChunkScreen(
|
||||
is TranslateChunkUIState.Loaded -> {
|
||||
val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText)
|
||||
|
||||
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
|
||||
// other button in the app uses rather than inventing a shade here.
|
||||
val buttonColors = ButtonDefaults.buttonColors()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -101,30 +120,59 @@ fun TranslateChunkScreen(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
modifier = if (translateChunkUIState.canSign) {
|
||||
Modifier
|
||||
} else {
|
||||
// Looking unavailable is not being unavailable:
|
||||
// without this a screen reader still announces
|
||||
// a button it is happy to press.
|
||||
Modifier.semantics { disabled() }
|
||||
},
|
||||
containerColor = if (translateChunkUIState.canSign) {
|
||||
FloatingActionButtonDefaults.containerColor
|
||||
} else {
|
||||
buttonColors.disabledContainerColor
|
||||
},
|
||||
contentColor = if (translateChunkUIState.canSign) {
|
||||
contentColorFor(FloatingActionButtonDefaults.containerColor)
|
||||
} else {
|
||||
buttonColors.disabledContentColor
|
||||
},
|
||||
onClick = {
|
||||
translateChunkViewModel.saveTranslation(
|
||||
if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton
|
||||
|
||||
translateChunkViewModel.proposeTranslation(
|
||||
localChatRoom = translateChunkUIState.localChatRoom,
|
||||
originalChunk = translateChunkUIState.originalChunk,
|
||||
translationField = translationFieldState,
|
||||
onSuccess = {
|
||||
// Return to a freshly-loaded chapter table so the
|
||||
// saved translation is reflected.
|
||||
onNavigateToRoute.invoke(
|
||||
TranslationChapterRoute(
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than back to
|
||||
// the chapter table. Nothing has been
|
||||
// translated yet -- the chunk is
|
||||
// translated when enough members sign --
|
||||
// so a table still showing it untranslated
|
||||
// would read as a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
translationChapterId = translationChapterId,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = {}
|
||||
onFailure = {
|
||||
onNavigateToRoute.invoke(
|
||||
ImplementationPendingRoute("Failed Translation")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = "Save translation"
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Propose translation"
|
||||
)
|
||||
Text("Save")
|
||||
Text("Propose Translation")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -138,6 +186,15 @@ fun TranslateChunkScreen(
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (!translateChunkUIState.canSign) {
|
||||
Text(
|
||||
text = "This group has no shared key, so it cannot sign a " +
|
||||
"translation. Run a shared key ceremony first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Original",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
@@ -201,6 +258,16 @@ private fun TranslateChunkScreenPreview() {
|
||||
chatRoomId = "chatRoomId",
|
||||
relayHint = null,
|
||||
initialTranslateChunkUIState = TranslateChunkUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = "chatRoomId",
|
||||
userPublicKey = "",
|
||||
subject = "Message title",
|
||||
description = "See something. Say somethin",
|
||||
initialGiftWrapPayloadId = "sdfaer",
|
||||
mlsGroupState = null
|
||||
),
|
||||
),
|
||||
originalChunk = MantraChunk(
|
||||
id = "chunkId",
|
||||
chapterId = "chapterId",
|
||||
@@ -212,9 +279,13 @@ private fun TranslateChunkScreenPreview() {
|
||||
signature = "",
|
||||
chatRoomId = "chatRoomId"
|
||||
),
|
||||
existingTranslationText = ""
|
||||
existingTranslationText = "",
|
||||
canSign = true
|
||||
),
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -10,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -188,15 +187,13 @@ private fun ChunkTranslationRow(
|
||||
val translated = pair.translationChunk?.text?.takeIf { it.isNotBlank() }
|
||||
TableRow(
|
||||
left = { Text(pair.originalChunk.text) },
|
||||
// The whole translation cell opens the chunk translation editor. When
|
||||
// there is no translation yet, the original text is shown greyed out as
|
||||
// a placeholder. It stays plain text, laid out like the original cell
|
||||
// beside it, rather than a button with its own shape and padding.
|
||||
rightModifier = Modifier.clickable(onClick = onClick),
|
||||
right = {
|
||||
// The translation cell is a button that opens the chunk translation
|
||||
// editor. When there is no translation yet, the original text is
|
||||
// shown greyed out as a placeholder.
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = translated ?: pair.originalChunk.text,
|
||||
@@ -220,13 +217,14 @@ private fun ChunkTranslationRow(
|
||||
private fun TableRow(
|
||||
left: @Composable () -> Unit,
|
||||
right: @Composable () -> Unit,
|
||||
rightModifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f).padding(12.dp)) { left() }
|
||||
VerticalDivider(modifier = Modifier.fillMaxHeight())
|
||||
Box(modifier = Modifier.weight(1f).padding(12.dp)) { right() }
|
||||
Box(modifier = Modifier.weight(1f).then(rightModifier).padding(12.dp)) { right() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -991,15 +991,22 @@ fun MantraNavHost(
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
// Replace this editor and the stale chapter table beneath it so
|
||||
// we land on a freshly-loaded table reflecting the saved translation.
|
||||
navController.navigate(route = actionRoute) {
|
||||
popUpTo<TranslationChapterRoute> {
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace this editor so back returns to the chapter table
|
||||
// rather than to a form whose proposal has already gone out.
|
||||
// The table itself is left alone: nothing is translated until
|
||||
// the group signs, so there is nothing new for it to show.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<TranslateChunkRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(route = actionRoute)
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.view.state.AddArtifactUIState
|
||||
@@ -75,6 +76,10 @@ class AddArtifactViewModel(
|
||||
* putting this in front of the group" and the group's only recourse
|
||||
* afterwards is social. A signature is the group saying it, and it takes a
|
||||
* quorum to say. A library is the group's, so the second is the honest one.
|
||||
*
|
||||
* Two events, one session: the artifact and the version it starts life with,
|
||||
* which a chapter later hangs off. All-or-nothing, which is right here --
|
||||
* an artifact with no version is inert, since nothing can attach to it.
|
||||
*/
|
||||
fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
@@ -103,10 +108,12 @@ class AddArtifactViewModel(
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// The version label rides on the artifact rather than following it as
|
||||
// a second event. The group signs the artifact; a first version
|
||||
// proposed on its own would cost a second quorum for one form, and
|
||||
// every device derives the same first version from what was signed.
|
||||
// The label rides on the artifact and the version is signed beside
|
||||
// it, in the same batch. A first version proposed on its own would
|
||||
// cost a second quorum for one form, which is why it used to be
|
||||
// derived on arrival instead; a batch costs one quorum, so the row a
|
||||
// chapter hangs off can carry the group's signature rather than
|
||||
// being rebuilt from the artifact by every device that holds it.
|
||||
val artifactEventTemplate = ArtifactEvent.build(
|
||||
name = name,
|
||||
url = url,
|
||||
@@ -117,12 +124,15 @@ class AddArtifactViewModel(
|
||||
)
|
||||
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
frostSigningRepository.proposeSigningBatch(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = artifactEventTemplate.kind,
|
||||
tags = artifactEventTemplate.tags,
|
||||
content = artifactEventTemplate.content,
|
||||
lead = artifactEventTemplate,
|
||||
// The version names the artifact it is of, and that id is a
|
||||
// hash over the group's key at the room's path -- neither of
|
||||
// which this screen knows or should. The artifact comes back
|
||||
// built, and the version is read off the label it declares.
|
||||
dependents = ArtifactVersionEvent::initialVersionOf
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose an artifact for signing", error)
|
||||
|
||||
@@ -15,6 +15,11 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.MantraChunk
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.view.state.TranslateChunkUIState
|
||||
|
||||
@@ -26,6 +31,8 @@ class TranslateChunkViewModel(
|
||||
val relayHint: String?,
|
||||
initialTranslateChunkUIState: TranslateChunkUIState,
|
||||
val mantraRepository: MantraRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var translateChunkUIState: TranslateChunkUIState by mutableStateOf(initialTranslateChunkUIState)
|
||||
@@ -38,51 +45,93 @@ class TranslateChunkViewModel(
|
||||
fun initiateTranslateChunk() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val originalChunk = mantraRepository.getChunk(chunkId)
|
||||
translateChunkUIState = if (originalChunk == null) {
|
||||
TranslateChunkUIState.Error("Couldn't find the chunk")
|
||||
} else {
|
||||
val existing = mantraRepository.getTranslationChunks(translationChapterId)
|
||||
.firstOrNull { it.chunkId == chunkId }
|
||||
TranslateChunkUIState.Loaded(
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
|
||||
translateChunkUIState = when {
|
||||
originalChunk == null -> TranslateChunkUIState.Error("Couldn't find the chunk")
|
||||
localChatRoom == null -> TranslateChunkUIState.Error("Couldn't find the chat room")
|
||||
else -> TranslateChunkUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
originalChunk = originalChunk,
|
||||
existingTranslationText = existing?.text.orEmpty(),
|
||||
// What the group has already signed for this chunk, so a
|
||||
// retranslation starts from it rather than from nothing.
|
||||
existingTranslationText = mantraRepository
|
||||
.getTranslationChunks(translationChapterId)
|
||||
.firstOrNull { it.chunkId == chunkId }
|
||||
?.text
|
||||
.orEmpty(),
|
||||
canSign = frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveTranslation(
|
||||
/**
|
||||
* Asks the group to sign this chunk's translation.
|
||||
*
|
||||
* The translation is not saved here and does not exist yet. What goes out is
|
||||
* a proposal to sign it, and the translated chunk appears -- on every
|
||||
* member's device at once, authored by this room's own key rather than by
|
||||
* whoever typed it -- when enough members have signed. That author is the
|
||||
* room's id: signing runs at the path the room was derived at, so a
|
||||
* translated chunk says which group rendered it simply by being signed.
|
||||
*
|
||||
* That is the difference from submitting one. A submission says "I am
|
||||
* putting this in front of the group" and the group's only recourse
|
||||
* afterwards is social. A signature is the group saying it, and it takes a
|
||||
* quorum to say. What a chunk means in another tongue is a claim the group
|
||||
* is making about somebody else's words, so the second is the honest one.
|
||||
*
|
||||
* A retranslation goes the same way: the event carries a different text and
|
||||
* so a different id, and it replaces the one the group signed before rather
|
||||
* than editing it -- there is nothing here to edit, since the old text is
|
||||
* already signed.
|
||||
*/
|
||||
fun proposeTranslation(
|
||||
localChatRoom: LocalChatRoom,
|
||||
originalChunk: MantraChunk,
|
||||
translationField: TextFieldState,
|
||||
onSuccess: () -> Unit,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
val text = translationField.text.toString()
|
||||
if (text.isBlank()) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onFailure.invoke()
|
||||
}
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against double submits from repeated FAB taps.
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val saved = runCatching {
|
||||
mantraRepository.saveTranslationChunk(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = chunkId,
|
||||
text = text,
|
||||
chatRoomId = chatRoomId,
|
||||
val translationChunkEventTemplate = TranslationChunkEvent.build(
|
||||
translationChapterId = translationChapterId,
|
||||
chunkId = originalChunk.id,
|
||||
// The translation sits where the chunk it translates sits. Read
|
||||
// off the source rather than counted here: a chapter is
|
||||
// translated a chunk at a time and in no particular order, so
|
||||
// counting what is translated so far would number them by who
|
||||
// got there first.
|
||||
index = originalChunk.index,
|
||||
text = text,
|
||||
)
|
||||
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = translationChunkEventTemplate.kind,
|
||||
tags = translationChunkEventTemplate.tags,
|
||||
content = translationChunkEventTemplate.content,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to save translation", error)
|
||||
logger.e("Failed to propose a translation for signing", error)
|
||||
}.getOrNull()
|
||||
|
||||
if (saved != null) {
|
||||
if (session != null) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onSuccess.invoke()
|
||||
onSuccess.invoke(session.id)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
@@ -105,6 +154,8 @@ class TranslateChunkViewModel(
|
||||
relayHint: String?,
|
||||
initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
TranslateChunkViewModel(
|
||||
@@ -115,6 +166,8 @@ class TranslateChunkViewModel(
|
||||
relayHint = relayHint,
|
||||
initialTranslateChunkUIState = initialTranslateChunkUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.MantraChunk
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
sealed interface TranslateChunkUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
val originalChunk: MantraChunk,
|
||||
val existingTranslationText: String = "",
|
||||
|
||||
/**
|
||||
* Whether the group holds a shared key. A translation is signed into
|
||||
* existence now rather than saved, so a group without one cannot
|
||||
* translate a chunk here at all.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): TranslateChunkUIState
|
||||
|
||||
data class Error(
|
||||
|
||||
Reference in New Issue
Block a user