feat(subgroups): say which group the parent is, not only which id

The parent card on a room's detail screen read "Parent group" over sixteen hex
characters. Every other card naming a room names it -- `SubgroupCard` has shown
`room?.chatRoom?.subject` since subgroups landed -- and the one card pointing the other
way up the lineage did not, although the room it points at is usually right there in the
database.

So: "Parent group" moves to the overline, the group's name becomes the headline, and the
truncated id moves underneath, where it can still be read against what the lineage says.
The label keeps its place rather than being replaced, because a card carrying a name and
nothing else says nothing about which way the lineage runs, and the signing key's card --
same icon column, same shape -- sits directly above this one.

**The name can only come from the room, and there is not always one.** A birth
certificate names the child and never the certifier, so a member of the subgroup who is
not also in the group above holds a verified lineage with nothing to call it by. That
case keeps the card it already had: the id as the headline, no supporting line. It is not
a corner -- it is the ordinary position of anyone welcomed into a subgroup rather than
founding one -- so the room is a nullable field rather than something to wait for.

`SubgroupManager.parentGroupsOf` is `parentsOf` with each id looked up, returning
`Parent(chatRoomId, room)` the way `subgroupsOf` returns `Subgroup`. The verified set and
its canonical order are untouched; a parent whose room is missing stays in the list. The
id-only `parentsOf` stays as it is, because `offersTo` compares ids and should not pay
for rooms it will not read.

Three tests: a parent this device is in is named by its room, one it is not in is still
listed with nothing to name it, and two parents keep the lineage's own order -- asserted
against `parentsOf` rather than a written-out order, since canonical means sorted by id
and nobody should have to know how two derived ids sort.

459 common tests, 985 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-02 22:18:53 +02:00
1 parent 1a6034dadb
commit 395bfa6b8b
7 files changed
+138 -21

No files matched your search

@@ -192,8 +192,8 @@ class DatabaseChatRepository(
false
}
override suspend fun parentsOf(chatRoomId: String): List<HexKey> = try {
SubgroupManager.parentsOf(database, chatRoomId)
override suspend fun parentsOf(chatRoomId: String): List<SubgroupManager.Parent> = try {
SubgroupManager.parentGroupsOf(database, chatRoomId)
} catch (e: Throwable) {
logger.e("Error reading the parents of $chatRoomId", e)
emptyList()
@@ -842,6 +842,35 @@ object SubgroupManager {
database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds()
.orEmpty()
/**
* A group this room is a child of, plus the room itself where this device
* has it.
*
* [room] is the only place a parent's *name* can come from: a certificate
* names the child, never the certifier, so a device that is not in the
* parent room knows the lineage and not what to call it. Null is therefore
* the ordinary case for a subgroup member who is not also a parent member,
* and the id is all such a screen can honestly show.
*/
data class Parent(
val chatRoomId: HexKey,
val room: LocalChatRoom?,
)
/**
* [parentsOf], with each parent's room where this device holds one.
*
* The same verified set in the same order -- this only looks each id up, and
* a parent whose room is missing stays in the list rather than being dropped.
*/
suspend fun parentGroupsOf(database: MantraDatabase, chatRoomId: String): List<Parent> =
parentsOf(database, chatRoomId).map { parentChatRoomId ->
Parent(
chatRoomId = parentChatRoomId,
room = database.chatRoomDao().findChatRoomById(parentChatRoomId)
)
}
/**
* What a certificate arriving in a room is, or null if it is nothing this
* room should believe.
@@ -78,8 +78,12 @@ interface ChatRepository {
* may be adopted by another later. A member welcomed in after the founding
* holds no state and gets an empty list even when the room really is a
* subgroup.
*
* Each carries the parent's room where this device has one, which is the
* only place its name can come from -- a certificate names the child and
* never the certifier.
*/
suspend fun parentsOf(chatRoomId: String): List<HexKey>
suspend fun parentsOf(chatRoomId: String): List<SubgroupManager.Parent>
/**
* One event the group signed, as the group signed it, or null when this
@@ -323,7 +327,9 @@ interface ChatRepository {
override suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup> =
emptyList()
override suspend fun parentsOf(chatRoomId: String): List<HexKey> = emptyList()
override suspend fun parentsOf(
chatRoomId: String
): List<SubgroupManager.Parent> = emptyList()
override suspend fun groupSignedEvent(
chatRoomId: String,
@@ -280,15 +280,40 @@ fun ChatRoomDetailScreen(
// later. They are a set rather than a path -- neither
// is "the" parent and the order says nothing about
// which came first -- so each gets an equal row.
chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId ->
item(key = "parent-$parentChatRoomId") {
chatRoomDetailUIState.parents.forEach { parent ->
item(key = "parent-${parent.chatRoomId}") {
// The parent's own name, which only its room
// can give: a certificate names the child and
// never the certifier, so a member of the
// subgroup who is not also in the parent has
// the id and nothing else to call it by.
val name = parent.room
?.chatRoom
?.subject
?.takeIf { it.isNotBlank() }
// Shown either way -- as the headline where
// there is no name, and under it where there
// is, since it is the part that can be
// compared against what the lineage says.
val identifier: @Composable () -> Unit = {
Text(
text = parent.chatRoomId
.take(16)
.inComparableGroups(),
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis
)
}
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
onNavigateToRoute.invoke(
ChatRoomDetailRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = parentChatRoomId,
chatRoomId = parent.chatRoomId,
relayHint = relayHint
)
)
@@ -307,19 +332,25 @@ fun ChatRoomDetailScreen(
contentDescription = Decorative
)
},
headlineContent = {
// The label keeps its place above the
// name rather than being replaced by
// it: a card carrying a group's name
// and nothing else says nothing about
// which way the lineage runs, and the
// signing key's card -- same icon
// column, same shape -- sits directly
// above this one.
overlineContent = {
Text(text = stringResource(Res.string.parent_group))
},
supportingContent = {
Text(
text = parentChatRoomId
.take(16)
.inComparableGroups(),
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis
)
}
headlineContent = {
if (name != null) {
Text(text = name)
} else {
identifier()
}
},
supportingContent = identifier.takeIf { name != null }
)
}
}
@@ -82,7 +82,7 @@ class ChatRoomDetailViewModel(
groupKeyState = chatRepository.groupKeyState(chatRoomId),
signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId),
subgroups = chatRepository.subgroupsOf(chatRoomId),
parentChatRoomIds = chatRepository.parentsOf(chatRoomId),
parents = chatRepository.parentsOf(chatRoomId),
canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null &&
chatRepository.canSign(chatRoomId),
adoptionOffers = chatRepository.adoptionOffersFor(chatRoomId),
@@ -41,15 +41,18 @@ sealed interface ChatRoomDetailUIState {
*/
val subgroups: List<SubgroupManager.Subgroup> = emptyList(),
/**
* The group this room is a subgroup of, or null when it is nobody's
* The groups this room is a subgroup of, empty when it is nobody's
* child.
*
* Verified: it comes off the room's key state, which only carries a
* parent whose certificate was checked. A member welcomed in after the
* founding holds no state and so sees nothing here, even in a room that
* really is a subgroup.
*
* Each carries the parent's room where this device holds one, so the
* card can name the group rather than only identify it.
*/
val parentChatRoomIds: List<HexKey> = emptyList(),
val parents: List<SubgroupManager.Parent> = emptyList(),
/**
* Whether this device could open a subgroup here at all.
*
@@ -777,6 +777,54 @@ class SubgroupManagerJvmTest {
Unit
}
@Test
fun `a parent this device is in is named by its room`() = runBlocking {
parentWith(members = listOf(alice, bob))
childWithKeyState(parents = listOf(parentRoomId))
val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single()
assertEquals(parentRoomId, parent.chatRoomId)
// The room, and nothing else, is where a parent's name comes from: a
// certificate names the child and never the certifier.
assertEquals("Ekklesia", parent.room?.chatRoom?.subject)
}
@Test
fun `a parent this device is not in is still listed, with nothing to name it`() = runBlocking {
// No parent room: the ordinary position of a subgroup member who is not
// also in the group above. The lineage is known -- it was checked
// against that group's own signature -- so the parent stays in the list
// and only its name is missing.
childWithKeyState(parents = listOf(parentRoomId))
val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single()
assertEquals(parentRoomId, parent.chatRoomId)
assertNull(parent.room)
}
@Test
fun `two parents keep the lineage's own order, named or not`() = runBlocking {
parentWith(members = listOf(alice, bob))
childWithKeyState(parents = listOf(parentRoomId, siblingRoomId))
val parents = SubgroupManager.parentGroupsOf(db, childRoomId)
// The same set in the same order -- canonical, which is sorted by id and
// so says nothing about which parent came first. Asserted against
// `parentsOf` rather than against a written-out order, since the ids are
// derived from keys and nobody should have to know how two of them sort.
assertEquals(
SubgroupManager.parentsOf(db, childRoomId),
parents.map { it.chatRoomId }
)
assertEquals(
mapOf(parentRoomId to "Ekklesia", siblingRoomId to null),
parents.associate { it.chatRoomId to it.room?.chatRoom?.subject }
)
}
private suspend fun refuseAdoptionOf(adopting: LocalChatRoom): String? =
SubgroupManager.refuseAdoption(
database = db,