diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7c67921322..c503f79ad0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -187,6 +187,7 @@ import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -2526,6 +2527,8 @@ class Account( // broadcastPrivately) instead of waiting for the newEventBundles // batcher; the later batched re-delivery is deduped by the chatroom. cache.getNoteIfExists(newEvent.id)?.let { newNotesPreProcessor.consume(it) } + + markDmRoomAsRead(newEvent) } override suspend fun sendNip17EncryptedFile(template: EventTemplate) { @@ -2586,6 +2589,28 @@ class Account( // batcher re-delivers this note later; the processor's replay path and // the chatroom add are both idempotent. mineNote?.let { newNotesPreProcessor.consume(it) } + + markDmRoomAsRead(signedEvents.msg) + } + + /** + * Sending a message into a DM room means the user has caught up with what the room + * showed when they replied: advance the local read marker to the newest known message — + * not just the sent one, whose local clock may lag behind a skew-ahead peer's — so the + * unread indicators clear without requiring the conversation to be reopened + * (#1286, #1287). No-op for private events that don't belong to a room (private notes, + * reactions, deletions). + */ + private fun markDmRoomAsRead(event: Event) { + if (event is ChatroomKeyable) { + val room = event.chatroomKey(signer.pubKey) + val newestInRoom = + chatroomList.rooms + .get(room) + ?.newestMessage + ?.createdAt() ?: 0L + markAsRead(privateChatLastReadRoute(room), maxOf(event.createdAt, newestInRoom)) + } } // --- Marmot Group Messaging --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadState.kt new file mode 100644 index 0000000000..24e2d00d86 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadState.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable + +/** + * Route key under which a private chat room's last-read time is stored in AccountSettings. + * Every marker writer (send paths, ingestion, room view, hidden-room sweep) and reader + * (Messages-tab dot, room-row bubble) must build the key through this function: a format + * drift between a writer and a reader silently splits read state (#1286). + */ +fun privateChatLastReadRoute(room: ChatroomKey) = "Room/${room.hashCode()}" + +/** + * True when [message] marks [room] as read up to its timestamp: the logged-in user authored + * it, so sending it — from this device, or from another one arriving via the self-addressed + * gift wrap — means they had caught up with the conversation (#1286, #1287). Notes-to-self + * rooms are exempt: there the user's own messages ARE the content still to be seen. + */ +fun chatMessageMarksRoomAsRead( + message: Event, + room: ChatroomKey, + loggedInUser: HexKey, +): Boolean = message.pubKey == loggedInUser && room.users.singleOrNull() != loggedInUser + +/** + * Read-marker route + timestamp for the newest message of a private chat room, or null when + * the room cannot be unread: no chat event, a newest message that counts as read (see + * [chatMessageMarksRoomAsRead]), or every participant hidden. + */ +fun unreadPrivateChatRoute( + newestMessage: Event?, + loggedInUser: HexKey, + isAllHidden: (Set) -> Boolean, +): Pair? { + if (newestMessage !is ChatroomKeyable) return null + val room = newestMessage.chatroomKey(loggedInUser) + if (chatMessageMarksRoomAsRead(newestMessage, room, loggedInUser)) return null + if (isAllHidden(room.users)) return null + return privateChatLastReadRoute(room) to newestMessage.createdAt +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 5bbe77ea39..239b90b500 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -68,6 +68,8 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder +import com.vitorpamplona.amethyst.model.privateChatLastReadRoute +import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.V4VPaymentHandler @@ -1886,7 +1888,7 @@ class AccountViewModel( } noteEvent is ChatroomKeyable -> { - account.markAsRead("Room/${noteEvent.chatroomKey(account.signer.pubKey).hashCode()}", noteEvent.createdAt) + account.markAsRead(privateChatLastReadRoute(noteEvent.chatroomKey(account.signer.pubKey)), noteEvent.createdAt) } noteEvent is DraftWrapEvent -> { @@ -1894,7 +1896,7 @@ class AccountViewModel( if (innerEvent is IsInPublicChatChannel) { account.markAsRead("Channel/${innerEvent.channelId()}", noteEvent.createdAt) } else if (innerEvent is ChatroomKeyable) { - account.markAsRead("Room/${innerEvent.chatroomKey(account.signer.pubKey).hashCode()}", noteEvent.createdAt) + account.markAsRead(privateChatLastReadRoute(innerEvent.chatroomKey(account.signer.pubKey)), noteEvent.createdAt) } } } @@ -1904,25 +1906,18 @@ class AccountViewModel( } } - private fun unreadPrivateChatRoute(chat: Note): Pair? { - val noteEvent = chat.event ?: return null - val room = (noteEvent as? ChatroomKeyable)?.chatroomKey(account.signer.pubKey) ?: return null - if (account.isAllHidden(room.users)) return null - return privateChatRoute(room) to noteEvent.createdAt - } + private fun unreadPrivateChatRoute(chat: Note): Pair? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden) private fun markHiddenChatroomsAsRead() { account.chatroomList.rooms.forEach { roomKey, chatroom -> if (account.isAllHidden(roomKey.users)) { chatroom.newestMessage?.createdAt()?.let { - account.markAsRead(privateChatRoute(roomKey), it) + account.markAsRead(privateChatLastReadRoute(roomKey), it) } } } } - private fun privateChatRoute(room: ChatroomKey) = "Room/${room.hashCode()}" - class Factory( val account: Account, val settings: UiSettingsState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 7c5eb319e8..95119bb921 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.chatMessageMarksRoomAsRead +import com.vitorpamplona.amethyst.model.privateChatLastReadRoute import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor @@ -96,7 +98,10 @@ class EventProcessor( is CallRenegotiateEvent, -> callManager?.onSignalingEvent(event) - is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) + is ChatroomKeyable -> { + chatHandler.add(event, eventNote, publicNote) + markOwnChatMessageAsRead(event) + } is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) @@ -110,6 +115,23 @@ class EventProcessor( } } + /** + * A chat message authored by this account — sent from this device through any code + * path, or arriving via the self-addressed gift wrap from another device — means the + * user was caught up with the room when they sent it, so advance the room's read + * marker here at the single ingestion choke point rather than at each send site + * (#1286, #1287). markAsRead is monotonic, so out-of-order history sync cannot move + * the marker backwards. Unsent drafts never reach this branch (DraftEventHandler + * indexes their rumors directly into the chatroom). + */ + private fun markOwnChatMessageAsRead(event: T) where T : Event, T : ChatroomKeyable { + val me = account.signer.pubKey + val room = event.chatroomKey(me) + if (chatMessageMarksRoomAsRead(event, room, me)) { + account.markAsRead(privateChatLastReadRoute(room), event.createdAt) + } + } + suspend fun delete(note: Note) { note.event?.let { event -> try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 4632448358..efdfd2c396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState +import com.vitorpamplona.amethyst.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -268,7 +269,7 @@ fun ChatroomViewUI( feedContentState = feedViewModel.feedState, accountViewModel = accountViewModel, nav = nav, - routeForLastRead = "Room/${room.hashCode()}", + routeForLastRead = privateChatLastReadRoute(room), avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index e54a7e3732..442e49cf2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -61,7 +61,9 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.chatMessageMarksRoomAsRead import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription @@ -143,7 +145,7 @@ fun ChatroomComposeChannelOrUser( val baseNoteEvent = baseNote.event if (baseNoteEvent is DraftWrapEvent) { ObserveDraftEvent(baseNote, accountViewModel) { innerNote -> - ChatroomEntry(innerNote, accountViewModel, nav) + ChatroomEntry(innerNote, accountViewModel, nav, isDraft = true) } } else { ChatroomEntry(baseNote, accountViewModel, nav) @@ -155,6 +157,7 @@ private fun ChatroomEntry( lastMessage: Note, accountViewModel: AccountViewModel, nav: INav, + isDraft: Boolean = false, ) { if (lastMessage is RelayGroupServerRoomNote) { RelayGroupServerRoomCompose(lastMessage, accountViewModel, nav) @@ -213,7 +216,7 @@ private fun ChatroomEntry( is ChatroomKeyable -> { val room = baseNoteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex) - UserRoomCompose(room, lastMessage, accountViewModel, nav) + UserRoomCompose(room, lastMessage, isDraft, accountViewModel, nav) } is EphemeralChatEvent -> { @@ -521,6 +524,7 @@ private fun ChannelTitleWithLabelInfo( private fun UserRoomCompose( room: ChatroomKey, lastMessage: Note, + isDraft: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { @@ -572,8 +576,15 @@ private fun UserRoomCompose( } } - val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle() - if ((lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { + // A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention. + val newestEvent = lastMessage.event + val countsAsRead = + !isDraft && + newestEvent != null && + chatMessageMarksRoomAsRead(newestEvent, room, accountViewModel.account.signer.pubKey) + + val lastReadTime by accountViewModel.account.loadLastReadFlow(privateChatLastReadRoute(room)).collectAsStateWithLifecycle() + if (!countsAsRead && (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { Spacer(modifier = Height4dpModifier) NewItemsBubble() } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadStateTest.kt new file mode 100644 index 0000000000..2227e72a70 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadStateTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The unread predicate behind the Messages tab dot and the room-row bubble: a room whose + * newest message was authored by the logged-in user counts as read (#1286, #1287), except + * notes-to-self rooms, where the user's own messages are the content still to be seen. + */ +class PrivateChatroomReadStateTest { + private val me: HexKey = "a".repeat(64) + private val peer: HexKey = "b".repeat(64) + + private val roomWithPeer = ChatroomKey(persistentSetOf(peer)) + private val selfRoom = ChatroomKey(persistentSetOf(me)) + + private fun message( + from: HexKey, + to: HexKey, + createdAt: Long, + ) = ChatMessageEvent( + id = "0".repeat(64), + pubKey = from, + createdAt = createdAt, + tags = arrayOf(arrayOf("p", to)), + content = "hello", + sig = "", + ) + + @Test + fun newestMessageFromPeerReturnsTheRoomRoute() { + val route = unreadPrivateChatRoute(message(from = peer, to = me, createdAt = 100), me, isAllHidden = { false }) + + assertEquals(privateChatLastReadRoute(roomWithPeer) to 100L, route) + } + + @Test + fun newestMessageAuthoredByMeCountsAsRead() { + assertNull(unreadPrivateChatRoute(message(from = me, to = peer, createdAt = 100), me, isAllHidden = { false })) + } + + @Test + fun notesToSelfRoomsCanStillBeUnread() { + val route = unreadPrivateChatRoute(message(from = me, to = me, createdAt = 100), me, isAllHidden = { false }) + + assertEquals(privateChatLastReadRoute(selfRoom) to 100L, route) + } + + @Test + fun hiddenRoomsAreNeverUnread() { + assertNull(unreadPrivateChatRoute(message(from = peer, to = me, createdAt = 100), me, isAllHidden = { true })) + } + + @Test + fun missingEventIsNotUnread() { + assertNull(unreadPrivateChatRoute(null, me, isAllHidden = { false })) + } + + @Test + fun nonChatEventsAreNotUnread() { + val reaction = + ReactionEvent( + id = "0".repeat(64), + pubKey = peer, + createdAt = 100, + tags = arrayOf(arrayOf("p", me)), + content = "+", + sig = "", + ) + + assertNull(unreadPrivateChatRoute(reaction, me, isAllHidden = { false })) + } + + @Test + fun myMessageMarksAPeerRoomAsRead() { + assertEquals(true, chatMessageMarksRoomAsRead(message(from = me, to = peer, createdAt = 100), roomWithPeer, me)) + } + + @Test + fun aPeerMessageNeverMarksTheRoomAsRead() { + assertEquals(false, chatMessageMarksRoomAsRead(message(from = peer, to = me, createdAt = 100), roomWithPeer, me)) + } + + @Test + fun myMessageDoesNotMarkTheSelfRoomAsRead() { + assertEquals(false, chatMessageMarksRoomAsRead(message(from = me, to = me, createdAt = 100), selfRoom, me)) + } +}