feat: sign a chapter into the artifact instead of submitting one
Adding a chapter no longer creates one. It opens a signing session over a ChapterEvent, and the chapter appears -- on every member's device at once, authored by the room's shared key rather than by whoever pasted the text -- when enough members have signed. The same trade the dialects and artifacts made: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social, while a signature is the group saying it and it takes a quorum to say. The text everybody translates from is the group's, so the second is the honest one. **The chunks.** This is the part chapters had that dialects and artifacts did not. A chapter was submitted along with a ChunkEvent per paragraph, and that cannot survive the change: a translation is of a chunk rather than of a chapter, so a chapter without them cannot be worked on, but the chunks cannot have their own quorum without costing one signing session per paragraph, and they cannot be invented locally -- an invented id differs on every device, so members would silently disagree about which chunk a translation is of while every screen showed the same chapter. So the chunks are split back out of the signed chapter's own text when it is applied, in MantraChunk.chunksOf, on the pattern MantraArtifactVersion.initialVersionOf already set. Same bytes in, same rows out, everywhere. They are rumors, because nobody signed them; what the group signed is the chapter they were split from. It splits only what the group signed. A chapter that arrived as a submission was sent with its own chunk events, written under the submitter's key, and deriving a second set beside them would leave every paragraph in the chapter twice under ids nothing reconciles -- including on a marmot reindex, which replays a room's group events without anybody adding anything. **The index.** Where a chapter sits in its version is read at proposal time and signed into the event, rather than derived on arrival like the chunks are. A device applying the chapter cannot recount it: it would be counting a version other members may have added to in a different order, and the count has to be the one the group put its signature to. The window between proposal and quorum is longer than the old write-and-submit window was, so two chapters proposed at once can still land on one index -- the same race as before, wider. **What went away.** MantraDao.addChapter and its way up through the repository. Nothing called it once the screen proposed instead, and leaving a path that authors a chapter under a member's key while the UI insists on a quorum would have double-created the chunks besides. MantraRepository.getChaptersForArtifactVersion replaces the one thing it did that is still needed: counting the index. **The screens.** AddChapterScreen loads the room, the artifact's latest version and canSign up front, disables the FAB when either is missing the way the dialect and artifact screens do, and on success lands on the session rather than on an artifact the chapter is not in yet. FrostSigningScreen described a chapter proposal by name alone, so a member was asked to sign text whose size they could not see; it now reads name, word count and chunk count, the way an artifact shows its url. **Tests.** Two files, and each was checked against a broken implementation rather than only against a working one: deriving the chunks from the clock, inheriting the chapter's counts across every chunk, authoring the derived rows as their reader, and losing the paragraph position are all caught, as is splitting a chapter that arrived as a submission. SignedChapterTest runs a real 2-of-3 quorum over an actual proposal, because the claim worth holding -- the chapter is the group's, carries proof of it, and every device splits it into the same chunks -- is invisible when it breaks. Not covered: applyInnerEvent's upserts, which need a database no test here stands up, and AddChapterViewModel, which is plumbing across two dispatchers over a template the tests already pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
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.MantraChapter
|
||||
import press.mantra.compose.database.model.MantraChunk
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraTranslationChapter
|
||||
@@ -19,8 +17,6 @@ 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.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
@@ -29,7 +25,6 @@ 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
|
||||
import press.mantra.compose.text.Markdown
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Dao
|
||||
@@ -201,81 +196,6 @@ abstract class MantraDao(
|
||||
return mantraArtifactVersion
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun addChapter(
|
||||
artifactId: String,
|
||||
name: String,
|
||||
originalText: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraChapter? {
|
||||
|
||||
// Chapters attach to an artifact version; use the latest one.
|
||||
val version = database.mantraArtifactVersionDao()
|
||||
.getArtifactVersionsByArtifactId(artifactId)
|
||||
.firstOrNull() ?: return null
|
||||
|
||||
val chapterIndex = database.mantraChapterDao()
|
||||
.getChaptersByArtifactVersionId(version.id)
|
||||
.size
|
||||
|
||||
val chapterEventTemplate = ChapterEvent.build(
|
||||
artifactVersionId = version.id,
|
||||
name = name,
|
||||
originalText = originalText,
|
||||
index = chapterIndex,
|
||||
wordCount = Markdown.wordCount(originalText),
|
||||
characterCount = Markdown.characterCount(originalText),
|
||||
)
|
||||
|
||||
val chapter = MantraChapter.fromChapterEventTemplate(
|
||||
chapterEventTemplate = chapterEventTemplate,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
) ?: return null
|
||||
|
||||
return try {
|
||||
database.mantraChapterDao().upsert(chapter)
|
||||
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(chapterEventTemplate, userPublicKey),
|
||||
text = "Added chapter to artifact", // TODO: Get artifact to use in text...
|
||||
)
|
||||
|
||||
// Split the markdown into paragraph chunks.
|
||||
Markdown.splitParagraphs(originalText).forEachIndexed { chunkIndex, paragraph ->
|
||||
val chunkEventTemplate = ChunkEvent.build(
|
||||
chapterId = chapter.id,
|
||||
text = paragraph,
|
||||
index = chunkIndex,
|
||||
wordCount = Markdown.wordCount(paragraph),
|
||||
characterCount = Markdown.characterCount(paragraph),
|
||||
)
|
||||
MantraChunk.fromChunkEventTemplate(
|
||||
chunkEventTemplate = chunkEventTemplate,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey,
|
||||
)?.let { chunk ->
|
||||
database.mantraChunkDao().upsert(chunk)
|
||||
|
||||
submitToGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(chunkEventTemplate, userPublicKey),
|
||||
text = "${chapter.name} added chunk $chunkIndex", // TODO: Use a portion of the actual chunked text...
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
chapter
|
||||
} catch (error: Throwable) {
|
||||
logger.e("Failed to add chapter \"$name\" to artifact $artifactId", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun addTranslationArtifactVersion(
|
||||
artifactId: String,
|
||||
|
||||
@@ -805,15 +805,17 @@ data class ChatMessage(
|
||||
}
|
||||
}
|
||||
ChapterEvent.KIND -> {
|
||||
val chapterEvent = ChapterEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
)
|
||||
|
||||
MantraChapter.fromChapterEvent(
|
||||
chapterEvent = ChapterEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chapterEvent = chapterEvent,
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraChapter ->
|
||||
database.mantraChapterDao().upsert(
|
||||
@@ -822,6 +824,24 @@ data class ChatMessage(
|
||||
)
|
||||
)
|
||||
|
||||
// A signed chapter arrives with the chunks it is made
|
||||
// of, split out here rather than sent, so that every
|
||||
// device holding the chapter holds the same chunks.
|
||||
// Nothing hangs off a chapter directly -- a translation
|
||||
// is of a chunk -- so a chapter without them cannot be
|
||||
// worked on. A submitted chapter brought its own, which
|
||||
// is why this splits only what the group signed.
|
||||
MantraChunk.chunksOf(
|
||||
chapterEvent = chapterEvent,
|
||||
chatRoomId = groupId,
|
||||
).forEach { chunk ->
|
||||
database.mantraChunkDao().upsert(
|
||||
chunk.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "chapter",
|
||||
|
||||
@@ -10,10 +10,12 @@ 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.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.IndexTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.WordStatisticsTag
|
||||
import press.mantra.compose.text.Markdown
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -90,6 +92,55 @@ data class MantraChunk(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The chunks a chapter is made of, derived from the chapter.
|
||||
*
|
||||
* The group signs a chapter; it does not sign these. So the chunks
|
||||
* cannot be events proposed on their own -- that would cost a quorum
|
||||
* per paragraph -- and they cannot be invented by whichever device
|
||||
* notices the chapter first, because an invented id differs on every
|
||||
* device and none of them would agree about which chunk a translation
|
||||
* is of. Deriving them from the signed chapter's own text gives every
|
||||
* device the same rows from the same bytes, which is the only property
|
||||
* that matters here.
|
||||
*
|
||||
* They are rumors -- empty signature -- because nobody signed them.
|
||||
* What the group signed is the chapter they were split out of, and that
|
||||
* is also the only chapter this splits: a chapter that arrived as a
|
||||
* submission was sent with its own chunk events, written under the
|
||||
* submitter's key, and deriving a second set beside them would leave
|
||||
* every paragraph in the chapter twice under ids nothing reconciles.
|
||||
*
|
||||
* Empty when the chapter has no text to split, which is every chapter
|
||||
* whose original text is blank.
|
||||
*/
|
||||
fun chunksOf(
|
||||
chapterEvent: ChapterEvent,
|
||||
chatRoomId: HexKey,
|
||||
): List<MantraChunk> {
|
||||
if (chapterEvent.sig.isEmpty()) return emptyList()
|
||||
|
||||
val originalText = chapterEvent.originalText() ?: return emptyList()
|
||||
|
||||
return Markdown.splitParagraphs(originalText).mapIndexedNotNull { index, paragraph ->
|
||||
fromChunkEventTemplate(
|
||||
chunkEventTemplate = ChunkEvent.build(
|
||||
chapterId = chapterEvent.id,
|
||||
text = paragraph,
|
||||
index = index,
|
||||
wordCount = Markdown.wordCount(paragraph),
|
||||
characterCount = Markdown.characterCount(paragraph),
|
||||
// The chapter's own timestamp, not the reader's: a device
|
||||
// applying the chapter an hour later has to arrive at the
|
||||
// same ids as the one that applied it first.
|
||||
createdAt = chapterEvent.createdAt,
|
||||
),
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = chapterEvent.pubKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fromChunkEventTemplate(
|
||||
chunkEventTemplate: EventTemplate<ChunkEvent>,
|
||||
chatRoomId: HexKey,
|
||||
|
||||
@@ -39,6 +39,9 @@ class DatabaseMantraRepository(
|
||||
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> =
|
||||
database.mantraChapterDao().getChaptersByArtifactId(artifactId)
|
||||
|
||||
override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List<MantraChapter> =
|
||||
database.mantraChapterDao().getChaptersByArtifactVersionId(artifactVersionId)
|
||||
|
||||
override suspend fun addTranslationArtifactVersion(
|
||||
artifactId: String,
|
||||
dialectId: String,
|
||||
@@ -93,22 +96,6 @@ class DatabaseMantraRepository(
|
||||
override suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk> =
|
||||
database.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(translationChapterId)
|
||||
|
||||
override suspend fun addChapter(
|
||||
artifactId: String,
|
||||
name: String,
|
||||
originalText: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraChapter? {
|
||||
return database.mantraDao().addChapter(
|
||||
artifactId = artifactId,
|
||||
name = name,
|
||||
originalText = originalText,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = userPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> =
|
||||
database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ class ChapterEvent(
|
||||
fun artifactVersionId() = artifactVersionIdReference()?.eventId
|
||||
|
||||
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name
|
||||
|
||||
/**
|
||||
* The markdown the chapter is, and the only place its chunks come from.
|
||||
*
|
||||
* Carried on the chapter rather than in an event per paragraph because the
|
||||
* group signs the chapter and nothing else. Chunks proposed separately would
|
||||
* need a quorum each, and ids invented locally differ on every device
|
||||
* holding the same chapter -- so they are split back out of this text when
|
||||
* the signed chapter is applied (see MantraChunk.chunksOf), which gives
|
||||
* every device the same rows from the same bytes.
|
||||
*/
|
||||
fun originalText() = tags.firstNotNullOfOrNull(OriginalTextTag::parse)?.originalText
|
||||
|
||||
fun index() = tags.firstNotNullOfOrNull(IndexTag::parse)?.index
|
||||
|
||||
@@ -22,6 +22,13 @@ interface MantraRepository {
|
||||
|
||||
suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter>
|
||||
|
||||
/**
|
||||
* The chapters of one version of an artifact, in reading order. A chapter
|
||||
* hangs off a version rather than off the artifact, so this is what a new
|
||||
* chapter's index counts.
|
||||
*/
|
||||
suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List<MantraChapter>
|
||||
|
||||
suspend fun getChapter(id: String): MantraChapter?
|
||||
|
||||
suspend fun getChunksForChapter(chapterId: String): List<MantraChunk>
|
||||
@@ -66,20 +73,6 @@ interface MantraRepository {
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationArtifactVersion?
|
||||
|
||||
/**
|
||||
* Add a chapter (markdown [originalText]) to the artifact's latest version.
|
||||
* Word/character counts are derived from the text, and the chapter is split
|
||||
* by paragraph into associated chunks. Returns null when the artifact has no
|
||||
* version to attach to.
|
||||
*/
|
||||
suspend fun addChapter(
|
||||
artifactId: String,
|
||||
name: String,
|
||||
originalText: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraChapter?
|
||||
|
||||
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
|
||||
|
||||
suspend fun getDialect(id: String): MantraDialect?
|
||||
@@ -114,6 +107,8 @@ interface MantraRepository {
|
||||
|
||||
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> = emptyList()
|
||||
|
||||
override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List<MantraChapter> = emptyList()
|
||||
|
||||
override suspend fun getChapter(id: String): MantraChapter? = null
|
||||
|
||||
override suspend fun getChunksForChapter(chapterId: String): List<MantraChunk> = emptyList()
|
||||
@@ -145,14 +140,6 @@ interface MantraRepository {
|
||||
userPublicKey: HexKey,
|
||||
): MantraTranslationArtifactVersion? = null
|
||||
|
||||
override suspend fun addChapter(
|
||||
artifactId: String,
|
||||
name: String,
|
||||
originalText: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraChapter? = null
|
||||
|
||||
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
|
||||
|
||||
override suspend fun getDialect(id: String): MantraDialect? = null
|
||||
|
||||
@@ -13,8 +13,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.DriveFileRenameOutline
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
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
|
||||
@@ -23,19 +25,28 @@ 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.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
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.text.Markdown
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
|
||||
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.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
@@ -51,6 +62,9 @@ fun AddChapterScreen(
|
||||
relayHint: String?,
|
||||
initialAddChapterUIState: AddChapterUIState = AddChapterUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
@@ -62,6 +76,8 @@ fun AddChapterScreen(
|
||||
relayHint = relayHint,
|
||||
initialAddChapterUIState = initialAddChapterUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -86,6 +102,15 @@ fun AddChapterScreen(
|
||||
val characterCount = Markdown.characterCount(originalText)
|
||||
val paragraphCount = Markdown.splitParagraphs(originalText).size
|
||||
|
||||
// A chapter hangs off a version, and the group has to be able to
|
||||
// sign; without both there is nothing this screen can propose.
|
||||
val artifactVersion = addChapterUIState.artifactVersion
|
||||
val canProposeChapter = artifactVersion != null && addChapterUIState.canSign
|
||||
|
||||
// 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(
|
||||
@@ -105,29 +130,59 @@ fun AddChapterScreen(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
modifier = if (canProposeChapter) {
|
||||
Modifier
|
||||
} else {
|
||||
// Looking unavailable is not being unavailable.
|
||||
Modifier.semantics { disabled() }
|
||||
},
|
||||
containerColor = if (canProposeChapter) {
|
||||
FloatingActionButtonDefaults.containerColor
|
||||
} else {
|
||||
buttonColors.disabledContainerColor
|
||||
},
|
||||
contentColor = if (canProposeChapter) {
|
||||
contentColorFor(FloatingActionButtonDefaults.containerColor)
|
||||
} else {
|
||||
buttonColors.disabledContentColor
|
||||
},
|
||||
onClick = {
|
||||
if (artifactVersion == null || !addChapterUIState.canSign) {
|
||||
return@ExtendedFloatingActionButton
|
||||
}
|
||||
|
||||
addChapterViewModel.addChapter(
|
||||
localChatRoom = addChapterUIState.localChatRoom,
|
||||
artifactVersion = artifactVersion,
|
||||
nameField = nameFieldState,
|
||||
originalTextField = originalTextFieldState,
|
||||
onSuccess = {
|
||||
onNavigateToRoute.invoke(
|
||||
ArtifactDetailRoute(
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than back to
|
||||
// the artifact. Nothing has been created
|
||||
// yet -- the chapter appears when enough
|
||||
// members sign -- so landing on the list
|
||||
// it is not in would read as a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
artifactId = artifactId,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = {}
|
||||
onFailure = {
|
||||
onNavigateToRoute.invoke(
|
||||
ImplementationPendingRoute("Failed Chapter")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Add chapter"
|
||||
contentDescription = "Propose chapter"
|
||||
)
|
||||
Text("Add Chapter")
|
||||
Text("Propose Chapter")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -137,6 +192,21 @@ fun AddChapterScreen(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (!addChapterUIState.canSign) {
|
||||
Text(
|
||||
text = "This group has no shared key, so it cannot sign a " +
|
||||
"chapter into existence. Run a shared key ceremony first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
} else if (artifactVersion == null) {
|
||||
Text(
|
||||
text = "This artifact has no version for a chapter to attach to.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = nameFieldState,
|
||||
@@ -201,6 +271,16 @@ private fun AddChapterScreenPreview() {
|
||||
chatRoomId = "chatRoomId",
|
||||
relayHint = null,
|
||||
initialAddChapterUIState = AddChapterUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = "chatRoomId",
|
||||
userPublicKey = "",
|
||||
subject = "Message title",
|
||||
description = "See something. Say somethin",
|
||||
initialGiftWrapPayloadId = "sdfaer",
|
||||
mlsGroupState = null
|
||||
),
|
||||
),
|
||||
artifact = MantraArtifact(
|
||||
id = "artifactId",
|
||||
publicKey = "author",
|
||||
@@ -211,9 +291,21 @@ private fun AddChapterScreenPreview() {
|
||||
license = "cc",
|
||||
chatRoomId = "chatRoomId",
|
||||
signature = ""
|
||||
)
|
||||
),
|
||||
artifactVersion = MantraArtifactVersion(
|
||||
id = "artifactVersionId",
|
||||
artifactId = "artifactId",
|
||||
publicKey = "author",
|
||||
versionLabel = "1.0",
|
||||
chatRoomId = "chatRoomId",
|
||||
signature = ""
|
||||
),
|
||||
canSign = true,
|
||||
),
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {}
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.text.Markdown
|
||||
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import press.mantra.compose.ui.view.model.FrostSigningViewModel
|
||||
@@ -319,7 +320,20 @@ private fun WhatIsBeingSigned(event: Event?) {
|
||||
|
||||
ChapterEvent.KIND -> "New chapter" to ChapterEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
).name().orEmpty()
|
||||
).let { chapter ->
|
||||
// The text is the substance of a chapter -- signing one is putting
|
||||
// the group's name to what everybody will translate from -- but it
|
||||
// is a whole chapter, so its size stands in for it here. The chunk
|
||||
// count is what the text will actually split into, counted the same
|
||||
// way the split itself counts (see MantraChunk.chunksOf).
|
||||
val chunkCount = chapter.originalText()?.let { Markdown.splitParagraphs(it).size }
|
||||
|
||||
listOfNotNull(
|
||||
chapter.name(),
|
||||
chapter.wordCount()?.let { "$it words" },
|
||||
chunkCount?.let { "$it ${if (it == 1) "chunk" else "chunks"}" }
|
||||
).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
|
||||
|
||||
@@ -1017,15 +1017,22 @@ fun MantraNavHost(
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
// Replace both this add screen and the stale artifact detail
|
||||
// beneath it so we land on a freshly-loaded detail screen.
|
||||
navController.navigate(route = actionRoute) {
|
||||
popUpTo<ArtifactDetailRoute> {
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace this add screen so back returns to the artifact
|
||||
// rather than to a form whose proposal has already gone out.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<AddChapterRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(
|
||||
route = actionRoute
|
||||
)
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
@@ -16,7 +16,13 @@ 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.MantraArtifactVersion
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.text.Markdown
|
||||
import press.mantra.compose.ui.view.state.AddChapterUIState
|
||||
|
||||
class AddChapterViewModel(
|
||||
@@ -26,6 +32,8 @@ class AddChapterViewModel(
|
||||
val relayHint: String?,
|
||||
initialAddChapterUIState: AddChapterUIState,
|
||||
val mantraRepository: MantraRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var addChapterUIState: AddChapterUIState by mutableStateOf(initialAddChapterUIState)
|
||||
@@ -38,27 +46,52 @@ class AddChapterViewModel(
|
||||
fun initiateAddChapter() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val artifact = mantraRepository.getArtifact(artifactId)
|
||||
addChapterUIState = if (artifact == null) {
|
||||
AddChapterUIState.Error("Couldn't find the artifact")
|
||||
} else {
|
||||
AddChapterUIState.Loaded(artifact = artifact)
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
|
||||
addChapterUIState = when {
|
||||
artifact == null -> AddChapterUIState.Error("Couldn't find the artifact")
|
||||
localChatRoom == null -> AddChapterUIState.Error("Couldn't find the chat room")
|
||||
else -> AddChapterUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
artifact = artifact,
|
||||
// Chapters attach to an artifact version; use the latest one.
|
||||
artifactVersion = mantraRepository.getArtifactVersions(artifactId).firstOrNull(),
|
||||
canSign = frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to sign a chapter into the artifact.
|
||||
*
|
||||
* The chapter is not created here and does not exist yet. What goes out is a
|
||||
* proposal to sign it, and the chapter -- with the chunks it splits into --
|
||||
* appears on every member's device at once, authored by this room's own key
|
||||
* rather than by whoever pasted the text, when enough members have signed.
|
||||
* That author is the room's id: signing runs at the path the room was
|
||||
* derived at, so a chapter says which group's artifact it belongs to 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. The text everyone will translate from is the group's, so
|
||||
* the second is the honest one.
|
||||
*/
|
||||
fun addChapter(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactVersion: MantraArtifactVersion,
|
||||
nameField: TextFieldState,
|
||||
originalTextField: TextFieldState,
|
||||
onSuccess: () -> Unit,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
val name = nameField.text.toString()
|
||||
val originalText = originalTextField.text.toString()
|
||||
|
||||
if (name.isBlank() || originalText.isBlank()) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onFailure.invoke()
|
||||
}
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,23 +100,42 @@ class AddChapterViewModel(
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val chapter = runCatching {
|
||||
mantraRepository.addChapter(
|
||||
artifactId = artifactId,
|
||||
name = name,
|
||||
originalText = originalText,
|
||||
chatRoomId = chatRoomId,
|
||||
// The chunks are not proposed. They are split out of the chapter's
|
||||
// own text when the signed chapter is applied (see
|
||||
// MantraChunk.chunksOf), so one quorum buys the whole chapter rather
|
||||
// than one per paragraph, and every device splits the same text the
|
||||
// same way.
|
||||
val chapterEventTemplate = ChapterEvent.build(
|
||||
artifactVersionId = artifactVersion.id,
|
||||
name = name,
|
||||
originalText = originalText,
|
||||
// Where this chapter sits in the version, read now rather than
|
||||
// derived on arrival: the index is signed into the event, and a
|
||||
// device applying it cannot recount from a version other members
|
||||
// may have added to in a different order.
|
||||
index = mantraRepository.getChaptersForArtifactVersion(artifactVersion.id).size,
|
||||
wordCount = Markdown.wordCount(originalText),
|
||||
characterCount = Markdown.characterCount(originalText),
|
||||
)
|
||||
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = chapterEventTemplate.kind,
|
||||
tags = chapterEventTemplate.tags,
|
||||
content = chapterEventTemplate.content,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to add chapter", error)
|
||||
logger.e("Failed to propose a chapter for signing", error)
|
||||
}.getOrNull()
|
||||
|
||||
if (chapter != null) {
|
||||
if (session != null) {
|
||||
nameField.clearText()
|
||||
originalTextField.clearText()
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onSuccess.invoke()
|
||||
onSuccess.invoke(session.id)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
@@ -105,6 +157,8 @@ class AddChapterViewModel(
|
||||
relayHint: String?,
|
||||
initialAddChapterUIState: AddChapterUIState = AddChapterUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
AddChapterViewModel(
|
||||
@@ -114,6 +168,8 @@ class AddChapterViewModel(
|
||||
relayHint = relayHint,
|
||||
initialAddChapterUIState = initialAddChapterUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
sealed interface AddChapterUIState {
|
||||
data class Loaded(
|
||||
val artifact: MantraArtifact
|
||||
val localChatRoom: LocalChatRoom,
|
||||
val artifact: MantraArtifact,
|
||||
|
||||
/**
|
||||
* The version the chapter would attach to: the artifact's latest.
|
||||
*
|
||||
* Null on an artifact that has none, which is one nothing can be added
|
||||
* to -- a chapter hangs off a version rather than off an artifact.
|
||||
*/
|
||||
val artifactVersion: MantraArtifactVersion? = null,
|
||||
|
||||
/**
|
||||
* Whether the group holds a shared key. A chapter is signed into
|
||||
* existence now rather than submitted, so a group without one cannot
|
||||
* add one here at all.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): AddChapterUIState
|
||||
|
||||
data class Error(
|
||||
|
||||
Reference in New Issue
Block a user