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() }
|
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? {
|
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? {
|
||||||
val aead = encryptionManager.aeadFor(coupleId) ?: return null
|
val aead = encryptionManager.aeadFor(coupleId) ?: return null
|
||||||
val cipher = runCatching { storageDataSource.downloadBytes(mediaUrl) }.getOrNull() ?: 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>> =
|
override fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>> =
|
||||||
dataSource.observeMessages(coupleId, conversationId, limit)
|
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) =
|
override suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) =
|
||||||
dataSource.sendMessage(coupleId, conversationId, userId, text)
|
dataSource.sendMessage(coupleId, conversationId, userId, text)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface ConversationRepository {
|
||||||
suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String
|
suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String
|
||||||
suspend fun markRead(coupleId: String, conversationId: String, userId: String)
|
suspend fun markRead(coupleId: String, conversationId: String, userId: String)
|
||||||
fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>>
|
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 sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
|
||||||
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
|
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
|
||||||
suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long)
|
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),
|
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(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 ->
|
itemsIndexed(state.messages, key = { _, m -> m.id }) { index, message ->
|
||||||
val isMe = message.userId == viewModel.currentUserId
|
val isMe = message.userId == viewModel.currentUserId
|
||||||
val isLastInRun = index == state.messages.lastIndex ||
|
val isLastInRun = index == state.messages.lastIndex ||
|
||||||
|
|
@ -141,12 +142,15 @@ fun ConversationScreen(
|
||||||
if (prev == null || !isSameChatDay(prev.createdAt, message.createdAt)) {
|
if (prev == null || !isSameChatDay(prev.createdAt, message.createdAt)) {
|
||||||
ChatDaySeparator(message.createdAt)
|
ChatDaySeparator(message.createdAt)
|
||||||
}
|
}
|
||||||
|
val seen = isMe && index == lastOwnIndex &&
|
||||||
|
message.createdAt > 0 && state.partnerReadAt >= message.createdAt
|
||||||
ChatMessageRow(
|
ChatMessageRow(
|
||||||
message = message,
|
message = message,
|
||||||
isCurrentUser = isMe,
|
isCurrentUser = isMe,
|
||||||
partnerAvatarUrl = state.partnerPhotoUrl,
|
partnerAvatarUrl = state.partnerPhotoUrl,
|
||||||
showAvatar = isLastInRun,
|
showAvatar = isLastInRun,
|
||||||
showTimestamp = isLastInRun,
|
showTimestamp = isLastInRun,
|
||||||
|
showSeen = seen,
|
||||||
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ data class ConversationUiState(
|
||||||
val messages: List<QuestionMessage> = emptyList(),
|
val messages: List<QuestionMessage> = emptyList(),
|
||||||
val pendingMedia: List<PendingMedia> = emptyList(),
|
val pendingMedia: List<PendingMedia> = emptyList(),
|
||||||
val canLoadMore: Boolean = false,
|
val canLoadMore: Boolean = false,
|
||||||
|
val partnerReadAt: Long = 0L,
|
||||||
val messageInput: String = "",
|
val messageInput: String = "",
|
||||||
val isLoading: Boolean = true
|
val isLoading: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
@ -109,6 +110,11 @@ class ConversationViewModel @Inject constructor(
|
||||||
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
|
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?,
|
partnerAvatarUrl: String?,
|
||||||
showAvatar: Boolean,
|
showAvatar: Boolean,
|
||||||
showTimestamp: Boolean,
|
showTimestamp: Boolean,
|
||||||
|
showSeen: Boolean = false,
|
||||||
loadDecryptedMedia: suspend (String) -> ByteArray?
|
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||||
) {
|
) {
|
||||||
val bubbleShape = if (isCurrentUser) {
|
val bubbleShape = if (isCurrentUser) {
|
||||||
|
|
@ -130,8 +131,9 @@ fun ChatMessageRow(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTimestamp) {
|
if (showTimestamp) {
|
||||||
|
val time = formatClockTime(message.createdAt)
|
||||||
Text(
|
Text(
|
||||||
text = formatClockTime(message.createdAt),
|
text = if (showSeen) "Seen · $time" else time,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue