From 7640461345a9ec94db86ff69f7796d8217b930ff Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:48:10 +0200 Subject: [PATCH] feat(subgroups): say what the room is for, where a quorum is deciding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the phase the origin tag was for. A parent admin approving a birth certificate is putting the group's signature to a room id produced by a ceremony most of them were not in; until now the screen offered them a name and a head count, which say how big the thing is and not what it does. The proposal now reads "#Setswana Luke · for the project Gospel of John · run by 4 members", and that middle clause is the only line that says what is being agreed to. `SubgroupManager.Subgroup` gains the parsed origin beside the name and admins it already parses, so every existing reader keeps working and the room's subgroup list can say what each child is for -- as an overline, because the supporting line is already carrying whether this device can open the room and both are worth a line. Names are resolved where the device holds the row and shown as a kind plus a short hash where it does not, which is `describe`'s existing bargain. That case is a fact rather than a gap: these entities live in the parent's tables, so a certificate is allowed to name a row the reader will never hold, and a test asserts the honest version rather than letting it read as a broken screen. An unrecognised kind is named by its number, or on the card left out entirely -- describing something wrongly is worse than not describing it. Resolution reuses the four `getX(id)` repository functions that already exist rather than adding a query, and happens once per load in the view model, because a composable cannot await a database. In `ChatRoomDetailViewModel` the list is read into a local first: resolving inside the argument list would make the map and the cards depend on evaluation order. The card's four strings are four complete sentences rather than a kind word interpolated into one, since a translated fragment assembled at runtime is a sentence no translator ever saw. Phase 6 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 4 + .../compose/managers/SubgroupManager.kt | 12 +++ .../mantra/compose/text/ProposedEvent.kt | 35 +++++++ .../ui/composable/ChatRoomDetailScreen.kt | 50 ++++++++++ .../ui/view/model/ChatRoomDetailViewModel.kt | 42 +++++++- .../ui/view/model/FrostSigningViewModel.kt | 33 +++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 10 ++ .../mantra/compose/text/ProposedEventTest.kt | 99 +++++++++++++++++++ 8 files changed, 284 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index cf280ebb..29213b0d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -32,6 +32,10 @@ Add chapter Add dialect Add to group + For the artifact %1$s + For the dialect %1$s + For the project %1$s + For the translation %1$s Give this a room of its own Add translation After this there is no turning back. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index faf20171..6f8ef1dd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -769,6 +769,17 @@ object SubgroupManager { val room: LocalChatRoom?, /** The child's key state, when this device holds one. */ val keyState: GroupKeyState?, + /** + * The dialect, artifact, translation or project this subgroup was made + * for, or null when it was made for nothing in particular. + * + * Off the certificate, like the name and the admins beside it, and + * carrying their limits: it is the *founding* intention, it is not + * checked, and it routinely names a row this device does not hold. A + * reader that cannot resolve it says so rather than hiding it -- see + * `ProposedEvent.summarize`. + */ + val origin: SubgroupOrigin.Ref?, ) { /** Whether this device can open the subgroup, rather than only know of it. */ val isJoined: Boolean get() = room != null @@ -823,6 +834,7 @@ object SubgroupManager { adminPublicKeys = SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags), room = database.chatRoomDao().findChatRoomById(chatRoomId), keyState = database.groupKeyStateDao().getByChatRoomId(chatRoomId), + origin = SubgroupBirthCertificateEvent.parseOrigin(event.tags), ) } .sortedByDescending { it.certificate.createdAt } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 00999643..35cfd4e6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -3,6 +3,7 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent @@ -277,6 +278,11 @@ object ProposedEvent { label = "A new subgroup of this group", detail = listOfNotNull( SubgroupBirthCertificateEvent.parseName(event.tags), + // What the room is *for*, which is the most useful sentence that + // can be put in front of a parent admin here: a name and a count + // say how big the thing is, and this says what it does. It is the + // whole reason the certificate carries an origin. + SubgroupBirthCertificateEvent.parseOrigin(event.tags)?.let { describeOrigin(it, resolve) }, SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags) .size .takeIf { it > 0 } @@ -287,6 +293,35 @@ object ProposedEvent { else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } + /** + * What a subgroup is for, in words: "for the project Gospel of John". + * + * Resolved where this device holds the row and shown as a kind and a short + * hash where it does not, which is [describe]'s bargain and is the honest + * answer here. A certificate routinely names a row the reader will never + * hold -- the entities live in the parent's tables and a subgroup's members + * need never have been in the parent -- so "for a project this device does + * not have" has to read as a fact rather than as a broken screen. + * + * An unrecognised kind is named by its number for the same reason + * [summarize] keeps the raw kind: refusing to describe something is better + * than describing it wrongly. + */ + private fun describeOrigin( + origin: SubgroupOrigin.Ref, + resolve: (HexKey) -> String? + ): String { + val what = when (origin.kind) { + DialectEvent.KIND -> "dialect" + ArtifactEvent.KIND -> "artifact" + TranslationArtifactVersionEvent.KIND -> "translation" + ProjectEvent.KIND -> "project" + else -> "kind ${origin.kind}" + } + + return "for the $what ${describe(origin.id, resolve)}" + } + /** * [id] as a name if [resolve] has one, and as a short hash otherwise. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 609005de..25cbbe29 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -105,6 +105,11 @@ import mantra.composeapp.generated.resources.subgroups import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager @@ -115,6 +120,10 @@ import mantra.composeapp.generated.resources.add_dialect import mantra.composeapp.generated.resources.create_project import mantra.composeapp.generated.resources.delete_group import mantra.composeapp.generated.resources.dialects +import mantra.composeapp.generated.resources.for_the_artifact +import mantra.composeapp.generated.resources.for_the_dialect +import mantra.composeapp.generated.resources.for_the_project +import mantra.composeapp.generated.resources.for_the_translation import mantra.composeapp.generated.resources.direct_message_detail import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here import mantra.composeapp.generated.resources.invite_new_member @@ -733,6 +742,7 @@ fun ChatRoomDetailScreen( ) { subgroup -> SubgroupCard( subgroup = subgroup, + originNames = chatRoomDetailUIState.subgroupOriginNames, onOpen = { // Into the child where this device has // it; at the certificate where it does @@ -1319,6 +1329,7 @@ internal fun GroupKeyStateSheetContent( @Composable private fun SubgroupCard( subgroup: SubgroupManager.Subgroup, + originNames: Map, onOpen: () -> Unit ) { Card(onClick = onOpen) { @@ -1329,6 +1340,15 @@ private fun SubgroupCard( trailingContent = { Icon(Icons.Default.ChevronRight, contentDescription = Decorative) }, + // What the room is for, above its name, where the certificate says. + // The supporting line below is already carrying whether this device + // can open the room, which is a different question and both are worth + // a line. + overlineContent = subgroup.origin?.let { origin -> + describeSubgroupOrigin(origin, originNames)?.let { described -> + { Text(described) } + } + }, headlineContent = { Text( text = subgroup.room?.chatRoom?.subject @@ -1355,6 +1375,36 @@ private fun SubgroupCard( } } +/** + * "For the project Gospel of John", or null for a kind this build does not know. + * + * Four complete sentences rather than a kind word interpolated into one, because + * a translated fragment assembled at runtime is a sentence no translator ever saw. + * + * An entity this device does not hold is named by a short hash, which is what + * the rest of the app does with something it has not seen. A certificate is + * allowed to name a row the reader will never have -- see + * `SubgroupManager.Subgroup.origin` -- so that case is a fact to state rather + * than a gap to hide. An unrecognised kind returns null and the line is simply + * absent: describing something wrongly is worse than not describing it. + */ +@Composable +private fun describeSubgroupOrigin( + origin: SubgroupOrigin.Ref, + originNames: Map +): String? { + val name = originNames[origin.id] ?: origin.id.take(12) + + return when (origin.kind) { + DialectEvent.KIND -> stringResource(Res.string.for_the_dialect, name) + ArtifactEvent.KIND -> stringResource(Res.string.for_the_artifact, name) + TranslationArtifactVersionEvent.KIND -> + stringResource(Res.string.for_the_translation, name) + ProjectEvent.KIND -> stringResource(Res.string.for_the_project, name) + else -> null + } +} + private fun String.inComparableGroups(): String = chunked(8).joinToString(" ") /** One labelled value of a key state, whole and comparable. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index b286f767..c1fe9aa5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -18,6 +18,11 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -75,6 +80,11 @@ class ChatRoomDetailViewModel( // Initiate new chat... and navigate to new chat... ChatRoomDetailUIState.Error("Missing chat room") } else { + // Read into a local so the names are resolved from exactly the + // list the cards are drawn from. Resolving inside the argument + // list would make the two depend on evaluation order. + val subgroups = chatRepository.subgroupsOf(chatRoomId) + ChatRoomDetailUIState.Loaded( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), @@ -82,7 +92,8 @@ class ChatRoomDetailViewModel( projects = mantraRepository.getProjects(chatRoomId), groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), - subgroups = chatRepository.subgroupsOf(chatRoomId), + subgroups = subgroups, + subgroupOriginNames = originNamesIn(subgroups), parents = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && chatRepository.canSign(chatRoomId), @@ -99,6 +110,35 @@ class ChatRoomDetailViewModel( } } + /** + * What each subgroup's origin is called, where this device holds the row. + * + * Only the rows the certificates actually name, and only from this device's + * own tables. An entity that is not here is left out, and the card shows a + * short hash -- which is truthful rather than a gap, because a certificate is + * allowed to name something the reader has never seen: these entities live in + * the parent's tables, and a parent member reading a certificate may hold the + * room without holding the row. + */ + private suspend fun originNamesIn( + subgroups: List + ): Map = buildMap { + subgroups.mapNotNull { it.origin }.forEach { origin -> + if (containsKey(origin.id)) return@forEach + + val name = when (origin.kind) { + DialectEvent.KIND -> mantraRepository.getDialect(origin.id)?.name + ArtifactEvent.KIND -> mantraRepository.getArtifact(origin.id)?.name + TranslationArtifactVersionEvent.KIND -> + mantraRepository.getTranslation(origin.id)?.name + ProjectEvent.KIND -> mantraRepository.getProject(origin.id)?.name + else -> null + } + + name?.let { put(origin.id, it) } + } + } + /** * Asks this group's quorum to accept [parentChatRoomId] as a parent. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt index e533a1e3..4872ee8e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt @@ -20,7 +20,11 @@ import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository @@ -152,6 +156,35 @@ class FrostSigningViewModel( } project(membership.projectId()) } + + // What a subgroup is being made for. This is the one branch + // where the resolution is the point of the whole proposal: a + // parent admin is putting the group's signature to a room id + // produced by a ceremony most of them were not in, and "for the + // translation Setswana Luke" is the only line on the screen that + // says what they are agreeing to. + // + // Unresolvable is a normal answer, not a gap. The entity lives + // in this room's tables, so a parent admin usually does hold it + // -- but a certificate can be read on a device that does not, + // and `summarize` then shows the kind and a short hash rather + // than pretending. + SubgroupBirthCertificateEvent.KIND -> { + val origin = SubgroupBirthCertificateEvent.parseOrigin(event.tags) + val id = origin?.id + + if (id != null && !containsKey(id)) { + val name = when (origin.kind) { + DialectEvent.KIND -> mantraRepository.getDialect(id)?.name + ArtifactEvent.KIND -> mantraRepository.getArtifact(id)?.name + TranslationArtifactVersionEvent.KIND -> + mantraRepository.getTranslation(id)?.name + ProjectEvent.KIND -> mantraRepository.getProject(id)?.name + else -> null + } + name?.let { put(id, it) } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 319aae57..0bf14202 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -44,6 +44,16 @@ sealed interface ChatRoomDetailUIState { * signed and the room being created. */ val subgroups: List = emptyList(), + /** + * What each subgroup's origin is called, where this device holds the row. + * + * Resolved once in the view model rather than per card, because a + * composable cannot await a database. Keyed by the entity's id, and an id + * that is missing here is shown as a short hash -- which is what the rest + * of the app does with something it has not seen, and is the honest answer: + * a certificate can name a row this device will never hold. + */ + val subgroupOriginNames: Map = emptyMap(), /** * The groups this room is a subgroup of, empty when it is nobody's * child. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 1915ed16..9e1bc053 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -2,6 +2,7 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.nostr.nip30303.ProjectEvent import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import kotlin.test.Test @@ -10,6 +11,7 @@ import kotlin.test.assertTrue import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage @@ -345,6 +347,103 @@ class ProposedEventTest { certificate = event(SubgroupBirthCertificateEvent.KIND, content = parentChatRoomId) ) + // --- what a subgroup is for, on the certificate a quorum is asked to sign --- + + /** The parent doing the certifying. Its own id, as the author would be. */ + private val certifyingParent = "9".repeat(64) + + /** + * The line that pays for the origin tag. + * + * A parent admin approving a certificate is putting the group's signature to + * a room id produced by a ceremony most of them were not in. A name and a + * head count say how big the thing is; this says what it does, and it is the + * only line on the screen that does. + */ + @Test + fun `a certificate says what the subgroup is for, by name where the row is held`() { + val projectId = "a1".padEnd(64, '0') + + val summary = ProposedEvent.summarize( + event = event( + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = "d".repeat(64), + parentChatRoomId = certifyingParent, + thresholdPublicKey = "02" + "e".repeat(64), + adminPublicKeys = listOf("1".repeat(64), "2".repeat(64), "3".repeat(64)), + name = "#Setswana Luke", + origin = SubgroupOrigin.Ref(kind = ProjectEvent.KIND, id = projectId) + ), + content = "d".repeat(64) + ), + resolve = { if (it == projectId) "Gospel of John" else null } + ) + + assertEquals("A new subgroup of this group", summary.label) + assertEquals( + "#Setswana Luke · for the project Gospel of John · run by 3 members", + summary.detail + ) + } + + /** + * Unresolvable is a fact to state, not a gap to hide. A certificate may name + * a row the reader will never hold -- these entities live in the parent's + * tables and a subgroup's members need never have been in the parent -- so + * the kind is still said and the id is truncated, which is what the rest of + * the app does with something it has not seen. + */ + @Test + fun `an unresolvable origin still says what kind of thing it is`() { + val summary = ProposedEvent.summarize( + event = event( + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = "d".repeat(64), + parentChatRoomId = certifyingParent, + thresholdPublicKey = "02" + "e".repeat(64), + adminPublicKeys = listOf("1".repeat(64)), + name = "#Sesotho", + origin = SubgroupOrigin.Ref( + kind = DialectEvent.KIND, + id = "beef".padEnd(64, '0') + ) + ), + content = "d".repeat(64) + ) + ) + + assertTrue( + summary.detail.contains("for the dialect beef00000000"), + "the detail did not name the kind and the short hash: ${summary.detail}" + ) + } + + /** + * A subgroup for nothing in particular says nothing about an origin, rather + * than saying "for nothing". That is the group's own "Add subgroup", and it + * is also every certificate signed before the tag existed. + */ + @Test + fun `a subgroup for nothing in particular reads as it always did`() { + val summary = ProposedEvent.summarize( + event = event( + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = "d".repeat(64), + parentChatRoomId = certifyingParent, + thresholdPublicKey = "02" + "e".repeat(64), + adminPublicKeys = listOf("1".repeat(64), "2".repeat(64)), + name = "#Translators" + ), + content = "d".repeat(64) + ) + ) + + assertEquals("#Translators · run by 2 members", summary.detail) + } + private fun event( kind: Int, tags: Array> = emptyArray(),