Observe profile and sync metadata/keypackage event

This commit is contained in:
Kgothatso Ngako
2026-07-13 00:12:12 +02:00
parent 8e1ab672bb
commit 947ae439f0
12 changed files with 117 additions and 61 deletions

View File

@@ -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<Profile?>
@Query("SELECT * FROM Profile WHERE publicKey IN (:publicKeys)")
fun getProfileByPublicKeys(publicKeys: List<String>): List<Profile>

View File

@@ -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?,

View File

@@ -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<at.torch.compose.database.model.intermdiate.LocalAccount?> {
override suspend fun observeLocalAccount(publicKey: HexKey): Flow<LocalAccount?> {
return database.unsignedNostrEventDao().observeProfile(publicKey)
}
override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? {
return database.profileDao().getProfileByPublicKey(publicKey)
}
override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow<Profile?> {
return database.profileDao().observeProfileByPublicKey(publicKey)
}
override suspend fun observeRelation(
publicKey: HexKey,
activePublicKey: HexKey

View File

@@ -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?,

View File

@@ -24,7 +24,11 @@ import kotlinx.coroutines.flow.Flow
interface NostrRepository {
suspend fun getLocalAccounts(): List<LocalAccount>
suspend fun observeProfile(publicKey: HexKey): Flow<LocalAccount?>
suspend fun observeLocalAccount(publicKey: HexKey): Flow<LocalAccount?>
suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile?
suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow<Profile?>
suspend fun observeRelation(publicKey: HexKey, activePublicKey: HexKey): Flow<List<Connection>>
@@ -144,7 +148,15 @@ interface NostrRepository {
TODO("Not yet implemented")
}
override suspend fun observeProfile(publicKey: HexKey): Flow<LocalAccount?> {
override suspend fun observeLocalAccount(publicKey: HexKey): Flow<LocalAccount?> {
TODO("Not yet implemented")
}
override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? {
TODO("Not yet implemented")
}
override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow<Profile?> {
TODO("Not yet implemented")
}

View File

@@ -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"
)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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...")
}
}
}

View File

@@ -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<Boolean> = 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
),

View File

@@ -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)

View File

@@ -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
)
}