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:
parent
3544b7a84a
commit
cfea8f0d41
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue