diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 92787dc3e..3331722f7 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -785,7 +785,10 @@
Delivered
Read
+ Not Sent
Yesterday
+ Message Not Sent
+ Your message could not be delivered. Would you like to try again?
Today
Send Money To Your Friends
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
index 0e88ad4bc..1476998d3 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
@@ -65,6 +65,8 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.math.min
@@ -135,6 +137,7 @@ internal class ChatViewModel @Inject constructor(
data object ResolveFailed : Event
data object SendMessage : Event
+ data class RetryMessage(val pendingId: String?, val content: MessageContent) : Event
data class NavigateToAmountEntry(val contact: DeviceContact) : Event
data object PresentDepositOptions : Event
@@ -269,7 +272,7 @@ internal class ChatViewModel @Inject constructor(
if (chatId != null) {
dispatchEvent(Event.ChatFound(chatId))
chatCoordinator.setActiveChatId(chatId)
- chatCoordinator.loadMessages(chatId)
+ viewModelScope.launch { chatCoordinator.loadMessages(chatId) }
chatCoordinator.dismissNotifications(chatId)
}
@@ -293,26 +296,24 @@ internal class ChatViewModel @Inject constructor(
// Resolve owner authority for sending cash
eventFlow
.filterIsInstance()
- .map { it.contact }
- .map {
- contactCoordinator.resolve(it.e164)
- }.onResult(
- onSuccess = {
- dispatchEvent(Event.ResolveCompleted(it))
- },
- onError = {
- dispatchEvent(Event.ResolveFailed)
+ .onEach { event ->
+ viewModelScope.launch {
+ contactCoordinator.resolve(event.contact.e164)
+ .onSuccess { dispatchEvent(Event.ResolveCompleted(it)) }
+ .onFailure { dispatchEvent(Event.ResolveFailed) }
}
- ).launchIn(viewModelScope)
+ }.launchIn(viewModelScope)
// Re-resolve the contact from the device (e.g. after adding via system contacts)
eventFlow
.filterIsInstance()
.mapNotNull { stateFlow.value.chattingWith?.e164 }
.onEach { e164 ->
- val refreshed = contactCoordinator.refreshContact(e164)
- if (refreshed != null) {
- dispatchEvent(Event.OnContactFound(refreshed))
+ viewModelScope.launch {
+ val refreshed = contactCoordinator.refreshContact(e164)
+ if (refreshed != null) {
+ dispatchEvent(Event.OnContactFound(refreshed))
+ }
}
}
.launchIn(viewModelScope)
@@ -341,7 +342,7 @@ internal class ChatViewModel @Inject constructor(
.filterIsInstance()
.onEach { event ->
val chatId = stateFlow.value.chatId ?: return@onEach
- chatCoordinator.advanceReadPointer(chatId, event.messageId)
+ viewModelScope.launch { chatCoordinator.advanceReadPointer(chatId, event.messageId) }
}
.launchIn(viewModelScope)
}
@@ -418,32 +419,31 @@ internal class ChatViewModel @Inject constructor(
}
.launchIn(viewModelScope)
- // Notify server of typing state changes
+ // Notify server of typing state changes (fire-and-forget to avoid
+ // blocking SharedFlow emission when the gRPC call hangs offline)
eventFlow.filterIsInstance()
.mapNotNull { stateFlow.value.chatId }
- .onEach { chatCoordinator.notifyTyping(it, TypingState.STARTED_TYPING) }
+ .onEach { viewModelScope.launch { chatCoordinator.notifyTyping(it, TypingState.STARTED_TYPING) } }
.launchIn(viewModelScope)
eventFlow.filterIsInstance()
.mapNotNull { stateFlow.value.chatId }
- .onEach { chatCoordinator.notifyTyping(it, TypingState.STILL_TYPING) }
+ .onEach { viewModelScope.launch { chatCoordinator.notifyTyping(it, TypingState.STILL_TYPING) } }
.launchIn(viewModelScope)
eventFlow.filterIsInstance()
.mapNotNull { stateFlow.value.chatId }
- .onEach { chatCoordinator.notifyTyping(it, TypingState.STOPPED_TYPING) }
+ .onEach { viewModelScope.launch { chatCoordinator.notifyTyping(it, TypingState.STOPPED_TYPING) } }
.launchIn(viewModelScope)
// Observe typing indicators once chatId is known
- stateFlow.map { it.chatId }
- .filterNotNull()
+ stateFlow.mapNotNull { it.chatId }
.flatMapLatest { chatId -> chatCoordinator.observeTypingIndicators(chatId) }
.onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) }
.launchIn(viewModelScope)
// Enable typing notifications once a payment has been exchanged
- stateFlow.map { it.chatId }
- .filterNotNull()
+ stateFlow.mapNotNull { it.chatId }
.distinctUntilChanged()
.flatMapLatest { chatId ->
chatCoordinator.observeMessages(chatId)
@@ -459,20 +459,45 @@ internal class ChatViewModel @Inject constructor(
private fun initSendHandlers() {
// Send text message
eventFlow.filterIsInstance()
- .map { stateFlow.value.chatInputState }
- .mapNotNull { textInput ->
- val textToSend = textInput.text.toString()
- val chatId = stateFlow.value.chatId ?: return@mapNotNull null
+ .onEach {
+ val textToSend = stateFlow.value.chatInputState.text.toString()
+ val chatId = stateFlow.value.chatId ?: return@onEach
+ if (textToSend.isBlank()) return@onEach
+
stateFlow.value.chatInputState.clearText()
- chatCoordinator.sendMessage(chatId, textToSend)
- }.onResult(
- onSuccess = {
- trace("message sent successfully")
- },
- onError = {
- trace("message failed to send - ${it.localizedMessage}")
+
+ viewModelScope.launch {
+ chatCoordinator.sendMessage(chatId, textToSend)
+ .onSuccess { trace("message sent successfully") }
+ .onFailure { trace("message failed to send - ${it.localizedMessage}") }
}
- )
+ }
+ .flowOn(Dispatchers.Main.immediate)
+ .launchIn(viewModelScope)
+
+ // Retry a failed message
+ eventFlow.filterIsInstance()
+ .onEach { (pendingClientIdHex, content) ->
+ val chatId = stateFlow.value.chatId ?: return@onEach
+ val pendingId = pendingClientIdHex ?: return@onEach
+
+ BottomBarManager.showInfo(
+ title = resources.getString(R.string.title_messageNotSent),
+ message = resources.getString(R.string.description_messageNotSent),
+ actions = listOf(
+ BottomBarAction(
+ text = resources.getString(R.string.action_retry),
+ ) {
+ viewModelScope.launch {
+ chatCoordinator.retryMessage(chatId, pendingId, listOf(content))
+ .onSuccess { trace("retry message sent successfully") }
+ .onFailure { trace("retry message failed - ${it.localizedMessage}") }
+ }
+ },
+ ),
+ showCancel = true,
+ )
+ }
.launchIn(viewModelScope)
// confirmation of amount and checks
@@ -687,6 +712,7 @@ internal class ChatViewModel @Inject constructor(
state.copy(resolveState = ResolveState.Failed)
}
is Event.SendMessage -> { state -> state }
+ is Event.RetryMessage -> { state -> state }
is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
is Event.PresentDepositOptions -> { state -> state }
is Event.OpenScreen -> { state -> state }
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
index aa300dc33..0829eb398 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
@@ -74,6 +74,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
val navigator = LocalCodeNavigator.current
val hazeState = rememberHazeState()
+ val keyboard = rememberKeyboardController()
ChatInputScaffold(
topBar = { ChatTopBar(navigator, state.chattingWith) },
@@ -101,6 +102,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
onAdvanceReadPointer = { messageId ->
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(messageId))
},
+ onRetryMessage = { bubble ->
+ keyboard.hideIfVisible {
+ viewModel.dispatchEvent(
+ ChatViewModel.Event.RetryMessage(bubble.pendingClientIdHex, bubble.content)
+ )
+ }
+ },
onRefreshContact = {
viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
},
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
index 5a419e901..5cf28c1bb 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt
@@ -61,6 +61,7 @@ internal fun MessageList(
separatorConfig: SeparatorConfig,
otherReadPointer: MessagePointer? = null,
onAdvanceReadPointer: ((Long) -> Unit)? = null,
+ onRetryMessage: ((ChatListItem.ContentBubble) -> Unit)? = null,
onRefreshContact: () -> Unit = {},
) {
val keyboard = rememberKeyboardController()
@@ -215,6 +216,9 @@ internal fun MessageList(
status = effectiveStatus,
readPointer = otherReadPointer,
animateEntrance = wasSending,
+ onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) {
+ { onRetryMessage?.invoke(item) }
+ } else null,
)
}
}
@@ -401,6 +405,7 @@ private fun shouldShowReceiptLabel(
): Boolean {
if (!item.isFromSelf) return false
val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false
+ if (status == ReceiptStatus.FAILED) return true
if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false
// index - 1 is the item below (newer) in reverseLayout
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt
index 5e1ec78b4..456ddd1a1 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt
@@ -12,6 +12,7 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -53,6 +54,7 @@ internal fun ReceiptLabel(
readPointer: MessagePointer?,
modifier: Modifier = Modifier,
animateEntrance: Boolean = false,
+ onRetryFailed: (() -> Unit)? = null,
) {
// iOS: "Delivered" hides instantly on send, then appears after 700ms with
// scale(0.95)+opacity spring (duration: 0.4, bounce: 0.12).
@@ -103,6 +105,7 @@ internal fun ReceiptLabel(
val text = when (animatedStatus) {
ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered)
ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read)
+ ReceiptStatus.FAILED -> stringResource(R.string.label_chatReceipt_notSent)
else -> return@AnimatedContent
}
@@ -110,14 +113,24 @@ internal fun ReceiptLabel(
readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: ""
Row(
- horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) {
+ modifier = if (animatedStatus == ReceiptStatus.FAILED && onRetryFailed != null) {
+ Modifier.clickable(onClick = onRetryFailed)
+ } else {
+ Modifier
+ },
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
+ ) {
Text(
modifier = Modifier.alignByBaseline(),
text = text,
style = CodeTheme.typography.caption.copy(
fontWeight = FontWeight.Bold,
),
- color = CodeTheme.colors.textSecondary,
+ color = if (animatedStatus == ReceiptStatus.FAILED) {
+ CodeTheme.colors.error
+ } else {
+ CodeTheme.colors.textSecondary
+ },
)
if (animatedStatus == ReceiptStatus.READ && readAtFormatted.isNotEmpty()) {
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
index e34eec357..0660397ef 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
@@ -8,6 +8,7 @@ import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMember
import com.flipcash.services.models.chat.ChatMessage
+import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.ReactionSummary
import com.flipcash.services.models.chat.TypingState
@@ -87,6 +88,9 @@ interface MessagingOperations {
/** Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. */
suspend fun sendMessage(chatId: ChatId, content: String): Result
+ /** Retries a failed pending message: resets to SENDING and re-sends to the server. */
+ suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result
+
/** Advances the local and remote read pointer for [chatId] to [messageId]. */
suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt
index 589e639b6..84678139d 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt
@@ -141,6 +141,10 @@ class MessagingDelegate @Inject constructor(
}
override suspend fun sendMessage(chatId: ChatId, content: String): Result {
+ if (content.isBlank()) {
+ return Result.failure(IllegalArgumentException("Cannot send a blank message"))
+ }
+
val senderId = userManager.accountId
?: return Result.failure(IllegalStateException("Cannot send message without an account"))
@@ -164,6 +168,22 @@ class MessagingDelegate @Inject constructor(
}
}
+ override suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result {
+ val clientMessageId = messageDataSource.retryPending(chatId, pendingClientIdHex)
+
+ return messagingController.sendMessage(chatId, content, clientMessageId)
+ .onSuccess { serverMessage ->
+ messageDataSource.confirmPending(chatId, clientMessageId, serverMessage)
+ advanceReadPointer(chatId, serverMessage.messageId)
+
+ metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId)
+ metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds())
+ }
+ .onFailure {
+ messageDataSource.failPending(chatId, clientMessageId)
+ }
+ }
+
override suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result {
val selfId = userManager.accountId ?: return Result.failure(
IllegalStateException("No account")
diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
index ec690e5a1..c8405b0da 100644
--- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
+++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
@@ -162,6 +162,16 @@ class ChatMessageDataSource @Inject constructor(
)
}
+ suspend fun retryPending(chatId: ChatId, pendingClientIdHex: String): ClientMessageId {
+ val clientMessageId = mapper.clientMessageIdFromHex(pendingClientIdHex)
+ db?.chatMessageDao()?.updatePendingStatus(
+ mapper.chatIdHex(chatId),
+ pendingClientIdHex,
+ MessageStatus.SENDING,
+ )
+ return clientMessageId
+ }
+
fun toChatMessage(entity: ChatMessageEntity): ChatMessage {
val message = mapper.toMessage(entity)
val selfId = userManager.accountId
diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt
index 079ab7d59..f87a62b9d 100644
--- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt
+++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt
@@ -169,6 +169,9 @@ class ChatEntityMapper @Inject constructor() {
fun clientMessageIdHex(clientMessageId: ClientMessageId): String =
clientMessageId.bytes.toList().hexEncodedString()
+ fun clientMessageIdFromHex(hex: String): ClientMessageId =
+ ClientMessageId(hex.hexToByteArray())
+
fun pointerToJson(pointer: MessagePointer): String {
return kotlinx.serialization.json.Json.encodeToString(
listOf(pointer.toSerialized())
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt
index 35987db50..684d51224 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt
@@ -29,6 +29,7 @@ import dev.bmcreations.protovalidate.orThrow
import io.grpc.ManagedChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -114,7 +115,8 @@ internal class ChatMessagingApi @Inject constructor(
request.validate().orThrow()
return withContext(Dispatchers.IO) {
- api.sendMessage(request)
+ api.withDeadlineAfter(30, TimeUnit.SECONDS)
+ .sendMessage(request)
}
}