From cfea8f0d416a607586fcbd892824e04d11771bf6 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 18:32:01 -0500 Subject: [PATCH] feat(chat): send/upload feedback + message pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pagination: observeMessages(limit) uses limitToLast(N); a single live window grows by a page when scrolled to the top (keeps just-sent messages in view, no merge needed). - Send feedback: 'Sending photo/voice…' chip above the composer with retry + dismiss on failure, plus a snackbar; media uploads fail fast when offline (connectivity pre-check + 30s Storage retry cap) instead of a stuck spinner. - Auto-scroll to bottom only on new messages when near the bottom (never on load-older). Co-Authored-By: Claude Opus 4.8 --- .../data/remote/FirebaseStorageDataSource.kt | 20 +++- .../remote/FirestoreConversationDataSource.kt | 8 +- .../repository/ConversationRepositoryImpl.kt | 4 +- .../repository/ConversationRepository.kt | 2 +- .../closer/ui/messages/ConversationScreen.kt | 92 ++++++++++++++++++- .../ui/messages/ConversationViewModel.kt | 72 ++++++++++++++- 6 files changed, 187 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt b/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt index b70dce06..aaff027b 100644 --- a/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt @@ -1,11 +1,14 @@ package app.closer.data.remote import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.net.Uri import com.google.firebase.storage.FirebaseStorage import com.google.firebase.storage.StorageMetadata import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.IOException import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume @@ -16,7 +19,17 @@ class FirebaseStorageDataSource @Inject constructor( @ApplicationContext private val context: Context ) { - private val storage = FirebaseStorage.getInstance() + // Cap retries so an offline upload surfaces a failure in seconds instead of the ~2-minute default. + private val storage = FirebaseStorage.getInstance().apply { + maxUploadRetryTimeMillis = 30_000 + maxOperationRetryTimeMillis = 30_000 + } + + private fun isOnline(): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return true + val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } suspend fun uploadProfilePhoto(uid: String, uri: Uri): String = suspendCancellableCoroutine { cont -> @@ -40,6 +53,11 @@ class FirebaseStorageDataSource @Inject constructor( */ suspend fun uploadEncryptedMedia(uid: String, encryptedBytes: ByteArray): String = suspendCancellableCoroutine { cont -> + // Fail fast when clearly offline so the chat shows a retry instead of a stuck spinner. + if (!isOnline()) { + cont.resumeWithException(IOException("No internet connection")) + return@suspendCancellableCoroutine + } val ref = storage.reference.child("users/$uid/chat_media/${java.util.UUID.randomUUID()}") val metadata = StorageMetadata.Builder() .setContentType("application/octet-stream") 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 c7070d2f..626daa88 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt @@ -149,9 +149,15 @@ class FirestoreConversationDataSource @Inject constructor( ).voidAwait() } - fun observeMessages(coupleId: String, conversationId: String): Flow> = callbackFlow { + /** + * Live listener on the newest [limit] messages (ascending for display). Using `limitToLast` + * keeps just-sent messages inside the window regardless of pending server-timestamp ordering; + * "load older" simply grows [limit] (a single live listener, so incoming messages still appear). + */ + fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow> = callbackFlow { val listener = messagesRef(coupleId, conversationId) .orderBy("createdAt", Query.Direction.ASCENDING) + .limitToLast(limit.toLong()) .addSnapshotListener { snap, err -> if (err != null || snap == null) return@addSnapshotListener val aead = encryptionManager.aeadFor(coupleId) 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 02794ac5..3b11e62a 100644 --- a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt @@ -25,8 +25,8 @@ class ConversationRepositoryImpl @Inject constructor( override suspend fun markRead(coupleId: String, conversationId: String, userId: String) = dataSource.markRead(coupleId, conversationId, userId) - override fun observeMessages(coupleId: String, conversationId: String): Flow> = - dataSource.observeMessages(coupleId, conversationId) + override fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow> = + dataSource.observeMessages(coupleId, conversationId, limit) 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 3c1ea87f..446a5e6a 100644 --- a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt @@ -9,7 +9,7 @@ interface ConversationRepository { suspend fun ensureMainConversation(coupleId: String) suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String suspend fun markRead(coupleId: String, conversationId: String, userId: String) - fun observeMessages(coupleId: String, conversationId: String): Flow> + fun observeMessages(coupleId: String, conversationId: String, limit: Int): 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 42175242..25ff42a6 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -14,21 +15,32 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -53,14 +65,37 @@ fun ConversationScreen( ) { val state by viewModel.uiState.collectAsState() val listState = rememberLazyListState() + val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(state.messages.size) { - if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex) + // Surface send failures as a snackbar. + LaunchedEffect(Unit) { + viewModel.events.collect { snackbarHostState.showSnackbar(it) } + } + + // Scroll to the bottom on first load and when a NEW message arrives while near the bottom — + // never when older history is prepended (that would yank the reader away). + var prevLastId by remember { mutableStateOf(null) } + val lastId = state.messages.lastOrNull()?.id + LaunchedEffect(lastId) { + if (lastId == null) return@LaunchedEffect + val lastIndex = state.messages.lastIndex + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + if (prevLastId == null || lastVisible >= lastIndex - 2) { + listState.animateScrollToItem(lastIndex) + } + prevLastId = lastId + } + + // Load older messages when the user scrolls to the very top. + val atTop by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } + LaunchedEffect(atTop, state.canLoadMore) { + if (atTop && state.canLoadMore) viewModel.loadOlderMessages() } Scaffold( containerColor = Color.Transparent, modifier = Modifier.background(closerBackgroundBrush()), + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { @@ -117,6 +152,14 @@ fun ConversationScreen( } } + state.pendingMedia.forEach { pm -> + PendingMediaChip( + pending = pm, + onRetry = { viewModel.retryMedia(pm.id) }, + onDismiss = { viewModel.dismissPending(pm.id) } + ) + } + ChatComposer( value = state.messageInput, onValueChange = viewModel::updateMessageInput, @@ -129,6 +172,51 @@ fun ConversationScreen( } } +@Composable +private fun PendingMediaChip( + pending: PendingMedia, + onRetry: () -> Unit, + onDismiss: () -> Unit +) { + val label = if (pending.type == "voice") "voice note" else "photo" + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (pending.failed) { + Text( + text = "Couldn't send $label", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + IconButton(onClick = onRetry, modifier = Modifier.size(28.dp)) { + Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = MaterialTheme.colorScheme.primary) + } + IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) { + Icon(Icons.Filled.Close, contentDescription = "Dismiss", tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Sending $label…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + @Composable private fun ConversationAvatar(url: String?) { val size = 32.dp 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 582e2893..7c7f97ce 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt @@ -12,21 +12,33 @@ import app.closer.domain.repository.UserRepository import app.closer.notifications.ActiveThreadMonitor import com.google.firebase.auth.FirebaseAuth import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.flatMapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.UUID import javax.inject.Inject +/** A media send in flight (or failed) — shown as a chip above the composer, not in the message list. */ +data class PendingMedia(val id: String, val type: String, val failed: Boolean = false) + data class ConversationUiState( val title: String = "", val partnerPhotoUrl: String? = null, val messages: List = emptyList(), + val pendingMedia: List = emptyList(), + val canLoadMore: Boolean = false, val messageInput: String = "", val isLoading: Boolean = true ) +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class ConversationViewModel @Inject constructor( private val repository: ConversationRepository, @@ -44,6 +56,15 @@ class ConversationViewModel @Inject constructor( private val _uiState = MutableStateFlow(ConversationUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** One-shot, non-sticky messages for the snackbar (send failures). */ + private val _events = MutableSharedFlow(extraBufferCapacity = 4) + val events: SharedFlow = _events.asSharedFlow() + + private val messageLimit = MutableStateFlow(PAGE_SIZE) + + private data class RetryData(val type: String, val bytes: ByteArray, val durationMs: Long) + private val retryStore = mutableMapOf() + init { // Reading this conversation suppresses its bubble + clears its unread. activeThreadMonitor.enter(conversationId) @@ -81,14 +102,21 @@ class ConversationViewModel @Inject constructor( runCatching { repository.markRead(coupleId, conversationId, currentUserId) } launch { - repository.observeMessages(coupleId, conversationId).collect { msgs -> - _uiState.update { it.copy(messages = msgs) } - runCatching { repository.markRead(coupleId, conversationId, currentUserId) } - } + messageLimit + .flatMapLatest { limit -> repository.observeMessages(coupleId, conversationId, limit) } + .collect { msgs -> + _uiState.update { it.copy(messages = msgs, canLoadMore = msgs.size >= messageLimit.value) } + runCatching { repository.markRead(coupleId, conversationId, currentUserId) } + } } } } + /** Grow the live window by another page (triggered when scrolled to the top). */ + fun loadOlderMessages() { + if (_uiState.value.canLoadMore) messageLimit.update { it + PAGE_SIZE } + } + fun updateMessageInput(text: String) { _uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) } } @@ -99,27 +127,63 @@ class ConversationViewModel @Inject constructor( _uiState.update { it.copy(messageInput = "") } viewModelScope.launch { runCatching { repository.sendMessage(coupleId, conversationId, currentUserId, text) } + .onFailure { _events.tryEmit("Couldn't send message. Check your connection.") } } } fun sendImage(imageBytes: ByteArray) { if (currentUserId.isEmpty() || imageBytes.isEmpty()) return + val id = UUID.randomUUID().toString() + retryStore[id] = RetryData("image", imageBytes, 0L) + addPending(PendingMedia(id, "image")) viewModelScope.launch { runCatching { repository.sendImageMessage(coupleId, conversationId, currentUserId, imageBytes) } + .onSuccess { removePending(id); retryStore.remove(id) } + .onFailure { markPendingFailed(id); _events.tryEmit("Couldn't send photo. Tap retry.") } } } fun sendVoice(audioBytes: ByteArray, durationMs: Long) { if (currentUserId.isEmpty() || audioBytes.isEmpty()) return + val id = UUID.randomUUID().toString() + retryStore[id] = RetryData("voice", audioBytes, durationMs) + addPending(PendingMedia(id, "voice")) viewModelScope.launch { runCatching { repository.sendVoiceMessage(coupleId, conversationId, currentUserId, audioBytes, durationMs) } + .onSuccess { removePending(id); retryStore.remove(id) } + .onFailure { markPendingFailed(id); _events.tryEmit("Couldn't send voice note. Tap retry.") } } } + fun retryMedia(id: String) { + val data = retryStore[id] ?: return + removePending(id) + retryStore.remove(id) + when (data.type) { + "image" -> sendImage(data.bytes) + "voice" -> sendVoice(data.bytes, data.durationMs) + } + } + + fun dismissPending(id: String) { + removePending(id) + retryStore.remove(id) + } + suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? = repository.loadDecryptedMedia(coupleId, mediaUrl) + private fun addPending(p: PendingMedia) = + _uiState.update { it.copy(pendingMedia = it.pendingMedia + p) } + + private fun removePending(id: String) = + _uiState.update { it.copy(pendingMedia = it.pendingMedia.filterNot { m -> m.id == id }) } + + private fun markPendingFailed(id: String) = + _uiState.update { it.copy(pendingMedia = it.pendingMedia.map { m -> if (m.id == id) m.copy(failed = true) else m }) } + companion object { const val MAX_MESSAGE_LENGTH = 2000 + const val PAGE_SIZE = 50 } }