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 0b667255b2..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,6 +1171,7 @@ internal class ChatViewModel @AssistedInject constructor(
when (message.deliveryStatus) {
DeliveryStatus.SENDING -> ReceiptStatus.SENDING
DeliveryStatus.FAILED -> 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
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..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
@@ -333,9 +333,12 @@ 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)
+ if (denied) messaging.rereadAfterDenied(chatId)
}
}
@@ -345,8 +348,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..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
@@ -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,14 @@ 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)
+ rereadAfterDenied(chatId)
+ } else {
+ messageDataSource.failPending(chatId, clientMessageId)
+ }
}
}
@@ -652,6 +659,21 @@ 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
+ }
+ // A removal may already have landed from the stream; don't write the caller back in.
+ metadataDataSource.upsertKeepingMembership(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 3c0a859384..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
@@ -330,6 +330,62 @@ 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())
+ 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) { 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.upsertKeepingMembership(stored) }
+ coVerify(exactly = 0) { metadata.upsert(any()) }
+ 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.upsertKeepingMembership(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.upsertKeepingMembership(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..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
@@ -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,20 @@ 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 { messaging.rereadAfterDenied(chatId) }
+ 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()
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/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) })
}
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/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/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 }
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