refactor(chat): stop syncing chat messages on screen open

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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 16:18:28 +02:00
parent 0ef0a33350
commit ca1093637c
3 changed files with 51 additions and 140 deletions

View File

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

View File

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

View File

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