From 947ae439f022168c71704650bcf79eb38ea1d86f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Mon, 13 Jul 2026 00:12:12 +0200 Subject: [PATCH] Observe profile and sync metadata/keypackage event --- .../torch/compose/database/dao/ProfileDao.kt | 3 + .../repository/DatabaseChatRepository.kt | 4 - .../repository/DatabaseNostrRepository.kt | 11 ++- .../compose/repository/ChatRepository.kt | 6 -- .../compose/repository/NostrRepository.kt | 16 +++- .../ui/composable/ChatRoomMessagingScreen.kt | 3 +- .../ui/composable/SearchResultScreen.kt | 23 +++-- ...ddMemberToChatRoomConfirmationViewModel.kt | 3 +- .../view/model/ChatRoomMessagingViewModel.kt | 92 +++++++++++++------ .../ui/view/model/FeedListViewModel.kt | 5 +- .../ui/view/model/NavigationViewModel.kt | 3 +- .../ui/view/model/SearchResultViewModel.kt | 9 +- 12 files changed, 117 insertions(+), 61 deletions(-) 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 70e7d852..04f7ec0a 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 @@ -28,6 +28,9 @@ interface ProfileDao { @Query("SELECT * FROM Profile WHERE publicKey = :publicKey") fun getProfileByPublicKey(publicKey: String): Profile? + @Query("SELECT * FROM Profile WHERE publicKey = :publicKey") + fun observeProfileByPublicKey(publicKey: String): Flow + @Query("SELECT * FROM Profile WHERE publicKey IN (:publicKeys)") fun getProfileByPublicKeys(publicKeys: List): List 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 469a5f13..1c6c2e55 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 @@ -117,10 +117,6 @@ class DatabaseChatRepository( return database.marmotKeyPackageDao().getMarmotKeyPackageForPublicKey(publicKey) } - override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? { - return database.profileDao().getProfileByPublicKey(publicKey) - } - override suspend fun createMlsDirectMessage( name: String?, description: String?, 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 ae9d3144..1f83fb8b 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 @@ -3,6 +3,7 @@ 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.NostrEvent +import at.torch.compose.database.model.Profile import at.torch.compose.database.model.UnsignedNostrEvent import at.torch.compose.database.model.intermdiate.LocalAccount import at.torch.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest @@ -65,10 +66,18 @@ class DatabaseNostrRepository( return database.unsignedNostrEventDao().getLocalAccounts() } - override suspend fun observeProfile(publicKey: HexKey): Flow { + override suspend fun observeLocalAccount(publicKey: HexKey): Flow { return database.unsignedNostrEventDao().observeProfile(publicKey) } + override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? { + return database.profileDao().getProfileByPublicKey(publicKey) + } + + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow { + return database.profileDao().observeProfileByPublicKey(publicKey) + } + override suspend fun observeRelation( publicKey: HexKey, activePublicKey: HexKey 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 f0a5bd32..6efe1cef 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt @@ -41,8 +41,6 @@ interface ChatRepository { suspend fun getMarmotKeyPackageForPublicKey(publicKey: HexKey): MarmotKeyPackage? - suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? - suspend fun createMlsDirectMessage( name: String? = null, description: String? = null, @@ -121,10 +119,6 @@ interface ChatRepository { TODO("Not yet implemented") } - override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? { - TODO("Not yet implemented") - } - override suspend fun createMlsDirectMessage( name: String?, description: String?, 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 fbceab43..7cdce49e 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/NostrRepository.kt @@ -24,7 +24,11 @@ import kotlinx.coroutines.flow.Flow interface NostrRepository { suspend fun getLocalAccounts(): List - suspend fun observeProfile(publicKey: HexKey): Flow + suspend fun observeLocalAccount(publicKey: HexKey): Flow + + suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? + + suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow suspend fun observeRelation(publicKey: HexKey, activePublicKey: HexKey): Flow> @@ -144,7 +148,15 @@ interface NostrRepository { TODO("Not yet implemented") } - override suspend fun observeProfile(publicKey: HexKey): Flow { + override suspend fun observeLocalAccount(publicKey: HexKey): Flow { + TODO("Not yet implemented") + } + + override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? { + TODO("Not yet implemented") + } + + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomMessagingScreen.kt index 3254131f..5f4c29c7 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Send @@ -185,7 +186,7 @@ fun ChatRoomMessagingScreen( } ) { Icon( - Icons.Default.Send, + Icons.AutoMirrored.Filled.Send, contentDescription = "Send" ) } diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/SearchResultScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/SearchResultScreen.kt index 65f94d2f..0c40c6d7 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/SearchResultScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/SearchResultScreen.kt @@ -50,6 +50,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import at.torch.compose.ui.composable.widgets.feed.ListView +import at.torch.compose.ui.view.model.SearchResultType +import at.torch.compose.ui.view.model.SearchResultViewModel +import at.torch.compose.ui.view.state.SearchResultListUIState import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -64,9 +67,9 @@ fun SearchResultScreen( ) { val scope = rememberCoroutineScope() - val searchViewModel: at.torch.compose.ui.view.model.SearchResultViewModel = viewModel( - factory = at.torch.compose.ui.view.model.SearchResultViewModel.factory( - initialSearchResultType = at.torch.compose.ui.view.model.SearchResultType.Profiles, + val searchViewModel: SearchResultViewModel = viewModel( + factory = SearchResultViewModel.factory( + initialSearchResultType = SearchResultType.Profiles, searchQuery = searchQuery, nostrRepository = nostrRepository, searchRepository = searchRepository @@ -154,14 +157,14 @@ fun SearchResultScreen( }, windowInsets = TopAppBarDefaults.windowInsets ) { - val pagerState = rememberPagerState { at.torch.compose.ui.view.model.SearchResultType.entries.size } + val pagerState = rememberPagerState { SearchResultType.entries.size } PrimaryScrollableTabRow( modifier = Modifier, edgePadding = 10.dp, selectedTabIndex = searchViewModel.searchResultType.ordinal, ) { - at.torch.compose.ui.view.model.SearchResultType.entries.forEach { searchResultType -> + SearchResultType.entries.forEach { searchResultType -> Tab( selected = searchViewModel.searchResultType == searchResultType, onClick = { @@ -194,14 +197,14 @@ fun SearchResultScreen( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - when(at.torch.compose.ui.view.model.SearchResultType.entries[pageIndex]) { - at.torch.compose.ui.view.model.SearchResultType.Profiles -> { + when(SearchResultType.entries[pageIndex]) { + SearchResultType.Profiles -> { Text("Profiles") } } when (val searchResultListUIState = searchViewModel.searchResultListUIState) { - at.torch.compose.ui.view.state.SearchResultListUIState.Error -> { + SearchResultListUIState.Error -> { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally @@ -214,7 +217,7 @@ fun SearchResultScreen( ) } } - is at.torch.compose.ui.view.state.SearchResultListUIState.Loaded -> { + is SearchResultListUIState.Loaded -> { // TODO: Handle gallery UI if (searchResultListUIState.localNostrEvents.isEmpty()) { Column( @@ -254,7 +257,7 @@ fun SearchResultScreen( } } - at.torch.compose.ui.view.state.SearchResultListUIState.Loading -> { + SearchResultListUIState.Loading -> { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally 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 81b751d2..e0bf19fe 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 @@ -18,6 +18,7 @@ import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch class AddMemberToChatRoomConfirmationViewModel( @@ -44,7 +45,7 @@ class AddMemberToChatRoomConfirmationViewModel( val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) addMemberToChatRoomConfirmationUIState = if (localChatRoom != null) { - val profile = chatRepository.getProfileWithPublicKey(profilePublicKey) + val profile = nostrRepository.getProfileWithPublicKey(profilePublicKey) if (profile != null) { val marmotKeyPackage = chatRepository.getMarmotKeyPackageForPublicKey(profilePublicKey) diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomMessagingViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomMessagingViewModel.kt index 7ed8d67f..2b620f75 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomMessagingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomMessagingViewModel.kt @@ -9,13 +9,21 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import at.torch.compose.database.GENESIS_AT +import at.torch.compose.database.builder.getRoomDatabase +import at.torch.compose.database.model.NegentropySynchronizeRequest +import at.torch.compose.database.model.types.SynchronizationFilter +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.ChatRoomMessagingRoute import at.torch.compose.ui.composable.navigation.routes.Route import at.torch.compose.ui.view.state.ChatRoomMessagingUIState import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch @@ -102,39 +110,67 @@ class ChatRoomMessagingViewModel( } } - val profile = chatRepository.getProfileWithPublicKey(chatRoomId) + nostrRepository.observeProfileWithPublicKey(chatRoomId).collect { profile -> + if (profile != null) { + logger.d("Create chat with: $profile") - if (profile != null) { - logger.d("Create chat with: $profile") - // Check if we have a keyPackageEvent... - val keyPackageEvent = chatRepository.getMarmotKeyPackageForPublicKey(chatRoomId) + if (profile.createdAt > GENESIS_AT) { + // Check if we have a keyPackageEvent... + val keyPackageEvent = chatRepository.getMarmotKeyPackageForPublicKey(chatRoomId) // TODO: Observe - if (keyPackageEvent == null) { - chatRoomMessagingUIState = ChatRoomMessagingUIState.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( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } catch (e: Throwable) { - chatRoomMessagingUIState = ChatRoomMessagingUIState.Error("Failed to create chat") + if (keyPackageEvent == null) { + chatRoomMessagingUIState = ChatRoomMessagingUIState.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( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } catch (e: Throwable) { + chatRoomMessagingUIState = ChatRoomMessagingUIState.Error("Failed to create chat") + } + } + } else { + chatRoomMessagingUIState = ChatRoomMessagingUIState.Error("Couldn't find profile... searching relays.") // Looking for profile... } + } else { + val synchronizationFilter = SynchronizationFilter( + kinds = arrayOf( + MetadataEvent.KIND, + KeyPackageEvent.KIND, + ChatMessageRelayListEvent.KIND + ), + authors = arrayOf( + chatRoomId + ), + limit = 1 + ) + nostrRepository.queueNegentropySynchronizeRequest( + Relays.DefaultDMRelayList.map { relay -> + NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relay.url, + synchronizationFilter = synchronizationFilter + ), + purpose = "initiate-chat", + synchronizationFilter = synchronizationFilter, + relayURL = relay.url, + level = 0 + ) + } + ) + chatRoomMessagingUIState = ChatRoomMessagingUIState.Error("Couldn't find profile... queued to search for relay.") // Looking for profile... } - - } else { - chatRoomMessagingUIState = ChatRoomMessagingUIState.Error("Couldn't find profile...") } } } diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/FeedListViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/FeedListViewModel.kt index 403a3b51..2b4efd7a 100755 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/FeedListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/FeedListViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import at.torch.compose.database.model.NegentropySynchronizeRequest import at.torch.compose.ui.composable.widgets.feed.ListView import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers @@ -44,8 +45,8 @@ class FeedListViewModel( val isSynchronizationPending: MutableState = mutableStateOf(false) val negentropySynchronizeRequests = at.torch.compose.nostr.Relays.DefaultDMRelayList.shuffled().map { normalizedRelayUrl -> - _root_ide_package_.at.torch.compose.database.model.NegentropySynchronizeRequest( - id = at.torch.compose.database.model.NegentropySynchronizeRequest.computeId( + NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( normalizedRelayUrl.url, synchronizationFilter = synchronizationFilter ), diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NavigationViewModel.kt index 935ab9f0..583012f0 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NavigationViewModel.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds @@ -147,7 +146,7 @@ class NavigationViewModel( private suspend fun loadNostrProfile(activeUserPublicKey: String) { - nostrRepository.observeProfile( + nostrRepository.observeLocalAccount( publicKey = activeUserPublicKey ).distinctUntilChanged().collect { localProfile -> processLocalAccount(localProfile) diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/SearchResultViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/SearchResultViewModel.kt index bf1da66b..1d0fa527 100755 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/SearchResultViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/SearchResultViewModel.kt @@ -12,6 +12,7 @@ import at.torch.compose.database.model.types.SynchronizationFilter import at.torch.compose.extensions.assureValidNpub import at.torch.compose.extensions.bech32ToHexOrNull import at.torch.compose.extensions.bech32ToHexOrThrow +import at.torch.compose.ui.view.state.SearchResultListUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @@ -35,12 +36,12 @@ class SearchResultViewModel( var searchResultType: SearchResultType by mutableStateOf(initialSearchResultType) private set - var searchResultListUIState: at.torch.compose.ui.view.state.SearchResultListUIState by mutableStateOf( - at.torch.compose.ui.view.state.SearchResultListUIState.Loading) + var searchResultListUIState: SearchResultListUIState by mutableStateOf( + SearchResultListUIState.Loading) private set fun updateSearchResultType (searchResultType: SearchResultType) { - searchResultListUIState = at.torch.compose.ui.view.state.SearchResultListUIState.Loading + searchResultListUIState = SearchResultListUIState.Loading this.searchResultType = searchResultType @@ -122,7 +123,7 @@ class SearchResultViewModel( viewModelScope.launch(Dispatchers.IO) { // This is something not taking into consideration the results we are actually looking for... nostrRepository.observeNostrFeed(synchronizationFilter).distinctUntilChanged().collect { localNostrEvents -> - searchResultListUIState = at.torch.compose.ui.view.state.SearchResultListUIState.Loaded( + searchResultListUIState = SearchResultListUIState.Loaded( localNostrEvents = localNostrEvents ) }