Skip to content

Commit 2d6d802

Browse files
committed
fix(chat): prevent offline message send from hanging and creating empty bubbles
Restructure SharedFlow event handlers to use fire-and-forget pattern (viewModelScope.launch) for network calls, preventing backpressure from blocking all event delivery when any gRPC call hangs offline. Capture text and clear input synchronously on Main thread via flowOn before launching the send coroutine. Add 30s gRPC deadline to sendMessage so pending bubbles transition to FAILED status instead of hanging indefinitely. Add tap-to-retry flow for failed messages with in-place status update (FAILED→SENDING) to avoid UI jitter. Guard against blank message sends at both VM and coordinator layers. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 68dfd3b commit 2d6d802

10 files changed

Lines changed: 132 additions & 38 deletions

File tree

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,10 @@
785785

786786
<string name="label_chatReceipt_delivered">Delivered</string>
787787
<string name="label_chatReceipt_read">Read</string>
788+
<string name="label_chatReceipt_notSent">Not Sent</string>
788789
<string name="label_chatReceipt_yesterday">Yesterday</string>
790+
<string name="title_messageNotSent">Message Not Sent</string>
791+
<string name="description_messageNotSent">Your message could not be delivered. Would you like to try again?</string>
789792
<string name="label_chatSeparator_today">Today</string>
790793

791794
<string name="title_sendFeatureIntro">Send Money To Your Friends</string>

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt

Lines changed: 61 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ import kotlinx.coroutines.flow.mapNotNull
6565
import kotlinx.coroutines.flow.onEach
6666
import kotlinx.coroutines.flow.stateIn
6767
import kotlinx.coroutines.flow.transformLatest
68+
import kotlinx.coroutines.flow.flowOn
69+
import kotlinx.coroutines.flow.mapNotNull
6870
import kotlinx.coroutines.launch
6971
import javax.inject.Inject
7072
import kotlin.math.min
@@ -135,6 +137,7 @@ internal class ChatViewModel @Inject constructor(
135137
data object ResolveFailed : Event
136138

137139
data object SendMessage : Event
140+
data class RetryMessage(val pendingId: String?, val content: MessageContent) : Event
138141

139142
data class NavigateToAmountEntry(val contact: DeviceContact) : Event
140143
data object PresentDepositOptions : Event
@@ -269,7 +272,7 @@ internal class ChatViewModel @Inject constructor(
269272
if (chatId != null) {
270273
dispatchEvent(Event.ChatFound(chatId))
271274
chatCoordinator.setActiveChatId(chatId)
272-
chatCoordinator.loadMessages(chatId)
275+
viewModelScope.launch { chatCoordinator.loadMessages(chatId) }
273276
chatCoordinator.dismissNotifications(chatId)
274277
}
275278

@@ -293,26 +296,24 @@ internal class ChatViewModel @Inject constructor(
293296
// Resolve owner authority for sending cash
294297
eventFlow
295298
.filterIsInstance<Event.OnContactFound>()
296-
.map { it.contact }
297-
.map {
298-
contactCoordinator.resolve(it.e164)
299-
}.onResult(
300-
onSuccess = {
301-
dispatchEvent(Event.ResolveCompleted(it))
302-
},
303-
onError = {
304-
dispatchEvent(Event.ResolveFailed)
299+
.onEach { event ->
300+
viewModelScope.launch {
301+
contactCoordinator.resolve(event.contact.e164)
302+
.onSuccess { dispatchEvent(Event.ResolveCompleted(it)) }
303+
.onFailure { dispatchEvent(Event.ResolveFailed) }
305304
}
306-
).launchIn(viewModelScope)
305+
}.launchIn(viewModelScope)
307306

308307
// Re-resolve the contact from the device (e.g. after adding via system contacts)
309308
eventFlow
310309
.filterIsInstance<Event.RefreshContact>()
311310
.mapNotNull { stateFlow.value.chattingWith?.e164 }
312311
.onEach { e164 ->
313-
val refreshed = contactCoordinator.refreshContact(e164)
314-
if (refreshed != null) {
315-
dispatchEvent(Event.OnContactFound(refreshed))
312+
viewModelScope.launch {
313+
val refreshed = contactCoordinator.refreshContact(e164)
314+
if (refreshed != null) {
315+
dispatchEvent(Event.OnContactFound(refreshed))
316+
}
316317
}
317318
}
318319
.launchIn(viewModelScope)
@@ -341,7 +342,7 @@ internal class ChatViewModel @Inject constructor(
341342
.filterIsInstance<Event.AdvanceReadPointer>()
342343
.onEach { event ->
343344
val chatId = stateFlow.value.chatId ?: return@onEach
344-
chatCoordinator.advanceReadPointer(chatId, event.messageId)
345+
viewModelScope.launch { chatCoordinator.advanceReadPointer(chatId, event.messageId) }
345346
}
346347
.launchIn(viewModelScope)
347348
}
@@ -418,32 +419,31 @@ internal class ChatViewModel @Inject constructor(
418419
}
419420
.launchIn(viewModelScope)
420421

421-
// Notify server of typing state changes
422+
// Notify server of typing state changes (fire-and-forget to avoid
423+
// blocking SharedFlow emission when the gRPC call hangs offline)
422424
eventFlow.filterIsInstance<Event.OnSelfTypingStarted>()
423425
.mapNotNull { stateFlow.value.chatId }
424-
.onEach { chatCoordinator.notifyTyping(it, TypingState.STARTED_TYPING) }
426+
.onEach { viewModelScope.launch { chatCoordinator.notifyTyping(it, TypingState.STARTED_TYPING) } }
425427
.launchIn(viewModelScope)
426428

427429
eventFlow.filterIsInstance<Event.OnSelfTypingStill>()
428430
.mapNotNull { stateFlow.value.chatId }
429-
.onEach { chatCoordinator.notifyTyping(it, TypingState.STILL_TYPING) }
431+
.onEach { viewModelScope.launch { chatCoordinator.notifyTyping(it, TypingState.STILL_TYPING) } }
430432
.launchIn(viewModelScope)
431433

432434
eventFlow.filterIsInstance<Event.OnSelfTypingStopped>()
433435
.mapNotNull { stateFlow.value.chatId }
434-
.onEach { chatCoordinator.notifyTyping(it, TypingState.STOPPED_TYPING) }
436+
.onEach { viewModelScope.launch { chatCoordinator.notifyTyping(it, TypingState.STOPPED_TYPING) } }
435437
.launchIn(viewModelScope)
436438

437439
// Observe typing indicators once chatId is known
438-
stateFlow.map { it.chatId }
439-
.filterNotNull()
440+
stateFlow.mapNotNull { it.chatId }
440441
.flatMapLatest { chatId -> chatCoordinator.observeTypingIndicators(chatId) }
441442
.onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) }
442443
.launchIn(viewModelScope)
443444

444445
// Enable typing notifications once a payment has been exchanged
445-
stateFlow.map { it.chatId }
446-
.filterNotNull()
446+
stateFlow.mapNotNull { it.chatId }
447447
.distinctUntilChanged()
448448
.flatMapLatest { chatId ->
449449
chatCoordinator.observeMessages(chatId)
@@ -459,20 +459,45 @@ internal class ChatViewModel @Inject constructor(
459459
private fun initSendHandlers() {
460460
// Send text message
461461
eventFlow.filterIsInstance<Event.SendMessage>()
462-
.map { stateFlow.value.chatInputState }
463-
.mapNotNull { textInput ->
464-
val textToSend = textInput.text.toString()
465-
val chatId = stateFlow.value.chatId ?: return@mapNotNull null
462+
.onEach {
463+
val textToSend = stateFlow.value.chatInputState.text.toString()
464+
val chatId = stateFlow.value.chatId ?: return@onEach
465+
if (textToSend.isBlank()) return@onEach
466+
466467
stateFlow.value.chatInputState.clearText()
467-
chatCoordinator.sendMessage(chatId, textToSend)
468-
}.onResult(
469-
onSuccess = {
470-
trace("message sent successfully")
471-
},
472-
onError = {
473-
trace("message failed to send - ${it.localizedMessage}")
468+
469+
viewModelScope.launch {
470+
chatCoordinator.sendMessage(chatId, textToSend)
471+
.onSuccess { trace("message sent successfully") }
472+
.onFailure { trace("message failed to send - ${it.localizedMessage}") }
474473
}
475-
)
474+
}
475+
.flowOn(Dispatchers.Main.immediate)
476+
.launchIn(viewModelScope)
477+
478+
// Retry a failed message
479+
eventFlow.filterIsInstance<Event.RetryMessage>()
480+
.onEach { (pendingClientIdHex, content) ->
481+
val chatId = stateFlow.value.chatId ?: return@onEach
482+
val pendingId = pendingClientIdHex ?: return@onEach
483+
484+
BottomBarManager.showInfo(
485+
title = resources.getString(R.string.title_messageNotSent),
486+
message = resources.getString(R.string.description_messageNotSent),
487+
actions = listOf(
488+
BottomBarAction(
489+
text = resources.getString(R.string.action_retry),
490+
) {
491+
viewModelScope.launch {
492+
chatCoordinator.retryMessage(chatId, pendingId, listOf(content))
493+
.onSuccess { trace("retry message sent successfully") }
494+
.onFailure { trace("retry message failed - ${it.localizedMessage}") }
495+
}
496+
},
497+
),
498+
showCancel = true,
499+
)
500+
}
476501
.launchIn(viewModelScope)
477502

478503
// confirmation of amount and checks
@@ -687,6 +712,7 @@ internal class ChatViewModel @Inject constructor(
687712
state.copy(resolveState = ResolveState.Failed)
688713
}
689714
is Event.SendMessage -> { state -> state }
715+
is Event.RetryMessage -> { state -> state }
690716
is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
691717
is Event.PresentDepositOptions -> { state -> state }
692718
is Event.OpenScreen -> { state -> state }

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
7474
val navigator = LocalCodeNavigator.current
7575

7676
val hazeState = rememberHazeState()
77+
val keyboard = rememberKeyboardController()
7778

7879
ChatInputScaffold(
7980
topBar = { ChatTopBar(navigator, state.chattingWith) },
@@ -101,6 +102,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
101102
onAdvanceReadPointer = { messageId ->
102103
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(messageId))
103104
},
105+
onRetryMessage = { bubble ->
106+
keyboard.hideIfVisible {
107+
viewModel.dispatchEvent(
108+
ChatViewModel.Event.RetryMessage(bubble.pendingClientIdHex, bubble.content)
109+
)
110+
}
111+
},
104112
onRefreshContact = {
105113
viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
106114
},

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ internal fun MessageList(
6161
separatorConfig: SeparatorConfig,
6262
otherReadPointer: MessagePointer? = null,
6363
onAdvanceReadPointer: ((Long) -> Unit)? = null,
64+
onRetryMessage: ((ChatListItem.ContentBubble) -> Unit)? = null,
6465
onRefreshContact: () -> Unit = {},
6566
) {
6667
val keyboard = rememberKeyboardController()
@@ -215,6 +216,9 @@ internal fun MessageList(
215216
status = effectiveStatus,
216217
readPointer = otherReadPointer,
217218
animateEntrance = wasSending,
219+
onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) {
220+
{ onRetryMessage?.invoke(item) }
221+
} else null,
218222
)
219223
}
220224
}
@@ -401,6 +405,7 @@ private fun shouldShowReceiptLabel(
401405
): Boolean {
402406
if (!item.isFromSelf) return false
403407
val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false
408+
if (status == ReceiptStatus.FAILED) return true
404409
if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false
405410

406411
// index - 1 is the item below (newer) in reverseLayout

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.compose.animation.scaleIn
1212
import androidx.compose.animation.scaleOut
1313
import androidx.compose.animation.shrinkVertically
1414
import androidx.compose.animation.togetherWith
15+
import androidx.compose.foundation.clickable
1516
import androidx.compose.foundation.layout.Arrangement
1617
import androidx.compose.foundation.layout.Box
1718
import androidx.compose.foundation.layout.Column
@@ -53,6 +54,7 @@ internal fun ReceiptLabel(
5354
readPointer: MessagePointer?,
5455
modifier: Modifier = Modifier,
5556
animateEntrance: Boolean = false,
57+
onRetryFailed: (() -> Unit)? = null,
5658
) {
5759
// iOS: "Delivered" hides instantly on send, then appears after 700ms with
5860
// scale(0.95)+opacity spring (duration: 0.4, bounce: 0.12).
@@ -103,21 +105,32 @@ internal fun ReceiptLabel(
103105
val text = when (animatedStatus) {
104106
ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered)
105107
ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read)
108+
ReceiptStatus.FAILED -> stringResource(R.string.label_chatReceipt_notSent)
106109
else -> return@AnimatedContent
107110
}
108111

109112
val readAtFormatted =
110113
readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: ""
111114

112115
Row(
113-
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) {
116+
modifier = if (animatedStatus == ReceiptStatus.FAILED && onRetryFailed != null) {
117+
Modifier.clickable(onClick = onRetryFailed)
118+
} else {
119+
Modifier
120+
},
121+
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
122+
) {
114123
Text(
115124
modifier = Modifier.alignByBaseline(),
116125
text = text,
117126
style = CodeTheme.typography.caption.copy(
118127
fontWeight = FontWeight.Bold,
119128
),
120-
color = CodeTheme.colors.textSecondary,
129+
color = if (animatedStatus == ReceiptStatus.FAILED) {
130+
CodeTheme.colors.error
131+
} else {
132+
CodeTheme.colors.textSecondary
133+
},
121134
)
122135

123136
if (animatedStatus == ReceiptStatus.READ && readAtFormatted.isNotEmpty()) {

apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.flipcash.app.core.contacts.DeviceContact
88
import com.flipcash.services.models.chat.ChatId
99
import com.flipcash.services.models.chat.ChatMember
1010
import com.flipcash.services.models.chat.ChatMessage
11+
import com.flipcash.services.models.chat.MessageContent
1112
import com.flipcash.services.models.chat.MessagePointer
1213
import com.flipcash.services.models.chat.ReactionSummary
1314
import com.flipcash.services.models.chat.TypingState
@@ -87,6 +88,9 @@ interface MessagingOperations {
8788
/** Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. */
8889
suspend fun sendMessage(chatId: ChatId, content: String): Result<ChatMessage>
8990

91+
/** Retries a failed pending message: resets to SENDING and re-sends to the server. */
92+
suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List<MessageContent>): Result<ChatMessage>
93+
9094
/** Advances the local and remote read pointer for [chatId] to [messageId]. */
9195
suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result<Unit>
9296

apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ class MessagingDelegate @Inject constructor(
141141
}
142142

143143
override suspend fun sendMessage(chatId: ChatId, content: String): Result<ChatMessage> {
144+
if (content.isBlank()) {
145+
return Result.failure(IllegalArgumentException("Cannot send a blank message"))
146+
}
147+
144148
val senderId = userManager.accountId
145149
?: return Result.failure(IllegalStateException("Cannot send message without an account"))
146150

@@ -164,6 +168,22 @@ class MessagingDelegate @Inject constructor(
164168
}
165169
}
166170

171+
override suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List<MessageContent>): Result<ChatMessage> {
172+
val clientMessageId = messageDataSource.retryPending(chatId, pendingClientIdHex)
173+
174+
return messagingController.sendMessage(chatId, content, clientMessageId)
175+
.onSuccess { serverMessage ->
176+
messageDataSource.confirmPending(chatId, clientMessageId, serverMessage)
177+
advanceReadPointer(chatId, serverMessage.messageId)
178+
179+
metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId)
180+
metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds())
181+
}
182+
.onFailure {
183+
messageDataSource.failPending(chatId, clientMessageId)
184+
}
185+
}
186+
167187
override suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result<Unit> {
168188
val selfId = userManager.accountId ?: return Result.failure(
169189
IllegalStateException("No account")

apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,16 @@ class ChatMessageDataSource @Inject constructor(
162162
)
163163
}
164164

165+
suspend fun retryPending(chatId: ChatId, pendingClientIdHex: String): ClientMessageId {
166+
val clientMessageId = mapper.clientMessageIdFromHex(pendingClientIdHex)
167+
db?.chatMessageDao()?.updatePendingStatus(
168+
mapper.chatIdHex(chatId),
169+
pendingClientIdHex,
170+
MessageStatus.SENDING,
171+
)
172+
return clientMessageId
173+
}
174+
165175
fun toChatMessage(entity: ChatMessageEntity): ChatMessage {
166176
val message = mapper.toMessage(entity)
167177
val selfId = userManager.accountId

apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ class ChatEntityMapper @Inject constructor() {
169169
fun clientMessageIdHex(clientMessageId: ClientMessageId): String =
170170
clientMessageId.bytes.toList().hexEncodedString()
171171

172+
fun clientMessageIdFromHex(hex: String): ClientMessageId =
173+
ClientMessageId(hex.hexToByteArray())
174+
172175
fun pointerToJson(pointer: MessagePointer): String {
173176
return kotlinx.serialization.json.Json.encodeToString(
174177
listOf(pointer.toSerialized())

services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import dev.bmcreations.protovalidate.orThrow
2929
import io.grpc.ManagedChannel
3030
import kotlinx.coroutines.Dispatchers
3131
import kotlinx.coroutines.withContext
32+
import java.util.concurrent.TimeUnit
3233
import javax.inject.Inject
3334
import javax.inject.Singleton
3435

@@ -114,7 +115,8 @@ internal class ChatMessagingApi @Inject constructor(
114115
request.validate().orThrow()
115116

116117
return withContext(Dispatchers.IO) {
117-
api.sendMessage(request)
118+
api.withDeadlineAfter(30, TimeUnit.SECONDS)
119+
.sendMessage(request)
118120
}
119121
}
120122

0 commit comments

Comments
 (0)