feat(chat): read receipts (Seen) via the conversation reads map

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-24 18:38:42 -05:00
parent 3aa182a466
commit 7f1b938aa5
6 changed files with 31 additions and 1 deletions

View File

@ -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<Long> = 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<String, com.google.firebase.Timestamp>).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

View File

@ -28,6 +28,9 @@ class ConversationRepositoryImpl @Inject constructor(
override fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>> =
dataSource.observeMessages(coupleId, conversationId, limit)
override fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> =
dataSource.observePartnerReadAt(coupleId, conversationId, currentUserId)
override suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) =
dataSource.sendMessage(coupleId, conversationId, userId, text)

View File

@ -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<List<QuestionMessage>>
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long>
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)

View File

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

View File

@ -34,6 +34,7 @@ data class ConversationUiState(
val messages: List<QuestionMessage> = emptyList(),
val pendingMedia: List<PendingMedia> = 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) } }
}
}
}

View File

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