From 55198de44ef6534b9a964ae1dcd44c013de8a949 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Thu, 18 Jun 2026 17:31:09 +0300 Subject: [PATCH] Chat room creation --- .../compose/database/dao/NostrNip17Dao.kt | 30 +-- .../torch/compose/database/model/ChatRoom.kt | 2 + .../repository/DatabaseChatRepository.kt | 11 +- .../repository/DatabaseNostrRepository.kt | 41 +--- .../compose/repository/ChatRepository.kt | 11 +- .../ui/composable/ChatRoomCreationScreen.kt | 187 ++++++++++++++++++ .../ui/composable/ChatRoomDetailScreen.kt | 3 +- .../ui/composable/CreateProfileScreen.kt | 19 +- .../torch/compose/ui/composable/HomeScreen.kt | 24 +-- .../ui/composable/navigation/TorchNavHost.kt | 18 +- .../routes/ChatRoomCreationRoute.kt | 9 + .../view/model/ChatRoomCreationViewModel.kt | 125 ++++++++++++ .../ui/view/model/ChatRoomDetailViewModel.kt | 46 ++--- .../state/form/ChatRoomCreationFormState.kt | 6 + 14 files changed, 414 insertions(+), 118 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomCreationScreen.kt create mode 100755 composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ChatRoomCreationRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomCreationViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/form/ChatRoomCreationFormState.kt diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrNip17Dao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrNip17Dao.kt index 2d89c3d3..2be91882 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrNip17Dao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrNip17Dao.kt @@ -28,36 +28,36 @@ abstract class NostrNip17Dao( val logger = Logger.withTag(TAG) @Transaction - open suspend fun getOrCreateChatRoom(activeUserPublicKey: HexKey, publicKey: String, relayHint: String?, defaultSubject: String? = null): at.torch.compose.database.model.intermdiate.LocalChatRoom? { - logger.d("getOrCreateChatRoom: $publicKey") + open suspend fun getOrCreateChatRoom(chatRoomId: String, activeUserPublicKey: HexKey, relayHint: String?, defaultSubject: String? = null, mlsGroupState: String? = null): LocalChatRoom? { + logger.d("getOrCreateChatRoom: $chatRoomId") val hexKeys = setOf( activeUserPublicKey, - publicKey +// publicKey ) - val chatRoomId = _root_ide_package_.at.torch.compose.database.model.ChatRoom.Companion.deriveChatRoomId(hexKeys) - logger.d("chatRoomId: $chatRoomId") val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId) if (localChatRoom != null) { + // TODO: might want to update the mlsGroupState? return localChatRoom } else { val profiles = database.profileDao().getProfileByPublicKeys(hexKeys.toList()) if (profiles.isNotEmpty()) { // Create new chatRoom - val chatRoom = _root_ide_package_.at.torch.compose.database.model.ChatRoom( + val chatRoom = ChatRoom( id = chatRoomId, userPublicKey = activeUserPublicKey, subject = defaultSubject, + mlsGroupState = mlsGroupState ) database.chatRoomDao().upsert(chatRoom) database.participantDao().upsert( profiles.map { - _root_ide_package_.at.torch.compose.database.model.Participant( + Participant( participantPublicKey = it.publicKey, chatRoomId = chatRoomId, relayHint = relayHint @@ -65,7 +65,7 @@ abstract class NostrNip17Dao( } ) - return _root_ide_package_.at.torch.compose.database.model.intermdiate.LocalChatRoom( + return LocalChatRoom( chatRoom = chatRoom ) } @@ -81,7 +81,7 @@ abstract class NostrNip17Dao( giftWrapEvent: GiftWrapEvent, receiverPublicKey: PTag ) { - val nostrEvent = _root_ide_package_.at.torch.compose.database.model.NostrEvent( + val nostrEvent = NostrEvent( id = giftWrapEvent.id, content = giftWrapEvent.content, createdAt = Instant.fromEpochSeconds(giftWrapEvent.createdAt), @@ -99,7 +99,7 @@ abstract class NostrNip17Dao( chatMessage?.let { database.chatMessageNostrEventRelationDao().upsert( - _root_ide_package_.at.torch.compose.database.model.ChatMessageNostrEventRelation( + ChatMessageNostrEventRelation( chatMessageId = it.id, nostrEventId = nostrEvent.id ) @@ -112,7 +112,7 @@ abstract class NostrNip17Dao( authors = arrayOf( receiverPublicKey.pubKey ), - since = _root_ide_package_.at.torch.compose.database.GENESIS_AT, + since = GENESIS_AT, limit = 5 ) logger.d("Found messageRelayLists: $chatMessageRelayListEvents") @@ -134,7 +134,7 @@ abstract class NostrNip17Dao( logger.d("Submit BroadcastNostrEventRequest to $relayUrl") val broadcastNostrEventRequestIds = database.broadcastNostrEventRequestDao().insert( listOf( - _root_ide_package_.at.torch.compose.database.model.BroadcastNostrEventRequest( + BroadcastNostrEventRequest( nostrEventId = nostrEvent.id, relayURL = relayUrl.url ) @@ -144,7 +144,7 @@ abstract class NostrNip17Dao( chatMessage?.let { broadcastNostrEventRequestIds.forEach { database.chatMessageBroadcastNostrEventRequestRelationDao().upsert( - _root_ide_package_.at.torch.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation( + ChatMessageBroadcastNostrEventRequestRelation( chatMessageId = chatMessage.id, broadcastNostrEventRequestId = it ) @@ -159,7 +159,7 @@ abstract class NostrNip17Dao( database.giftWrapMessageDao().upsert( - _root_ide_package_.at.torch.compose.database.model.GiftWrapMessage( + GiftWrapMessage( id = giftWrapEvent.id, nostrEventId = nostrEvent.id, content = giftWrapEvent.content, @@ -173,7 +173,7 @@ abstract class NostrNip17Dao( ) database.giftWrapSealDao().upsert( - _root_ide_package_.at.torch.compose.database.model.GiftWrapSeal( + GiftWrapSeal( id = sealedRumorEvent.id, publicKey = sealedRumorEvent.pubKey, content = sealedRumorEvent.content, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/ChatRoom.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/ChatRoom.kt index 6e673176..9e92ff46 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/ChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/ChatRoom.kt @@ -52,6 +52,8 @@ data class ChatRoom( */ val subject: String?, + val mlsGroupState: String?, // TODO: Can this be nullable??? + /** * Derived from kind14/15 tags * [ diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseChatRepository.kt index 49ad1167..49d6f583 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseChatRepository.kt @@ -4,6 +4,7 @@ import at.torch.compose.database.GENESIS_AT import at.torch.compose.database.model.ChatMessage import at.torch.compose.database.model.GiftWrapPayload import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind @@ -45,16 +46,18 @@ class DatabaseChatRepository( override suspend fun getOrCreateChatRoom( + chatRoomId: String, activeUserPublicKey: HexKey, - publicKey: String, relayHint: String?, - defaultSubject: String? + defaultSubject: String?, + mlsGroupState: String? ): at.torch.compose.database.model.intermdiate.LocalChatRoom? = try { return database.nostrNip17Dao().getOrCreateChatRoom( + chatRoomId = chatRoomId, activeUserPublicKey = activeUserPublicKey, - publicKey = publicKey, relayHint = relayHint, - defaultSubject = defaultSubject + defaultSubject = defaultSubject, + mlsGroupState = mlsGroupState ) } catch (e: Throwable) { logger.e("Error getting or creating chat", e) diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseNostrRepository.kt index 31048829..8429aafd 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseNostrRepository.kt @@ -8,6 +8,7 @@ import at.torch.compose.database.model.UnsignedNostrEvent import at.torch.compose.nostr.Relays import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.KEY_PACKAGE_LIFETIME_SECONDS import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 @@ -161,10 +162,11 @@ class DatabaseNostrRepository( ) unsignedNostrEvents.add( - _root_ide_package_.at.torch.compose.database.model.UnsignedNostrEvent( + UnsignedNostrEvent( pubKey = publicKey, - kind = AdvertisedRelayListEvent.KIND, - tags = _root_ide_package_.at.torch.compose.nostr.Relays.DefaultNIP65List.map { it.toTagArray() } + kind = KeyPackageRelayListEvent.KIND, + tags = Relays.DefaultDMRelayList.map { arrayOf("relay", it.url) } + .plusElement(AltTag.assemble(KeyPackageRelayListEvent.ALT_DESCRIPTION)) .toTypedArray(), content = "" ) @@ -173,10 +175,10 @@ class DatabaseNostrRepository( unsignedNostrEvents.add( - _root_ide_package_.at.torch.compose.database.model.UnsignedNostrEvent( + UnsignedNostrEvent( pubKey = publicKey, kind = SearchRelayListEvent.KIND, - tags = _root_ide_package_.at.torch.compose.nostr.Relays.DefaultSearchRelayList.map { + tags = Relays.DefaultSearchRelayList.map { RelayTag.assemble( it ) @@ -189,35 +191,6 @@ class DatabaseNostrRepository( ) ) - unsignedNostrEvents.add( - _root_ide_package_.at.torch.compose.database.model.UnsignedNostrEvent( - pubKey = publicKey, - kind = IndexerRelayListEvent.KIND, - tags = arrayOf(AltTag.assemble(IndexerRelayListEvent.ALT)), - privateTags = _root_ide_package_.at.torch.compose.nostr.Relays.DefaultIndexerRelayList.map { - RelayTag.assemble( - it - ) - }.toTypedArray(), - content = "" - ) - ) - - unsignedNostrEvents.add( - _root_ide_package_.at.torch.compose.database.model.UnsignedNostrEvent( - pubKey = publicKey, - kind = ChannelListEvent.KIND, - tags = arrayOf( - AltTag.assemble( - ChannelListEvent.ALT - ) - ), - privateTags = _root_ide_package_.at.torch.compose.nostr.Relays.DefaultChannels.map { it.toTagArray() } - .toTypedArray(), - content = "" - ) - ) - unsignedNostrEvents.add( UnsignedNostrEvent( pubKey = publicKey, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt index f2b72207..5a51d46b 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt @@ -20,9 +20,11 @@ interface ChatRepository { suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow> suspend fun getOrCreateChatRoom( + chatRoomId: String, activeUserPublicKey: HexKey, - publicKey: String, relayHint: String?, - defaultSubject: String? = null + relayHint: String?, + defaultSubject: String?, + mlsGroupState: String? ): at.torch.compose.database.model.intermdiate.LocalChatRoom? suspend fun getChatMessageRelayForPublicKey(publicKey: HexKey): ChatMessageRelayListEvent? @@ -63,10 +65,11 @@ interface ChatRepository { } override suspend fun getOrCreateChatRoom( + chatRoomId: String, activeUserPublicKey: HexKey, - publicKey: String, relayHint: String?, - defaultSubject: String? + defaultSubject: String?, + mlsGroupState: String? ): at.torch.compose.database.model.intermdiate.LocalChatRoom? { return null } diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomCreationScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomCreationScreen.kt new file mode 100644 index 00000000..000d88fd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomCreationScreen.kt @@ -0,0 +1,187 @@ +package at.torch.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import at.torch.compose.ui.composable.widgets.LoadingDataIndicator +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Description +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +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 at.torch.compose.repository.ChatRepository +import at.torch.compose.repository.NostrRepository +import at.torch.compose.ui.composable.navigation.routes.Route +import at.torch.compose.ui.view.model.ChatRoomCreationViewModel +import at.torch.compose.ui.view.state.form.TextField +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.ActiveWallet +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun ChatRoomCreationScreen( + activeWalletStateFlow: StateFlow, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + onNavigateToChatRoom: (Route) -> Unit +) { + val chatRoomCreationViewModel: ChatRoomCreationViewModel = viewModel ( + factory = ChatRoomCreationViewModel.factory( + activeWalletStateFlow = activeWalletStateFlow, + nostrRepository = nostrRepository, + chatRepository = chatRepository + ) + ) + + Scaffold { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).fillMaxWidth() + ) { + + Column( + modifier = Modifier.fillMaxWidth().padding(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + TextField( + modifier = Modifier.fillMaxWidth(), + state = chatRoomCreationViewModel.chatRoomCreationFormState.nameField.textFieldState, + isError = chatRoomCreationViewModel.chatRoomCreationFormState.nameField.errorMessage.value != null, + supportingText = chatRoomCreationViewModel.chatRoomCreationFormState.nameField.errorMessage.value?.let { + { + Text( + text = it, + modifier = Modifier.padding( + bottom = 8.dp + ) + ) + } + }, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + ), + label = { + Text( + text = "Name (eg. Alan Turin)", + maxLines = 1, + ) + }, + placeholder = { + Text( + text = "Enter the name you want to use for your profile", + maxLines = 1, + ) + }, + leadingIcon = { + Icon( + Icons.Default.Badge, + contentDescription = "Name" + ) + } + ) + + Text( + text = "This will be the display name for your profile and also important for search.", + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + state = chatRoomCreationViewModel.chatRoomCreationFormState.descriptionField.textFieldState, + isError = chatRoomCreationViewModel.chatRoomCreationFormState.descriptionField.errorMessage.value != null, + supportingText = chatRoomCreationViewModel.chatRoomCreationFormState.descriptionField.errorMessage.value?.let { + { + Text( + text = it, + modifier = Modifier.padding( + bottom = 8.dp + ) + ) + } + }, + lineLimits = TextFieldLineLimits.MultiLine(4), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + ), + label = { + Text( + text = "Introduce yourself", + maxLines = 1, + + ) + }, + placeholder = { + Text( + text = "What should people know about you?", + maxLines = 1, + ) + }, + leadingIcon = { + Icon( + Icons.Default.Description, + contentDescription = "Name" + ) + } + ) + + Text( + text = "This will be shown when people open your profile.", + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center + ) + + + Button( + onClick = { + chatRoomCreationViewModel.createChatRoom( + onNavigateToChatRoom = { + + } + ) + } + ) { + Text( + "Create chat" + ) + } + } + } + } +} + +@Preview +@Composable +private fun LoadingScreenPreview() { + _root_ide_package_.at.torch.compose.ui.theme.TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + ChatRoomCreationScreen( + activeWalletStateFlow = MutableStateFlow(null), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToChatRoom = {} + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomDetailScreen.kt index 129930a8..40ae713d 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomDetailScreen.kt @@ -276,7 +276,8 @@ private fun ChatRoomDetailScreenPreview() { id = "", userPublicKey = "", subject = "Message title", - initialGiftWrapPayloadId = 0 + initialGiftWrapPayloadId = 0, + mlsGroupState = null ), ) ), diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/CreateProfileScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/CreateProfileScreen.kt index 8c404085..feb23a12 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/CreateProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/CreateProfileScreen.kt @@ -35,6 +35,7 @@ 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 at.torch.compose.ui.view.state.CreateProfileUIState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -358,7 +359,7 @@ fun CreateAccountScreenPreview() { ) { CreateProfileScreen( initialCreateProfileUIState = -// CreateProfileUIState.InputPrompt, + CreateProfileUIState.InputPrompt, // CreateProfileUIState.Error, // CreateProfileUIState.ConfirmInput( // name = "Alan Turing", @@ -385,14 +386,14 @@ fun CreateAccountScreenPreview() { // name = "Alan Turing", // bio = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider." // ), - _root_ide_package_.at.torch.compose.ui.view.state.CreateProfileUIState.ProfileReady( - profile = _root_ide_package_.at.torch.compose.database.model.Profile( - publicKey = "", - nostrEventId = "", - displayName = "Alan Turing", - about = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider." - ) - ), +// _root_ide_package_.at.torch.compose.ui.view.state.CreateProfileUIState.ProfileReady( +// profile = _root_ide_package_.at.torch.compose.database.model.Profile( +// publicKey = "", +// nostrEventId = "", +// displayName = "Alan Turing", +// about = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider." +// ) +// ), onNavigateToEndThis = {}, writeSeed = {}, nostrRepository = _root_ide_package_.at.torch.compose.repository.NostrRepository.Companion.NO_OP_NOSTR_REPOSITORY diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/HomeScreen.kt index 2994ea5a..c30a6075 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/HomeScreen.kt @@ -1,11 +1,5 @@ package at.torch.compose.ui.composable -import at.torch.compose.database.model.NostrEvent -import at.torch.compose.database.model.Profile -import at.torch.compose.database.model.intermdiate.LocalProfileWithFollowing -import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute -import at.torch.compose.ui.composable.widgets.LoadingDataIndicator -import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -14,7 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Create +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -50,7 +44,7 @@ fun HomeScreen( initialHomeScreenUIState: at.torch.compose.ui.view.state.HomeScreenUIState = _root_ide_package_.at.torch.compose.ui.view.state.HomeScreenUIState.Loading, onNavigateToEvent: (at.torch.compose.ui.composable.navigation.routes.Route) -> Unit, onNavigateToDirectMessageDetail: (at.torch.compose.ui.composable.navigation.routes.Route) -> Unit, - onNavigateToWriteNewNote: () -> Unit, + onNavigateToChatRoomCreation: () -> Unit, onNavigateToSearch: () -> Unit, nostrRepository: at.torch.compose.repository.NostrRepository, chatRepository: at.torch.compose.repository.ChatRepository @@ -136,15 +130,15 @@ fun HomeScreen( }, floatingActionButton = { ExtendedFloatingActionButton( - onClick = onNavigateToWriteNewNote, + onClick = onNavigateToChatRoomCreation, icon = { Icon( - Icons.Default.Create, - contentDescription = "Write" + Icons.Default.Add, + contentDescription = "New Chat" ) }, text = { - Text("Write") + Text("New Chat") } ) } @@ -182,8 +176,8 @@ fun HomeScreen( HorizontalPager( state = homeScreenViewModel.pagerState, ) { pageIndex -> - when (val homeScreenTypeType = _root_ide_package_.at.torch.compose.ui.view.model.HomeScreenType.entries[pageIndex]) { - _root_ide_package_.at.torch.compose.ui.view.model.HomeScreenType.Messages -> { + when (val homeScreenTypeType = HomeScreenType.entries[pageIndex]) { + HomeScreenType.Messages -> { val chatRoomListViewModel: at.torch.compose.ui.view.model.ChatRoomListViewModel = viewModel( key = homeScreenTypeType.name, factory = _root_ide_package_.at.torch.compose.ui.view.model.ChatRoomListViewModel.Companion.factory( @@ -256,7 +250,7 @@ It has survived not only five centuries, but also the leap into electronic types ) ), onNavigateToEvent = {}, - onNavigateToWriteNewNote = {}, + onNavigateToChatRoomCreation = {}, onNavigateToSearch = {}, onNavigateToDirectMessageDetail = {}, nostrRepository = _root_ide_package_.at.torch.compose.repository.NostrRepository.Companion.NO_OP_NOSTR_REPOSITORY, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt index e7fbb0c9..839fa654 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt @@ -14,6 +14,9 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.composable +import at.torch.compose.ui.composable.ChatRoomCreationScreen +import at.torch.compose.ui.composable.HomeScreen +import at.torch.compose.ui.composable.navigation.routes.ChatRoomCreationRoute import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal import kotlinx.coroutines.CoroutineExceptionHandler @@ -290,6 +293,15 @@ fun TorchNavHost( ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ChatRoomCreationScreen( + activeUserPublicKey = route.activeUserPublicKey, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() _root_ide_package_.at.torch.compose.ui.composable.WriteNewNoteScreen( @@ -375,16 +387,16 @@ fun TorchNavHost( } composable { backStackEntry -> val route = backStackEntry.toRoute() - _root_ide_package_.at.torch.compose.ui.composable.HomeScreen( + HomeScreen( activeUserPublicKey = route.activeUserPublicKey, onNavigateToEvent = { eventRoute -> navController.navigate( route = eventRoute ) }, - onNavigateToWriteNewNote = { + onNavigateToChatRoomCreation = { navController.navigate( - route = WriteNewNoteRoute( + route = ChatRoomCreationRoute( activeUserPublicKey = route.activeUserPublicKey ) ) diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ChatRoomCreationRoute.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ChatRoomCreationRoute.kt new file mode 100755 index 00000000..2ac49be0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ChatRoomCreationRoute.kt @@ -0,0 +1,9 @@ +package at.torch.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatRoomCreationRoute( + val activeUserPublicKey: String, +): Route() { +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomCreationViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomCreationViewModel.kt new file mode 100644 index 00000000..e3bed0ae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomCreationViewModel.kt @@ -0,0 +1,125 @@ +package at.torch.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +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 at.torch.compose.extensions.toHex +import at.torch.compose.nostr.Relays +import at.torch.compose.repository.ChatRepository +import at.torch.compose.repository.NostrRepository +import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import at.torch.compose.ui.composable.navigation.routes.Route +import at.torch.compose.ui.view.state.ChatRoomDetailUIState +import at.torch.compose.ui.view.state.form.ChatRoomCreationFormState +import at.torch.compose.ui.view.state.form.CreateProfileFormState +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +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.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.RandomInstance +import fr.acinq.bitcoin.Crypto +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 + +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()) + + val group = MlsGroup.create(keyPair.pubKey, keyPair.privKey, extras) + + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getOrCreateChatRoom( + chatRoomId = group.groupId.toHex(), + activeUserPublicKey = keyPair.pubKey.toHexKey(), + relayHint = null, + defaultSubject = null, + mlsGroupState = group.saveState().encodeTls().toHex() + ) + + if (localChatRoom == null) { + onNavigateToChatRoom.invoke( + ImplementationPendingRoute("Something went wrong") + ) + } else { + onNavigateToChatRoom.invoke( + ImplementationPendingRoute("Chat Room view ${localChatRoom.chatRoom.id}") + ) + } + + } + + + + } + } + + companion object { + private const val TAG = "ChatRoomCreationViewModel" + + fun factory( + activeWalletStateFlow: StateFlow, + nostrRepository: NostrRepository, + chatRepository: ChatRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ChatRoomCreationViewModel( + activeWalletStateFlow = activeWalletStateFlow, + nostrRepository = nostrRepository, + chatRepository = chatRepository + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomDetailViewModel.kt index 52012a79..99c71762 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import at.torch.compose.ui.view.state.ChatRoomDetailUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.Crypto @@ -20,12 +21,12 @@ class ChatRoomDetailViewModel( val directMessageId: String, val activeUserPublicKey: HexKey, val relayHint: String?, - initialChatRoomDetailUIState: at.torch.compose.ui.view.state.ChatRoomDetailUIState, + initialChatRoomDetailUIState: ChatRoomDetailUIState, val nostrRepository: at.torch.compose.repository.NostrRepository, val chatRepository: at.torch.compose.repository.ChatRepository ): ViewModel() { - var chatRoomDetailUIState: at.torch.compose.ui.view.state.ChatRoomDetailUIState by mutableStateOf(initialChatRoomDetailUIState) + var chatRoomDetailUIState: ChatRoomDetailUIState by mutableStateOf(initialChatRoomDetailUIState) private set @@ -35,38 +36,17 @@ class ChatRoomDetailViewModel( val isActionPending: MutableState = mutableStateOf(false) fun initiateChatRoomDetail() { - if (Crypto.isPubKeyCompressed(directMessageId.hexToByteArray())) { - logger.d("compressed (most likely chat room): $directMessageId") - // Get ChatRoomById - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(directMessageId) + logger.d("compressed (most likely chat room): $directMessageId") + // Get ChatRoomById + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(directMessageId) - chatRoomDetailUIState = if (localChatRoom == null) { - _root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Error - } else { - _root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Loaded( - localChatRoom = localChatRoom - ) - } - - } - } else { - logger.d("Uncompressed publicKey (most likely npub): $directMessageId") - viewModelScope.launch(Dispatchers.IO) { - // We need to get or create a chat with the user on this publicKey... - val localChatRoom = chatRepository.getOrCreateChatRoom( - activeUserPublicKey = activeUserPublicKey, - publicKey = directMessageId, - relayHint = relayHint + chatRoomDetailUIState = if (localChatRoom == null) { + ChatRoomDetailUIState.Error + } else { + ChatRoomDetailUIState.Loaded( + localChatRoom = localChatRoom ) - - chatRoomDetailUIState = if (localChatRoom == null) { - _root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Error - } else { - _root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Loaded( - localChatRoom = localChatRoom - ) - } } } } @@ -79,7 +59,7 @@ class ChatRoomDetailViewModel( activeUserPublicKey: HexKey, publicKey: String, relayHint: String?, - initialChatRoomDetailUIState: at.torch.compose.ui.view.state.ChatRoomDetailUIState = _root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Loading, + initialChatRoomDetailUIState: ChatRoomDetailUIState = ChatRoomDetailUIState.Loading, nostrRepository: at.torch.compose.repository.NostrRepository, chatRepository: at.torch.compose.repository.ChatRepository ): ViewModelProvider.Factory = viewModelFactory { diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/form/ChatRoomCreationFormState.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/form/ChatRoomCreationFormState.kt new file mode 100644 index 00000000..8681f7f5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/form/ChatRoomCreationFormState.kt @@ -0,0 +1,6 @@ +package at.torch.compose.ui.view.state.form + +data class ChatRoomCreationFormState( + val nameField: TextField = TextField(), + val descriptionField: TextField = TextField() +) \ No newline at end of file