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(),