diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/ChatRoomType.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/ChatRoomType.kt index d44108a6..33449269 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/ChatRoomType.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/ChatRoomType.kt @@ -29,10 +29,23 @@ enum class ChatRoomType { const val MINIMUM_ROBUST_GROUP_SIZE = 3 /** - * How many of [adminCount] admins have to approve a change — a simple - * majority, so no half of the group can move without the other. + * Fewest admins a [ROBUST] room can be made to run on. One approval is not a + * quorum — it is one person acting alone, which is [CONVENIENT]. */ - fun approvalThreshold(adminCount: Int): Int = adminCount / 2 + 1 + const val MINIMUM_QUORUM = 2 + + /** + * The quorum a [ROBUST] group of [adminCount] starts on — a simple majority, + * so no half of the group can move without the other. The user can move it + * anywhere in [quorumRange] from there. + */ + fun defaultQuorum(adminCount: Int): Int = maxOf(MINIMUM_QUORUM, adminCount / 2 + 1) + + /** + * Quorums a [ROBUST] group of [adminCount] can be run on: never fewer than + * [MINIMUM_QUORUM], never more than the admins who exist to approve. + */ + fun quorumRange(adminCount: Int): IntRange = MINIMUM_QUORUM..adminCount /** Whether a group of [memberCount] people can be run as [ROBUST]. */ fun isRobustAvailable(memberCount: Int): Boolean = memberCount >= MINIMUM_ROBUST_GROUP_SIZE diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 2128c365..27131b7b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -13,8 +13,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -22,6 +24,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton @@ -102,8 +105,8 @@ fun SelectChatRoomTypeScreen( val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded val adminCount = selectChatRoomTypeViewModel.adminCount - val approvalThreshold = selectChatRoomTypeViewModel.approvalThreshold val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable + val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST Scaffold( topBar = { @@ -209,18 +212,30 @@ fun SelectChatRoomTypeScreen( title = "Robust", summary = "Every member is an admin.", detail = if (isRobustAvailable) { - "All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by $approvalThreshold of the $adminCount admins before it takes effect." + "All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of you before it takes effect." } else { - "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a majority of the admins before it takes effect." + "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.", - isSelected = selectedChatRoomType == ChatRoomType.ROBUST, + isSelected = isRobustSelected, isEnabled = isRobustAvailable, disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, onClick = { selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST) } - ) + ) { + // Ask for the quorum only once robust is the choice — before that + // there is no decision to make. + if (isRobustSelected) { + QuorumPicker( + quorum = selectChatRoomTypeViewModel.quorum.value, + adminCount = adminCount, + quorumRange = selectChatRoomTypeViewModel.quorumRange, + explanation = selectChatRoomTypeViewModel.quorumExplanation(), + onQuorumChange = selectChatRoomTypeViewModel::setQuorum + ) + } + } } } } @@ -273,6 +288,7 @@ private fun ChatRoomTypeCard( onClick: () -> Unit, isEnabled: Boolean = true, disabledReason: String? = null, + content: @Composable () -> Unit = {}, ) { Card( modifier = Modifier.fillMaxWidth(), @@ -335,10 +351,71 @@ private fun ChatRoomTypeCard( color = MaterialTheme.colorScheme.error ) } + + content() } } } +/** + * Asks how many admins have to sign off on a change. Stepped rather than typed: + * the range is small, both ends are bounded, and the caption has to keep up. + */ +@Composable +private fun QuorumPicker( + quorum: Int, + adminCount: Int, + quorumRange: IntRange, + explanation: String, + onQuorumChange: (Int) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text( + text = "How many admins have to approve a change?", + style = MaterialTheme.typography.titleSmall + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilledIconButton( + onClick = { onQuorumChange(quorum - 1) }, + enabled = quorum > quorumRange.first + ) { + Icon( + Icons.Default.Remove, + contentDescription = "Fewer approvals" + ) + } + + Text( + text = "$quorum of $adminCount", + style = MaterialTheme.typography.titleMedium + ) + + FilledIconButton( + onClick = { onQuorumChange(quorum + 1) }, + enabled = quorum < quorumRange.last + ) { + Icon( + Icons.Default.Add, + contentDescription = "More approvals" + ) + } + } + + Text( + text = explanation, + style = MaterialTheme.typography.labelMedium + ) + } +} + @Preview @Composable private fun SelectChatRoomTypeScreenPreview() { 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 ac316829..24498cd0 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 @@ -87,8 +87,14 @@ class SelectChatRoomTypeViewModel( */ val adminCount: Int = (memberPublicKeys + activeUserPublicKey).distinct().size - /** How many of [adminCount] admins a change needs, under [ChatRoomType.ROBUST]. */ - val approvalThreshold: Int = ChatRoomType.approvalThreshold(adminCount) + /** Quorums this group can be run on, under [ChatRoomType.ROBUST]. */ + val quorumRange: IntRange = ChatRoomType.quorumRange(adminCount) + + /** + * How many of [adminCount] admins have to approve a change, under + * [ChatRoomType.ROBUST]. Starts on a simple majority and is the user's to move. + */ + val quorum: MutableState = mutableStateOf(ChatRoomType.defaultQuorum(adminCount)) /** Whether this group is big enough to be run as [ChatRoomType.ROBUST]. */ val isRobustAvailable: Boolean = ChatRoomType.isRobustAvailable(adminCount) @@ -127,6 +133,25 @@ class SelectChatRoomTypeViewModel( selectedChatRoomType.value = chatRoomType } + fun setQuorum(value: Int) { + // Same freeze as the type itself: past creation the group's governance is + // already stamped into its epoch-0 context. + if (isActionPending.value || createdChatRoomId.value != null) return + + quorum.value = value.coerceIn(quorumRange) + } + + /** Spells out what the chosen quorum costs the group day to day. */ + fun quorumExplanation(): String { + val chosenQuorum = quorum.value + + return if (chosenQuorum == adminCount) { + "Every admin has to agree. If one of you goes quiet, nothing about the group can change." + } else { + "Any $chosenQuorum of you can approve a change — the other ${adminCount - chosenQuorum} don't have to be around." + } + } + /** Names the member behind [publicKey], falling back to the key itself. */ fun displayNameFor(publicKey: HexKey): String { val members = (selectChatRoomTypeUIState as? SelectChatRoomTypeUIState.Loaded)?.members @@ -175,7 +200,10 @@ class SelectChatRoomTypeViewModel( // exactly this list. // TODO: Robust rooms still need the t-of-n approval itself, i.e. FROST // signing over admin changes. Today the list is set but every admin can - // still commit on their own. + // still commit on their own, and the quorum the user picked has nowhere to + // live: MIP-01's wire format is fixed, so it cannot ride in MarmotGroupData + // without breaking byte-compatibility with mdk/whitenoise. It needs a + // ChatRoom column (and the Room migration that comes with it). val adminPubkeys = when (selectedChatRoomType.value) { ChatRoomType.CONVENIENT -> listOf(keyPair.pubKey.toHexKey()) ChatRoomType.ROBUST -> (listOf(keyPair.pubKey.toHexKey()) + memberPublicKeys).distinct()