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 new file mode 100644 index 00000000..d44108a6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/ChatRoomType.kt @@ -0,0 +1,40 @@ +package press.mantra.compose.database.model.types + +import kotlinx.serialization.Serializable + +/** + * How a chat room is governed. + * + * The choice is stamped into the epoch-0 `MarmotGroupData.admin_pubkeys` list, + * which is what `MarmotInboundManager.processGroupMembershipChanges` reads to + * flag participants as admins on every side of the group. + */ +@Serializable +enum class ChatRoomType { + /** Only the member who created the room administers it. */ + CONVENIENT, + + /** + * Every member administers the room, and a change needs a threshold of them + * to approve it. + */ + ROBUST; + + companion object { + /** + * Smallest group [ROBUST] is offered for. Below three, a majority is not a + * meaningful check: two admins means every change needs both of them, and + * one means the creator is deciding alone — which is [CONVENIENT] already. + */ + 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. + */ + fun approvalThreshold(adminCount: Int): Int = adminCount / 2 + 1 + + /** 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/SelectChatRoomMembersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt index eb5aa799..1d9d40c6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt @@ -7,16 +7,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.automirrored.filled.NavigateNext import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton @@ -37,7 +34,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.Profile -import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -46,15 +42,13 @@ import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.SelectChatRoomMembersViewModel import press.mantra.compose.ui.view.state.SelectChatRoomMembersUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow /** - * Second step of group creation: pick who is in the group, then create it. + * Second step of group creation: pick who is in the group. * * The list is whatever profiles are already in the local store — no directory - * lookup happens here. + * lookup happens here. Creating the group waits for the next step, where the + * user says how it should be run. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -63,9 +57,7 @@ fun SelectChatRoomMembersScreen( name: String, description: String?, initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loading, - activeWalletStateFlow: StateFlow, nostrRepository: NostrRepository, - chatRepository: ChatRepository, onNavigateToRoute: (Route) -> Unit, ) { val selectChatRoomMembersViewModel: SelectChatRoomMembersViewModel = viewModel( @@ -74,9 +66,7 @@ fun SelectChatRoomMembersScreen( name = name, description = description, initialSelectChatRoomMembersUIState = initialSelectChatRoomMembersUIState, - activeWalletStateFlow = activeWalletStateFlow, - nostrRepository = nostrRepository, - chatRepository = chatRepository + nostrRepository = nostrRepository ) ) @@ -96,9 +86,7 @@ fun SelectChatRoomMembersScreen( } } is SelectChatRoomMembersUIState.Loaded -> { - val isActionPending = selectChatRoomMembersViewModel.isActionPending.value val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size - val membersNotAdded = selectChatRoomMembersViewModel.membersNotAdded Scaffold( topBar = { @@ -117,27 +105,21 @@ fun SelectChatRoomMembersScreen( floatingActionButton = { ExtendedFloatingActionButton( onClick = { - selectChatRoomMembersViewModel.createChatRoom( + selectChatRoomMembersViewModel.selectChatRoomType( onNavigateToRoute = onNavigateToRoute ) } ) { - if (isActionPending) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp) - ) - } else { - Icon( - Icons.Default.Check, - contentDescription = "Create chat" - ) - } + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = "Next" + ) Text( - text = when { - selectChatRoomMembersViewModel.createdChatRoomId.value != null -> "Open chat" - selectedCount > 0 -> "Create chat with $selectedCount" - else -> "Create chat" + text = if (selectedCount > 0) { + "Next with $selectedCount" + } else { + "Next" } ) } @@ -159,22 +141,6 @@ fun SelectChatRoomMembersScreen( Column( modifier = Modifier.padding(innerPadding).fillMaxSize() ) { - if (membersNotAdded.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth().padding(10.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Text( - modifier = Modifier.padding(15.dp), - text = "${name} was created, but ${membersNotAdded.joinToString { it.humanReadableNameOrPubkey() }} couldn't be added yet. Invite them again from the chat once they're on Torch.", - style = MaterialTheme.typography.bodyMedium - ) - } - } - if (selectChatRoomMembersUIState.profiles.isEmpty()) { Column( modifier = Modifier.weight(1f).fillMaxWidth().padding(20.dp), @@ -198,7 +164,7 @@ fun SelectChatRoomMembersScreen( ) Text( - text = "You can still create the chat now and invite people later.", + text = "You can still carry on and invite people later.", style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) @@ -317,9 +283,7 @@ private fun SelectChatRoomMembersScreenPreview() { ) ) ), - activeWalletStateFlow = MutableStateFlow(null), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, onNavigateToRoute = {} ) } 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 new file mode 100644 index 00000000..2128c365 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -0,0 +1,377 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.Check +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.view.model.SelectChatRoomTypeViewModel +import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.ActiveWallet +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Last step of group creation: convenient (one admin) or robust (everyone + * admins, changes need a threshold to approve), and then build the group. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun SelectChatRoomTypeScreen( + activeUserPublicKey: HexKey, + name: String, + description: String?, + memberPublicKeys: List, + initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, + activeWalletStateFlow: StateFlow, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + onNavigateToRoute: (Route) -> Unit, +) { + val selectChatRoomTypeViewModel: SelectChatRoomTypeViewModel = viewModel( + factory = SelectChatRoomTypeViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + name = name, + description = description, + memberPublicKeys = memberPublicKeys, + initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, + activeWalletStateFlow = activeWalletStateFlow, + nostrRepository = nostrRepository, + chatRepository = chatRepository + ) + ) + + when (val selectChatRoomTypeUIState = selectChatRoomTypeViewModel.selectChatRoomTypeUIState) { + is SelectChatRoomTypeUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(50.dp) + ) + Text( + text = selectChatRoomTypeUIState.message, + textAlign = TextAlign.Center + ) + } + } + is SelectChatRoomTypeUIState.Loaded -> { + val isActionPending = selectChatRoomTypeViewModel.isActionPending.value + val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value + val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded + val adminCount = selectChatRoomTypeViewModel.adminCount + val approvalThreshold = selectChatRoomTypeViewModel.approvalThreshold + val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "How should $name be run?", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + selectChatRoomTypeViewModel.createChatRoom( + onNavigateToRoute = onNavigateToRoute + ) + } + ) { + if (isActionPending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + Icons.Default.Check, + contentDescription = "Create chat" + ) + } + + Text( + text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) { + "Open chat" + } else { + "Create chat" + } + ) + } + }, + actions = { + Text( + modifier = Modifier.padding(start = 15.dp), + text = when (selectChatRoomTypeUIState.members.size) { + 0 -> "Just you for now" + 1 -> "You and 1 other" + else -> "You and ${selectChatRoomTypeUIState.members.size} others" + }, + style = MaterialTheme.typography.labelLarge + ) + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (membersNotAdded.isNotEmpty()) { + 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 ${membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }} couldn't be added yet. Invite them again from the chat once they're on Torch.", + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Text( + text = "This decides who can change the group later. You can't switch afterwards.", + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp) + ) + + ChatRoomTypeCard( + icon = Icons.Default.Bolt, + title = "Convenient", + summary = "You are the only admin.", + detail = "You can add or remove people and change the group's name or description on your own, without waiting for anybody.", + footnote = "Nothing about the group can change unless you do it — and nobody else can carry it on if you lose your keys.", + isSelected = selectedChatRoomType == ChatRoomType.CONVENIENT, + onClick = { + selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.CONVENIENT) + } + ) + + ChatRoomTypeCard( + icon = Icons.Default.Groups, + 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." + } 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." + }, + footnote = "No single admin can change the group alone, and the group outlives any one of you.", + isSelected = selectedChatRoomType == ChatRoomType.ROBUST, + isEnabled = isRobustAvailable, + disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, + onClick = { + selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST) + } + ) + } + } + } + SelectChatRoomTypeUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + 20.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + + Spacer( + modifier = Modifier.weight(1f) + ) + + Text( + text = "How should $name be run?", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } + } + + LaunchedEffect(true) { + if (initialSelectChatRoomTypeUIState == SelectChatRoomTypeUIState.Loading) { + selectChatRoomTypeViewModel.initiate() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChatRoomTypeCard( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + summary: String, + detail: String, + footnote: String, + isSelected: Boolean, + onClick: () -> Unit, + isEnabled: Boolean = true, + disabledReason: String? = null, +) { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + enabled = isEnabled, + colors = if (isSelected) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + } else { + CardDefaults.cardColors() + } + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(15.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = onClick, + enabled = isEnabled + ) + + Icon( + imageVector = icon, + contentDescription = null + ) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + } + + Text( + text = summary, + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = footnote, + style = MaterialTheme.typography.labelMedium + ) + + disabledReason?.let { + Text( + text = it, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@Preview +@Composable +private fun SelectChatRoomTypeScreenPreview() { + TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + SelectChatRoomTypeScreen( + activeUserPublicKey = "", + name = "Group Discussions", + description = "See something... say something.", + memberPublicKeys = listOf("hex", "otherHex"), + initialSelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loaded( + members = listOf( + Profile( + publicKey = "hex", + displayName = "Man With A Plan", + about = "Making plans and getting things done...", + nostrEventId = "nostrEventId" + ), + Profile( + publicKey = "otherHex", + displayName = "Woman Of Few Words", + about = "Says it once, says it well.", + nostrEventId = "otherNostrEventId" + ) + ) + ), + activeWalletStateFlow = MutableStateFlow(null), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 80f18437..70486ca1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -35,6 +35,7 @@ import press.mantra.compose.ui.composable.SearchMemberToAddToChatRoomScreen import press.mantra.compose.ui.composable.SearchResultScreen import press.mantra.compose.ui.composable.SearchScreen import press.mantra.compose.ui.composable.SelectChatRoomMembersScreen +import press.mantra.compose.ui.composable.SelectChatRoomTypeScreen import press.mantra.compose.ui.composable.ShareProfileScreen import press.mantra.compose.ui.composable.SignInToProfileScreen import press.mantra.compose.ui.composable.SocialPreconditionScreen @@ -63,6 +64,7 @@ import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToC import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomMembersRoute +import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute import press.mantra.compose.ui.composable.navigation.routes.SignInRoute import press.mantra.compose.ui.composable.navigation.routes.SocialPreconditionRoute @@ -424,6 +426,22 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, name = route.name, description = route.description, + nostrRepository = databaseNostrRepository, + onNavigateToRoute = { selectChatRoomTypeRoute -> + navController.navigate( + selectChatRoomTypeRoute + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SelectChatRoomTypeScreen( + activeUserPublicKey = route.activeUserPublicKey, + name = route.name, + description = route.description, + memberPublicKeys = route.memberPublicKeys, activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, @@ -431,7 +449,7 @@ fun MantraNavHost( navController.navigate( chatRoomResultRoute ) { - // Drop both creation steps: backing out of the new chat should land + // Drop every creation step: backing out of the new chat should land // on whatever the user was doing before, not back in the form. popUpTo( ChatRoomCreationRoute( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectChatRoomTypeRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectChatRoomTypeRoute.kt new file mode 100644 index 00000000..43097ca1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectChatRoomTypeRoute.kt @@ -0,0 +1,16 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Final step of group creation: how the group should be run. Everything the + * earlier steps collected rides along, because the governance choice feeds the + * epoch-0 group context and so nothing can be persisted before it is made. + */ +@Serializable +data class SelectChatRoomTypeRoute( + val activeUserPublicKey: String, + val name: String, + val description: String? = null, + val memberPublicKeys: List = emptyList() +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomMembersViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomMembersViewModel.kt index 544c2500..d6ad2888 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomMembersViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomMembersViewModel.kt @@ -1,6 +1,5 @@ package press.mantra.compose.ui.view.model -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -13,53 +12,31 @@ import androidx.lifecycle.viewmodel.viewModelFactory import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.SynchronizationFilter -import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.Relays -import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute -import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute import press.mantra.compose.ui.view.state.SelectChatRoomMembersUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.utils.RandomInstance -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.time.Duration.Companion.seconds /** - * Picks the members of a brand new group chat and, once the user confirms, - * creates the MLS group and invites everyone that was ticked. + * Second step of group creation: who the group is for. * * Only profiles already in the local store are offered — this screen is a - * picker, not a directory search. + * picker, not a directory search. Nothing is persisted here; the picked keys + * ride on to [SelectChatRoomTypeViewModel], which mints the group. */ class SelectChatRoomMembersViewModel( val activeUserPublicKey: HexKey, val name: String, val description: String?, initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState, - val activeWalletStateFlow: StateFlow, val nostrRepository: NostrRepository, - val chatRepository: ChatRepository, ): ViewModel() { var selectChatRoomMembersUIState: SelectChatRoomMembersUIState by mutableStateOf(initialSelectChatRoomMembersUIState) @@ -67,21 +44,9 @@ class SelectChatRoomMembersViewModel( private val logger = Logger.withTag(TAG) - val isActionPending: MutableState = mutableStateOf(false) - /** Public keys the user has ticked, in the order they were ticked. */ val selectedPublicKeys = mutableStateListOf() - /** - * Set as soon as the room exists. Creating a group is not idempotent, so a second - * tap after a partially failed invite round has to reuse this room rather than - * mint another one. - */ - val createdChatRoomId: MutableState = mutableStateOf(null) - - /** Members the group was created without, because no key package ever showed up. */ - val membersNotAdded = mutableStateListOf() - fun initiate() { viewModelScope.launch(Dispatchers.IO) { val profiles = nostrRepository.searchableProfiles( @@ -93,17 +58,13 @@ class SelectChatRoomMembersViewModel( ) // Ask for everybody's key package up front: by the time the user has finished - // ticking names the relay round trip is usually done, so creating the group - // doesn't have to sit and wait for it. + // ticking names and chosen how the group is run, the relay round trip is + // usually done, so creating the group doesn't have to sit and wait for it. scheduleKeyPackageEventSynchronization(profiles) } } fun toggleMember(publicKey: HexKey) { - // Once the room exists the picker is spent — further ticks would silently do - // nothing, since creation (and with it the invite round) has already run. - if (isActionPending.value || createdChatRoomId.value != null) return - if (!selectedPublicKeys.remove(publicKey)) { selectedPublicKeys.add(publicKey) } @@ -111,174 +72,19 @@ class SelectChatRoomMembersViewModel( fun isSelected(publicKey: HexKey): Boolean = selectedPublicKeys.contains(publicKey) - fun createChatRoom( + fun selectChatRoomType( onNavigateToRoute: (Route) -> Unit ) { - if (isActionPending.value) return + logger.d("selectChatRoomType with ${selectedPublicKeys.size} member(s)") - // The room survived a partial invite round — the user has read who was left out - // and just wants to get on with the chat. - createdChatRoomId.value?.let { chatRoomId -> - onNavigateToRoute.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null - ) - ) - return - } - - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() - if (nostrPrivateKey == null) { - selectChatRoomMembersUIState = SelectChatRoomMembersUIState.Error( - "Couldn't read your keys. Please try again." - ) - return - } - - isActionPending.value = true - - val keyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() - ) - - val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost... - - // 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`) - // so later invitees receive a pre-populated group from the - // welcome and never have to chase an undecryptable bootstrap - // commit that predates their membership. - val metadata = MarmotGroupData.bootstrap( - nostrGroupId = gid, - creatorPubKey = keyPair.pubKey.toHexKey(), - outboxRelays = Relays.DefaultDMRelayList.map { it.url }, - name = name, - description = description.orEmpty() - ) - - val extras = listOf(metadata.toExtension()) - - val signingKeyPair = Ed25519.generateKeyPair() -// TODO: Use nostrPrivateKey Ed25519.publicFromPrivate(privateKey).let { ed25519pubKey -> -// Ed25519KeyPair(privateKey, ed25519pubKey) -// } - val group = MlsGroup.create(keyPair.pubKey, signingKeyPair.privateKey, extras) - - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getOrCreateChatRoom( - // Key the room on the Marmot `nostr_group_id` baked into the group's - // 0xF2EE extension, NOT on `MlsGroup`'s own randomly generated groupId — - // they are unrelated 32-byte values. GroupEvents are h-tagged with the - // room id, while every inbound path (and the invitee's own join) resolves - // rooms by nostrGroupId, so using the MLS id meant the two sides could - // never see each other's events. - chatRoomId = gid, - activeUserPublicKey = keyPair.pubKey.toHexKey(), - relayHint = null, - defaultSubject = name, + onNavigateToRoute.invoke( + SelectChatRoomTypeRoute( + activeUserPublicKey = activeUserPublicKey, + name = name, description = description, - mlsGroupState = group.saveState().encodeTls().toHex() + memberPublicKeys = selectedPublicKeys.toList() ) - - if (localChatRoom == null) { - withContext(Dispatchers.Main) { - isActionPending.value = false - onNavigateToRoute.invoke( - ImplementationPendingRoute("Something went wrong") - ) - } - return@launch - } - - createdChatRoomId.value = localChatRoom.chatRoom.id - - val notAdded = inviteSelectedMembers(chatRoomId = localChatRoom.chatRoom.id) - - withContext(Dispatchers.Main) { - isActionPending.value = false - - if (notAdded.isEmpty()) { - onNavigateToRoute.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = keyPair.pubKey.toHexKey(), - chatRoomId = localChatRoom.chatRoom.id, - relayHint = null - ) - ) - } else { - // The group is real and usable without them; say who is missing instead - // of dropping the user into a chat that is quietly short a few people. - membersNotAdded.clear() - membersNotAdded.addAll(notAdded) - } - } - } - } - - /** - * Adds every ticked profile to the freshly created group and returns the ones that - * could not be added. - */ - private suspend fun inviteSelectedMembers(chatRoomId: String): List { - val selectedProfiles = selectedProfiles() - if (selectedProfiles.isEmpty()) return emptyList() - - // Resolve the key packages concurrently so the whole batch costs one relay round - // trip rather than one per member. - val keyPackages = coroutineScope { - selectedProfiles.map { profile -> - async { - profile to withTimeoutOrNull(KEY_PACKAGE_LOOKUP_TIMEOUT) { - chatRepository.observeMarmotKeyPackageForPublicKey(profile.publicKey) - .filterNotNull() - .first() - } - } - }.awaitAll() - } - - val notAdded = mutableListOf() - - keyPackages.forEach { (profile, marmotKeyPackage) -> - if (marmotKeyPackage == null) { - logger.w("No key package for ${profile.publicKey}; leaving them out of $chatRoomId") - notAdded.add(profile) - return@forEach - } - - // Re-read the room before each invite: `inviteMember` advances the MLS epoch and - // persists the new state, so reusing the snapshot taken before the previous - // invite would build this commit on top of state the group has already left. - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - if (localChatRoom == null) { - logger.e("Chat room $chatRoomId disappeared mid-invite") - notAdded.add(profile) - return@forEach - } - - runCatching { - chatRepository.inviteMember( - localChatRoom = localChatRoom, - peerPublicKey = profile.publicKey, - peerKeyPackage = marmotKeyPackage - ) - }.onFailure { throwable -> - logger.e("Failed to invite ${profile.publicKey} to $chatRoomId", throwable) - notAdded.add(profile) - } - } - - return notAdded - } - - private fun selectedProfiles(): List { - val profiles = (selectChatRoomMembersUIState as? SelectChatRoomMembersUIState.Loaded)?.profiles - ?: return emptyList() - - return profiles.filter { selectedPublicKeys.contains(it.publicKey) } + ) } private suspend fun scheduleKeyPackageEventSynchronization(profiles: List) { @@ -311,17 +117,12 @@ class SelectChatRoomMembersViewModel( companion object { private const val TAG = "SelectChatRoomMembersViewModel" - /** Shared budget for pulling every selected member's key package off the relays. */ - private val KEY_PACKAGE_LOOKUP_TIMEOUT = 20.seconds - fun factory( activeUserPublicKey: HexKey, name: String, description: String?, initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loading, - activeWalletStateFlow: StateFlow, - nostrRepository: NostrRepository, - chatRepository: ChatRepository + nostrRepository: NostrRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { SelectChatRoomMembersViewModel( @@ -329,9 +130,7 @@ class SelectChatRoomMembersViewModel( name = name, description = description, initialSelectChatRoomMembersUIState = initialSelectChatRoomMembersUIState, - activeWalletStateFlow = activeWalletStateFlow, - nostrRepository = nostrRepository, - chatRepository = chatRepository + nostrRepository = nostrRepository ) } } 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 new file mode 100644 index 00000000..ac316829 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -0,0 +1,347 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +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.nostr.Relays +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.utils.RandomInstance +import fr.acinq.phoenix.data.ActiveWallet +import fr.acinq.phoenix.managers.nostrPrivateKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.seconds + +/** + * Last step of group creation: how the group should be run, and then actually + * building it. + * + * The choice is not cosmetic — it decides who lands in the epoch-0 + * `admin_pubkeys` list, so the group can only be minted once it has been made. + */ +class SelectChatRoomTypeViewModel( + val activeUserPublicKey: HexKey, + val name: String, + val description: String?, + val memberPublicKeys: List, + initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState, + val activeWalletStateFlow: StateFlow, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, +): ViewModel() { + + var selectChatRoomTypeUIState: SelectChatRoomTypeUIState by mutableStateOf(initialSelectChatRoomTypeUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** Convenient until the user says otherwise: it is the choice that always works. */ + val selectedChatRoomType: MutableState = mutableStateOf(ChatRoomType.CONVENIENT) + + /** + * Set as soon as the room exists. Creating a group is not idempotent, so a second + * tap after a partially failed invite round has to reuse this room rather than + * mint another one. + */ + val createdChatRoomId: MutableState = mutableStateOf(null) + + /** Members the group was created without, because no key package ever showed up. */ + val membersNotAdded = mutableStateListOf() + + /** + * Everyone who administers the group under [ChatRoomType.ROBUST]: the picked + * members plus the creator. + */ + val adminCount: Int = (memberPublicKeys + activeUserPublicKey).distinct().size + + /** How many of [adminCount] admins a change needs, under [ChatRoomType.ROBUST]. */ + val approvalThreshold: Int = ChatRoomType.approvalThreshold(adminCount) + + /** Whether this group is big enough to be run as [ChatRoomType.ROBUST]. */ + val isRobustAvailable: Boolean = ChatRoomType.isRobustAvailable(adminCount) + + /** Why robust is off the table, or null when it is available. */ + val robustUnavailableReason: String? = if (isRobustAvailable) { + null + } else { + val shortfall = ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE - adminCount + val missing = if (shortfall == 1) "1 more person" else "$shortfall more people" + + "Needs at least ${ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE} people in the group. Go back and add $missing." + } + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val members = memberPublicKeys.mapNotNull { publicKey -> + nostrRepository.getProfileWithPublicKey(publicKey) + } + + selectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loaded( + members = members + ) + } + } + + fun selectChatRoomType(chatRoomType: ChatRoomType) { + // Once the room exists the choice is baked into its epoch-0 group context and + // re-picking here would change nothing. + if (isActionPending.value || createdChatRoomId.value != null) return + + // Belt and braces alongside the disabled card: a group this small cannot carry + // a meaningful threshold, so it never becomes robust. + if (chatRoomType == ChatRoomType.ROBUST && !isRobustAvailable) return + + selectedChatRoomType.value = chatRoomType + } + + /** Names the member behind [publicKey], falling back to the key itself. */ + fun displayNameFor(publicKey: HexKey): String { + val members = (selectChatRoomTypeUIState as? SelectChatRoomTypeUIState.Loaded)?.members + + return members?.firstOrNull { it.publicKey == publicKey }?.humanReadableNameOrPubkey() + ?: publicKey.take(SHORTENED_PUBLIC_KEY_LENGTH) + } + + fun createChatRoom( + onNavigateToRoute: (Route) -> Unit + ) { + if (isActionPending.value) return + + // The room survived a partial invite round — the user has read who was left out + // and just wants to get on with the chat. + createdChatRoomId.value?.let { chatRoomId -> + onNavigateToRoute.invoke( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null + ) + ) + return + } + + val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + if (nostrPrivateKey == null) { + selectChatRoomTypeUIState = SelectChatRoomTypeUIState.Error( + "Couldn't read your keys. Please try again." + ) + return + } + + isActionPending.value = true + + val keyPair = KeyPair( + privKey = nostrPrivateKey.value.toByteArray() + ) + + val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost... + + // Convenient rooms keep the creator as the lone admin; robust rooms hand + // everyone the same authority. This is the whole behavioural difference + // between the two — the inbound path derives Participant.adminAt from + // 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. + val adminPubkeys = when (selectedChatRoomType.value) { + ChatRoomType.CONVENIENT -> listOf(keyPair.pubKey.toHexKey()) + ChatRoomType.ROBUST -> (listOf(keyPair.pubKey.toHexKey()) + memberPublicKeys).distinct() + } + + // 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`) + // so later invitees receive a pre-populated group from the + // welcome and never have to chase an undecryptable bootstrap + // commit that predates their membership. + val metadata = MarmotGroupData.bootstrap( + nostrGroupId = gid, + creatorPubKey = keyPair.pubKey.toHexKey(), + outboxRelays = Relays.DefaultDMRelayList.map { it.url }, + name = name, + description = description.orEmpty() + ).copy( + adminPubkeys = adminPubkeys + ) + + val extras = listOf(metadata.toExtension()) + + val signingKeyPair = Ed25519.generateKeyPair() +// TODO: Use nostrPrivateKey Ed25519.publicFromPrivate(privateKey).let { ed25519pubKey -> +// Ed25519KeyPair(privateKey, ed25519pubKey) +// } + val group = MlsGroup.create(keyPair.pubKey, signingKeyPair.privateKey, extras) + + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getOrCreateChatRoom( + // Key the room on the Marmot `nostr_group_id` baked into the group's + // 0xF2EE extension, NOT on `MlsGroup`'s own randomly generated groupId — + // they are unrelated 32-byte values. GroupEvents are h-tagged with the + // room id, while every inbound path (and the invitee's own join) resolves + // rooms by nostrGroupId, so using the MLS id meant the two sides could + // never see each other's events. + chatRoomId = gid, + activeUserPublicKey = keyPair.pubKey.toHexKey(), + relayHint = null, + defaultSubject = name, + description = description, + mlsGroupState = group.saveState().encodeTls().toHex() + ) + + if (localChatRoom == null) { + withContext(Dispatchers.Main) { + isActionPending.value = false + onNavigateToRoute.invoke( + ImplementationPendingRoute("Something went wrong") + ) + } + return@launch + } + + createdChatRoomId.value = localChatRoom.chatRoom.id + + val notAdded = inviteMembers(chatRoomId = localChatRoom.chatRoom.id) + + withContext(Dispatchers.Main) { + isActionPending.value = false + + if (notAdded.isEmpty()) { + onNavigateToRoute.invoke( + ChatRoomMessagingRoute( + activeUserPublicKey = keyPair.pubKey.toHexKey(), + chatRoomId = localChatRoom.chatRoom.id, + relayHint = null + ) + ) + } else { + // The group is real and usable without them; say who is missing instead + // of dropping the user into a chat that is quietly short a few people. + membersNotAdded.clear() + membersNotAdded.addAll(notAdded) + } + } + } + } + + /** + * Adds every picked member to the freshly created group and returns the ones that + * could not be added. + */ + private suspend fun inviteMembers(chatRoomId: String): List { + if (memberPublicKeys.isEmpty()) return emptyList() + + // Resolve the key packages concurrently so the whole batch costs one relay round + // trip rather than one per member. + val keyPackages = coroutineScope { + memberPublicKeys.map { publicKey -> + async { + publicKey to withTimeoutOrNull(KEY_PACKAGE_LOOKUP_TIMEOUT) { + chatRepository.observeMarmotKeyPackageForPublicKey(publicKey) + .filterNotNull() + .first() + } + } + }.awaitAll() + } + + val notAdded = mutableListOf() + + keyPackages.forEach { (publicKey, marmotKeyPackage) -> + if (marmotKeyPackage == null) { + logger.w("No key package for $publicKey; leaving them out of $chatRoomId") + notAdded.add(publicKey) + return@forEach + } + + // Re-read the room before each invite: `inviteMember` advances the MLS epoch and + // persists the new state, so reusing the snapshot taken before the previous + // invite would build this commit on top of state the group has already left. + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (localChatRoom == null) { + logger.e("Chat room $chatRoomId disappeared mid-invite") + notAdded.add(publicKey) + return@forEach + } + + runCatching { + chatRepository.inviteMember( + localChatRoom = localChatRoom, + peerPublicKey = publicKey, + peerKeyPackage = marmotKeyPackage + ) + }.onFailure { throwable -> + logger.e("Failed to invite $publicKey to $chatRoomId", throwable) + notAdded.add(publicKey) + } + } + + return notAdded + } + + companion object { + private const val TAG = "SelectChatRoomTypeViewModel" + + /** Shared budget for pulling every picked member's key package off the relays. */ + private val KEY_PACKAGE_LOOKUP_TIMEOUT = 20.seconds + + /** Enough of a pubkey to tell two members apart when no profile is stored. */ + private const val SHORTENED_PUBLIC_KEY_LENGTH = 12 + + fun factory( + activeUserPublicKey: HexKey, + name: String, + description: String?, + memberPublicKeys: List, + initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, + activeWalletStateFlow: StateFlow, + nostrRepository: NostrRepository, + chatRepository: ChatRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SelectChatRoomTypeViewModel( + activeUserPublicKey = activeUserPublicKey, + name = name, + description = description, + memberPublicKeys = memberPublicKeys, + initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, + activeWalletStateFlow = activeWalletStateFlow, + nostrRepository = nostrRepository, + chatRepository = chatRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectChatRoomTypeUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectChatRoomTypeUIState.kt new file mode 100644 index 00000000..fd905853 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectChatRoomTypeUIState.kt @@ -0,0 +1,16 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.Profile + +sealed interface SelectChatRoomTypeUIState { + data class Loaded( + /** The picked members, for naming them back to the user. */ + val members: List + ): SelectChatRoomTypeUIState + + data class Error( + val message: String + ): SelectChatRoomTypeUIState + + data object Loading: SelectChatRoomTypeUIState +}