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:
null 2026-06-24 18:47:39 -05:00
parent 5b9596e042
commit f29d4699ca
6 changed files with 81 additions and 1 deletions

View File

@ -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). */
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
val listener = conversationsRef(coupleId).document(conversationId)

View File

@ -31,6 +31,12 @@ class ConversationRepositoryImpl @Inject constructor(
override fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> =
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) =
dataSource.sendMessage(coupleId, conversationId, userId, text)

View File

@ -11,6 +11,8 @@ interface ConversationRepository {
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>
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 sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long)

View File

@ -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 ->
PendingMediaChip(
pending = pm,

View File

@ -13,14 +13,19 @@ import app.closer.notifications.ActiveThreadMonitor
import com.google.firebase.auth.FirebaseAuth
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
@ -35,6 +40,7 @@ data class ConversationUiState(
val pendingMedia: List<PendingMedia> = emptyList(),
val canLoadMore: Boolean = false,
val partnerReadAt: Long = 0L,
val partnerTyping: Boolean = false,
val messageInput: String = "",
val isLoading: Boolean = true
)
@ -75,6 +81,7 @@ class ConversationViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
activeThreadMonitor.leave(conversationId)
stopTyping() // fire-and-forget; clears our typing flag when leaving
}
private fun load() {
@ -115,9 +122,42 @@ class ConversationViewModel @Inject constructor(
repository.observePartnerReadAt(coupleId, conversationId, currentUserId)
.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). */
fun loadOlderMessages() {
if (_uiState.value.canLoadMore) messageLimit.update { it + PAGE_SIZE }
@ -125,12 +165,14 @@ class ConversationViewModel @Inject constructor(
fun updateMessageInput(text: String) {
_uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) }
if (text.isNotBlank()) onUserTyping()
}
fun sendMessage() {
val text = _uiState.value.messageInput.trim()
if (text.isBlank() || currentUserId.isEmpty()) return
_uiState.update { it.copy(messageInput = "") }
stopTyping()
viewModelScope.launch {
runCatching { repository.sendMessage(coupleId, conversationId, currentUserId, text) }
.onFailure { _events.tryEmit("Couldn't send message. Check your connection.") }
@ -209,5 +251,6 @@ class ConversationViewModel @Inject constructor(
companion object {
const val MAX_MESSAGE_LENGTH = 2000
const val PAGE_SIZE = 50
private const val TYPING_TTL_MS = 6000L
}
}

View File

@ -422,7 +422,7 @@ service cloud.firestore {
// last-message preview must be ciphertext (encrypted on-device before write).
allow write: if isCouplesMember(coupleId)
&& 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)
|| isCiphertext(request.resource.data.lastMessagePreview));