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:
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.
|
||||
*
|
||||
|
||||
+50
@@ -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. */
|
||||
|
||||
+41
-1
@@ -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.
|
||||
*
|
||||
|
||||
+33
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user