diff --git a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt index f1b14f10..df4b0131 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt @@ -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 = 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).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 = callbackFlow { val listener = conversationsRef(coupleId).document(conversationId) diff --git a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt index 97244047..bce7fc67 100644 --- a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt @@ -31,6 +31,12 @@ class ConversationRepositoryImpl @Inject constructor( override fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow = 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 = + dataSource.observePartnerTyping(coupleId, conversationId, currentUserId) + override suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) = dataSource.sendMessage(coupleId, conversationId, userId, text) diff --git a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt index 7e39f26f..89c79741 100644 --- a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt @@ -11,6 +11,8 @@ interface ConversationRepository { suspend fun markRead(coupleId: String, conversationId: String, userId: String) fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow> fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow + fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean) + fun observePartnerTyping(coupleId: String, conversationId: String, currentUserId: String): Flow 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) diff --git a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt index ec2cd1b0..75baff0f 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt index e31799ac..f7b7dad0 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt @@ -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 = 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 } } diff --git a/firestore.rules b/firestore.rules index 0c6f41af..c260c96c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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));