feat(chat): send/upload feedback + message pagination

- 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 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-24 18:32:01 -05:00
parent 3544b7a84a
commit cfea8f0d41
6 changed files with 187 additions and 11 deletions

View File

@ -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")

View File

@ -149,9 +149,15 @@ class FirestoreConversationDataSource @Inject constructor(
).voidAwait()
}
fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>> = 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<List<QuestionMessage>> = 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)

View File

@ -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<List<QuestionMessage>> =
dataSource.observeMessages(coupleId, conversationId)
override fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>> =
dataSource.observeMessages(coupleId, conversationId, limit)
override suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) =
dataSource.sendMessage(coupleId, conversationId, userId, text)

View File

@ -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<List<QuestionMessage>>
fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>>
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

@ -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<String?>(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

View File

@ -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<QuestionMessage> = emptyList(),
val pendingMedia: List<PendingMedia> = 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<ConversationUiState> = _uiState.asStateFlow()
/** One-shot, non-sticky messages for the snackbar (send failures). */
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 4)
val events: SharedFlow<String> = _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<String, RetryData>()
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
}
}