From ca1093637c81d941d74d126107360232fba86af8 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:18:28 +0200 Subject: [PATCH] refactor(chat): stop syncing chat messages on screen open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The point of the previous four commits. Both chat screens scheduled a message sync every time they were opened; both are now covered by subscriptions that are already open, so the sync on open is work with nothing left to do. **ChatRoomListViewModel** no longer syncs on initiate(). scheduleSynchronization itself stays, and is unchanged: it is exactly the reconciliation LiveSubscriptionManager runs when the app returns from the background, and it is what an explicit user-initiated refresh should call. That is the one case the live tier genuinely does not answer, because it is the user saying they believe something is missing. **ChatMessageListViewModel** loses its message sync entirely. The MLS branch (negentropy over 445 h-tagged with this one room) is a strict subset of live-groups-*. The NIP-17 branch's gift wrap sync is a subset of live-giftwrap. What survives is discovery rather than sync: if we do not hold a participant's kind-10050 we cannot address a message to them, and that is worth resolving the moment a chat is opened rather than whenever a background pass reaches it. The function now does only that, and only when the relay list is actually missing — it used to queue a request in both branches of that test. **The dead "sent-messages" reconciliation is deleted**, in the view model and at both sites in NostrDao. It asked for kind 1059 with authors=[userPublicKey], and could never match a single event: a gift wrap is signed with a throwaway KeyPair (DatabaseChatRepository), so its pubkey is random and never ours. It was also unnecessary — createNip17ChatRoom puts us in our own participant list, so we wrap a copy to ourselves and the live gift wrap subscription picks our own sent messages up on every device. Removing it inverts the surrounding test in NostrDao from `if (relayList != null) { sync } else { discover }` to `if (relayList == null) { discover }`. The discovery half is untouched. Net effect on a session: opening the chat list queues nothing, opening a chat queues at most a kind-10050 lookup for a participant we cannot yet address, and messages arrive because a subscription is open rather than because a screen asked. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 77 +++---------- .../ui/view/model/ChatMessageListViewModel.kt | 103 +++++------------- .../ui/view/model/ChatRoomListViewModel.kt | 11 +- 3 files changed, 51 insertions(+), 140 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 304a048c..026e3439 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -639,41 +639,15 @@ abstract class NostrDao( } } - if (chatMessageRelayListEvent != null) { - // Sync messages from this relay that were sent by us - val synchronizationFilter = - SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - authors = arrayOf( - userPublicKey - ), - 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 { + // A "sent-messages" reconciliation used to be queued here, + // for gift wraps with authors=[userPublicKey] p-tagged at this + // participant. It could never match anything: a gift wrap is + // signed with a throwaway key (see DatabaseChatRepository), so + // its pubkey is random and never ours. It was also unnecessary + // — createNip17ChatRoom puts us in our own participant list, so + // we wrap a copy to ourselves and the live gift wrap + // subscription picks our own sent messages up on every device. + if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") // Sync ChatMessageRelayListEvent publicKey... @@ -875,35 +849,10 @@ abstract class NostrDao( } } - if (chatMessageRelayListEvent != null) { - // Sync messages from this relay that were sent by us - val synchronizationFilter = SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - authors = arrayOf( - userPublicKey - ), - 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 { + // See the identical block above: the "sent-messages" + // reconciliation that used to live here could never match, and is + // covered by the live gift wrap subscription regardless. + if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") // Sync ChatMessageRelayListEvent publicKey... diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 27d40b07..7cb011d2 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -70,9 +70,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.view.state.ChatMessageListUIState import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged @@ -110,63 +108,50 @@ class ChatMessageListViewModel( } } + /** + * Finds out where this room's participants read their messages. + * + * This used to schedule the room's message sync as well — gift wraps for a NIP-17 room, + * group events for an MLS one. Both are now covered by the subscriptions + * LiveSubscriptionManager holds open for the whole account, so opening a chat no longer + * asks for its messages; they are already arriving. + * + * What is left is discovery, not message sync: if we do not hold a participant's + * kind-10050 we cannot address a message to them, and that is worth resolving the moment + * a chat is opened rather than whenever a background pass gets to it. + */ fun scheduleSynchronization() { logger.d("scheduleSynchronization") viewModelScope.launch(Dispatchers.IO) { - // Sync Notifications... might want to also run this in the background if (localChatRoom.chatRoom.mlsGroupState == null) { localChatRoom.localParticipants.filter { it.participant.participantPublicKey != localChatRoom.chatRoom.userPublicKey }.forEach { recipients -> val chatMessageRelayListEvent = chatRepository.getChatMessageRelayForPublicKey(recipients.participant.participantPublicKey) - val relayAndSynchronizationFilter = if (chatMessageRelayListEvent != null) { - // Sync messages from this relay... - Pair( - chatMessageRelayListEvent.relays(), - SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - tags = mapOf( - Pair("p", listOf(recipients.participant.participantPublicKey)) - ), - limit = 50 - ) - ) + // We already know where to reach them; nothing to find out. + if (chatMessageRelayListEvent != null) return@forEach - } else { - // Try to find the ChatMessageRelayList for this participant... - isReceiverChatMessageRelayListMissing.value = true - // TODO: compute the timestamp for when list we sent messages and use that as since... - Pair( - Relays.DefaultDMRelayList, - SynchronizationFilter( - kinds = arrayOf( - ChatMessageRelayListEvent.KIND, - ), - authors = arrayOf( - recipients.participant.participantPublicKey - ), - limit = 50 - ) - ) - } + isReceiverChatMessageRelayListMissing.value = true - logger.i("GiftWrapFilter: ${relayAndSynchronizationFilter.second}") + val chatMessageRelayListFilter = SynchronizationFilter( + kinds = arrayOf( + ChatMessageRelayListEvent.KIND, + ), + authors = arrayOf( + recipients.participant.participantPublicKey + ), + limit = 50 + ) - val negentropySynchronizeRequests = relayAndSynchronizationFilter.first.map { normalizedRelayUrl -> + val negentropySynchronizeRequests = Relays.DefaultDMRelayList.map { normalizedRelayUrl -> NegentropySynchronizeRequest( id = NegentropySynchronizeRequest.computeId( relayURL = normalizedRelayUrl.url, - synchronizationFilter = relayAndSynchronizationFilter.second + synchronizationFilter = chatMessageRelayListFilter ), - purpose = if (isReceiverChatMessageRelayListMissing.value) { - "chat-message-relays" - } else { - "sent-messages" - }, - synchronizationFilter = relayAndSynchronizationFilter.second, + purpose = "chat-message-relays", + synchronizationFilter = chatMessageRelayListFilter, relayURL = normalizedRelayUrl.url, level = 0 ) @@ -177,39 +162,7 @@ class ChatMessageListViewModel( negentropySynchronizeRequests ) } - } else { - // Sync mlsMessages - val relayChatRoomMaps = Relays.DefaultDMRelayList.map { dmRelay -> - dmRelay.url to listOf( - localChatRoom.chatRoom.id - ) - } - - val negentropySyncRequests = relayChatRoomMaps.map { relayChatRoomMap -> - val mlsGroupMessageFilter = SynchronizationFilter( - kinds = arrayOf(GroupEvent.KIND), - tags = mapOf("h" to relayChatRoomMap.second), - ) - logger.d("mlsGroupMessageFilter: $mlsGroupMessageFilter") - - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = relayChatRoomMap.first, - synchronizationFilter = mlsGroupMessageFilter - ), - purpose = "mlsMessages", - synchronizationFilter = mlsGroupMessageFilter, - relayURL = relayChatRoomMap.first, - level = 0 - ) - } - - logger.d("negentropySyncRequests: ${negentropySyncRequests.map { it.synchronizationFilter }}") - nostrRepository.queueNegentropySynchronizeRequest( - negentropySyncRequests - ) } - } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt index 994f76bb..8251e967 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt @@ -50,7 +50,6 @@ class ChatRoomListViewModel( fun initiate() { logger.d("init") - scheduleSynchronization() observeChatRoomFeed() } @@ -68,6 +67,16 @@ class ChatRoomListViewModel( } } + /** + * Reconciles this account's chat history against the relays: gift wraps addressed to us, + * and group events for every group we are in. + * + * No longer called on open. LiveSubscriptionManager holds both of those subscriptions + * open for as long as the app is in the foreground and runs exactly this reconciliation + * when it returns from the background, so opening the list is no longer a reason to ask. + * This stays for an explicit user-initiated refresh — the one case the live tier does not + * answer, because it is the user saying they believe something is missing. + */ fun scheduleSynchronization() { logger.d("scheduleSynchronization") viewModelScope.launch(Dispatchers.IO) {