feat: open a robust group's key ceremony as it is created
Picking "robust" made a NIP-17 room and left it at that. The quorum the user had just set was read, explained, coerced into range -- and then dropped on the floor, with a TODO where it should have gone saying so: NIP-17 has no group state to change and so nothing to approve, and a different member set is simply a different room. That TODO had an answer the app has been able to give since ChillDkgRitual- Manager landed. The one thing a t-of-n rule can attach to here is a key the members generate together and cannot sign with unless t of n of them are present, and everything a ceremony needs is settled the moment the room exists: who is in it, and how many of them have to agree. So the room now opens one, and the proposal is its first message. ## Why at creation rather than behind the button The button is still there on the shared-key screen, and this changes nothing about it. What it cannot do is be found. A group that picked robust and got a plain NIP-17 room has the thing that makes it robust sitting one unmarked navigation away, and until somebody takes it the group's governance is a number nobody enforces. It is also how the rest of the group hears of the room at all. Standing up a NIP-17 room sends nothing to anybody -- there is no invite, no welcome, no key package -- so before this the first anyone learned of a robust group was whenever somebody happened to type into it. The proposal is now the first event out, and NostrDao already builds the room on the receiving side from a DKG payload's p-tags for exactly this reason. ## The order this runs in The room is created first, then the ceremony is proposed, then the screen navigates. createdChatRoomId is set the moment the room exists, before the proposal, so a second tap reuses that room rather than minting another -- and it is what freezes the type and quorum pickers, both of which are answered by then. Proposing before navigating means the chat opens with the ceremony already in it rather than filling in underneath the user. It costs no round trip: proposeRitual writes rows and queues a gift-wrap payload, and NotaryViewModel seals and broadcasts on its own schedule. The quorum is passed through as the threshold with no coercion. The screen derives its range from the picked members plus the creator, and createNip17ChatRoom stores exactly that set as the room's participants, so the range proposeRitual validates against is the same one the picker was bounded by. ## When the ceremony does not open Nothing is rolled back. The room is real, the group can talk in it, and the ceremony can be opened later from the group's details -- so failing the whole creation would be throwing away the part that worked. But it is not navigated past either. The screen stays put and says what happened, the way it already does when a Marmot group is created without some of its members; the button flips to "Open chat", which is what the user is left with. A robust group quietly without a key is the one outcome here worth interrupting for. ## Elsewhere The robust card's footnote now says that creating the group starts a key ceremony every member takes part in. Members are about to be asked to approve joining it, contributing to the key, and confirming the result, and none of that should be the first they hear of it. MantraNavHost hands the screen the DkgRepository it already builds for the ritual and approval routes; the preview takes the no-op. Left alone: DkgRitualViewModel still cannot read back the quorum a room was created with, because ChatRoom does not persist it. Its threshold picker re-derives a majority default, which now only matters for rooms made before this change or after a failed ceremony. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
@@ -70,6 +71,7 @@ fun SelectChatRoomTypeScreen(
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
) {
|
||||
val selectChatRoomTypeViewModel: SelectChatRoomTypeViewModel = viewModel(
|
||||
@@ -81,7 +83,8 @@ fun SelectChatRoomTypeScreen(
|
||||
initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
)
|
||||
|
||||
@@ -104,6 +107,7 @@ fun SelectChatRoomTypeScreen(
|
||||
val isActionPending = selectChatRoomTypeViewModel.isActionPending.value
|
||||
val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value
|
||||
val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded
|
||||
val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value
|
||||
val adminCount = selectChatRoomTypeViewModel.adminCount
|
||||
val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable
|
||||
val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST
|
||||
@@ -188,6 +192,22 @@ fun SelectChatRoomTypeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (keyCeremonyNotStarted) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(15.dp),
|
||||
text = "$name was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "This decides who can change the group later. You can't switch afterwards.",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@@ -216,7 +236,11 @@ fun SelectChatRoomTypeScreen(
|
||||
} else {
|
||||
"Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect."
|
||||
},
|
||||
footnote = "No single admin can change the group alone, and the group outlives any one of you.",
|
||||
// Says what tapping create actually does, because it is the one
|
||||
// thing here that asks something of everybody else: the group's
|
||||
// first act is a ceremony each member has to approve their way
|
||||
// through before there is a key.
|
||||
footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.",
|
||||
isSelected = isRobustSelected,
|
||||
isEnabled = isRobustAvailable,
|
||||
disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason,
|
||||
@@ -447,6 +471,7 @@ private fun SelectChatRoomTypeScreenPreview() {
|
||||
activeWalletStateFlow = MutableStateFlow(null),
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
|
||||
onNavigateToRoute = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -543,6 +543,7 @@ fun MantraNavHost(
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
nostrRepository = databaseNostrRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onNavigateToRoute = { chatRoomResultRoute ->
|
||||
navController.navigate(
|
||||
chatRoomResultRoute
|
||||
|
||||
@@ -15,6 +15,7 @@ import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.Relays
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
@@ -59,6 +60,7 @@ class SelectChatRoomTypeViewModel(
|
||||
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
val nostrRepository: NostrRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val dkgRepository: DkgRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var selectChatRoomTypeUIState: SelectChatRoomTypeUIState by mutableStateOf(initialSelectChatRoomTypeUIState)
|
||||
@@ -81,6 +83,15 @@ class SelectChatRoomTypeViewModel(
|
||||
/** Members the group was created without, because no key package ever showed up. */
|
||||
val membersNotAdded = mutableStateListOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Set when a [ChatRoomType.ROBUST] room was made but its key ceremony was not.
|
||||
*
|
||||
* The room is real and usable either way -- only the shared key is missing, and
|
||||
* it can be started again from the group's details. Worth saying rather than
|
||||
* navigating on, because the key is the whole of what robust means.
|
||||
*/
|
||||
val keyCeremonyNotStarted: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* Everyone who administers the group under [ChatRoomType.ROBUST]: the picked
|
||||
* members plus the creator.
|
||||
@@ -188,13 +199,16 @@ class SelectChatRoomTypeViewModel(
|
||||
|
||||
isActionPending.value = true
|
||||
|
||||
val nostrPrivateKeyBytes = nostrPrivateKey.value.toByteArray()
|
||||
val keyPair = KeyPair(
|
||||
privKey = nostrPrivateKey.value.toByteArray()
|
||||
privKey = nostrPrivateKeyBytes
|
||||
)
|
||||
|
||||
when (selectedChatRoomType.value) {
|
||||
ChatRoomType.CONVENIENT -> createMarmotChatRoom(keyPair, onNavigateToRoute)
|
||||
ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, onNavigateToRoute)
|
||||
// The ceremony needs the secret itself, not the pair: the ChillDKG host
|
||||
// key is derived from it rather than being it.
|
||||
ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, nostrPrivateKeyBytes, onNavigateToRoute)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,13 +301,23 @@ class SelectChatRoomTypeViewModel(
|
||||
* invite anyone to and no key package to wait on — everybody picked is in the room
|
||||
* the moment it exists, and learns about it from the first message.
|
||||
*
|
||||
* TODO: the quorum the user picked has nowhere to live here. NIP-17 has no group
|
||||
* state to change and so nothing to approve — the member set is whatever a message
|
||||
* is addressed to, and a different set is simply a different room. Enforcing t-of-n
|
||||
* needs a governance layer this protocol does not have.
|
||||
* That first message is the key ceremony, opened here rather than left for somebody
|
||||
* to find a button for.
|
||||
*
|
||||
* NIP-17 itself has nothing for the quorum to govern — no group state to change, and
|
||||
* a different member set is simply a different room — so the only thing that can
|
||||
* carry it is a key the group generates together and can only sign with t of n of
|
||||
* them present. Everything that ceremony needs is settled by the time the room
|
||||
* exists: who is in it, and how many of them have to agree. Waiting would mean
|
||||
* handing the user the room they asked for minus the thing that makes it robust.
|
||||
*
|
||||
* Opening it is also how the rest of the group hears of the room at all. Standing up
|
||||
* a NIP-17 room sends nothing to anybody; the proposal is the first event out, and
|
||||
* the inbound path builds the same room on the other side from its p-tags.
|
||||
*/
|
||||
private fun createNip17ChatRoom(
|
||||
keyPair: KeyPair,
|
||||
nostrPrivateKey: ByteArray,
|
||||
onNavigateToRoute: (Route) -> Unit
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -304,18 +328,43 @@ class SelectChatRoomTypeViewModel(
|
||||
description = description
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isActionPending.value = false
|
||||
|
||||
if (localChatRoom == null) {
|
||||
if (localChatRoom == null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
isActionPending.value = false
|
||||
onNavigateToRoute.invoke(
|
||||
ImplementationPendingRoute("Something went wrong")
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
createdChatRoomId.value = localChatRoom.chatRoom.id
|
||||
|
||||
// The room's membership is the set this screen was opened for, so the
|
||||
// quorum picked against [adminCount] is the same t-of-n the ceremony is
|
||||
// asked for. A room that already has a ceremony -- the id is derived from
|
||||
// its members, so making the same group twice returns the same room --
|
||||
// hands that one back instead of opening a second.
|
||||
val session = dkgRepository.proposeRitual(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = keyPair.pubKey.toHexKey(),
|
||||
nostrPrivateKey = nostrPrivateKey,
|
||||
threshold = quorum.value
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isActionPending.value = false
|
||||
|
||||
if (session == null) {
|
||||
// Nothing is rolled back: the room works, the group can talk in it,
|
||||
// and the ceremony can be opened again from the group's details.
|
||||
// Said here rather than navigated past, because a robust group
|
||||
// without a shared key is not what the user asked for.
|
||||
logger.e("Created ${localChatRoom.chatRoom.id} without a key ceremony")
|
||||
keyCeremonyNotStarted.value = true
|
||||
return@withContext
|
||||
}
|
||||
|
||||
createdChatRoomId.value = localChatRoom.chatRoom.id
|
||||
|
||||
onNavigateToRoute.invoke(
|
||||
ChatRoomMessagingRoute(
|
||||
activeUserPublicKey = keyPair.pubKey.toHexKey(),
|
||||
@@ -393,7 +442,8 @@ class SelectChatRoomTypeViewModel(
|
||||
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading,
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
SelectChatRoomTypeViewModel(
|
||||
@@ -404,7 +454,8 @@ class SelectChatRoomTypeViewModel(
|
||||
initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user