From 7f1b938aa5aa5c4a79caf9ec0306b41544dca52e Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 18:38:42 -0500 Subject: [PATCH] feat(chat): read receipts (Seen) via the conversation reads map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observe the partner's last-read timestamp on the conversation doc; show 'Seen · time' under the last own message once the partner has read past it. No rules change (reuses reads). Co-Authored-By: Claude Opus 4.8 --- .../data/remote/FirestoreConversationDataSource.kt | 14 ++++++++++++++ .../data/repository/ConversationRepositoryImpl.kt | 3 +++ .../domain/repository/ConversationRepository.kt | 1 + .../app/closer/ui/messages/ConversationScreen.kt | 4 ++++ .../closer/ui/messages/ConversationViewModel.kt | 6 ++++++ .../ui/messages/components/ChatComponents.kt | 4 +++- 6 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt index 626daa88..008be8c3 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt @@ -166,6 +166,20 @@ class FirestoreConversationDataSource @Inject constructor( awaitClose { listener.remove() } } + /** Emits the partner's most recent read timestamp for this conversation (0 if never read). */ + fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow = callbackFlow { + val listener = conversationsRef(coupleId).document(conversationId) + .addSnapshotListener { snap, err -> + if (err != null || snap == null) return@addSnapshotListener + @Suppress("UNCHECKED_CAST") + val reads = (snap.get("reads") as? Map).orEmpty() + val partnerReadAt = reads.filterKeys { it != currentUserId } + .values.maxOfOrNull { it.toDate().time } ?: 0L + trySend(partnerReadAt) + } + awaitClose { listener.remove() } + } + suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? { val aead = encryptionManager.aeadFor(coupleId) ?: return null val cipher = runCatching { storageDataSource.downloadBytes(mediaUrl) }.getOrNull() ?: return null diff --git a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt index 3b11e62a..54cb161a 100644 --- a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt @@ -28,6 +28,9 @@ class ConversationRepositoryImpl @Inject constructor( override fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow> = dataSource.observeMessages(coupleId, conversationId, limit) + override fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow = + dataSource.observePartnerReadAt(coupleId, conversationId, currentUserId) + override suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) = dataSource.sendMessage(coupleId, conversationId, userId, text) diff --git a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt index 446a5e6a..870b9899 100644 --- a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt @@ -10,6 +10,7 @@ interface ConversationRepository { suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String suspend fun markRead(coupleId: String, conversationId: String, userId: String) fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow> + fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray) suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) diff --git a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt index 25ff42a6..629c181d 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt @@ -133,6 +133,7 @@ fun ConversationScreen( contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + val lastOwnIndex = state.messages.indexOfLast { it.userId == viewModel.currentUserId } itemsIndexed(state.messages, key = { _, m -> m.id }) { index, message -> val isMe = message.userId == viewModel.currentUserId val isLastInRun = index == state.messages.lastIndex || @@ -141,12 +142,15 @@ fun ConversationScreen( if (prev == null || !isSameChatDay(prev.createdAt, message.createdAt)) { ChatDaySeparator(message.createdAt) } + val seen = isMe && index == lastOwnIndex && + message.createdAt > 0 && state.partnerReadAt >= message.createdAt ChatMessageRow( message = message, isCurrentUser = isMe, partnerAvatarUrl = state.partnerPhotoUrl, showAvatar = isLastInRun, showTimestamp = isLastInRun, + showSeen = seen, loadDecryptedMedia = viewModel::loadDecryptedMedia ) } diff --git a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt index 7c7f97ce..16f178f3 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt @@ -34,6 +34,7 @@ data class ConversationUiState( val messages: List = emptyList(), val pendingMedia: List = emptyList(), val canLoadMore: Boolean = false, + val partnerReadAt: Long = 0L, val messageInput: String = "", val isLoading: Boolean = true ) @@ -109,6 +110,11 @@ class ConversationViewModel @Inject constructor( runCatching { repository.markRead(coupleId, conversationId, currentUserId) } } } + + launch { + repository.observePartnerReadAt(coupleId, conversationId, currentUserId) + .collect { readAt -> _uiState.update { it.copy(partnerReadAt = readAt) } } + } } } diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt index 80392649..51aa35ac 100644 --- a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -90,6 +90,7 @@ fun ChatMessageRow( partnerAvatarUrl: String?, showAvatar: Boolean, showTimestamp: Boolean, + showSeen: Boolean = false, loadDecryptedMedia: suspend (String) -> ByteArray? ) { val bubbleShape = if (isCurrentUser) { @@ -130,8 +131,9 @@ fun ChatMessageRow( } if (showTimestamp) { + val time = formatClockTime(message.createdAt) Text( - text = formatClockTime(message.createdAt), + text = if (showSeen) "Seen · $time" else time, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), modifier = Modifier