From 7c106301888fcd05fa809dba9b704e1e67820c21 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Thu, 4 Jun 2026 12:31:25 +0200 Subject: [PATCH] Working with ChatMessageRelayList --- .../dao/BroadcastNostrEventReceiptDao.kt | 2 +- ...geBroadcastNostrEventRequestRelationDao.kt | 8 +++ .../compose/database/dao/NostrDao.kt | 72 ++++++++++++++++++- .../compose/database/model/Participant.kt | 2 + .../repository/DatabaseChatRepository.kt | 30 ++++++++ .../repository/DatabaseNostrRepository.kt | 24 +++++++ .../compose/repository/ChatRepository.kt | 7 ++ .../ui/composable/navigation/AuxNavHost.kt | 1 + .../ChatRoomDetailMessageListViewModel.kt | 63 ++++++++++------ .../ui/view/model/ChatRoomDetailViewModel.kt | 7 -- .../ui/view/model/SynchronizationViewModel.kt | 6 +- 11 files changed, 190 insertions(+), 32 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/BroadcastNostrEventReceiptDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/BroadcastNostrEventReceiptDao.kt index 73b22375..372827f5 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/BroadcastNostrEventReceiptDao.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/BroadcastNostrEventReceiptDao.kt @@ -18,5 +18,5 @@ interface BroadcastNostrEventReceiptDao { fun getBroadcastNostrEventReceiptByNostrEventId(nostrEventId: String): Flow @Upsert - suspend fun upsert(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) + suspend fun upsert(broadcastNostrEventReceipt: BroadcastNostrEventReceipt): Long } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ChatMessageBroadcastNostrEventRequestRelationDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ChatMessageBroadcastNostrEventRequestRelationDao.kt index a57322fd..29f4e409 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ChatMessageBroadcastNostrEventRequestRelationDao.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ChatMessageBroadcastNostrEventRequestRelationDao.kt @@ -1,11 +1,19 @@ package ac.cord.auxiliary.compose.database.dao import ac.cord.auxiliary.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation +import ac.cord.auxiliary.compose.database.model.NostrEvent import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Transaction import androidx.room3.Upsert +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlin.time.Instant @Dao interface ChatMessageBroadcastNostrEventRequestRelationDao { + @Query("SELECT * FROM ChatMessageBroadcastNostrEventRequestRelation WHERE broadcastNostrEventRequestId = :broadcastNostrEventRequestId") + suspend fun getChatMessageBroadcastNostrEventRequestRelationByBroadcastRequest(broadcastNostrEventRequestId: Long): ChatMessageBroadcastNostrEventRequestRelation? @Upsert suspend fun upsert(chatMessageBroadcastNostrEventRequestRelation: ChatMessageBroadcastNostrEventRequestRelation) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrDao.kt index 39c433e4..3074c32d 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrDao.kt @@ -371,12 +371,13 @@ abstract class NostrDao( ) if (localChatRoom == null) { + val userPublicKey = SeedManager.activeKeyPair().pubKey.toHex() database.chatRoomDao().upsert( ChatRoom( id = chatRoomId, - userPublicKey = SeedManager.activeKeyPair().pubKey.toHex(), - subject = null, // TODO: Get subject from tags... - createdAt = giftWrapPayload.createdAt, + userPublicKey = userPublicKey, + subject = decryptedGiftWrapPayload.parseSubject(), // TODO: Get subject from tags... + createdAt = decryptedGiftWrapPayload.createdAt, initialGiftWrapPayloadId = giftWrapPayload.id ) ) @@ -425,6 +426,71 @@ abstract class NostrDao( database.participantDao().upsert( participants ) + + participants.filter { it.participantPublicKey != userPublicKey }.forEach { participant -> + val publicKey = participant.participantPublicKey + + val chatMessageRelayListEvent = database.nostrEventDao().getAuthoredNostrEvents( + kinds = arrayOf( + ChatMessageRelayListEvent.KIND + ), + authors = arrayOf( + publicKey + ), + since = GENESIS_AT, + limit = 5 + ).firstOrNull()?.let { nostrEvent -> + if (nostrEvent.kind != ChatMessageRelayListEvent.KIND) { + logger.e("getAuthoredNostrEvents returned invalid ChatMessageRelayListEvent for ${publicKey}: $nostrEvent") + null + } else { + ChatMessageRelayListEvent( + id = nostrEvent.id, + tags = nostrEvent.tags, + pubKey = nostrEvent.pubKey, + content = nostrEvent.content, + createdAt = nostrEvent.createdAt.epochSeconds, + sig = nostrEvent.sig + ) + } + } + + if (chatMessageRelayListEvent != null) { + // Sync messages from this relay... + val synchronizationFilter = SynchronizationFilter( + kinds = arrayOf( + GiftWrapEvent.KIND, + ), + tags = mapOf( + Pair("p", listOf(participant.participantPublicKey)) + ), + limit = 50 + ) + database.negentropySynchronizeRequestDao().insert( + chatMessageRelayListEvent.relays().map { normalizedRelayUrl -> + NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = normalizedRelayUrl.url, + synchronizationFilter = synchronizationFilter + ), + purpose = "sent-messages", + synchronizationFilter = synchronizationFilter, + relayURL = normalizedRelayUrl.url, + level = 0 + ) + } + ) + } else { + logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") + + // Sync ChatMessageRelayListEvent publicKey... + // TODO: Get relayHint form participant... + if (profilePublicKeysToSync.containsKey(relayURL)) { + profilePublicKeysToSync[relayURL] = mutableSetOf() + } + profilePublicKeysToSync[relayURL]?.add(participant.participantPublicKey) + } + } } else { giftWrapPayload.parseSubject()?.let { subject -> database.chatRoomDao().upsert( diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/Participant.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/Participant.kt index ea6ce089..35486b51 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/Participant.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/Participant.kt @@ -44,6 +44,8 @@ data class Participant( val participantPublicKey: HexKey, val chatRoomId: String, + // TODO: Add relayHint... + override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = Clock.System.now(), override val savedAt: Instant = Clock.System.now(), diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseChatRepository.kt index 38a7d628..795380f3 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseChatRepository.kt @@ -1,6 +1,7 @@ package ac.cord.auxiliary.compose.database.repository import ac.cord.auxiliary.compose.database.AuxDatabase +import ac.cord.auxiliary.compose.database.GENESIS_AT import ac.cord.auxiliary.compose.database.model.ChatMessage import ac.cord.auxiliary.compose.database.model.ChatMessageNostrEventRelation import ac.cord.auxiliary.compose.database.model.ChatRoom @@ -15,6 +16,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -54,6 +56,34 @@ class DatabaseChatRepository( return null } + override suspend fun getChatMessageRelayForPublicKey(publicKey: HexKey): ChatMessageRelayListEvent? { + // TODO: Implement a UserChatMessageRelayList model + return database.nostrEventDao().getAuthoredNostrEvents( + kinds = arrayOf( + ChatMessageRelayListEvent.KIND + ), + authors = arrayOf( + publicKey + ), + since = GENESIS_AT, + limit = 5 + ).firstOrNull()?.let { nostrEvent -> + if (nostrEvent.kind != ChatMessageRelayListEvent.KIND) { + logger.e("getAuthoredNostrEvents returned invalid ChatMessageRelayListEvent for ${publicKey}: $nostrEvent") + null + } else { + ChatMessageRelayListEvent( + id = nostrEvent.id, + tags = nostrEvent.tags, + pubKey = nostrEvent.pubKey, + content = nostrEvent.content, + createdAt = nostrEvent.createdAt.epochSeconds, + sig = nostrEvent.sig + ) + } + } + } + override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt index 3a8f6c0f..3d43b9a6 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt @@ -4,6 +4,7 @@ import ac.cord.auxiliary.compose.database.AuxDatabase import ac.cord.auxiliary.compose.database.GENESIS_AT import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventReceipt import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventRequest +import ac.cord.auxiliary.compose.database.model.ChatMessageBroadcastNostrEventReceiptRelation import ac.cord.auxiliary.compose.database.model.Connection import ac.cord.auxiliary.compose.database.model.NegentropySynchronizeRequest import ac.cord.auxiliary.compose.database.model.NostrEvent @@ -406,6 +407,29 @@ class DatabaseNostrRepository( status = status ) ) + + if (status == "published") { + val broadcastNostrEventReceiptId = database.broadcastNostrEventReceiptDao().upsert( + BroadcastNostrEventReceipt( + nostrEventId = broadcastNostrEventRequest.nostrEventId, + unsignedNostrEventId = broadcastNostrEventRequest.unsignedNostrEventId, + isAccepted = true, + relayURL = broadcastNostrEventRequest.relayURL + ) + ) + + database.chatMessageBroadcastNostrEventRequestRelationDao().getChatMessageBroadcastNostrEventRequestRelationByBroadcastRequest( + broadcastNostrEventRequestId = broadcastNostrEventRequest.id + )?.let { chatMessageBroadcastNostrEventRequestRelation -> + // This is actually a chatMessage related broadcast event... + database.chatMessageBroadcastNostrEventReceiptRelationDao().upsert( + ChatMessageBroadcastNostrEventReceiptRelation( + chatMessageId = chatMessageBroadcastNostrEventRequestRelation.chatMessageId, + broadcastNostrEventReceiptId = broadcastNostrEventReceiptId + ) + ) + } + } } override suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/ChatRepository.kt index 6a7aadc1..9ecef73a 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/ChatRepository.kt @@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -23,6 +24,8 @@ interface ChatRepository { suspend fun getOrCreateChatRoom(publicKey: String, defaultSubject: String? = null): LocalChatRoom? + suspend fun getChatMessageRelayForPublicKey(publicKey: HexKey): ChatMessageRelayListEvent? + suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, @@ -58,6 +61,10 @@ interface ChatRepository { return null } + override suspend fun getChatMessageRelayForPublicKey(publicKey: HexKey): ChatMessageRelayListEvent? { + return null + } + override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt index bd98a1de..8e45dc09 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt @@ -118,6 +118,7 @@ fun AuxNavHost( val synchronizationViewModel: SynchronizationViewModel = viewModel( factory = SynchronizationViewModel.factory( nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, relayRepository = databaseNostrRepository, scope = applicationIOScope ) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/ChatRoomDetailMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/ChatRoomDetailMessageListViewModel.kt index aa18e39b..e66b764f 100755 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/ChatRoomDetailMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/ChatRoomDetailMessageListViewModel.kt @@ -1,6 +1,8 @@ package ac.cord.auxiliary.compose.ui.view.model +import ac.cord.auxiliary.compose.database.model.NegentropySynchronizeRequest import ac.cord.auxiliary.compose.database.model.intermdiate.LocalChatRoom +import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter import ac.cord.auxiliary.compose.extensions.toFormattedTimeAndDateString import ac.cord.auxiliary.compose.repository.ChatRepository import ac.cord.auxiliary.compose.repository.NostrRepository @@ -24,9 +26,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.KeyOff -import androidx.compose.material.icons.filled.LockClock import androidx.compose.material.icons.filled.Pending -import androidx.compose.material.icons.filled.PendingActions import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -34,6 +34,7 @@ import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -48,6 +49,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged @@ -63,6 +65,8 @@ class ChatRoomDetailMessageListViewModel( var chatRoomDetailMessageListUIState: ChatRoomDetailMessageListUIState by mutableStateOf(initialChatRoomDetailMessageListUIState) private set + val isReceiverChatMessageRelayListMissing: MutableState = mutableStateOf(false) + fun initiate() { logger.d("init") scheduleSynchronization() @@ -85,23 +89,43 @@ class ChatRoomDetailMessageListViewModel( fun scheduleSynchronization() { logger.d("scheduleSynchronization") -// viewModelScope.launch(Dispatchers.IO) { -// // Sync Notifications... might want to also run this in the background -// nostrRepository.queueNegentropySynchronizeRequest( -// Relays.DefaultDMRelayList.shuffled().map { normalizedRelayUrl -> -// NegentropySynchronizeRequest( -// id = NegentropySynchronizeRequest.computeId( -// relayURL = normalizedRelayUrl.url, -// synchronizationFilter = synchronizationFilter -// ), -// purpose = "chat", -// synchronizationFilter = synchronizationFilter, -// relayURL = normalizedRelayUrl.url, -// level = 0 -// ) -// } -// ) -// } + + viewModelScope.launch(Dispatchers.IO) { + // Sync Notifications... might want to also run this in the background + localChatRoom.participants.filter { it.participant.participantPublicKey != localChatRoom.chatRoom.userPublicKey }.forEach { recipients -> + + val chatMessageRelayListEvent = chatRepository.getChatMessageRelayForPublicKey(recipients.participant.participantPublicKey) + + if (chatMessageRelayListEvent != null) { + // Sync messages from this relay... + val synchronizationFilter = SynchronizationFilter( + kinds = arrayOf( + GiftWrapEvent.KIND, + ), + tags = mapOf( + Pair("p", listOf(recipients.participant.participantPublicKey)) + ), + limit = 50 + ) + nostrRepository.queueNegentropySynchronizeRequest( + chatMessageRelayListEvent.relays().map { normalizedRelayUrl -> + NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = normalizedRelayUrl.url, + synchronizationFilter = synchronizationFilter + ), + purpose = "sent-messages", + synchronizationFilter = synchronizationFilter, + relayURL = normalizedRelayUrl.url, + level = 0 + ) + } + ) + } else { + isReceiverChatMessageRelayListMissing.value = true + } + } + } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -218,7 +242,6 @@ class ChatRoomDetailMessageListViewModel( style = MaterialTheme.typography.labelSmall ) if (localChatMessage.chatMessage.isUserMessage) { - // TODO: Add delivered and read receipt.. if (localChatMessage.chatMessageBroadcastNostrEventReceiptRelation != null) { Icon( Icons.Default.Check, diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/ChatRoomDetailViewModel.kt index 3fc790c6..b477508a 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -34,13 +34,7 @@ class ChatRoomDetailViewModel( val isActionPending: MutableState = mutableStateOf(false) - fun queueSynchronization() { - logger.d { "queueSynchronization" } - } - - // TODO: Render MessageList... fun initiateChatRoomDetail() { - // TODO: GetOrCreateChatRoom if (Crypto.isPubKeyCompressed(directMessageId.hexToByteArray())) { logger.d("compressed: $directMessageId") // Get ChatRoomById @@ -71,7 +65,6 @@ class ChatRoomDetailViewModel( } } } - queueSynchronization() } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SynchronizationViewModel.kt index 525b2c08..44978cbe 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SynchronizationViewModel.kt @@ -9,6 +9,7 @@ import ac.cord.auxiliary.compose.network.relays.RelaysSocketManager import ac.cord.auxiliary.compose.network.sockets.NostrIncomingMessage import ac.cord.auxiliary.compose.network.sockets.NostrSocketClientFactory import ac.cord.auxiliary.compose.repository.CachingImportRepository +import ac.cord.auxiliary.compose.repository.ChatRepository import ac.cord.auxiliary.compose.repository.NostrRepository import ac.cord.auxiliary.compose.repository.RelayRepository import androidx.lifecycle.ViewModel @@ -37,6 +38,7 @@ import kotlin.time.Duration.Companion.milliseconds class SynchronizationViewModel( val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, val relayRepository: RelayRepository, val scope: CoroutineScope, ): ViewModel() { @@ -54,12 +56,14 @@ class SynchronizationViewModel( fun factory( nostrRepository: NostrRepository, + chatRepository: ChatRepository, relayRepository: RelayRepository, scope: CoroutineScope, ): ViewModelProvider.Factory = viewModelFactory { initializer { SynchronizationViewModel( nostrRepository = nostrRepository, + chatRepository = chatRepository, relayRepository = relayRepository, scope = scope ) @@ -372,7 +376,7 @@ class SynchronizationViewModel( logger.d("Nostr Publish Result: $nostrPublishResult") nostrRepository.broadcastProcessed( localBroadcastNostrEventRequest.broadcastNostrEventRequest, - "published" + "published" // TODO: Might want to pass the nostrPublishResult.result ) } }