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:
parent
3aa182a466
commit
7f1b938aa5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue