diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 59f14509..f9ecbcd6 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -57,11 +57,11 @@
Create new chat
Create profile
Create project
- Create the #admins group
+ Create the admin room
Agree the group's signing key
- The group is agreeing what the #admins room will sign with. It takes %1$s of %2$s members, and the request is in this chat.
- The group has agreed what the #admins room will sign with.
- The group could not agree what the #admins room will sign with. Ask again — a fresh request is the only safe way to retry.
+ The group is agreeing what the admin room will sign with. It takes %1$s of %2$s members, and the request is in this chat.
+ The group has agreed what the admin room will sign with.
+ The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry.
Before the room exists, the group signs a statement of which key it will sign with. The room is then created already knowing it.
Creating new chat.
Currently no contacts. Please search and chat with a few people.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt
index fcaffae4..525af65b 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt
@@ -76,7 +76,7 @@ interface DkgSessionDao {
/**
* Every ceremony this device came out of holding a share, newest first.
*
- * Signing happens in the #admins room, whose id is derived from the key
+ * Signing happens in the admin room, whose id is derived from the key
* rather than from the room the ceremony ran in, so the key is found by
* matching that derivation rather than by a stored room id.
*/
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt
index 563d1269..dcdbb6f8 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt
@@ -188,7 +188,7 @@ abstract class MarmotOutboundDao(
* Adds every member in [peers] to a group in a single MLS commit.
*
* The shape to use whenever the whole membership is known up front -- creating a
- * room, or opening the #admins room after a key ceremony. One commit, one
+ * room, or opening the admin room after a key ceremony. One commit, one
* Welcome carrying an EncryptedGroupSecrets per joiner, and crucially **no
* intermediate epoch for anybody to miss**.
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt
index c715be78..5f204355 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt
@@ -886,7 +886,7 @@ abstract class NostrDao(
} else if (FrostSigningEvents.isFrostSigningKind(decryptedGiftWrapPayload.kind)) {
// A FROST signing message for one of our NIP-17
// groups, which is where a group makes its first
- // signature: the statement of what its #admins room
+ // signature: the statement of what its admin room
// signs with, agreed before that room is created.
// See `GroupKeyStateManager.propose`.
//
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt
index bf6cb6e6..eaa2fffb 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt
@@ -77,7 +77,7 @@ data class GroupSignedEvent(
*
* The exception is a group's first signature: its `GroupKeyStateEvent` is
* signed in the NIP-17 room the ceremony ran in, and is authored by the
- * #admins room that does not exist yet -- so this names where it happened
+ * admin room that does not exist yet -- so this names where it happened
* and [publicKey] names who signed. [verifies] cannot pass on that row, for
* the reason it gives.
*/
@@ -154,7 +154,7 @@ data class GroupSignedEvent(
* of the derivation rather than of this check.
*
* And a group's first signature is made in the NIP-17 room its ceremony ran
- * in, about the #admins room it is about to create, so [chatRoomId] is where
+ * in, about the admin room it is about to create, so [chatRoomId] is where
* it happened and [publicKey] is who signed. Check that one with
* `GroupKeyStateEvent.isSignedByGroup`, which asks the question this row
* cannot: whether the *named* room signed it.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt
index 77cb8324..dd4b69b8 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt
@@ -51,7 +51,7 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
*
* ### Two transports, because a group signs before it has a room
*
- * Almost every signature is made in the group's #admins room, and there a
+ * Almost every signature is made in the group's admin room, and there a
* signing message is an ordinary Marmot inner event needing no addressing of its
* own: the room already exists, its membership is exactly the share holders, and
* its id is derived from the key. One encrypted group event reaches everyone.
@@ -73,7 +73,7 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
* they expect is the id of the room they found it in.
*
* A session in a NIP-17 room signs as the room the ceremony's key derives --
- * the #admins room that does not exist yet -- which is the same rule read
+ * the admin room that does not exist yet -- which is the same rule read
* forwards: what a group signs as is the room the signature belongs to.
*
* The path comes from the room, never from a proposal -- [signingPath] -- because
@@ -1294,7 +1294,7 @@ object FrostSigningManager {
/**
* The key a room signs with, or null when it has none.
*
- * Signing usually runs in the #admins room, which is not where the ceremony
+ * Signing usually runs in the admin room, which is not where the ceremony
* ran. A ceremony needs a NIP-17 group -- every member an equal admin, no
* MLS tree to be outside of -- while a group event needs an MLS one, so the
* two cannot be the same room.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt
index d95be257..780d80e3 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt
@@ -45,7 +45,7 @@ import press.mantra.compose.nostr.subgroup.SubgroupParentage
* coordinator --[ 30320 proposal over a 30326 ]-> the same NIP-17 room
* ...a quorum signs, on gift wraps...
* every device holds the signed 30326, naming a room nobody has made yet
- * coordinator creates the #admins room --> adopt() files the state as it appears
+ * coordinator creates the admin room --> adopt() files the state as it appears
* ```
*
* It used to be the new room's first application message, which meant the group
@@ -75,7 +75,7 @@ object GroupKeyStateManager {
* Asks the group to say what the room it is about to make signs with.
*
* Called once, by the ceremony's coordinator, in the NIP-17 room the
- * ceremony ran in -- before the #admins room exists. [localChatRoom] is
+ * ceremony ran in -- before the admin room exists. [localChatRoom] is
* therefore where the session *runs*, and is not usually the room the state
* is *about*: that one is derived here, from [key] at [path], because the
* room's id and the key it signs as are the same value.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt
index c450c0f9..c25a8c11 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt
@@ -24,10 +24,10 @@ import press.mantra.compose.repository.ChatRepository
* Creating a Marmot room at an id the group's key derives, once, for every flow
* that needs one.
*
- * Two do: the `#admins` room a group opens after its own ceremony, and a
- * subgroup, which is the same act with four different values. This was 120 lines
- * inside `DkgRitualViewModel` and is here unchanged in behaviour, because the
- * rules it already gets right are not rules worth deriving twice.
+ * Two do: the admin room a group opens after its own ceremony, and a subgroup,
+ * which is the same act with four different values. This was 120 lines inside
+ * `DkgRitualViewModel` and is here unchanged in behaviour, because the rules it
+ * already gets right are not rules worth deriving twice.
*
* ### The three that are not obvious
*
@@ -96,6 +96,11 @@ object MarmotGroupCreation {
* [parentChatRoomId] is written onto the room when this is a subgroup. It is
* the verified value off the child's own key state -- never a claim off a
* proposal.
+ *
+ * [name] is put under [MarmotGroupName] here rather than trusted from the
+ * caller. Every Marmot room on a device is a `#name`, and this is the funnel
+ * both flows that make one already go through, so it is the one place the
+ * convention cannot be forgotten.
*/
suspend fun create(
database: MantraDatabase,
@@ -112,6 +117,8 @@ object MarmotGroupCreation {
): Outcome {
chatRepository.getChatRoomByIdentifier(groupId)?.let { return Outcome.Existing(it) }
+ val groupName = MarmotGroupName.of(name)
+
val peers = adminPublicKeys.filterNot { it == userPublicKey }
val keyPackages = keyPackagesFor(chatRepository, peers, keyPackageLookupTimeout)
@@ -131,7 +138,7 @@ object MarmotGroupCreation {
// hardcodes a single admin.
val metadata = MarmotGroupData(
nostrGroupId = groupId,
- name = name,
+ name = groupName,
description = description,
adminPubkeys = adminPublicKeys.toList(),
relays = Relays.DefaultDMRelayList.map { it.url }
@@ -151,7 +158,7 @@ object MarmotGroupCreation {
chatRoomId = groupId,
activeUserPublicKey = userPublicKey,
relayHint = null,
- defaultSubject = name,
+ defaultSubject = groupName,
description = description,
mlsGroupState = group.saveState().encodeTls().toHex()
) ?: return Outcome.Failed("Couldn't create the group. Please try again.")
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupName.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupName.kt
new file mode 100644
index 00000000..10ab958a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupName.kt
@@ -0,0 +1,46 @@
+package press.mantra.compose.managers
+
+/**
+ * What a Marmot room is called: a `#`, then the name.
+ *
+ * A device's room list holds two unrelated kinds of room. A NIP-17 room is a
+ * conversation between the people in it; a Marmot room is a *group* -- it has an
+ * id its key derives, a membership baked into an MLS tree, admins who can act for
+ * it, and a signature anyone can check. Nothing on a row says which one is which,
+ * and the two behave differently enough that guessing is a mistake. The `#` says
+ * it, in the one place a user always reads.
+ *
+ * This replaces naming a group's Marmot room `"Ekklesia (#admins)"`. That form
+ * said the same thing in more words and only for the *admin* room -- a subgroup
+ * got no marker at all, so half the Marmot rooms on a device were unmarked, and
+ * "Ekklesia (#admins)" sorted nowhere near "Ekklesia". `#Ekklesia` marks both
+ * kinds, and marks them at the front where a truncated row still shows it.
+ *
+ * Applied where a Marmot room is minted rather than where it is drawn, because
+ * the name is baked into the epoch-0 `MarmotGroupData` every member is welcomed
+ * with: a name that only existed at the point of display would be a name this
+ * device alone could see.
+ */
+object MarmotGroupName {
+ /** Shown in a name field so the user reads the name they are about to get. */
+ const val PREFIX = "#"
+
+ /**
+ * [name] under the convention, and [name] again if it is already there.
+ *
+ * Idempotent on purpose. A subgroup's name is normalised twice from the same
+ * bare subject and by two unrelated callers -- once into the birth
+ * certificate the parent's quorum signs, once onto the room itself -- and
+ * those two have to be the same string, or the room is not called what its
+ * parent certified.
+ *
+ * A blank name is handed back untouched rather than turned into a bare `#`.
+ * No caller passes one -- each has a fallback for the room whose subject is
+ * missing -- and inventing a name here would hide that if one ever did.
+ */
+ fun of(name: String): String {
+ val bare = name.trim().trimStart('#').trim()
+
+ return if (bare.isEmpty()) name.trim() else PREFIX + bare
+ }
+}
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 f5ed5367..c176d25a 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt
@@ -218,6 +218,12 @@ object SubgroupManager {
require(name.isNotBlank()) { "A subgroup has to be called something" }
+ // The certificate is a signature over the name, so it has to be the name
+ // the room ends up with. `MarmotGroupCreation` normalises again on the
+ // way in and `MarmotGroupName.of` is idempotent, so the two agree by
+ // construction rather than by both callers remembering.
+ val subgroupName = MarmotGroupName.of(name)
+
// One certificate per child. A second is a `d`-tag replacement of the
// first rather than a second subgroup, and spending a quorum's attention
// to restate something they have already signed is worse than doing
@@ -240,7 +246,7 @@ object SubgroupManager {
parentChatRoomId = parentChatRoomId,
thresholdPublicKey = thresholdPublicKey,
adminPublicKeys = adminPublicKeys,
- name = name,
+ name = subgroupName,
path = path
),
content = subgroupChatRoomId
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt
index 0118918e..e0c1a659 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt
@@ -16,7 +16,7 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
* implied by the 3xxxx range never apply. Almost always that is a Marmot group
* event -- MLS-encrypted to the group and then wrapped again under the group's
* exporter secret. The exception is a group's first session, which agrees what
- * its #admins room will sign with *before* that room exists and so runs in the
+ * its admin room will sign with *before* that room exists and so runs in the
* NIP-17 room its ceremony ran in, on gift wraps. See
* `GroupKeyStateManager.propose`.
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
index 042a1d11..de8442ad 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
@@ -165,7 +165,7 @@ interface ChatRepository {
* Creates a Marmot room at an id the group's key derives, with
* [adminPublicKeys] as its admins, and welcomes them into it.
*
- * Two flows want exactly this: the `#admins` room a group opens after its
+ * Two flows want exactly this: the admin room a group opens after its
* ceremony, and a subgroup. See `MarmotGroupCreation`, which holds the rules
* -- every key package before anything exists, the admins baked into epoch 0,
* and the key state filed before the members are added.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
index 991436f2..e60ec7f0 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
@@ -93,7 +93,7 @@ interface DkgRepository {
)
/**
- * Asks the group to sign a statement of which ceremony's key its #admins
+ * Asks the group to sign a statement of which ceremony's key its admin
* room will sign with, before that room is created.
*
* Called once by the ceremony's coordinator, in the NIP-17 room the ceremony
@@ -120,7 +120,7 @@ interface DkgRepository {
fun observeSigningSessions(chatRoomId: String): Flow>
/**
- * The key state the group has signed for the #admins room this room's
+ * The key state the group has signed for the admin room this room's
* ceremony will make, whether or not that room exists yet.
*
* The gate on creating it. Null means the group has not finished agreeing
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
index 14ee441f..75c07645 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
@@ -80,7 +80,7 @@ import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.composable.widgets.Decorative
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
-import mantra.composeapp.generated.resources.create_the_admins_group
+import mantra.composeapp.generated.resources.create_the_admin_room
import androidx.compose.material.icons.filled.AccountTree
import mantra.composeapp.generated.resources.create_the_subgroup
import mantra.composeapp.generated.resources.ask_the_parent_group_to_certify
@@ -100,9 +100,9 @@ import mantra.composeapp.generated.resources.start_key_ceremony
import mantra.composeapp.generated.resources.the_ceremony_was_abandoned
import mantra.composeapp.generated.resources.the_group_can_hold_one_key_together_split_so
import mantra.composeapp.generated.resources.the_group_has_a_shared_key
-import mantra.composeapp.generated.resources.the_group_has_agreed_what_the_admins_room
-import mantra.composeapp.generated.resources.the_group_could_not_agree_what_the_admins
-import mantra.composeapp.generated.resources.the_group_is_agreeing_what_the_admins_room
+import mantra.composeapp.generated.resources.the_group_has_agreed_what_the_admin_room
+import mantra.composeapp.generated.resources.the_group_could_not_agree_what_the_admin
+import mantra.composeapp.generated.resources.the_group_is_agreeing_what_the_admin_room
import mantra.composeapp.generated.resources.agree_the_groups_signing_key
import mantra.composeapp.generated.resources.before_the_room_exists_the_group_signs
import mantra.composeapp.generated.resources.this_is_fixed_once_the_ceremony_runs
@@ -514,7 +514,7 @@ private fun RitualProgress(
)
// What the group has to settle before there is a room: which
- // ceremony's key the #admins room signs with. Signed first and
+ // ceremony's key the admin room signs with. Signed first and
// filed as the room is created, so the room's founding fact is
// never something it has to go and ask about afterwards.
//
@@ -595,20 +595,20 @@ private fun RitualProgress(
) {
Icon(Icons.Default.CheckCircle, contentDescription = Decorative)
Text(
- text = stringResource(Res.string.the_group_has_agreed_what_the_admins_room),
+ text = stringResource(Res.string.the_group_has_agreed_what_the_admin_room),
style = MaterialTheme.typography.labelMedium
)
}
keyStateFailed -> Text(
- text = stringResource(Res.string.the_group_could_not_agree_what_the_admins),
+ text = stringResource(Res.string.the_group_could_not_agree_what_the_admin),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
keyStateSession != null -> Text(
text = stringResource(
- Res.string.the_group_is_agreeing_what_the_admins_room,
+ Res.string.the_group_is_agreeing_what_the_admin_room,
keyStateSession.threshold,
keyStateSession.participantCount
),
@@ -657,7 +657,7 @@ private fun RitualProgress(
text = if (parentChatRoomId != null) {
stringResource(Res.string.create_the_subgroup)
} else {
- stringResource(Res.string.create_the_admins_group)
+ stringResource(Res.string.create_the_admin_room)
}
)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt
index 36181177..00efaa4f 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt
@@ -421,7 +421,7 @@ private fun FrostSigningScreenPreview() {
chatRoom = ChatRoom(
id = "chatRoomId",
userPublicKey = "",
- subject = "Group (#admins)",
+ subject = "#Ekklesia",
description = null,
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt
index 36cc6159..0ad4346f 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt
@@ -428,7 +428,7 @@ private fun ProposalListScreenPreview() {
chatRoom = ChatRoom(
id = "chatRoomId",
userPublicKey = "u".repeat(64),
- subject = "Group (#admins)",
+ subject = "#Ekklesia",
description = null,
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt
index 28932cde..c2baba8c 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt
@@ -51,6 +51,7 @@ import mantra.composeapp.generated.resources.what_is_the_subgroup_called
import mantra.composeapp.generated.resources.who_runs_the_subgroup
import mantra.composeapp.generated.resources.you_coordinate_this_subgroup
import org.jetbrains.compose.resources.stringResource
+import press.mantra.compose.managers.MarmotGroupName
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.composable.navigation.routes.Route
@@ -151,6 +152,12 @@ fun SelectSubgroupAdminsScreen(
onValueChange = viewModel::setName,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
+ // Every Marmot room is a `#name`, and a subgroup is
+ // one. Shown in the field rather than added on
+ // confirm alone, so the name the coordinator reads
+ // here is the name the parent's admins will be asked
+ // to approve.
+ prefix = { Text(MarmotGroupName.PREFIX) },
label = { Text(stringResource(Res.string.what_is_the_subgroup_called)) }
)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
index f4ceb058..f452df8c 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
@@ -238,7 +238,7 @@ class DkgRitualViewModel(
}
/**
- * Follows the group agreeing what its #admins room will sign with.
+ * Follows the group agreeing what its admin room will sign with.
*
* Nothing here watches a [GroupKeyState] row, because there is no room yet
* for one to belong to -- being able to follow this before the room exists
@@ -348,7 +348,7 @@ class DkgRitualViewModel(
}
/**
- * Asks the group to sign what its #admins room will sign with.
+ * Asks the group to sign what its admin room will sign with.
*
* The step before the room exists, and the reason it can be created knowing
* its own key state rather than being told afterwards. Offered to the
@@ -578,14 +578,14 @@ class DkgRitualViewModel(
// A subgroup is called what the coordinator called it; an admin room is
// called after the group it administers, because it has no name of its
- // own to be given.
+ // own to be given. Either way the bare name goes down and
+ // `MarmotGroupCreation` puts it under `MarmotGroupName`, so the NIP-17
+ // room the ceremony ran in stays `Ekklesia` and the Marmot room it stands
+ // up is `#Ekklesia`.
val subgroupParent = loaded.parentChatRoomId
- val name = if (subgroupParent != null) {
- loaded.localChatRoom.chatRoom.subject ?: "Subgroup"
- } else {
- "${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)"
- }
+ val name = loaded.localChatRoom.chatRoom.subject
+ ?: if (subgroupParent != null) "Subgroup" else "Group"
isActionPending.value = true
dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList())
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt
index fccb41f0..2d574794 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt
@@ -13,6 +13,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.extensions.toHex
+import press.mantra.compose.managers.MarmotGroupName
import press.mantra.compose.nostr.Relays
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
@@ -222,6 +223,12 @@ class SelectChatRoomTypeViewModel(
) {
val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost...
+ // Every Marmot room is a `#name`. This one does not go through
+ // `MarmotGroupCreation` -- its id is random rather than derived, so it has
+ // no key state to adopt and no admin set to bake in -- which is why the
+ // convention is applied here as well as there.
+ val groupName = MarmotGroupName.of(name)
+
// Stamp initial metadata via the shared factory so UI + CLI stay
// byte-identical. Bake the MarmotGroupData extension into the
// epoch-0 GroupContext directly (see `MarmotManager.createGroup`)
@@ -232,7 +239,7 @@ class SelectChatRoomTypeViewModel(
nostrGroupId = gid,
creatorPubKey = keyPair.pubKey.toHexKey(),
outboxRelays = Relays.DefaultDMRelayList.map { it.url },
- name = name,
+ name = groupName,
description = description.orEmpty()
)
@@ -255,7 +262,7 @@ class SelectChatRoomTypeViewModel(
chatRoomId = gid,
activeUserPublicKey = keyPair.pubKey.toHexKey(),
relayHint = null,
- defaultSubject = name,
+ defaultSubject = groupName,
description = description,
mlsGroupState = group.saveState().encodeTls().toHex()
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt
index df7fc00d..183578e1 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.withContext
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.managers.MarmotGroupCreation
+import press.mantra.compose.managers.MarmotGroupName
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
@@ -66,6 +67,15 @@ class SelectSubgroupAdminsViewModel(
/** Members ticked, in the order they were ticked. The coordinator is never here. */
val selectedPublicKeys = mutableStateListOf()
+ /**
+ * What the coordinator typed, bare.
+ *
+ * The `#` is drawn beside the field rather than pushed into this state:
+ * normalising on every keystroke would move the caret out from under somebody
+ * halfway through a word. It is added by `MarmotGroupName.of` where the
+ * subgroup's name is actually used -- in the certificate the parent signs and
+ * on the room itself -- which is idempotent, so the two agree.
+ */
val name: MutableState = mutableStateOf("")
val threshold: MutableState = mutableStateOf(ChatRoomType.MINIMUM_QUORUM)
@@ -213,11 +223,22 @@ class SelectSubgroupAdminsViewModel(
return@launch
}
+ // The ceremony room keeps the bare name. It is a NIP-17 room -- where
+ // the subgroup is made, not the subgroup -- and the `#` is what tells
+ // those two rows apart afterwards, so putting it on both would spend
+ // the mark to say nothing.
+ //
+ // The subgroup gets it twice over, and never from here:
+ // `proposeBirthCertificate` normalises the name the parent signs and
+ // `MarmotGroupCreation` normalises the name the room carries, both
+ // reading this subject. `MarmotGroupName.of` is idempotent, so those
+ // are the same string -- which they have to be, or the room is not
+ // called what its parent certified.
val ceremonyRoom = chatRepository.createNip17ChatRoom(
userPublicKey = activeUserPublicKey,
participantPublicKeys = selectedPublicKeys.toList(),
- subject = name.value,
- description = "Making ${name.value} a subgroup of " +
+ subject = name.value.trim(),
+ description = "Making ${MarmotGroupName.of(name.value)} a subgroup of " +
(loaded.parentRoom.chatRoom.subject ?: "this group") + "."
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt
index e79b867d..52be6cb9 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt
@@ -37,7 +37,7 @@ sealed interface DkgRitualUIState {
val pendingApproval: DkgApprovalStep? = null,
/**
* Members whose key package the coordinator could not find, and so who stop
- * the #admins room being created at all.
+ * the admin room being created at all.
*
* Names rather than keys, because the answer to this is to go and ask that
* person to open the app. Empty when nothing is blocked, which is also the
@@ -45,7 +45,7 @@ sealed interface DkgRitualUIState {
*/
val adminGroupBlockedOn: List = emptyList(),
/**
- * The session in which the group is agreeing what its #admins room will
+ * The session in which the group is agreeing what its admin room will
* sign with, once somebody has opened one.
*
* A ceremony's room holds exactly one of these and nothing else, because
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotGroupNameTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotGroupNameTest.kt
new file mode 100644
index 00000000..ad9bde32
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotGroupNameTest.kt
@@ -0,0 +1,50 @@
+package press.mantra.compose.managers
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * The convention, checked at the one function that applies it.
+ *
+ * Idempotence is the property worth a test. The name a subgroup ends up with
+ * passes through here four times -- typed, carried on the ceremony room, signed
+ * into the birth certificate, normalised again at creation -- and a rule that
+ * added a `#` each time would leave the room called something its parent never
+ * certified.
+ */
+class MarmotGroupNameTest {
+ @Test
+ fun `a plain name gets the prefix`() {
+ assertEquals("#Ekklesia", MarmotGroupName.of("Ekklesia"))
+ }
+
+ @Test
+ fun `a name that already has the prefix keeps exactly one`() {
+ assertEquals("#Ekklesia", MarmotGroupName.of("#Ekklesia"))
+ assertEquals("#Ekklesia", MarmotGroupName.of("###Ekklesia"))
+ }
+
+ @Test
+ fun `applying it twice says the same thing as applying it once`() {
+ val once = MarmotGroupName.of("Translators")
+
+ assertEquals(once, MarmotGroupName.of(once))
+ }
+
+ @Test
+ fun `surrounding space is not part of the name`() {
+ assertEquals("#Ekklesia", MarmotGroupName.of(" Ekklesia "))
+ assertEquals("#Ekklesia", MarmotGroupName.of("# Ekklesia"))
+ }
+
+ @Test
+ fun `space inside the name is`() {
+ assertEquals("#Book club", MarmotGroupName.of("Book club"))
+ }
+
+ @Test
+ fun `a blank name is left alone rather than turned into a bare hash`() {
+ assertEquals("", MarmotGroupName.of(" "))
+ assertEquals("#", MarmotGroupName.of("#"))
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt
index 33599999..d8f8bd19 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt
@@ -194,7 +194,7 @@ class FrostSigningSessionDaoJvmTest {
}
/**
- * Signing happens in the #admins room, and a device can be in more than one. A session from
+ * Signing happens in the admin room, and a device can be in more than one. A session from
* another room appearing here would have a signer answering a request its group never made.
*/
@Test
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt
index f58bf006..bb59a688 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt
@@ -161,7 +161,7 @@ class GroupSignedEventDaoJvmTest {
ChatRoom(
id = chatRoomId,
userPublicKey = member,
- subject = "#admins",
+ subject = "#Ekklesia",
description = SharedKeyDerivation.describe("Admins"),
mlsGroupState = null,
)
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt
index a70f1ca2..31c3687a 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt
@@ -98,7 +98,7 @@ class ChapterBatchProposalJvmTest {
val chatRoom = ChatRoom(
id = roomId,
userPublicKey = proposer,
- subject = "#admins",
+ subject = "#Ekklesia",
description = SharedKeyDerivation.describe("Admins of the group."),
mlsGroupState = null
)
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt
index 951be1cc..6500d8c9 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt
@@ -191,7 +191,7 @@ class ChronicleApplyJvmTest {
ChatRoom(
id = chatRoomId,
userPublicKey = user,
- subject = "#admins",
+ subject = "#Ekklesia",
description = null,
mlsGroupState = null,
)
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt
index 2f65958d..fe713e8d 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt
@@ -201,7 +201,7 @@ class ChronicleAssemblyJvmTest {
ChatRoom(
id = chatRoomId,
userPublicKey = newMember,
- subject = "#admins",
+ subject = "#Ekklesia",
description = null,
mlsGroupState = null,
)
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/MarmotGroupCreationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/MarmotGroupCreationJvmTest.kt
index 071aea08..3461fcb8 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/MarmotGroupCreationJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/MarmotGroupCreationJvmTest.kt
@@ -63,7 +63,7 @@ class MarmotGroupCreationJvmTest {
database = db,
chatRepository = repository,
groupId = groupId,
- name = "Ekklesia (#admins)",
+ name = "#Ekklesia",
purpose = "Admins of Ekklesia.",
// A group of one, so nothing has to be invited and no key package has
// to be found. The flag is the whole of what is being tested.
@@ -94,7 +94,7 @@ class MarmotGroupCreationJvmTest {
database = db,
chatRepository = repository,
groupId = groupId,
- name = "Ekklesia (#admins)",
+ name = "#Ekklesia",
purpose = "Admins of Ekklesia.",
adminPublicKeys = setOf(user),
userPublicKey = user,
@@ -116,6 +116,35 @@ class MarmotGroupCreationJvmTest {
assertTrue(data.adminPubkeys.contains(user))
}
+ @Test
+ fun `the room is named under the convention whatever the caller passed`(): Unit = runBlocking {
+ seedProfile(user)
+
+ MarmotGroupCreation.create(
+ database = db,
+ chatRepository = repository,
+ groupId = groupId,
+ // A bare name, as a caller reading a room's subject straight off the
+ // database hands it over. The convention is this function's to apply:
+ // it is where both flows that make a Marmot room meet, and the name
+ // goes into the epoch-0 group context every member is welcomed with,
+ // so a caller that forgot would mark the room on no device at all.
+ name = "Ekklesia",
+ purpose = "Admins of Ekklesia.",
+ adminPublicKeys = setOf(user),
+ userPublicKey = user,
+ keyPair = keyPair,
+ )
+
+ val room = assertNotNull(db.chatRoomDao().findChatRoomById(groupId))
+ val data = assertNotNull(room.chatRoom.toMlsGroup()?.currentMarmotData())
+
+ // Both, and the same string in each: the row is what this device draws in
+ // its room list, and the group data is what every other member reads.
+ assertEquals("#Ekklesia", room.chatRoom.subject)
+ assertEquals("#Ekklesia", data.name)
+ }
+
private suspend fun seedProfile(publicKey: String) {
val nostrEventId = publicKey.take(63) + "f"
db.nostrEventDao().upsert(
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt
index 79c0f283..05c24f91 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt
@@ -237,7 +237,7 @@ class SignedGroupKeyStateTest {
val chatRoom = ChatRoom(
id = roomId,
userPublicKey = publicKey,
- subject = "#admins",
+ subject = "#Ekklesia",
description = description,
mlsGroupState = mlsGroupState
)
@@ -601,7 +601,7 @@ class SignedGroupKeyStateTest {
*/
private val ceremonyRoomId = "ceremonyroom".padEnd(64, '0')
- /** A member's device as it is *before* the #admins room exists. */
+ /** A member's device as it is *before* the admin room exists. */
private suspend fun ceremonyDevice(publicKey: HexKey, signerIndex: Int): Device =
device(
publicKey = publicKey,
@@ -614,13 +614,13 @@ class SignedGroupKeyStateTest {
mlsGroupState = null
)
- /** Brings the #admins room into being on a device, as creating or joining it would. */
+ /** Brings the admin room into being on a device, as creating or joining it would. */
private suspend fun createAdminRoom(device: Device) {
device.db.chatRoomDao().upsert(
ChatRoom(
id = adminRoomId,
userPublicKey = device.publicKey,
- subject = "#admins",
+ subject = "#Ekklesia",
description = adminRoomDescription,
mlsGroupState = "00"
)
@@ -1391,7 +1391,7 @@ class SignedGroupKeyStateTest {
// than anything walked to. There is no room key to sign as, so it signs
// as the group's -- which is what it did before any of this.
//
- // A NIP-17 room in the same shape signs as the #admins room the ceremony
+ // A NIP-17 room in the same shape signs as the admin room the ceremony
// will make, which is the case below. The two are told apart by whether
// the room has MLS state, and by nothing a proposer can reach.
val undeerived = "d".repeat(64)
@@ -1420,7 +1420,7 @@ class SignedGroupKeyStateTest {
@Test
fun `a NIP-17 room that did not host the ceremony cannot sign at all`() = runBlocking {
// The other half of the rule that lets a ceremony's room sign as the
- // room it is about to make. That room signs as the #admins room *its
+ // room it is about to make. That room signs as the admin room *its
// own* ceremony derives; a NIP-17 room this member merely happens to be
// in reaches no key and no path, so it never gets as far as choosing an
// identity to sign under.
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt
index c7f21435..e1400675 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt
@@ -31,6 +31,7 @@ import kotlin.time.Instant
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.database.model.GroupSignedEvent
@@ -39,6 +40,7 @@ import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.extensions.toHex
+import press.mantra.compose.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
/**
@@ -428,6 +430,39 @@ class SubgroupManagerJvmTest {
}
}
+ @Test
+ fun `the name the parent is asked to sign is the name the room will carry`(): Unit =
+ runBlocking {
+ // A parent that can actually sign: the proposal has to get as far as
+ // writing the event whose tags are what this is about.
+ val parentRoom = parentWith(listOf(alice, bob))
+
+ val session = SubgroupManager.proposeBirthCertificate(
+ database = db,
+ parentRoom = parentRoom,
+ userPublicKey = user,
+ key = completedChildCeremony(),
+ adminPublicKeys = listOf(user, alice, bob),
+ // Bare, as it comes off the ceremony room's subject -- that room
+ // is NIP-17 and keeps the name unmarked.
+ name = "Translation team",
+ )
+
+ val proposed = Event.fromJson(
+ assertNotNull(db.frostSigningSessionDao().getItems(session.id).firstOrNull())
+ .unsignedEventJson
+ )
+
+ // A quorum is being asked to sign a name, and `MarmotGroupCreation`
+ // will name the room by the same rule from the same subject. If only
+ // one of the two applied it, the parent would certify "Translation
+ // team" and the room would be called "#Translation team".
+ assertEquals(
+ MarmotGroupName.of("Translation team"),
+ SubgroupBirthCertificateEvent.parseName(proposed.tags)
+ )
+ }
+
@Test
fun `an unnamed subgroup cannot be certified`(): Unit = runBlocking {
seedRoom(parentRoomId)
@@ -530,7 +565,7 @@ class SubgroupManagerJvmTest {
/**
* A parent this device could actually make a subgroup of.
*
- * Built as what a real parent *is* -- the `#admins` room derived from the
+ * Built as what a real parent *is* -- the admin room derived from the
* group's own key -- rather than as a NIP-17 room with MLS state bolted on.
* That is not fussiness: `ChatRoom.deriveChatRoomId` returns a 33-byte
* compressed key (66 hex) and `MarmotGroupData.nostrGroupId` takes 32 bytes,
@@ -607,6 +642,21 @@ class SubgroupManagerJvmTest {
secretShare = "cc".repeat(32),
)
)
+
+ // A ceremony's signer ids come from its host keys, sorted -- there is
+ // no stored signer id to seed. Without these the parent holds a key it
+ // cannot find itself in, and `proposeSigningBatch` refuses before it
+ // writes anything.
+ (members + user).forEachIndexed { index, member ->
+ db.dkgSessionDao().upsert(
+ DkgParticipantMessage(
+ sessionId = "parent-ceremony",
+ participantPublicKey = member,
+ kind = DkgRitualEvents.HOST_KEY,
+ payload = "0${index + 1}".repeat(33)
+ )
+ )
+ }
}
return assertNotNull(db.chatRoomDao().findChatRoomById(parentRoomId))
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt
index 5edda78e..90f6f4cc 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt
@@ -105,7 +105,7 @@ class TranslationBatchProposalJvmTest {
val chatRoom = ChatRoom(
id = roomId,
userPublicKey = proposer,
- subject = "#admins",
+ subject = "#Ekklesia",
description = SharedKeyDerivation.describe("Admins of the group."),
mlsGroupState = null
)
diff --git a/docs/marmot-membership.md b/docs/marmot-membership.md
index 46a783b3..24510262 100644
--- a/docs/marmot-membership.md
+++ b/docs/marmot-membership.md
@@ -106,7 +106,7 @@ with that peer's key package.
Both callers that know their membership up front now use it —
`SelectChatRoomTypeViewModel.inviteMembers` at room creation, and
-`DkgRitualViewModel.inviteAdmins` for the `#admins` room.
+`DkgRitualViewModel.inviteAdmins` for the admin room.
### Why this needed no schema change
diff --git a/docs/member-chronicle.md b/docs/member-chronicle.md
index c2ffcf8c..e2986b9d 100644
--- a/docs/member-chronicle.md
+++ b/docs/member-chronicle.md
@@ -79,7 +79,7 @@ if (!event.pubKey.equals(author, ignoreCase = true)) return false
That derived value is the room's id. `GroupKeyState.isMatchedBy` enforces it,
`FrostSigningManager.signingPath` resolves the path by it, and
-`DkgRitualViewModel` creates the `#admins` room *at* it. So for any room with a
+`DkgRitualViewModel` creates the admin room *at* it. So for any room with a
shared key, `marmotGroupId(thresholdPublicKey, path) == chatRoomId`, and the
check collapses to:
diff --git a/docs/shared-key-ceremony.md b/docs/shared-key-ceremony.md
index 919131bf..65657faa 100644
--- a/docs/shared-key-ceremony.md
+++ b/docs/shared-key-ceremony.md
@@ -134,15 +134,25 @@ protocol checks this, so `ChillDkgRitualOrderingTest` does.
The group has a threshold public key; each device keeps its own share, restorable
from that member's wallet backup and nobody else's.
-From there the coordinator can create the `#admins` room — a Marmot group whose id
-is derived from the shared key. See [shared-key-derivation.md](./shared-key-derivation.md)
-for how, and [marmot-membership.md](./marmot-membership.md) for how members are
-added to it.
+From there the coordinator can create the group's admin room — a Marmot group
+whose id is derived from the shared key. See
+[shared-key-derivation.md](./shared-key-derivation.md) for how, and
+[marmot-membership.md](./marmot-membership.md) for how members are added to it.
+
+It is named `#` and then the group's name — `#Ekklesia` for a group called
+Ekklesia. Every Marmot room on a device carries that mark and no NIP-17 room does,
+which is the only thing on a room list that says which of the two a row is. The
+rule is one function, `MarmotGroupName.of`, applied where a room is minted rather
+than where it is drawn: the name is baked into the epoch-0 `MarmotGroupData` every
+member is welcomed with, so a name added at display time would be a name this
+device alone could see. It replaces `Ekklesia (#admins)`, which marked only the
+admin room, left every subgroup unmarked, and sorted nowhere near the group it
+belonged to.
## Known gaps
- No ceremony has been run on a physical device.
-- A member invited to the `#admins` room whose Welcome never goes out is
+- A member invited to the admin room whose Welcome never goes out is
indistinguishable, from the coordinator's side, from one who joined.
- The ritual's request chat lines are `3n + 4` per ceremony — 19 lines for five
members. Compact, but they dominate a transcript while a ceremony runs.
diff --git a/docs/shared-key-derivation.md b/docs/shared-key-derivation.md
index 00e1d318..2332f2f0 100644
--- a/docs/shared-key-derivation.md
+++ b/docs/shared-key-derivation.md
@@ -110,7 +110,7 @@ leak reaches the others.
`derive` and `marmotGroupId` both take `path: List`, defaulting to
`MARMOT_ADMIN_GROUP_PATH` (`m/9420/0/0`). Any depth works.
-`9420` is arbitrary and has to stay put: the derived key *is* the `#admins` room's
+`9420` is arbitrary and has to stay put: the derived key *is* the admin room's
id, so changing the path orphans every room already created — members would derive
a different id and stop finding the room at all.
diff --git a/docs/subgroups.md b/docs/subgroups.md
index 5120e2a3..15565c75 100644
--- a/docs/subgroups.md
+++ b/docs/subgroups.md
@@ -698,12 +698,20 @@ point of a subgroup is that it can be run by people the parent does not let run
the parent.
**A name field at the top**, because nothing else in the flow can supply one.
-`createAdminGroup` synthesises `"${parent.subject} (#admins)"` and gets away with
-it — a group has exactly one admin room and the name states a relationship rather
-than a choice — but a group can have many subgroups and "Ekklesia (#subgroup)"
-names none of them. The name is required, travels on the route to Phase 6's
-`MarmotGroupData`, and is copied into the birth certificate so the parent's admins
-approve something legible.
+`createAdminGroup` gets away with naming the admin room after the group it
+administers — a group has exactly one — but a group can have many subgroups, and
+naming them all after the parent names none of them. The name is required, travels
+on the route to Phase 6's `MarmotGroupData`, and is copied into the birth
+certificate so the parent's admins approve something legible.
+
+`MarmotGroupName.of` puts the `#` on it. The field shows the prefix beside what is
+typed rather than in it, so the caret does not move under somebody mid-word, and
+the ceremony room keeps the bare name — it is a NIP-17 room, and the mark is what
+tells it apart from the subgroup it stands up. The subgroup's own name is derived
+from that subject twice, by two unrelated callers: `proposeBirthCertificate`
+normalises the name the parent signs, `MarmotGroupCreation` normalises the name the
+room carries. Those two have to be the same string or the subgroup is not called
+what its parent certified, which is what the rule being idempotent buys.
### Key packages, checked here rather than discovered at step 4