From fb44b16b5071d7f3fa34f7465674482ccc648fa3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 09:50:46 -0400 Subject: [PATCH 1/6] fix(chat): store a denied send as its own status --- .../app/messenger/internal/ChatViewModel.kt | 1 + .../persistence/entities/ChatMessageEntity.kt | 5 +++ .../converters/ChatTypeConvertersTest.kt | 7 +++++ .../app/persistence/dao/ChatMessageDaoTest.kt | 31 +++++++++++++++++++ .../sources/ChatMessageDataSource.kt | 9 ++++++ .../sources/mapper/chat/ChatEntityMapper.kt | 1 + .../mapper/chat/ChatEntityMapperTest.kt | 18 +++++++++++ .../services/models/chat/DeliveryStatus.kt | 2 +- 8 files changed, 73 insertions(+), 1 deletion(-) 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 0b667255b2..f46bb77f05 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 @@ -1171,6 +1171,7 @@ internal class ChatViewModel @AssistedInject constructor( when (message.deliveryStatus) { DeliveryStatus.SENDING -> ReceiptStatus.SENDING DeliveryStatus.FAILED -> ReceiptStatus.FAILED + DeliveryStatus.DENIED -> ReceiptStatus.FAILED DeliveryStatus.SENT -> ReceiptStatus.SENT } } else null diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt index eca8b8f4be..acc5de388d 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt @@ -9,6 +9,11 @@ enum class MessageStatus { SENDING, SENT, FAILED, + /** + * The server refused the send (`Denied`). Final: a retry gets the same answer. Stored by name, + * so no migration; a build that predates it reads the row as SENT. + */ + DENIED, } /** How an end-to-end encrypted row stands on this device. Null on a row sent in plaintext. */ diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index 9c4d9296e0..24cc4c0578 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.persistence.converters +import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.services.models.VerifiableContactMethod import kotlin.test.Test import kotlin.test.assertEquals @@ -267,4 +268,10 @@ class ChatTypeConvertersTest { } // endregion + + @Test + fun `DENIED round-trips by name`() { + assertEquals("DENIED", converter.fromMessageStatus(MessageStatus.DENIED)) + assertEquals(MessageStatus.DENIED, converter.toMessageStatus("DENIED")) + } } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 2bb803be88..16904b7b09 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -364,6 +364,37 @@ class ChatMessageDaoTest { assertEquals(MessageStatus.FAILED, dao.getByClientId(CHAT_HEX, CLIENT_HEX)?.status) } + @Test + fun `the sweep leaves a denied send alone`() = runTest { + dao.upsert(pending("refused")) + dao.updatePendingStatus(CHAT_HEX, CLIENT_HEX, MessageStatus.DENIED) + + dao.failInterruptedSends() + + assertEquals(MessageStatus.DENIED, dao.getByClientId(CHAT_HEX, CLIENT_HEX)?.status) + } + + /** A confirm that lands after the denial means the server took it after all. */ + @Test + fun `a late confirm settles a denied send as sent`() = runTest { + dao.upsert(pending("refused")) + dao.updatePendingStatus(CHAT_HEX, CLIENT_HEX, MessageStatus.DENIED) + + dao.confirmPendingMessage(CHAT_HEX, CLIENT_HEX, text(6, "refused")) + + assertEquals(MessageStatus.SENT, dao.getByClientId(CHAT_HEX, CLIENT_HEX)?.status) + } + + @Test + fun `a refresh drops a denied send like a failed one`() = runTest { + dao.upsert(pending("refused")) + dao.updatePendingStatus(CHAT_HEX, CLIENT_HEX, MessageStatus.DENIED) + + dao.deletePendingExcept(CHAT_HEX, CLIENT_HEX, keepMessageId = 99) + + assertNull(dao.getByClientId(CHAT_HEX, CLIENT_HEX)) + } + @Test fun `the sweep leaves a sent message alone`() = runTest { dao.upsert(text(1, "already sent")) 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 f5cd172265..57cac1de08 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 @@ -391,6 +391,15 @@ class ChatMessageDataSource @Inject constructor( ) } + /** Settles a send the server refused. Unlike [failPending], the row offers no retry. */ + suspend fun denyPending(chatId: ChatId, clientMessageId: ClientMessageId) { + db?.chatMessageDao()?.updatePendingStatus( + mapper.chatIdHex(chatId), + mapper.clientMessageIdHex(clientMessageId), + MessageStatus.DENIED, + ) + } + /** * Fails the sends a previous process left in flight. See [ChatMessageDao.failInterruptedSends]. */ 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 1053b1d317..a671ba8df9 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 @@ -206,6 +206,7 @@ class ChatEntityMapper @Inject constructor() { MessageStatus.SENDING -> DeliveryStatus.SENDING MessageStatus.SENT -> DeliveryStatus.SENT MessageStatus.FAILED -> DeliveryStatus.FAILED + MessageStatus.DENIED -> DeliveryStatus.DENIED }, pendingClientIdHex = entity.pendingClientIdHex, encryption = entity.toEncryption(), diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 504bfe72c2..c49a4d999d 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -1,5 +1,7 @@ package com.flipcash.app.persistence.sources.mapper.chat +import com.flipcash.services.models.chat.DeliveryStatus +import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMemberWithProfile import com.flipcash.app.persistence.entities.ChatMetadataEntity @@ -130,6 +132,22 @@ class ChatEntityMapperTest { assertEquals(false, entity(MessageContent.Text("still here")).isDeleted) } + @Test + fun `a denied row maps to a denied delivery`() { + val entity = mapper.toEntity( + CHAT_HEX, + ChatMessage( + messageId = 1, + senderId = listOf(0xAB.toByte()), + content = listOf(MessageContent.Text("refused")), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 1, + ), + ).copy(status = MessageStatus.DENIED) + + assertEquals(DeliveryStatus.DENIED, mapper.toMessage(entity).deliveryStatus) + } + /** * `MessageContent.Encrypted` is a decode result kept verbatim (not decrypted) so it can be * persisted and later re-encoded faithfully -- see that type's doc. A byte-for-byte round diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt index 809be07ac9..f57e20b070 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt @@ -1,3 +1,3 @@ package com.flipcash.services.models.chat -enum class DeliveryStatus { SENDING, SENT, FAILED } +enum class DeliveryStatus { SENDING, SENT, FAILED, DENIED } From b07b9c7c27fc69144439d0a0646e1999c68a89aa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 09:52:22 -0400 Subject: [PATCH 2/6] fix(chat): settle a denied send instead of failing it The server answers Denied when it won't take a message from this user in this chat, so a retry gets the same answer. Text and photo sends now mark the row DENIED instead of FAILED, and the photo path drops its stored blob as it already does for EncryptionNotAllowed. --- .../chat/internal/delegates/MediaSendDelegate.kt | 15 +++++++++++---- .../chat/internal/delegates/MessagingDelegate.kt | 12 +++++++++--- .../shared/chat/MessagingSendEncryptionTest.kt | 14 ++++++++++++++ .../shared/chat/media/MediaSendDelegateTest.kt | 14 ++++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt index 95b2d3f9b1..2a1dedf233 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt @@ -333,9 +333,11 @@ class MediaSendDelegate internal constructor( } } .onFailure { cause -> - // A chat that stopped taking ciphertext won't take this blob on a retry either. - val terminal = cause is SendMessageError.EncryptionNotAllowed - fail(chatId, clientMessageId, hex, retryable = !terminal, discard = record.takeIf { terminal }) + // A chat that stopped taking ciphertext won't take this blob on a retry either, and + // a denied sender won't be let in by one. + val denied = cause is SendMessageError.Denied + val terminal = denied || cause is SendMessageError.EncryptionNotAllowed + fail(chatId, clientMessageId, hex, retryable = !terminal, discard = record.takeIf { terminal }, denied = denied) } } @@ -345,8 +347,13 @@ class MediaSendDelegate internal constructor( hex: String, retryable: Boolean, discard: PendingMediaRecord?, + denied: Boolean = false, ) { - messageDataSource.failPending(chatId, clientMessageId) + if (denied) { + messageDataSource.denyPending(chatId, clientMessageId) + } else { + messageDataSource.failPending(chatId, clientMessageId) + } stages.update { it + (hex to Stage.Failed(retryable)) } // A terminal failure has nothing a retry could resend, so the queue entry and the file go. if (discard != null) discard(discard) 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 6d906e9ed0..7ad7410ae3 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 @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.internal.delegates +import com.flipcash.services.models.SendMessageError import androidx.core.app.NotificationManagerCompat import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager @@ -371,7 +372,7 @@ class MessagingDelegate @Inject constructor( /** * Seals [payload] if the chat encrypts, sends it, and settles the pending row. A failure to * seal fails the row like a failed request, so it can be retried; so does the server refusing - * the ciphertext with `EncryptionNotAllowed`. + * the ciphertext with `EncryptionNotAllowed`. A `Denied` settles the row as denied, with no retry. */ private suspend fun send( chatId: ChatId, @@ -393,8 +394,13 @@ class MessagingDelegate @Inject constructor( metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId) metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds()) } - .onFailure { - messageDataSource.failPending(chatId, clientMessageId) + .onFailure { cause -> + // The server won't take this from this user in this chat; a retry gets the same answer. + if (cause is SendMessageError.Denied) { + messageDataSource.denyPending(chatId, clientMessageId) + } else { + messageDataSource.failPending(chatId, clientMessageId) + } } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt index 3c0a859384..2339a2e7ad 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -330,6 +330,20 @@ class MessagingSendEncryptionTest { coVerify { messages.failPending(chatId, clientMessageId) } } + @Test + fun `Denied settles the send without a retry`() = runTest { + storedChat(chat()) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns + Result.failure(SendMessageError.Denied()) + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertIs(result.exceptionOrNull()) + coVerify { messages.denyPending(chatId, clientMessageId) } + coVerify(exactly = 0) { messages.failPending(any(), any()) } + } + @Test fun `a retry seals the stored plaintext again`() = runTest { storedChat(chat()) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt index dcf4951584..04c727be06 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt @@ -10,6 +10,7 @@ import com.flipcash.app.persistence.sources.PendingMediaRecord import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.models.BlobRejectedException import com.flipcash.services.models.ModerationResult +import com.flipcash.services.models.SendMessageError import com.flipcash.services.models.blob.EncryptedConstraints import com.flipcash.services.models.blob.MimeTypeConstraints import com.flipcash.services.models.blob.UploadPolicy @@ -219,6 +220,19 @@ class MediaSendDelegateTest { assertEquals(setOf(ids[1]), records.keys) // kept for retry } + @Test + fun `a denied post settles the row and drops the stored photo`() = runTest { + val (delegate, uploads) = delegate() + sendResult = { Result.failure(SendMessageError.Denied()) } + val ids = delegate.sendMedia(chatId, listOf(stage(uploads, "a")), "", null).getOrThrow() + runCurrent() + + coVerify { messageDataSource.denyPending(chatId, match { hex(it) == ids[0] }) } + coVerify(exactly = 0) { messageDataSource.failPending(any(), any()) } + assertNull(records[ids[0]]) + assertEquals(MediaSendProgress.Failed(false), delegate.progressOf(ids[0])) + } + @Test fun `a failed post fails the row and keeps the stored photo`() = runTest { val (delegate, uploads) = delegate() From 895c3158e7b5dcea0a83fd3d723b90b73ee8cd4d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 09:54:23 -0400 Subject: [PATCH 3/6] fix(chat): say a denied message can't be posted instead of offering a retry A DENIED row now reads "Not Delivered. You can't post in this chat" in the error colour, always shows, and isn't tappable. Photo bubbles stop offering Retry for any failure the send path marked non-retryable; they used to ignore that flag. --- apps/flipcash/core/src/main/res/values/strings.xml | 1 + .../com/flipcash/app/messenger/internal/ChatViewModel.kt | 2 +- .../messenger/internal/screens/components/ReceiptLabel.kt | 3 ++- .../messenger/internal/screens/components/ReceiptRules.kt | 2 +- .../internal/screens/components/ReceiptHandoffTest.kt | 7 +++++++ .../kotlin/com/flipcash/shared/chat/models/ChatListItem.kt | 2 +- .../flipcash/shared/chat/ui/media/MediaMessageBubble.kt | 7 +++++-- 7 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index e6b013469a..3b3926652c 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -892,6 +892,7 @@ Delivered Read Not Delivered. Tap to retry + Not Delivered. You can\'t post in this chat Yesterday Message Not Sent Your message could not be delivered. Would you like to try again? 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 f46bb77f05..0fed26f032 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 @@ -1171,7 +1171,7 @@ internal class ChatViewModel @AssistedInject constructor( when (message.deliveryStatus) { DeliveryStatus.SENDING -> ReceiptStatus.SENDING DeliveryStatus.FAILED -> ReceiptStatus.FAILED - DeliveryStatus.DENIED -> ReceiptStatus.FAILED + DeliveryStatus.DENIED -> ReceiptStatus.DENIED DeliveryStatus.SENT -> ReceiptStatus.SENT } } else null 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 a313076253..740ef1663f 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 @@ -62,6 +62,7 @@ internal fun ReceiptLabel( ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered) ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read) ReceiptStatus.FAILED -> stringResource(R.string.label_chatReceipt_notSent) + ReceiptStatus.DENIED -> stringResource(R.string.label_chatReceipt_denied) else -> return@AnimatedContent } @@ -82,7 +83,7 @@ internal fun ReceiptLabel( style = CodeTheme.typography.caption.copy( fontWeight = FontWeight.Bold, ), - color = if (animatedStatus == ReceiptStatus.FAILED) { + color = if (animatedStatus == ReceiptStatus.FAILED || animatedStatus == ReceiptStatus.DENIED) { CodeTheme.colors.errorText } else { CodeTheme.colors.textSecondary diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt index a7ff452d22..1a098a9617 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt @@ -71,7 +71,7 @@ internal fun shouldShowReceiptLabel( // A split message's receipt goes under its last row only; the rows above share its status. if (!item.isLastRow) return false val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false - if (status == ReceiptStatus.FAILED) return true + if (status == ReceiptStatus.FAILED || status == ReceiptStatus.DENIED) return true val confirmed = item.confirmedStatus(otherReadPointer, settlingKey) ?: return false // Walking toward the newest: it keeps its line while nothing newer takes it -- no newer send diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt index ebb492667f..ea9e089f38 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt @@ -75,6 +75,13 @@ class ReceiptHandoffTest { assertEquals(listOf(true, true), shown(rows, settlingKey = send.messageKey)) } + @Test + fun `a denied send always shows its line`() { + val send = mine(0, ReceiptStatus.DENIED, clientId = "c2") + val rows = listOf(send, mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(true, true), shown(rows, settlingKey = send.messageKey)) + } + @Test fun `a first send that is settling shows no line anywhere`() { val send = mine(1, ReceiptStatus.SENT, clientId = "c1") diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index cde94003e6..0d15fffe6d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -9,7 +9,7 @@ import com.flipcash.shared.chat.ui.UndecryptableHint import com.getcode.opencode.model.core.ID import kotlin.time.Instant -enum class ReceiptStatus { SENDING, SENT, READ, FAILED } +enum class ReceiptStatus { SENDING, SENT, READ, FAILED, DENIED } sealed interface ChatListItem { val itemKey: Any diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt index d0f55a7c03..355056b44c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt @@ -151,7 +151,10 @@ internal fun MediaMessageBubble( val progress by remember(item.pendingClientIdHex) { derivedStateOf { item.pendingClientIdHex?.let { progressOf()[it] } } } - val failed = item.receiptStatus == ReceiptStatus.FAILED || progress is MediaSendProgress.Failed + val denied = item.receiptStatus == ReceiptStatus.DENIED + val failed = denied || item.receiptStatus == ReceiptStatus.FAILED || progress is MediaSendProgress.Failed + // A denied or terminal failure still renders as failed, but a tap can't fix it. + val retryable = !denied && (progress as? MediaSendProgress.Failed)?.retryable != false var loadError by remember(model) { mutableStateOf(null) } val unavailable = loadError?.let(::isPhotoUnavailable) == true @@ -159,7 +162,7 @@ internal fun MediaMessageBubble( val onClick: (() -> Unit)? = when { !interactive -> null - failed -> ({ actionHandler(ChatAction.RetryMessage(item)) }) + failed -> if (retryable) ({ actionHandler(ChatAction.RetryMessage(item)) }) else null canOpenPhoto(blurhashOnly, failed = false, unavailable = unavailable) && localUri == null -> ({ actionHandler(ChatAction.OpenPhoto(item)) }) else -> null From 180f48c3c0ca19794eb6daa1a59e906ce25ff3a2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 09:56:58 -0400 Subject: [PATCH 4/6] fix(chat): re-read a chat's rules after a denied send Denied carries no reason. If a speaker rule changed, the stored rules are stale and the composer stays up; re-reading the chat lets speakerBlock see the new rules and hide it. A removal from the group still isn't visible, because GetChat doesn't return the caller's membership. --- .../internal/delegates/MediaSendDelegate.kt | 1 + .../internal/delegates/MessagingDelegate.kt | 15 +++++++ .../chat/MessagingSendEncryptionTest.kt | 41 +++++++++++++++++++ .../chat/media/MediaSendDelegateTest.kt | 1 + 4 files changed, 58 insertions(+) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt index 2a1dedf233..f643f84f7c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt @@ -338,6 +338,7 @@ class MediaSendDelegate internal constructor( val denied = cause is SendMessageError.Denied val terminal = denied || cause is SendMessageError.EncryptionNotAllowed fail(chatId, clientMessageId, hex, retryable = !terminal, discard = record.takeIf { terminal }, denied = denied) + if (denied) messaging.rereadAfterDenied(chatId) } } 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 7ad7410ae3..98020f32ac 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 @@ -398,6 +398,7 @@ class MessagingDelegate @Inject constructor( // The server won't take this from this user in this chat; a retry gets the same answer. if (cause is SendMessageError.Denied) { messageDataSource.denyPending(chatId, clientMessageId) + rereadAfterDenied(chatId) } else { messageDataSource.failPending(chatId, clientMessageId) } @@ -658,6 +659,20 @@ class MessagingDelegate @Inject constructor( senderResolver.clear() } + /** + * The server refused a send without saying why. If a speaker rule changed, the stored rules are + * stale and the composer is still up; re-reading the chat lets `speakerBlock` see the new rules. + * A removal from the group is not visible here: GetChat does not return the caller's membership. + */ + internal suspend fun rereadAfterDenied(chatId: ChatId) { + val metadata = chatController.getChat(chatId).getOrElse { + trace(tag = TAG, message = "Couldn't re-read $chatId after a denied send", type = TraceType.Silent) + return + } + metadataDataSource.upsert(metadata) + memberDataSource.upsert(chatId, metadata.members) + } + // endregion private companion object { diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt index 2339a2e7ad..6f665b676b 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -336,6 +336,7 @@ class MessagingSendEncryptionTest { answerSends() coEvery { controller.sendMessage(chatId, any(), any()) } returns Result.failure(SendMessageError.Denied()) + coEvery { chatController.getChat(chatId, any()) } returns Result.failure(GetChatError.Other()) val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) @@ -344,6 +345,46 @@ class MessagingSendEncryptionTest { coVerify(exactly = 0) { messages.failPending(any(), any()) } } + @Test + fun `Denied re-reads the chat so its rules can hide the composer`() = runTest { + val stored = chat() + storedChat(stored) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns Result.failure(SendMessageError.Denied()) + coEvery { chatController.getChat(chatId, any()) } returns Result.success(stored) + + delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + coVerify { metadata.upsert(stored) } + coVerify { members.upsert(chatId, stored.members) } + } + + @Test + fun `a failed re-read leaves the denial in place`() = runTest { + storedChat(chat()) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns Result.failure(SendMessageError.Denied()) + coEvery { chatController.getChat(chatId, any()) } returns Result.failure(GetChatError.Other()) + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertIs(result.exceptionOrNull()) + coVerify { messages.denyPending(chatId, clientMessageId) } + coVerify(exactly = 0) { metadata.upsert(any()) } + } + + @Test + fun `EncryptionNotAllowed does not re-read the chat`() = runTest { + storedChat(chat()) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns + Result.failure(SendMessageError.EncryptionNotAllowed()) + + delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + coVerify(exactly = 0) { metadata.upsert(any()) } + } + @Test fun `a retry seals the stored plaintext again`() = runTest { storedChat(chat()) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt index 04c727be06..6bbe047b66 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt @@ -228,6 +228,7 @@ class MediaSendDelegateTest { runCurrent() coVerify { messageDataSource.denyPending(chatId, match { hex(it) == ids[0] }) } + coVerify { messaging.rereadAfterDenied(chatId) } coVerify(exactly = 0) { messageDataSource.failPending(any(), any()) } assertNull(records[ids[0]]) assertEquals(MediaSendProgress.Failed(false), delegate.progressOf(ids[0])) From f25a36910597e0fd0eb1032d016892681cdbe5db Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 09:57:54 -0400 Subject: [PATCH 5/6] fix(chat): stop reporting a denied send, typing notice or read pointer A denied send now settles as DENIED with its own message, and typing and read-pointer callers already ignore the result, so these refusals no longer need to reach Bugsnag. One user produced 6 of the 18 overnight events this way. --- .../main/kotlin/com/flipcash/services/models/Errors.kt | 9 ++++++--- .../kotlin/com/flipcash/services/models/ErrorsTest.kt | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index f9687dbc31..701372122e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -543,7 +543,8 @@ sealed class SendMessageError( override val message: String? = null, override val cause: Throwable? = null ): CodeServerError(message, cause) { - class Denied : SendMessageError("Denied") + // The server refused this user in this chat. The row settles as DENIED; nothing to report. + class Denied : SendMessageError("Denied"), UnreportedError // Sender sent EncryptedContent to a chat that doesn't allow it (non-DM). E2eePolicy only // encrypts in a DM, so this means the chat's type changed under us; the send fails and can // be retried. @@ -559,7 +560,8 @@ sealed class AdvancePointerError( override val message: String? = null, override val cause: Throwable? = null ): CodeServerError(message, cause) { - class Denied : AdvancePointerError("Denied") + // A read pointer the server won't take is dropped; callers ignore the result. + class Denied : AdvancePointerError("Denied"), UnreportedError class MessageNotFound : AdvancePointerError("Message not found") class Unrecognized : AdvancePointerError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : AdvancePointerError(message = cause?.message, cause = cause), NotifiableError @@ -569,7 +571,8 @@ sealed class NotifyIsTypingError( override val message: String? = null, override val cause: Throwable? = null ): CodeServerError(message, cause) { - class Denied : NotifyIsTypingError("Denied") + // A typing notice the server won't take is dropped; callers ignore the result. + class Denied : NotifyIsTypingError("Denied"), UnreportedError class Unrecognized : NotifyIsTypingError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : NotifyIsTypingError(message = cause?.message, cause = cause), NotifiableError } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt index 332734b316..d829d8992b 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt @@ -153,6 +153,13 @@ class ErrorsTest { assertIs(GetMessagesError.NotFound()) } + @Test + fun `Denied chat errors are not reported`() { + assertIs(SendMessageError.Denied()) + assertIs(NotifyIsTypingError.Denied()) + assertIs(AdvancePointerError.Denied()) + } + // -- SendMessageError -- @Test From b05700b8a0a102d8737519680c1f267cd270d7ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 10:01:17 -0400 Subject: [PATCH 6/6] fix(chat): keep stored membership when re-reading after a denied send ChatEntityMapper.toEntity writes is_member = 1 by default. If the stream's MemberLeft landed before the re-read returned, the upsert put a removed user back in the feed as a member. The re-read now keeps whatever membership is stored. --- .../chat/internal/delegates/MessagingDelegate.kt | 3 ++- .../shared/chat/MessagingSendEncryptionTest.kt | 7 ++++--- .../app/persistence/sources/ChatMetadataDataSource.kt | 10 ++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) 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 98020f32ac..06223cac54 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 @@ -669,7 +669,8 @@ class MessagingDelegate @Inject constructor( trace(tag = TAG, message = "Couldn't re-read $chatId after a denied send", type = TraceType.Silent) return } - metadataDataSource.upsert(metadata) + // A removal may already have landed from the stream; don't write the caller back in. + metadataDataSource.upsertKeepingMembership(metadata) memberDataSource.upsert(chatId, metadata.members) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt index 6f665b676b..4640101996 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -355,7 +355,8 @@ class MessagingSendEncryptionTest { delegate.sendMessage(chatId, "hello", replyToMessageId = null) - coVerify { metadata.upsert(stored) } + coVerify { metadata.upsertKeepingMembership(stored) } + coVerify(exactly = 0) { metadata.upsert(any()) } coVerify { members.upsert(chatId, stored.members) } } @@ -370,7 +371,7 @@ class MessagingSendEncryptionTest { assertIs(result.exceptionOrNull()) coVerify { messages.denyPending(chatId, clientMessageId) } - coVerify(exactly = 0) { metadata.upsert(any()) } + coVerify(exactly = 0) { metadata.upsertKeepingMembership(any()) } } @Test @@ -382,7 +383,7 @@ class MessagingSendEncryptionTest { delegate.sendMessage(chatId, "hello", replyToMessageId = null) - coVerify(exactly = 0) { metadata.upsert(any()) } + coVerify(exactly = 0) { metadata.upsertKeepingMembership(any()) } } @Test diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 8bc485cb35..e6a2bfaa87 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -102,6 +102,16 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.upsert(mapper.toEntity(metadata)) } + /** + * Writes a fresh server read of a chat the caller may have left. GetChat doesn't return the + * caller's membership, so the stored value stands instead of [upsert]'s `true`. + */ + suspend fun upsertKeepingMembership(metadata: ChatMetadata) { + val dao = db?.chatMetadataDao() ?: return + val isMember = dao.getById(mapper.chatIdHex(metadata.chatId))?.isMember ?: false + dao.upsert(mapper.toEntity(metadata, isMember = isMember)) + } + suspend fun upsert(metadatas: List) { db?.chatMetadataDao()?.upsert(metadatas.map { mapper.toEntity(it) }) }