From 4047a2bae95ed3faf7efa3f9e6e0bb0106c95a9a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 12:41:28 +0200 Subject: [PATCH] refactor: say what an event is in one place, not on one screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FrostSigningScreen` turned an event into words -- "New chapter", "Genesis 1 · 797 words · 31 chunks" -- inside a private composable, which was the right place for it while one screen was the only place a proposal was ever seen. The room's list of proposals is about to need the same words, and two copies of this mapping is two names for one thing. They would not drift immediately; they would drift the first time a kind is added and only one of them learns about it, and the reader would meet an artifact on one screen and "Event of kind 30300" on the other. `ProposedEvent.summarize` is the same `when`, moved whole, returning a label and a detail instead of a `Pair` so the two halves are named where they are read. Not a composable and deliberately: nothing about naming a thing needs a composition, and a plain function can be called from a view model, which is where the list does it -- once per emission rather than once per recomposition of a scrolling row. The reasoning moved with it, because it is reasoning about the words rather than about the screen: a member deciding whether to sign is deciding about a dialect or an artifact, "kind 30304" answers a question nobody asked, and the raw kind stays for anything unrecognised since refusing to describe an event is better than describing it wrongly. What stays on the screen is what is true only there: a batch is all-or-nothing, so an event that cannot be read is a reason to refuse the whole proposal rather than a gap to render around. No behaviour change. Same strings, same order, same fallback. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/text/ProposedEvent.kt | 113 ++++++++++++++++++ .../ui/composable/FrostSigningScreen.kt | 87 ++------------ 2 files changed, 122 insertions(+), 78 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt new file mode 100644 index 00000000..7d551150 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -0,0 +1,113 @@ +package press.mantra.compose.text + +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.ChapterEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.TranslationChapterEvent + +/** + * An event a group is being asked to sign, said in words. + * + * Shown as the thing rather than as an event: a member deciding whether to sign + * is deciding about a dialect or an artifact, and "kind 30304" answers a + * question nobody asked. The raw kind stays for anything not recognised, since + * refusing to describe an event is better than describing it wrongly. + * + * Not a composable, and deliberately: the same words appear on the signing + * screen and in the room's list of proposals, and two copies of this mapping + * would drift into two different names for one thing. + */ +object ProposedEvent { + /** + * [label] is what kind of thing it is, [detail] which one. Both are shown + * together, so neither repeats the other. + */ + data class Summary(val label: String, val detail: String) + + fun summarize(event: Event): Summary = when (event.kind) { + DialectEvent.KIND -> Summary( + label = "New dialect", + detail = DialectEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { dialect -> + listOfNotNull(dialect.name(), dialect.country(), dialect.language()) + .joinToString(" · ") + } + ) + + ArtifactEvent.KIND -> Summary( + label = "New artifact", + detail = ArtifactEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { artifact -> + // The url is the substance of an artifact -- signing one is putting + // the group's name to what it points at -- so it goes next to the + // name rather than being left for the detail screen afterwards. + listOfNotNull(event.content, artifact.versionLabel(), artifact.url()) + .joinToString(" · ") + } + ) + + ChapterEvent.KIND -> Summary( + label = "New chapter", + detail = ChapterEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).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 says how many of the batch's other items came out of this + // text, which is the rest of what is being signed. + 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(" · ") + } + ) + + TranslationArtifactVersionEvent.KIND -> Summary( + label = "New translation", + detail = TranslationArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { translation -> + // Named after the dialect it is into, which is the whole of what is + // being decided: everything else in the batch follows from it. + listOfNotNull(translation.name(), translation.visibility(), translation.license()) + .joinToString(" · ") + } + ) + + TranslationChapterEvent.KIND -> Summary( + label = "Chapter of the translation", + detail = TranslationChapterEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { chapter -> + // A translation chapter is a place for translated chunks to hang + // off, so there is nothing in it to read -- only where in the work + // it sits. Counted from one, the way the chapter list reads. + chapter.index()?.let { "Chapter ${it + 1}" } ?: "Position unknown" + } + ) + + // 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: + // the key is the room's own id, which the member is already looking at. + GroupKeyStateEvent.KIND -> Summary( + label = "This group's shared key", + detail = listOfNotNull( + GroupKeyStateEvent.parsePath(event.tags)?.let(SharedKeyDerivation::formatPath), + GroupKeyStateEvent.parseDkgSessionId(event.tags)?.take(12) + ).joinToString(" · ") + ) + + else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 86af594c..a62b92d1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -49,16 +49,9 @@ import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.managers.FrostSigningManager -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.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.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.text.Markdown +import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.FrostSigningViewModel @@ -322,10 +315,11 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { /** * What the group is being asked to put its name to. * - * Shown as the thing rather than as an event: a member deciding whether to sign - * is deciding about a dialect or an artifact, and "kind 30304" answers a - * question nobody asked. The raw kind stays for anything not recognised, since - * refusing to describe an event is better than describing it wrongly. + * One entry per event, each said as the thing it is rather than as an event -- + * see [ProposedEvent], which the room's list of proposals says it the same way + * from. Nothing is described here that could not be read: a batch is + * all-or-nothing, so a member who cannot see one of its events is being asked to + * sign something they cannot check. */ @Composable private fun WhatIsBeingSigned(events: List, expected: Int) { @@ -370,75 +364,12 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { /** One event of the batch, described as the thing it is. */ @Composable private fun OneThingBeingSigned(event: Event) { - val (label, detail) = when (event.kind) { - DialectEvent.KIND -> "New dialect" to DialectEvent( - event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig - ).let { dialect -> - listOfNotNull(dialect.name(), dialect.country(), dialect.language()) - .joinToString(" · ") - } - - ArtifactEvent.KIND -> "New artifact" to ArtifactEvent( - event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig - ).let { artifact -> - // The url is the substance of an artifact -- signing one is putting - // the group's name to what it points at -- so it goes next to the - // name rather than being left for the detail screen afterwards. - listOfNotNull(event.content, artifact.versionLabel(), artifact.url()) - .joinToString(" · ") - } - - ChapterEvent.KIND -> "New chapter" to ChapterEvent( - event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig - ).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 says how many of the batch's other items came out of this - // text, which is the rest of what is being signed. - 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(" · ") - } - - TranslationArtifactVersionEvent.KIND -> "New translation" to TranslationArtifactVersionEvent( - event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig - ).let { translation -> - // Named after the dialect it is into, which is the whole of what is - // being decided: everything else in the batch follows from it. - listOfNotNull(translation.name(), translation.visibility(), translation.license()) - .joinToString(" · ") - } - - TranslationChapterEvent.KIND -> "Chapter of the translation" to TranslationChapterEvent( - event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig - ).let { chapter -> - // A translation chapter is a place for translated chunks to hang - // off, so there is nothing in it to read -- only where in the work - // it sits. Counted from one, the way the chapter list reads. - chapter.index()?.let { "Chapter ${it + 1}" } ?: "Position unknown" - } - - // 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: - // the key is the room's own id, which the member is already looking at. - GroupKeyStateEvent.KIND -> "This group's shared key" to listOfNotNull( - GroupKeyStateEvent.parsePath(event.tags)?.let(SharedKeyDerivation::formatPath), - GroupKeyStateEvent.parseDkgSessionId(event.tags)?.take(12) - ).joinToString(" · ") - - else -> "Event of kind ${event.kind}" to event.content - } + val summary = ProposedEvent.summarize(event) Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { - Text(text = label, style = MaterialTheme.typography.labelMedium) + Text(text = summary.label, style = MaterialTheme.typography.labelMedium) - Text(text = detail, style = MaterialTheme.typography.titleMedium) + Text(text = summary.detail, style = MaterialTheme.typography.titleMedium) } }