diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/ProfileDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/ProfileDao.kt index 407acfd6..70e7d852 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/ProfileDao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/ProfileDao.kt @@ -4,22 +4,32 @@ import androidx.room3.Dao import androidx.room3.Insert import androidx.room3.Query import androidx.room3.Upsert +import at.torch.compose.database.GENESIS_AT +import at.torch.compose.database.model.Profile +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.Flow import kotlin.time.Instant @Dao interface ProfileDao { @Query("SELECT * FROM Profile WHERE createdAt > :createdAt LIMIT :limit") - fun getAllProfiles(limit: Int = 21, createdAt: Instant = _root_ide_package_.at.torch.compose.database.GENESIS_AT): List + fun getAllProfiles(limit: Int = 21, createdAt: Instant = GENESIS_AT): List + + @Query("SELECT * FROM Profile WHERE publicKey NOT IN (:excludedPublicKeys) AND createdAt > :createdAt LIMIT :limit") + fun getAllProfilesExcludingOnesWithThesePublicKeys( + excludedPublicKeys: Array, + limit: Int = 21, createdAt: Instant = GENESIS_AT + ): List + @Query("SELECT * FROM NostrEvent WHERE kind = 0 AND content LIKE '%' || :searchTerm || '%' LIMIT :limit") fun filterProfiles(searchTerm: String, limit: Int = 21): List @Query("SELECT * FROM Profile WHERE publicKey = :publicKey") - fun getProfileByPublicKey(publicKey: String): at.torch.compose.database.model.Profile? + fun getProfileByPublicKey(publicKey: String): Profile? @Query("SELECT * FROM Profile WHERE publicKey IN (:publicKeys)") - fun getProfileByPublicKeys(publicKeys: List): List + fun getProfileByPublicKeys(publicKeys: List): List @Query("SELECT * FROM NostrEvent WHERE kind = 0 AND pubKey = :publicKey") fun observeProfileWithFollowersByPublicKey(publicKey: String): Flow @@ -28,9 +38,9 @@ interface ProfileDao { fun observeProfileWithFollowingByPublicKey(publicKey: String): Flow @Insert - suspend fun insertPlaceholderProfile(profile: at.torch.compose.database.model.Profile) + suspend fun insertPlaceholderProfile(profile: Profile) @Upsert - suspend fun upsert(profile: at.torch.compose.database.model.Profile) + suspend fun upsert(profile: Profile) } \ No newline at end of file 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 6bbc7a1b..3ad84758 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 @@ -2,7 +2,6 @@ package at.torch.compose.database.repository import at.torch.compose.database.GENESIS_AT import at.torch.compose.database.model.BroadcastNostrEventRequest -import at.torch.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation import at.torch.compose.database.model.NostrEvent import at.torch.compose.database.model.UnsignedNostrEvent import at.torch.compose.database.model.intermdiate.LocalAccount @@ -734,8 +733,10 @@ class DatabaseNostrRepository( return database.nostrEventDao().observeNostrEventById(nostrEventId) } - override suspend fun searchableProfiles(): List { - return database.profileDao().getAllProfiles() + override suspend fun searchableProfiles(excludingPublicKeys: Array): List { + return database.profileDao().getAllProfilesExcludingOnesWithThesePublicKeys( + excludedPublicKeys = excludingPublicKeys + ) } override suspend fun filterProfiles(searchTerm: String): List { diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/NostrRepository.kt index 464e842e..fbceab43 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/NostrRepository.kt @@ -120,7 +120,9 @@ interface NostrRepository { suspend fun observeLocalNostrEventById(nostrEventId: String): Flow - suspend fun searchableProfiles(): List + suspend fun searchableProfiles( + excludingPublicKeys: Array = emptyArray() + ): List suspend fun filterProfiles(searchTerm: String): List @@ -292,7 +294,7 @@ interface NostrRepository { TODO("Not yet implemented") } - override suspend fun searchableProfiles(): List { + override suspend fun searchableProfiles(excludingPublicKeys: Array): List { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt index e19f5854..b7339326 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt @@ -10,16 +10,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Send import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults @@ -34,16 +38,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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 at.torch.compose.database.model.ChatRoom +import at.torch.compose.database.model.Profile import at.torch.compose.database.model.intermdiate.LocalChatRoom import at.torch.compose.repository.ChatRepository import at.torch.compose.repository.NostrRepository import at.torch.compose.ui.composable.navigation.routes.AddMemberToChatRoomConfirmationRoute import at.torch.compose.ui.composable.navigation.routes.Route import at.torch.compose.ui.composable.widgets.LoadingDataIndicator +import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar import at.torch.compose.ui.theme.TorchTheme import at.torch.compose.ui.view.model.ChatRoomDetailMessageListViewModel import at.torch.compose.ui.view.model.SearchMemberToAddToChatRoomViewModel @@ -118,21 +125,70 @@ fun SearchMemberToAddToChatRoomScreen( modifier = Modifier.padding(innerPadding).fillMaxSize() ) { Column( - modifier = Modifier.weight(1f).fillMaxWidth() + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) ) { - key(true) { - Button( - onClick = { - onNavigateToRoute.invoke( - AddMemberToChatRoomConfirmationRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } + + if (searchMemberToAddToChatRoomUIState.contacts.isEmpty()) { + Spacer( + modifier = Modifier.height(20.dp) + ) + Text( + modifier = Modifier.padding(20.dp), + text = "Currently no contacts. Please search and chat with a few people.", + textAlign = TextAlign.Center + ) + + Spacer( + modifier = Modifier.height(20.dp) + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(5.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + reverseLayout = true ) { - Text("Contact List TODO") + + items( + items = searchMemberToAddToChatRoomUIState.contacts, + key = { it.publicKey } + ) { profile -> + Card( + onClick = { + onNavigateToRoute.invoke( + AddMemberToChatRoomConfirmationRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + profilePublicKey = profile.publicKey, + relayHint = relayHint, + ) + ) + } + ) { + ListItem( + leadingContent = { + ProfileAvatar( + profile = profile + ) + }, + headlineContent = { + Text( + profile.humanReadableNameOrPubkey() + ) + }, + supportingContent = { + profile.about?.let { + Text( + text = it, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } + } + ) + } + } } } } @@ -140,8 +196,6 @@ fun SearchMemberToAddToChatRoomScreen( key(true) { chatRoomDetailMessageListViewModel.initiate() } - - // TODO: Check that we have direct message relays for this user... } } } @@ -207,6 +261,14 @@ private fun ChatRoomDetailScreenPreview() { initialGiftWrapPayloadId = "sdfaer", mlsGroupState = null ), + ), + contacts = listOf( + Profile( + publicKey = "hex", + displayName = "Man With A Plan", + about = "Making plans and getting things done...", + nostrEventId = "nostrEventId" + ) ) ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/AddMemberToChatRoomConfirmationRoute.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/AddMemberToChatRoomConfirmationRoute.kt index cf7f33d5..66d09722 100755 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/AddMemberToChatRoomConfirmationRoute.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/AddMemberToChatRoomConfirmationRoute.kt @@ -1,10 +1,12 @@ package at.torch.compose.ui.composable.navigation.routes +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.serialization.Serializable @Serializable data class AddMemberToChatRoomConfirmationRoute( val activeUserPublicKey: String, val chatRoomId: String, // TODO: have this as a publicKey + val profilePublicKey: HexKey, val relayHint: String? ): Route() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt index a4ab892b..bdfea73f 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt @@ -32,8 +32,6 @@ class AddMemberToChatRoomConfirmationViewModel( var addMemberToChatRoomConfirmationUIState: AddMemberToChatRoomConfirmationUIState by mutableStateOf(initialAddMemberToChatRoomConfirmationUIState) private set - - private val logger = Logger.withTag(TAG) val isActionPending: MutableState = mutableStateOf(false) diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/SearchMemberToAddToChatRoomViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/SearchMemberToAddToChatRoomViewModel.kt index 05a57a34..d70e8db0 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/SearchMemberToAddToChatRoomViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/SearchMemberToAddToChatRoomViewModel.kt @@ -11,8 +11,6 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import at.torch.compose.repository.ChatRepository import at.torch.compose.repository.NostrRepository -import at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute -import at.torch.compose.ui.composable.navigation.routes.Route import at.torch.compose.ui.view.state.SearchMemberToAddToChatRoomUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -49,97 +47,15 @@ class SearchMemberToAddToChatRoomViewModel( SearchMemberToAddToChatRoomUIState.Error("Failed to load chat") } else { SearchMemberToAddToChatRoomUIState.Loaded( - localChatRoom = localChatRoom + localChatRoom = localChatRoom, + contacts = nostrRepository.searchableProfiles( + excludingPublicKeys = localChatRoom.localParticipants.map { it.participant.participantPublicKey }.toTypedArray() + ) ) } } } - fun initiateNewChat( - onNavigateToChat: (Route) -> Unit - ) { - logger.d("initiateNewChat: $chatRoomId") - // Get ChatRoomById - viewModelScope.launch(Dispatchers.IO) { - - // Check if we have a chat with activeUserPublicKey and chatRoomId (pubkey) as participants - val participants = chatRepository.getAllParticipantsWithPubKey(chatRoomId) - - participants.forEach { participant -> - chatRepository.getChatRoomByIdentifier(participant.chatRoomId)?.let { localChatRoom -> - // TODO: Check that chatRoom is not a group chat... - if (localChatRoom.localParticipants.size == 1) { - // This is a chat with self... - // TODO: navigateToTheChat... - viewModelScope.launch(Dispatchers.Main) { - onNavigateToChat.invoke( - ChatRoomDetailRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = localChatRoom.chatRoom.id, - relayHint = relayHint - ) - ) - } - return@launch - - } else if (localChatRoom.localParticipants.size == 2) { - val activeUserParticipant = localChatRoom.localParticipants.find { localParticipant -> localParticipant.participant.participantPublicKey == activeUserPublicKey } - - if (activeUserParticipant != null) { - // We have found the chat we are looking for... - viewModelScope.launch(Dispatchers.Main) { - onNavigateToChat.invoke( - ChatRoomDetailRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = localChatRoom.chatRoom.id, - relayHint = relayHint - ) - ) - } - return@launch - } - } - } - } - - val profile = chatRepository.getProfileWithPublicKey(chatRoomId) - - if (profile != null) { - logger.d("Create chat with: $profile") - // Check if we have a keyPackageEvent... - val keyPackageEvent = chatRepository.getMarmotKeyPackageForPublicKey(chatRoomId) - - if (keyPackageEvent == null) { - searchMemberToAddToChatRoomUIState = SearchMemberToAddToChatRoomUIState.Error("Missing Key Package") // TODO: Introduce missing key package UI State... - } else { - // Create new chat and invite local - try { - val chatRoomId = chatRepository.createMlsDirectMessage( - userPublicKey = activeUserPublicKey, - peerPublicKey = chatRoomId, - peerKeyPackage = keyPackageEvent, - ) - - onNavigateToChat.invoke( - ChatRoomDetailRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } catch (e: Throwable) { - searchMemberToAddToChatRoomUIState = SearchMemberToAddToChatRoomUIState.Error("Failed to create chat") - - } - } - - } else { - searchMemberToAddToChatRoomUIState = SearchMemberToAddToChatRoomUIState.Error("Couldn't find profile...") - } - } - } - - companion object { private const val TAG = "SearchMemberToAddToChatRoomViewModel" diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/SearchMemberToAddToChatRoomUIState.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/SearchMemberToAddToChatRoomUIState.kt index 29eb21f2..321507e6 100755 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/SearchMemberToAddToChatRoomUIState.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/SearchMemberToAddToChatRoomUIState.kt @@ -1,10 +1,12 @@ package at.torch.compose.ui.view.state +import at.torch.compose.database.model.Profile import at.torch.compose.database.model.intermdiate.LocalChatRoom sealed interface SearchMemberToAddToChatRoomUIState { data class Loaded( - val localChatRoom: LocalChatRoom + val localChatRoom: LocalChatRoom, + val contacts: List ): SearchMemberToAddToChatRoomUIState data class Error(