feat(marmot): put a # in front of every group's name, and retire (#admins)
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled

A device's room list holds two unrelated kinds of room and nothing on a row said
which. A NIP-17 room is a conversation between the people in it. A Marmot room is
a *group* -- an id its key derives, a membership baked into an MLS tree, admins
who can act for it, a signature anyone holding the id can check -- and the two
behave differently enough that guessing is a mistake.

`"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half.
Only the admin room got it; a subgroup got no marker at all, so as soon as a group
had one child, half the Marmot rooms on the device were unmarked. It also sorted
nowhere near the group it belonged to, and a truncated row drops a trailing suffix
first -- so the marker was missing exactly where the list is crowded enough to
need it.

**The rule is `MarmotGroupName.of`, and it runs 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 `#` added at display time would be a name this
device alone could see. `#Ekklesia` marks both kinds of group room, and marks them
at the front.

**Three mints, because there are three ways a Marmot room comes into existence.**
`MarmotGroupCreation.create` is the funnel for two of them -- the admin room a
group opens after its ceremony, and a subgroup -- and normalising there means
neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s
convenient room, has a random id rather than a derived one, so it has no key state
to adopt and no admin set to bake in and does not pass through that funnel; it
applies the rule itself.

**Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice
from the same bare ceremony-room subject, by two callers that never see each
other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's
quorum is asked to sign, and `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 -- and a certificate is a signature over the name, so a
verifier comparing them would see a real mismatch. `of` being idempotent is what
makes them agree by construction rather than by both sites being kept in step.

**The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup
is made, not the subgroup -- and prefixing it too produced two identically-named
rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits
beside `#Translators` (the group it stood up), which is the distinction the `#`
exists to draw. Its subject is trimmed, so the bare name and the two normalised
ones cannot differ by whitespace.

**The `#` is drawn beside the name field, not pushed into its state.** `name` in
`SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the
convention, because normalising on every keystroke moves the caret out from under
somebody halfway through a word. The coordinator still reads the name they are
about to get.

Four strings lose the old name -- "Create the #admins group" becomes "Create the
admin room", and the three about what "the #admins room" will sign with now say
"the admin room". Their keys are renamed with them, since the keys in this
catalogue are derived from the text. Around twenty comments, two screen previews
and seven test fixtures follow.

Docs: the ceremony note states the convention and what it replaces, and the
subgroups note's name-field section is rewritten -- it had been arguing from the
`"${parent.subject} (#admins)"` synthesis that no longer exists.
`docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured
debugging log, and rewriting it would falsify a record.

Three tests. `MarmotGroupNameTest` pins the rule, idempotence included.
`MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both
the room row this device draws and the group data every other member reads.
`SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed
event's tags back out of the signing session: the name the parent is asked to sign
is the name `MarmotGroupCreation` will give the room. That last one needed the
signable-parent fixture to seed host keys, since a ceremony's signer ids are
derived from them rather than stored.

**Rooms that already exist keep their names.** The name lives in the epoch-0 group
context, so renaming one is an MLS commit every member has to process -- a
different change from a naming convention, and not made here.

403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case
strings and 0 dp literals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-09 11:41:30 +02:00
parent 5c893ab108
commit 45cc80b538
36 changed files with 320 additions and 79 deletions

View File

@@ -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.
*/

View File

@@ -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**.
*

View File

@@ -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`.
//

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.")

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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`.
*

View File

@@ -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.

View File

@@ -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<List<LocalFrostSigningSession>>
/**
* 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

View File

@@ -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)
}
)
}

View File

@@ -421,7 +421,7 @@ private fun FrostSigningScreenPreview() {
chatRoom = ChatRoom(
id = "chatRoomId",
userPublicKey = "",
subject = "Group (#admins)",
subject = "#Ekklesia",
description = null,
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null

View File

@@ -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

View File

@@ -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)) }
)
}

View File

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

View File

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

View File

@@ -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<HexKey>()
/**
* 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<String> = mutableStateOf("")
val threshold: MutableState<Int> = 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") + "."
)

View File

@@ -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<String> = 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