feat(chat): typing indicator
Debounced typing flag (typing:{uid:ts}) on the conversation doc, cleared on stop/send/
leave; partner sees 'typing…' with a ~6s TTL safety net (ticker-driven auto-hide). Rules
allow members to write the typing field. Live verification pending the Phase B deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5b9596e042
commit
f29d4699ca
|
|
@ -192,6 +192,26 @@ class FirestoreConversationDataSource @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget typing flag on the conversation doc (ephemeral; cleared on stop/send/leave). */
|
||||||
|
fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean) {
|
||||||
|
val ref = conversationsRef(coupleId).document(conversationId)
|
||||||
|
val value = if (typing) FieldValue.serverTimestamp() else FieldValue.delete()
|
||||||
|
ref.update(mapOf("typing.$userId" to value)).addOnFailureListener { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emits the partner's latest typing timestamp (0 if not typing). */
|
||||||
|
fun observePartnerTyping(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 typing = (snap.get("typing") as? Map<String, com.google.firebase.Timestamp>).orEmpty()
|
||||||
|
val at = typing.filterKeys { it != currentUserId }.values.maxOfOrNull { it.toDate().time } ?: 0L
|
||||||
|
trySend(at)
|
||||||
|
}
|
||||||
|
awaitClose { listener.remove() }
|
||||||
|
}
|
||||||
|
|
||||||
/** Emits the partner's most recent read timestamp for this conversation (0 if never read). */
|
/** 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 {
|
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
|
||||||
val listener = conversationsRef(coupleId).document(conversationId)
|
val listener = conversationsRef(coupleId).document(conversationId)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,12 @@ class ConversationRepositoryImpl @Inject constructor(
|
||||||
override fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> =
|
override fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> =
|
||||||
dataSource.observePartnerReadAt(coupleId, conversationId, currentUserId)
|
dataSource.observePartnerReadAt(coupleId, conversationId, currentUserId)
|
||||||
|
|
||||||
|
override fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean) =
|
||||||
|
dataSource.setTyping(coupleId, conversationId, userId, typing)
|
||||||
|
|
||||||
|
override fun observePartnerTyping(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> =
|
||||||
|
dataSource.observePartnerTyping(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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ interface ConversationRepository {
|
||||||
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>
|
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long>
|
||||||
|
fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean)
|
||||||
|
fun observePartnerTyping(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)
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,15 @@ fun ConversationScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.partnerTyping) {
|
||||||
|
Text(
|
||||||
|
text = "typing…",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.padding(start = 20.dp, bottom = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
state.pendingMedia.forEach { pm ->
|
state.pendingMedia.forEach { pm ->
|
||||||
PendingMediaChip(
|
PendingMediaChip(
|
||||||
pending = pm,
|
pending = pm,
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,19 @@ import app.closer.notifications.ActiveThreadMonitor
|
||||||
import com.google.firebase.auth.FirebaseAuth
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -35,6 +40,7 @@ data class ConversationUiState(
|
||||||
val pendingMedia: List<PendingMedia> = emptyList(),
|
val pendingMedia: List<PendingMedia> = emptyList(),
|
||||||
val canLoadMore: Boolean = false,
|
val canLoadMore: Boolean = false,
|
||||||
val partnerReadAt: Long = 0L,
|
val partnerReadAt: Long = 0L,
|
||||||
|
val partnerTyping: Boolean = false,
|
||||||
val messageInput: String = "",
|
val messageInput: String = "",
|
||||||
val isLoading: Boolean = true
|
val isLoading: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
@ -75,6 +81,7 @@ class ConversationViewModel @Inject constructor(
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
activeThreadMonitor.leave(conversationId)
|
activeThreadMonitor.leave(conversationId)
|
||||||
|
stopTyping() // fire-and-forget; clears our typing flag when leaving
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun load() {
|
private fun load() {
|
||||||
|
|
@ -115,8 +122,41 @@ class ConversationViewModel @Inject constructor(
|
||||||
repository.observePartnerReadAt(coupleId, conversationId, currentUserId)
|
repository.observePartnerReadAt(coupleId, conversationId, currentUserId)
|
||||||
.collect { readAt -> _uiState.update { it.copy(partnerReadAt = readAt) } }
|
.collect { readAt -> _uiState.update { it.copy(partnerReadAt = readAt) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partner typing: combine the typing timestamp with a ticker so it auto-hides after ~6s
|
||||||
|
// even if the partner's "stop" write never arrives (crash safety net).
|
||||||
|
launch {
|
||||||
|
combine(
|
||||||
|
repository.observePartnerTyping(coupleId, conversationId, currentUserId),
|
||||||
|
flow { while (true) { emit(Unit); delay(2000) } }
|
||||||
|
) { typingAt, _ -> typingAt > 0 && System.currentTimeMillis() - typingAt < TYPING_TTL_MS }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { typing -> _uiState.update { it.copy(partnerTyping = typing) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var typingStopJob: Job? = null
|
||||||
|
private var lastTypingPingAt = 0L
|
||||||
|
|
||||||
|
private fun onUserTyping() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastTypingPingAt > 1500) {
|
||||||
|
lastTypingPingAt = now
|
||||||
|
repository.setTyping(coupleId, conversationId, currentUserId, true)
|
||||||
|
}
|
||||||
|
typingStopJob?.cancel()
|
||||||
|
typingStopJob = viewModelScope.launch {
|
||||||
|
delay(3000)
|
||||||
|
stopTyping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopTyping() {
|
||||||
|
typingStopJob?.cancel()
|
||||||
|
lastTypingPingAt = 0L
|
||||||
|
repository.setTyping(coupleId, conversationId, currentUserId, false)
|
||||||
|
}
|
||||||
|
|
||||||
/** Grow the live window by another page (triggered when scrolled to the top). */
|
/** Grow the live window by another page (triggered when scrolled to the top). */
|
||||||
fun loadOlderMessages() {
|
fun loadOlderMessages() {
|
||||||
|
|
@ -125,12 +165,14 @@ class ConversationViewModel @Inject constructor(
|
||||||
|
|
||||||
fun updateMessageInput(text: String) {
|
fun updateMessageInput(text: String) {
|
||||||
_uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) }
|
_uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) }
|
||||||
|
if (text.isNotBlank()) onUserTyping()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage() {
|
fun sendMessage() {
|
||||||
val text = _uiState.value.messageInput.trim()
|
val text = _uiState.value.messageInput.trim()
|
||||||
if (text.isBlank() || currentUserId.isEmpty()) return
|
if (text.isBlank() || currentUserId.isEmpty()) return
|
||||||
_uiState.update { it.copy(messageInput = "") }
|
_uiState.update { it.copy(messageInput = "") }
|
||||||
|
stopTyping()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { repository.sendMessage(coupleId, conversationId, currentUserId, text) }
|
runCatching { repository.sendMessage(coupleId, conversationId, currentUserId, text) }
|
||||||
.onFailure { _events.tryEmit("Couldn't send message. Check your connection.") }
|
.onFailure { _events.tryEmit("Couldn't send message. Check your connection.") }
|
||||||
|
|
@ -209,5 +251,6 @@ class ConversationViewModel @Inject constructor(
|
||||||
companion object {
|
companion object {
|
||||||
const val MAX_MESSAGE_LENGTH = 2000
|
const val MAX_MESSAGE_LENGTH = 2000
|
||||||
const val PAGE_SIZE = 50
|
const val PAGE_SIZE = 50
|
||||||
|
private const val TYPING_TTL_MS = 6000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -422,7 +422,7 @@ service cloud.firestore {
|
||||||
// last-message preview must be ciphertext (encrypted on-device before write).
|
// last-message preview must be ciphertext (encrypted on-device before write).
|
||||||
allow write: if isCouplesMember(coupleId)
|
allow write: if isCouplesMember(coupleId)
|
||||||
&& request.resource.data.keys().hasOnly(
|
&& request.resource.data.keys().hasOnly(
|
||||||
['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads'])
|
['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads', 'typing'])
|
||||||
&& (!('lastMessagePreview' in request.resource.data)
|
&& (!('lastMessagePreview' in request.resource.data)
|
||||||
|| isCiphertext(request.resource.data.lastMessagePreview));
|
|| isCiphertext(request.resource.data.lastMessagePreview));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue