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). */
|
||||
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
|
||||
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> =
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue