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