feat(subgroups): say what the room is for, where a quorum is deciding

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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-03 01:48:10 +02:00
1 parent c77187e78d
commit 7640461345
8 files changed
+284 -1

No files matched your search

@@ -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 }
@@ -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.
*
@@ -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<String, String>,
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, String>
): 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. */
@@ -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<SubgroupManager.Subgroup>
): Map<HexKey, String> = 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.
*
@@ -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) }
}
}
}
}
}
@@ -44,6 +44,16 @@ sealed interface ChatRoomDetailUIState {
* signed and the room being created.
*/
val subgroups: List<SubgroupManager.Subgroup> = 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<HexKey, String> = emptyMap(),
/**
* The groups this room is a subgroup of, empty when it is nobody's
* child.