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 5af1d838b..0088d4bf0 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 @@ -152,6 +152,29 @@ interface MessagingOperations { /** Retries a failed pending message: resets to SENDING and re-sends to the server. */ suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result + /** + * Replaces [messageId]'s body with [text], optimistically. + * + * The change is visible immediately as a [PendingMutation] over the stored row; the row itself + * is only written once the server agrees. Returns the server's version of the message. + */ + suspend fun editMessage(chatId: ChatId, messageId: Long, text: String): Result + + /** + * Deletes [messageId] for everyone, optimistically. + * + * "For everyone" is the only delete the wire models — there is no local-only variant to choose + * between. Same overlay-then-reconcile path as [editMessage]. + */ + suspend fun deleteMessage(chatId: ChatId, messageId: Long): Result + + /** + * Emits the edits and deletes in [chatId] that the server has not answered yet, keyed by + * message id, for composing over the stored transcript with + * [applying][com.flipcash.shared.chat.applying]. + */ + fun observePendingMutations(chatId: ChatId): Flow> + /** 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/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt new file mode 100644 index 000000000..001741127 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt @@ -0,0 +1,98 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Instant + +/** + * A single thing the viewer is allowed to do to a message. + * + * Permissions are modelled as capabilities rather than roles. `Member` on the wire is + * `{ user_id, user_profile, pointers }` with no role field, and the server only ever answers + * `DENIED`, `CANNOT_EDIT`, or `CANNOT_DELETE` — so anything the client decides here is scaffolding + * the server overrules. Resolving a set of capabilities means a later role taxonomy becomes one + * more input to [resolveCapabilities] and no call site changes: a menu asks what can be done to a + * message, never who the viewer is. + * + * [Reply] is resolved but not yet wired to a surface. It is here so the reply work lands as a new + * menu row rather than a second capability model. + */ +enum class MessageCapability { + Copy, + Reply, + Edit, + Delete, +} + +/** + * Client-side limits on what may be done to a message. + * + * @param editWindow how long after sending a message stays editable, or `null` for no limit. The + * server does not publish a window today, so the default leaves edit open and lets `CANNOT_EDIT` + * be the authority. + */ +data class MessagePolicy( + val editWindow: Duration? = null, +) { + companion object { + val Default = MessagePolicy() + } +} + +/** + * Resolves what [message] allows, per the capability table shared with iOS: + * + * | Message | Capabilities | + * |---|---| + * | Own text, confirmed, within the edit window | Copy, Reply, Edit, Delete | + * | Own text, confirmed, outside a configured window | Copy, Reply, Delete | + * | Own text, unconfirmed (`eventSequence == 0`) | none | + * | Another participant's text | Copy, Reply | + * | Any cash or tip message | Reply | + * | A tombstone | none | + */ +fun resolveCapabilities( + message: ChatMessage, + policy: MessagePolicy = MessagePolicy.Default, + now: Instant = Clock.System.now(), +): Set { + val contents = message.content + if (contents.isEmpty()) return emptySet() + + // Nothing left to act on: there is no text to copy and the delete already happened. + if (contents.any { it is MessageContent.Deleted }) return emptySet() + + // `expected_event_sequence` is validated `>= 1`, so no valid edit or delete request can be + // built for a message the server has not acknowledged. The empty set is not a style choice — + // offering copy alone on a message that may still fail to send reads as a half-broken menu. + if (message.eventSequence == 0L) return emptySet() + + // Cash is never editable: `EditMessageRequest.content` accepts Text, Reply, and Media, never + // Cash. It is deliberately not deletable either, so a payment cannot be hidden from the + // transcript that records it. + if (contents.any { it is MessageContent.Cash }) return setOf(MessageCapability.Reply) + + // Server-authored notices, not a participant's message. + if (contents.all { it is MessageContent.System }) return emptySet() + + val hasText = contents.any { it is MessageContent.Text || it is MessageContent.Reply } + + return buildSet { + // Media carries no text, and this change edits text only. Not covered by the shared table; + // revisit when media messages actually ship. + if (hasText) add(MessageCapability.Copy) + add(MessageCapability.Reply) + if (message.isFromSelf) { + if (hasText && policy.allowsEdit(message, now)) add(MessageCapability.Edit) + add(MessageCapability.Delete) + } + } +} + +/** True while [message] is still inside the configured edit window, or always if there is none. */ +private fun MessagePolicy.allowsEdit(message: ChatMessage, now: Instant): Boolean { + val window = editWindow ?: return true + return now - message.timestamp <= window +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PendingMutation.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PendingMutation.kt new file mode 100644 index 000000000..fcaa3eadd --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PendingMutation.kt @@ -0,0 +1,70 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +/** + * An edit or delete the user has asked for and the server has not yet answered. + * + * The database holds the server's version and only the server's version. A mutation in flight is + * held here instead and composed over the stored row on the way to the screen, so the transcript + * updates on the tap while the row underneath stays truthful. Reconciliation is then a matter of + * dropping the overlay — there is no local write to undo. + * + * @param expectedSequence the message's `eventSequence` at the moment the request was built. It is + * what goes on the wire as `expected_event_sequence`, and it is what makes the overlay + * self-expiring: see [applying]. + */ +data class PendingMutation( + val messageId: Long, + val expectedSequence: Long, + val kind: Kind, +) { + sealed interface Kind { + /** The body is shown as [text] until the server confirms it. */ + data class Edited(val text: String, val editedAt: Instant) : Kind + + /** The bubble is shown as a tombstone until the server confirms it. */ + data class Deleted(val deletedAt: Instant, val deletedBy: ID?) : Kind + } +} + +/** + * Composes [mutation] over this message, or returns it untouched when there is nothing pending. + * + * The overlay expires on a strictly higher `eventSequence`: once the stored row has moved past the + * sequence the request was built against, the server has spoken — whether it agreed, or something + * else changed the message first — and the row is the better answer. That guard is what lets a + * caller persist the server's reply and drop the overlay in either order without flashing stale + * text at the reader. + */ +fun ChatMessage.applying(mutation: PendingMutation?): ChatMessage { + if (mutation == null) return this + if (eventSequence > mutation.expectedSequence) return this + + return when (val kind = mutation.kind) { + is PendingMutation.Kind.Edited -> copy( + content = content.replacingText(kind.text), + lastEditedTs = kind.editedAt, + ) + + is PendingMutation.Kind.Deleted -> copy( + content = listOf(MessageContent.Deleted(kind.deletedAt, kind.deletedBy)), + ) + } +} + +/** + * Swaps the body text while leaving everything wrapping it in place — so editing a reply keeps its + * citation instead of flattening it to a bare text message. + */ +internal fun List.replacingText(text: String): List = + map { content -> + when (content) { + is MessageContent.Text -> MessageContent.Text(text) + is MessageContent.Reply -> content.copy(content = content.content.replacingText(text)) + else -> content + } + } 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 01d2a08fa..ae9f54946 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 @@ -23,18 +23,24 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.models.DeleteMessageError +import com.flipcash.services.models.EditMessageError import com.flipcash.shared.chat.ChatHydrationState import com.flipcash.shared.chat.MessagingOperations +import com.flipcash.shared.chat.PendingMutation import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.replacingText import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceType import com.getcode.utils.trace import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -68,6 +74,15 @@ class MessagingDelegate @Inject constructor( private val analytics: FlipcashAnalyticsService, ) : MessagingOperations { + /** + * Edits and deletes awaiting a server answer, per chat, keyed by message id. + * + * In memory on purpose. The database is the server's version of the transcript; a mutation the + * server has not confirmed does not belong in it, and keeping it out means a rollback is a map + * removal rather than a compensating write. + */ + private val pendingMutations = MutableStateFlow>>(emptyMap()) + // region MessagingOperations override suspend fun getOtherMember(chatId: ChatId): ChatMember? { @@ -216,6 +231,55 @@ class MessagingDelegate @Inject constructor( } } + override fun observePendingMutations(chatId: ChatId): Flow> = + pendingMutations.map { it[chatId].orEmpty() }.distinctUntilChanged() + + override suspend fun editMessage(chatId: ChatId, messageId: Long, text: String): Result { + val edited = text.trim() + if (edited.isBlank()) { + return Result.failure(IllegalArgumentException("Cannot edit a message to be blank")) + } + + val stored = messageDataSource.getMessage(chatId, messageId) + ?: return Result.failure(IllegalStateException("No local copy of message $messageId")) + + // Read the stored body rather than building a bare text message, so anything wrapping the + // text — a reply citation, once replies ship — survives the edit. + val content = stored.content.replacingText(edited) + val expectedSequence = stored.eventSequence + + putMutation( + chatId = chatId, + mutation = PendingMutation( + messageId = messageId, + expectedSequence = expectedSequence, + kind = PendingMutation.Kind.Edited(edited, Clock.System.now()), + ), + ) + + return messagingController.editMessage(chatId, messageId, content, expectedSequence) + .reconcile(chatId, messageId) { it is EditMessageError.Conflict } + } + + override suspend fun deleteMessage(chatId: ChatId, messageId: Long): Result { + val stored = messageDataSource.getMessage(chatId, messageId) + ?: return Result.failure(IllegalStateException("No local copy of message $messageId")) + + val expectedSequence = stored.eventSequence + + putMutation( + chatId = chatId, + mutation = PendingMutation( + messageId = messageId, + expectedSequence = expectedSequence, + kind = PendingMutation.Kind.Deleted(Clock.System.now(), userManager.accountId), + ), + ) + + return messagingController.deleteMessage(chatId, messageId, expectedSequence) + .reconcile(chatId, messageId) { it is DeleteMessageError.Conflict } + } + override suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result { val selfId = userManager.accountId ?: return Result.failure( IllegalStateException("No account") @@ -303,7 +367,61 @@ class MessagingDelegate @Inject constructor( } } + /** + * Settles a mutation against the server's answer, then drops the overlay either way. + * + * Ordering matters on success: persist first, drop second. Room publishes asynchronously, and + * the `eventSequence` guard in `applying` retires the overlay the moment the newer row lands, + * so the reader never sees a gap. Dropping first would flash the pre-edit text. + * + * A conflict means someone else moved the message first. There is no automatic retry — the + * user's edit was written against a version that no longer exists, and silently re-applying it + * over whatever replaced it is how you clobber someone. Re-read instead so the transcript shows + * what is actually there, and let the caller tell the user. + * + * Any other failure reverts. A delete that did not happen means the message is still there, and + * showing it again is the truth. + */ + private suspend fun Result.reconcile( + chatId: ChatId, + messageId: Long, + isConflict: (Throwable) -> Boolean, + ): Result = this + .onSuccess { serverMessage -> + messageDataSource.upsert(chatId, listOf(serverMessage)) + clearMutation(chatId, messageId) + } + .onFailure { cause -> + if (isConflict(cause)) { + messagingController.getMessage(chatId, messageId) + .onSuccess { messageDataSource.upsert(chatId, listOf(it)) } + .onFailure { + trace( + tag = TAG, + message = "Re-reading conflicted message $messageId failed", + type = TraceType.Error, + error = it, + ) + } + } + clearMutation(chatId, messageId) + } + + private fun putMutation(chatId: ChatId, mutation: PendingMutation) { + pendingMutations.update { all -> + all + (chatId to (all[chatId].orEmpty() + (mutation.messageId to mutation))) + } + } + + private fun clearMutation(chatId: ChatId, messageId: Long) { + pendingMutations.update { all -> + val remaining = all[chatId].orEmpty() - messageId + if (remaining.isEmpty()) all - chatId else all + (chatId to remaining) + } + } + internal suspend fun clear() { + pendingMutations.value = emptyMap() metadataDataSource.clear() messageDataSource.clear() memberDataSource.clear() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt new file mode 100644 index 000000000..08d029064 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt @@ -0,0 +1,141 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * The capability table, resolved once so no menu site has to re-derive it. Group roles land here + * later as another input to [resolveCapabilities], which is why these assertions are written + * against the returned set rather than against any particular menu. + */ +@RunWith(RobolectricTestRunner::class) +class MessageCapabilityTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") + private val sentAt = Instant.fromEpochSeconds(1_000) + + private fun message( + content: List, + isFromSelf: Boolean = true, + eventSequence: Long = 4, + ) = ChatMessage( + messageId = 1, + senderId = if (isFromSelf) selfId else otherId, + content = content, + timestamp = sentAt, + unreadSeq = 0, + eventSequence = eventSequence, + isFromSelf = isFromSelf, + ) + + private fun text(isFromSelf: Boolean = true, eventSequence: Long = 4) = + message(listOf(MessageContent.Text("hello")), isFromSelf, eventSequence) + + private fun cash(isFromSelf: Boolean = true) = message( + listOf( + MessageContent.Cash( + intentId = listOf(9), + amount = Fiat(quarks = 100L), + mint = mint, + ), + ), + isFromSelf, + ) + + @Test + fun `own text message is copyable, editable and deletable`() { + assertEquals( + setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + resolveCapabilities(text()), + ) + } + + @Test + fun `someone else's text message is copyable but not editable or deletable`() { + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + resolveCapabilities(text(isFromSelf = false)), + ) + } + + @Test + fun `cash is never editable or deletable, by either party`() { + assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(cash())) + assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(cash(isFromSelf = false))) + } + + @Test + fun `a tombstone offers nothing`() { + val deleted = message(listOf(MessageContent.Deleted(sentAt, selfId))) + assertEquals(emptySet(), resolveCapabilities(deleted)) + } + + @Test + fun `an unconfirmed message offers nothing`() { + // `expected_event_sequence` is validated `>= 1`, so no valid edit or delete request exists + // for a message the server has not acknowledged yet. + assertEquals(emptySet(), resolveCapabilities(text(eventSequence = 0))) + } + + @Test + fun `system notices are not a participant's message`() { + val system = message(listOf(MessageContent.System("Anna joined"))) + assertEquals(emptySet(), resolveCapabilities(system)) + } + + @Test + fun `empty content offers nothing`() { + assertEquals(emptySet(), resolveCapabilities(message(emptyList()))) + } + + @Test + fun `a reply counts as text, so it stays copyable and editable`() { + val reply = message( + listOf(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("hi")))), + ) + assertEquals( + setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + resolveCapabilities(reply), + ) + } + + @Test + fun `an edit window drops Edit once it lapses and leaves Delete alone`() { + val policy = MessagePolicy(editWindow = 15.minutes) + + assertEquals( + setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + resolveCapabilities(text(), policy, now = sentAt + 14.minutes), + ) + + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + resolveCapabilities(text(), policy, now = sentAt + 16.minutes), + ) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt new file mode 100644 index 000000000..3d645e813 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt @@ -0,0 +1,173 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.EditMessageError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The optimistic mutation round trip through the delegate: the overlay goes up before the request, + * and comes down on the answer — after the server's row is stored on success, and without one on + * failure, which is what makes a failed delete put the message back. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MessagingMutationTest { + + private val chatId = ChatId("aabbccdd") + private val selfId = listOf(1, 2, 3) + + private val stored = ChatMessage( + messageId = 1, + senderId = selfId, + content = listOf(MessageContent.Text("before")), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 0, + eventSequence = 4, + isFromSelf = true, + ) + + private val confirmed = stored.copy( + content = listOf(MessageContent.Text("after")), + lastEditedTs = Instant.fromEpochSeconds(2_000), + eventSequence = 5, + ) + + private fun delegateWith( + messagingController: ChatMessagingController, + messageDataSource: ChatMessageDataSource, + ): MessagingDelegate { + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + return MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = messagingController, + metadataDataSource = mockk(relaxed = true), + messageDataSource = messageDataSource, + memberDataSource = mockk(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + ) + } + + private fun dataSource(): ChatMessageDataSource { + val source = mockk(relaxed = true) + coEvery { source.getMessage(chatId, 1) } returns stored + return source + } + + @Test + fun `an edit is shown before the server answers, and stored before the overlay comes down`() = runTest { + val gate = CompletableDeferred() + val controller = mockk(relaxed = true) + coEvery { controller.editMessage(chatId, 1, any(), 4) } coAnswers { + gate.await() + Result.success(confirmed) + } + val source = dataSource() + val delegate = delegateWith(controller, source) + + val request = launch { delegate.editMessage(chatId, messageId = 1, text = "after") } + runCurrent() + + val inFlight = delegate.observePendingMutations(chatId).first() + assertEquals(setOf(1L), inFlight.keys) + assertEquals(4, inFlight.getValue(1L).expectedSequence) + assertEquals("after", assertIs(inFlight.getValue(1L).kind).text) + + gate.complete(Unit) + request.join() + + coVerify(exactly = 1) { source.upsert(chatId, listOf(confirmed)) } + assertTrue(delegate.observePendingMutations(chatId).first().isEmpty()) + } + + @Test + fun `the edit body is built from the stored content, not from a bare text message`() = runTest { + val replied = stored.copy( + content = listOf( + MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("before"))), + ), + ) + val controller = mockk(relaxed = true) + coEvery { controller.editMessage(any(), any(), any(), any()) } returns Result.success(confirmed) + val source = mockk(relaxed = true) + coEvery { source.getMessage(chatId, 1) } returns replied + + delegateWith(controller, source).editMessage(chatId, messageId = 1, text = "after") + + coVerify(exactly = 1) { + controller.editMessage( + chatId, + 1, + listOf(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("after")))), + 4, + ) + } + } + + @Test + fun `a failed delete puts the message back`() = runTest { + val controller = mockk(relaxed = true) + coEvery { controller.deleteMessage(chatId, 1, 4) } returns Result.failure(EditMessageError.Denied()) + val source = dataSource() + val delegate = delegateWith(controller, source) + + val result = delegate.deleteMessage(chatId, messageId = 1) + + assertTrue(result.isFailure) + assertTrue(delegate.observePendingMutations(chatId).first().isEmpty()) + coVerify(exactly = 0) { source.upsert(chatId, any()) } + } + + @Test + fun `a conflicted edit re-reads the server's copy instead of retrying`() = runTest { + val serverCopy = confirmed.copy(content = listOf(MessageContent.Text("someone else's edit"))) + val controller = mockk(relaxed = true) + coEvery { controller.editMessage(chatId, 1, any(), 4) } returns + Result.failure(EditMessageError.Conflict()) + coEvery { controller.getMessage(chatId, 1) } returns Result.success(serverCopy) + val source = dataSource() + val delegate = delegateWith(controller, source) + + val result = delegate.editMessage(chatId, messageId = 1, text = "after") + + assertTrue(result.isFailure) + coVerify(exactly = 1) { controller.editMessage(chatId, 1, any(), 4) } + coVerify(exactly = 1) { source.upsert(chatId, listOf(serverCopy)) } + assertTrue(delegate.observePendingMutations(chatId).first().isEmpty()) + } + + @Test + fun `an edit to blank is refused before anything is sent`() = runTest { + val controller = mockk(relaxed = true) + val source = dataSource() + val delegate = delegateWith(controller, source) + + val result = delegate.editMessage(chatId, messageId = 1, text = " ") + + assertTrue(result.isFailure) + coVerify(exactly = 0) { controller.editMessage(any(), any(), any(), any()) } + assertTrue(delegate.observePendingMutations(chatId).first().isEmpty()) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PendingMutationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PendingMutationTest.kt new file mode 100644 index 000000000..a0ff54ab3 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PendingMutationTest.kt @@ -0,0 +1,102 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.time.Instant + +/** + * The optimistic overlay: what the transcript shows between a mutation being sent and the server + * answering. The overlay is never written to the database, so "rolling back" is dropping it — + * which these tests express as [ChatMessage.applying] with a null mutation. + */ +class PendingMutationTest { + + private val selfId = listOf(1, 2, 3) + private val sentAt = Instant.fromEpochSeconds(1_000) + private val mutatedAt = Instant.fromEpochSeconds(2_000) + + private fun message( + content: List = listOf(MessageContent.Text("before")), + eventSequence: Long = 4, + ) = ChatMessage( + messageId = 1, + senderId = selfId, + content = content, + timestamp = sentAt, + unreadSeq = 0, + eventSequence = eventSequence, + isFromSelf = true, + ) + + private fun edit(expectedSequence: Long = 4, text: String = "after") = PendingMutation( + messageId = 1, + expectedSequence = expectedSequence, + kind = PendingMutation.Kind.Edited(text, mutatedAt), + ) + + private fun delete(expectedSequence: Long = 4) = PendingMutation( + messageId = 1, + expectedSequence = expectedSequence, + kind = PendingMutation.Kind.Deleted(mutatedAt, selfId), + ) + + @Test + fun `an edit swaps the body and marks the message edited`() { + val overlaid = message().applying(edit()) + + assertEquals(listOf(MessageContent.Text("after")), overlaid.content) + assertEquals(mutatedAt, overlaid.lastEditedTs) + } + + @Test + fun `editing a reply keeps its citation instead of flattening it`() { + val reply = message( + listOf(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("before")))), + ) + + val overlaid = reply.applying(edit()) + + assertEquals( + listOf(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("after")))), + overlaid.content, + ) + } + + @Test + fun `a delete replaces the body with a tombstone`() { + val overlaid = message().applying(delete()) + + assertEquals(listOf(MessageContent.Deleted(mutatedAt, selfId)), overlaid.content) + } + + @Test + fun `the overlay still applies at the sequence it was written against`() { + val overlaid = message(eventSequence = 4).applying(edit(expectedSequence = 4)) + + assertEquals(listOf(MessageContent.Text("after")), overlaid.content) + } + + @Test + fun `a newer stored row retires the overlay`() { + // The server's version has landed. Persisting before dropping the overlay is safe precisely + // because this check hands the row over the moment it is newer. + val stored = message(eventSequence = 5) + + val overlaid = stored.applying(edit(expectedSequence = 4)) + + assertSame(stored, overlaid) + } + + @Test + fun `dropping the mutation restores the stored message`() { + val stored = message() + + assertSame(stored, stored.applying(null)) + assertEquals(listOf(MessageContent.Text("before")), stored.applying(null).content) + assertNull(stored.applying(null).lastEditedTs) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 59f294887..cdc81c25e 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -46,6 +46,9 @@ interface ChatMessageDao { @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex LIMIT 1") suspend fun getByClientId(chatIdHex: String, clientIdHex: String): ChatMessageEntity? + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId LIMIT 1") + suspend fun getMessage(chatIdHex: String, messageId: Long): ChatMessageEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: ChatMessageEntity) 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 e65169318..215cedf3b 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 @@ -124,6 +124,10 @@ class ChatMessageDataSource @Inject constructor( suspend fun getLatestMessageId(chatId: ChatId): Long? = db?.chatMessageDao()?.getLatest(mapper.chatIdHex(chatId))?.messageId + /** The locally-stored copy of a single message, or `null` if this device has never seen it. */ + suspend fun getMessage(chatId: ChatId, messageId: Long): ChatMessage? = + db?.chatMessageDao()?.getMessage(mapper.chatIdHex(chatId), messageId)?.let { toChatMessage(it) } + suspend fun getInboundMessagesInRange( chatId: ChatId, selfId: ID,