From c5c4bc9ed6ad950746937903b3bc9f7d28d01187 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 29 Aug 2026 16:14:37 +0200 Subject: [PATCH] feat: pick group members before creating a chat room Group creation was a single screen: name + description, then "Create chat" minted the MLS group and dropped the user straight into an empty room, with no way to bring anyone along except the existing one-at-a-time invite path (chat room detail -> search member -> confirm). Add a second step that lists the profiles already in the local store and lets the user tick everyone the group is for, so a group is created together with its members. The flow is now: ChatRoomCreationRoute (name + description) -> SelectChatRoomMembersRoute (pick people, create, invite) -> ChatRoomMessagingRoute New: ui/composable/navigation/routes/SelectChatRoomMembersRoute.kt * Carries activeUserPublicKey plus the name/description gathered by step one, so nothing is persisted until the user confirms who is in. * `description` is nullable with a default, and ChatRoomCreationViewModel maps blank text onto null: an empty string is not something navigation round-trips reliably as a path argument. New: ui/view/state/SelectChatRoomMembersUIState.kt * The Loading/Loaded/Error triple the other chat screens use. Loaded carries the pickable profiles. New: ui/view/model/SelectChatRoomMembersViewModel.kt * initiate() lists nostrRepository.searchableProfiles() excluding the active user -- purely local rows, no directory lookup. It also queues a negentropy sync for every listed profile's KeyPackageEvent up front, so the packages needed to actually add anybody have usually landed by the time the user has finished ticking names. * createChatRoom() moves here from ChatRoomCreationViewModel, unchanged in substance: bootstrap MarmotGroupData into the epoch-0 GroupContext, MlsGroup.create, then getOrCreateChatRoom keyed on the Marmot nostr_group_id (not MlsGroup's own groupId). Minting the group here rather than in step one is the point of the split -- backing out of the picker no longer strands a member-less room in the chat list. * inviteSelectedMembers() resolves the selected members' key packages concurrently under one shared 20s budget (a single relay round trip for the batch instead of one timeout per member) by observing observeMarmotKeyPackageForPublicKey, then invites sequentially. The room is re-read from the repository before every invite: inviteMember advances the MLS epoch and persists the new state, so reusing the snapshot taken before the previous invite would build the next commit on top of state the group has already left. * A member whose key package never shows up does not sink the group. It is created without them and the screen names who was left out; createdChatRoomId then turns the action into "Open chat" against the room that already exists rather than minting a second one, and toggleMember is frozen once the room exists so further ticks cannot look like they will still be honoured. New: ui/composable/SelectChatRoomMembersScreen.kt * Checkbox list over Loaded.profiles, reusing the row shape of SearchMemberToAddToChatRoomScreen (ProfileAvatar + name + about); both the row and the checkbox toggle selection. * BottomAppBar carries the running selection count and an ExtendedFloatingActionButton labelled "Create chat with N", which swaps to a progress indicator while the group is being built. * An empty local store gets an explanatory state that still allows creating the chat and inviting people later. ChatRoomCreationViewModel * Reduced to the details form. Group creation, the wallet keypair and both repositories move to the picker, so factory() now takes no arguments at all. * Gains validateInput() (mirroring CreateProfileViewModel) so an unnamed chat cannot advance, and selectMembers() to hand the collected name/description to the next route. ChatRoomCreationScreen * Takes activeUserPublicKey from the route instead of the wallet flow and the two repositories; the button becomes "Choose who to chat with". * Fix the "groupd" typo in the name placeholder. MantraNavHost * Register SelectChatRoomMembersRoute. Opening the finished chat pops back through ChatRoomCreationRoute inclusive, so backing out of a brand new chat lands where the user started rather than in the half-filled creation form. Known limits: the picker inherits ProfileDao's default LIMIT 21, so only the first 21 local profiles are offered (the same cap the existing add-member search already lives with), and a selected profile can only join if their KeyPackageEvent is reachable -- contacts who have never published one always land in the "couldn't be added" list. Verified: ./gradlew :composeApp:compileCommonMainKotlinMetadata ./gradlew :composeApp:compileDebugKotlinAndroid Co-Authored-By: Claude Opus 5 --- .../ui/composable/ChatRoomCreationScreen.kt | 33 +- .../composable/SelectChatRoomMembersScreen.kt | 327 +++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 29 +- .../routes/SelectChatRoomMembersRoute.kt | 15 + .../view/model/ChatRoomCreationViewModel.kt | 151 ++------ .../model/SelectChatRoomMembersViewModel.kt | 339 ++++++++++++++++++ .../state/SelectChatRoomMembersUIState.kt | 15 + 7 files changed, 773 insertions(+), 136 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectChatRoomMembersRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomMembersViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectChatRoomMembersUIState.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt index 9b8cf0d1..8a2917b5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt @@ -25,27 +25,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -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.view.model.ChatRoomCreationViewModel -import fr.acinq.phoenix.data.ActiveWallet -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable fun ChatRoomCreationScreen( - activeWalletStateFlow: StateFlow, - nostrRepository: NostrRepository, - chatRepository: ChatRepository, - onNavigateToChatRoom: (Route) -> Unit + activeUserPublicKey: HexKey, + onNavigateToRoute: (Route) -> Unit ) { val chatRoomCreationViewModel: ChatRoomCreationViewModel = viewModel ( - factory = ChatRoomCreationViewModel.factory( - activeWalletStateFlow = activeWalletStateFlow, - nostrRepository = nostrRepository, - chatRepository = chatRepository - ) + factory = ChatRoomCreationViewModel.factory() ) Scaffold { innerPadding -> @@ -84,7 +74,7 @@ fun ChatRoomCreationScreen( }, placeholder = { Text( - text = "Enter the name you want to use for your groupd", + text = "Enter the name you want to use for your group", maxLines = 1, ) }, @@ -150,13 +140,14 @@ fun ChatRoomCreationScreen( Button( onClick = { - chatRoomCreationViewModel.createChatRoom( - onNavigateToChatRoom = onNavigateToChatRoom + chatRoomCreationViewModel.selectMembers( + activeUserPublicKey = activeUserPublicKey, + onNavigateToRoute = onNavigateToRoute ) } ) { Text( - "Create chat" + "Choose who to chat with" ) } } @@ -172,10 +163,8 @@ private fun LoadingScreenPreview() { modifier = Modifier.fillMaxSize() ) { ChatRoomCreationScreen( - activeWalletStateFlow = MutableStateFlow(null), - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToChatRoom = {} + activeUserPublicKey = "", + onNavigateToRoute = {} ) } } 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 new file mode 100644 index 00000000..eb5aa799 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt @@ -0,0 +1,327 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +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 +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +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.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.composable.widgets.profile.ProfileAvatar +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. + * + * The list is whatever profiles are already in the local store — no directory + * lookup happens here. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun SelectChatRoomMembersScreen( + activeUserPublicKey: HexKey, + name: String, + description: String?, + initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loading, + activeWalletStateFlow: StateFlow, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + onNavigateToRoute: (Route) -> Unit, +) { + val selectChatRoomMembersViewModel: SelectChatRoomMembersViewModel = viewModel( + factory = SelectChatRoomMembersViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + name = name, + description = description, + initialSelectChatRoomMembersUIState = initialSelectChatRoomMembersUIState, + activeWalletStateFlow = activeWalletStateFlow, + nostrRepository = nostrRepository, + chatRepository = chatRepository + ) + ) + + when (val selectChatRoomMembersUIState = selectChatRoomMembersViewModel.selectChatRoomMembersUIState) { + is SelectChatRoomMembersUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(50.dp) + ) + Text( + text = selectChatRoomMembersUIState.message, + textAlign = TextAlign.Center + ) + } + } + is SelectChatRoomMembersUIState.Loaded -> { + val isActionPending = selectChatRoomMembersViewModel.isActionPending.value + val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size + val membersNotAdded = selectChatRoomMembersViewModel.membersNotAdded + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Add people to $name", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + selectChatRoomMembersViewModel.createChatRoom( + onNavigateToRoute = onNavigateToRoute + ) + } + ) { + if (isActionPending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + Icons.Default.Check, + contentDescription = "Create chat" + ) + } + + Text( + text = when { + selectChatRoomMembersViewModel.createdChatRoomId.value != null -> "Open chat" + selectedCount > 0 -> "Create chat with $selectedCount" + else -> "Create chat" + } + ) + } + }, + actions = { + Text( + modifier = Modifier.padding(start = 15.dp), + text = if (selectedCount > 0) { + "$selectedCount selected" + } else { + "No one selected yet" + }, + style = MaterialTheme.typography.labelLarge + ) + } + ) + } + ) { innerPadding -> + 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), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Spacer( + modifier = Modifier.weight(1f) + ) + + Text( + text = "No one to add yet.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Text( + text = "Search for people and chat with them first — everyone you know locally shows up here.", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + Text( + text = "You can still create the chat now and invite people later.", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } else { + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth().padding(5.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items( + items = selectChatRoomMembersUIState.profiles, + key = { it.publicKey } + ) { profile -> + val isSelected = selectChatRoomMembersViewModel.isSelected(profile.publicKey) + + Card( + onClick = { + selectChatRoomMembersViewModel.toggleMember(profile.publicKey) + } + ) { + ListItem( + leadingContent = { + ProfileAvatar( + profile = profile + ) + }, + headlineContent = { + Text( + profile.humanReadableNameOrPubkey() + ) + }, + supportingContent = { + profile.about?.let { + Text( + text = it, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } + }, + trailingContent = { + Checkbox( + checked = isSelected, + onCheckedChange = { + selectChatRoomMembersViewModel.toggleMember(profile.publicKey) + } + ) + } + ) + } + } + } + } + } + } + } + SelectChatRoomMembersUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + 20.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + + Spacer( + modifier = Modifier.weight(1f) + ) + + Text( + text = "Add people to $name", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } + } + + LaunchedEffect(true) { + if (initialSelectChatRoomMembersUIState == SelectChatRoomMembersUIState.Loading) { + selectChatRoomMembersViewModel.initiate() + } + } +} + +@Preview +@Composable +private fun SelectChatRoomMembersScreenPreview() { + TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + SelectChatRoomMembersScreen( + activeUserPublicKey = "", + name = "Group Discussions", + description = "See something... say something.", + initialSelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loaded( + profiles = listOf( + Profile( + publicKey = "hex", + displayName = "Man With A Plan", + about = "Making plans and getting things done...", + nostrEventId = "nostrEventId" + ) + ) + ), + 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 af114bac..80f18437 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 @@ -34,6 +34,7 @@ import press.mantra.compose.ui.composable.NostrEventDetailScreen 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.ShareProfileScreen import press.mantra.compose.ui.composable.SignInToProfileScreen import press.mantra.compose.ui.composable.SocialPreconditionScreen @@ -61,6 +62,7 @@ import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRout import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute 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.ShareProfileRoute import press.mantra.compose.ui.composable.navigation.routes.SignInRoute import press.mantra.compose.ui.composable.navigation.routes.SocialPreconditionRoute @@ -407,14 +409,37 @@ fun MantraNavHost( val route = backStackEntry.toRoute() ChatRoomCreationScreen( + activeUserPublicKey = route.activeUserPublicKey, + onNavigateToRoute = { selectMembersRoute -> + navController.navigate( + selectMembersRoute + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SelectChatRoomMembersScreen( + activeUserPublicKey = route.activeUserPublicKey, + name = route.name, + description = route.description, activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, - onNavigateToChatRoom = { chatRoomResultRoute -> + onNavigateToRoute = { chatRoomResultRoute -> navController.navigate( chatRoomResultRoute ) { - popUpTo(route) + // Drop both creation steps: backing out of the new chat should land + // on whatever the user was doing before, not back in the form. + popUpTo( + ChatRoomCreationRoute( + activeUserPublicKey = route.activeUserPublicKey + ) + ) { + inclusive = true + } } } ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectChatRoomMembersRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectChatRoomMembersRoute.kt new file mode 100644 index 00000000..51288590 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectChatRoomMembersRoute.kt @@ -0,0 +1,15 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Second step of group creation: the name/description gathered by + * [ChatRoomCreationRoute] travel with the route so the group is only ever + * created once the user has picked who is in it. + */ +@Serializable +data class SelectChatRoomMembersRoute( + val activeUserPublicKey: String, + val name: String, + val description: String? = null +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomCreationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomCreationViewModel.kt index 62164ea4..5357cdea 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomCreationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomCreationViewModel.kt @@ -1,138 +1,65 @@ package press.mantra.compose.ui.view.model -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf 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.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.SelectChatRoomMembersRoute import press.mantra.compose.ui.view.state.form.ChatRoomCreationFormState 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.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.flow.StateFlow -import kotlinx.coroutines.launch +import com.vitorpamplona.quartz.nip01Core.core.HexKey +/** + * First step of group creation: collect what the group is called and what it is for. + * The group itself is only minted once members have been picked, in + * [SelectChatRoomMembersViewModel]. + */ class ChatRoomCreationViewModel( - val activeWalletStateFlow: StateFlow, - val nostrRepository: NostrRepository, - val chatRepository: ChatRepository, val chatRoomCreationFormState: ChatRoomCreationFormState = ChatRoomCreationFormState(), ): ViewModel() { private val logger = Logger.withTag(TAG) - val isActionPending: MutableState = mutableStateOf(false) - - - fun createChatRoom( - onNavigateToChatRoom: (Route) -> Unit - ) { - // TODO: Load keypair... - activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey -> - val keyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() - ) - - val name = chatRoomCreationFormState.nameField.textFieldState.text - val description = chatRoomCreationFormState.descriptionField.textFieldState.text - - 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.toString(), - description = description.toString() - ) - - val extras = listOf(metadata.toExtension()) - - nostrPrivateKey.value.toByteArray().let { privateKey -> - 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.toString(), - description = description.toString(), - mlsGroupState = group.saveState().encodeTls().toHex() - ) - - viewModelScope.launch(Dispatchers.Main) { - if (localChatRoom != null) { - // TODO: Broadcast the thing... - - // TODO: Navigate to invitation screen... - onNavigateToChatRoom.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = keyPair.pubKey.toHexKey(), - chatRoomId = localChatRoom.chatRoom.id, - relayHint = null - ) - ) - } else { - onNavigateToChatRoom.invoke( - ImplementationPendingRoute("Something went wrong") - ) - } - } - - } - } + fun validateInput(): Boolean { + if (chatRoomCreationFormState.nameField.textFieldState.text.isBlank()) { + chatRoomCreationFormState.nameField.errorMessage.value = "Please input a name for the chat" + return false + } else { + chatRoomCreationFormState.nameField.errorMessage.value = null } + + return true + } + + fun selectMembers( + activeUserPublicKey: HexKey, + onNavigateToRoute: (Route) -> Unit + ) { + if (!validateInput()) return + + val description = chatRoomCreationFormState.descriptionField.textFieldState.text.toString() + + logger.d("selectMembers for new chat room") + + onNavigateToRoute.invoke( + SelectChatRoomMembersRoute( + activeUserPublicKey = activeUserPublicKey, + name = chatRoomCreationFormState.nameField.textFieldState.text.toString(), + // Keep empty text out of the route: a blank path argument is not something + // navigation round-trips reliably. + description = description.ifBlank { null } + ) + ) } companion object { private const val TAG = "ChatRoomCreationViewModel" - fun factory( - activeWalletStateFlow: StateFlow, - nostrRepository: NostrRepository, - chatRepository: ChatRepository - ): ViewModelProvider.Factory = viewModelFactory { + fun factory(): ViewModelProvider.Factory = viewModelFactory { initializer { - ChatRoomCreationViewModel( - activeWalletStateFlow = activeWalletStateFlow, - nostrRepository = nostrRepository, - chatRepository = chatRepository - ) + ChatRoomCreationViewModel() } } } -} \ No newline at end of file +} 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 new file mode 100644 index 00000000..544c2500 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomMembersViewModel.kt @@ -0,0 +1,339 @@ +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.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.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. + * + * Only profiles already in the local store are offered — this screen is a + * picker, not a directory search. + */ +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) + private set + + 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( + excludingPublicKeys = arrayOf(activeUserPublicKey) + ) + + selectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loaded( + profiles = profiles + ) + + // 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. + 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) + } + } + + fun isSelected(publicKey: HexKey): Boolean = selectedPublicKeys.contains(publicKey) + + 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) { + 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, + 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 = 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) { + if (profiles.isEmpty()) return + + // TODO: Sync from aggregated relays of profile KeyPackageRelays... + val synchronizationFilter = SynchronizationFilter( + authors = profiles.map { it.publicKey }.toTypedArray(), + kinds = arrayOf( + KeyPackageEvent.KIND + ) + ) + + nostrRepository.queueNegentropySynchronizeRequest( + Relays.DefaultDMRelayList.map { normalizedRelayUrl -> + NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = normalizedRelayUrl.url, + synchronizationFilter = synchronizationFilter + ), + purpose = "key-packages", + synchronizationFilter = synchronizationFilter, + relayURL = normalizedRelayUrl.url, + level = 0 + ) + } + ) + } + + 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 + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SelectChatRoomMembersViewModel( + activeUserPublicKey = activeUserPublicKey, + name = name, + description = description, + initialSelectChatRoomMembersUIState = initialSelectChatRoomMembersUIState, + activeWalletStateFlow = activeWalletStateFlow, + nostrRepository = nostrRepository, + chatRepository = chatRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectChatRoomMembersUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectChatRoomMembersUIState.kt new file mode 100644 index 00000000..6f4c7be9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectChatRoomMembersUIState.kt @@ -0,0 +1,15 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.Profile + +sealed interface SelectChatRoomMembersUIState { + data class Loaded( + val profiles: List + ): SelectChatRoomMembersUIState + + data class Error( + val message: String + ): SelectChatRoomMembersUIState + + data object Loading: SelectChatRoomMembersUIState +}