diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index d90d40120c..089cdb6569 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -895,6 +895,11 @@ You: %1$s Is Typing… + Edited + You deleted this message + This message was deleted + Message deleted + Unknown Contact Allow Full Contact Access Make sure you can send cash and identify people you know 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 181a570b12..f436c7de16 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 @@ -235,6 +235,10 @@ internal class ChatViewModel @Inject constructor( timestamp = message.timestamp, receiptStatus = receiptStatus, pendingClientIdHex = message.pendingClientIdHex, + isEdited = message.lastEditedTs != null, + // A null author is a moderation removal, which reads as someone else's. + deletedByViewer = (enriched as? MessageContent.Deleted)?.deletedBy + ?.let { it == userManager.accountId } == true, ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index f51d6ff5cf..92ca4bf553 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -35,6 +35,7 @@ import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import androidx.compose.runtime.CompositionLocalProvider import com.flipcash.shared.chat.models.ChatAction @@ -401,10 +402,18 @@ private fun HandleMessageReads( } } +/** + * A tombstone anchors nothing. The receipt describes the delivery of a message that is no longer + * there, so leaving it attached would caption a deleted bubble with "Read". + */ +private val ChatListItem.ContentBubble.carriesReceipt: Boolean + get() = isFromSelf && content !is MessageContent.Deleted + private fun effectiveReceiptStatus( bubble: ChatListItem.ContentBubble, otherReadPointer: MessagePointer?, ): ReceiptStatus? { + if (!bubble.carriesReceipt) return null val base = bubble.receiptStatus ?: return null val pointerValue = otherReadPointer?.value ?: 0L if (base == ReceiptStatus.SENT && bubble.messageId in 1..pointerValue) { @@ -421,20 +430,33 @@ private fun effectiveReceiptStatus( * At group boundaries, labels are suppressed when the nearest self-group * below already shows the same status (avoids duplicate "Read" labels). */ +/** The nearest bubble below [index], stepping over the viewer's own tombstones. */ +private fun receiptNeighbourBelow( + index: Int, + messages: LazyPagingItems, +): ChatListItem.ContentBubble? { + for (i in (index - 1) downTo 0) { + val bubble = messages.peek(i) as? ChatListItem.ContentBubble ?: return null + if (bubble.isFromSelf && bubble.content is MessageContent.Deleted) continue + return bubble + } + return null +} + private fun shouldShowReceiptLabel( index: Int, item: ChatListItem.ContentBubble, messages: LazyPagingItems, otherReadPointer: MessagePointer?, ): Boolean { - if (!item.isFromSelf) return false + if (!item.carriesReceipt) return false val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false if (status == ReceiptStatus.FAILED) return true if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false - // index - 1 is the item below (newer) in reverseLayout - val below = if (index > 0) messages.peek(index - 1) else null - val belowBubble = below as? ChatListItem.ContentBubble + // index - 1 is the item below (newer) in reverseLayout. A tombstone still belongs to the self + // group for bubble shaping, so it is stepped over here rather than treated as a group boundary. + val belowBubble = receiptNeighbourBelow(index, messages) // Within a self-group: show at intra-group status boundaries only if (belowBubble != null && belowBubble.isFromSelf) { @@ -461,7 +483,7 @@ private fun shouldShowReceiptLabel( for (i in (index - 1) downTo 0) { val peek = messages.peek(i) ?: break val bubble = peek as? ChatListItem.ContentBubble ?: continue - if (bubble.isFromSelf) return effectiveReceiptStatus(bubble, otherReadPointer) != status + if (bubble.carriesReceipt) return effectiveReceiptStatus(bubble, otherReadPointer) != status } return true } diff --git a/apps/flipcash/shared/chat-ui/build.gradle.kts b/apps/flipcash/shared/chat-ui/build.gradle.kts index 6f084d8e70..d74c220448 100644 --- a/apps/flipcash/shared/chat-ui/build.gradle.kts +++ b/apps/flipcash/shared/chat-ui/build.gradle.kts @@ -23,4 +23,5 @@ dependencies { implementation(project(":apps:flipcash:shared:theme")) testImplementation(libs.robolectric) + testImplementation(libs.bundles.unit.testing) } 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 5a8a80505e..92a71f321d 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 @@ -22,12 +22,20 @@ sealed interface ChatListItem { val timestamp: Instant, val receiptStatus: ReceiptStatus? = null, val pendingClientIdHex: String? = null, + /** Drives the corner-pinned "Edited" marker. */ + val isEdited: Boolean = false, + /** Chooses between "You deleted this message" and "This message was deleted". */ + val deletedByViewer: Boolean = false, ) : ChatListItem { override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex" + + // A tombstone shares the text bubble's content type on purpose: deleting a message is an + // in-place update of a row the list already holds, and giving it a type of its own would + // make the list drop that row and insert a new one. override val itemContentType: Any = when (content) { is MessageContent.Text -> "text-bubble" + is MessageContent.Deleted -> "text-bubble" is MessageContent.Cash -> "cash-bubble" - is MessageContent.Deleted -> "deleted-message" is MessageContent.Media -> "media" is MessageContent.Reply -> "reply-message" is MessageContent.System -> "system-message" diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index dea5b0a82d..4b19f2ca3b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -79,8 +79,12 @@ private fun ChatSummary.formatPreview( resources.getString(previewRes, label) } + // The conversation list says a message is gone rather than going blank, which would + // otherwise read as the whole conversation having no messages. + is MessageContent.Deleted -> + resources.getString(R.string.label_chat_preview_deletedMessage) + // TODO: - is MessageContent.Deleted -> null is MessageContent.Media -> null is MessageContent.Reply -> null is MessageContent.System -> null diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 26a4622610..804644eb70 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -20,21 +20,31 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Text +import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper @@ -59,6 +69,7 @@ import com.getcode.ui.core.addIf enum class BubblePosition { Solo, First, Middle, Last } private const val BUBBLE_MAX_WIDTH_FRACTION = 0.78f +private val EDITED_MARKER_GAP = 6.dp private const val CASH_BUBBLE_MAX_WIDTH_FRACTION = 0.64f @Composable @@ -72,7 +83,7 @@ fun ContentBubble( val bubbleMaxWidth = when (item.content) { is MessageContent.Text -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Cash -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION - is MessageContent.Deleted -> maxWidth + is MessageContent.Deleted -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Media -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Reply -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION is MessageContent.System -> maxWidth @@ -89,6 +100,24 @@ fun ContentBubble( isFromSelf = item.isFromSelf, position = position, maxWidth = bubbleMaxWidth, + isEdited = item.isEdited, + ) + + // A tombstone is a text bubble with different words. Rendering it through the same + // composable is what makes a delete an in-place update of the row already on screen. + is MessageContent.Deleted -> TextBubble( + modifier = modifier, + text = stringResource( + if (item.deletedByViewer) { + R.string.label_messageDeletedByYou + } else { + R.string.label_messageDeleted + } + ), + isFromSelf = item.isFromSelf, + position = position, + maxWidth = bubbleMaxWidth, + isTombstone = true, ) is MessageContent.Cash -> CashBubble( @@ -106,7 +135,6 @@ fun ContentBubble( ) // TODO - is MessageContent.Deleted -> Unit is MessageContent.Media -> Unit is MessageContent.Reply -> Unit is MessageContent.System -> Unit @@ -115,6 +143,8 @@ fun ContentBubble( } } +private const val EDITED_MARKER_SLOT = "edited-marker" + @Composable private fun TextBubble( text: String, @@ -122,23 +152,86 @@ private fun TextBubble( position: BubblePosition, maxWidth: Dp, modifier: Modifier = Modifier, + isEdited: Boolean = false, + isTombstone: Boolean = false, ) { Bubble(isFromSelf, position, maxWidth, modifier) { val linkStyle = SpanStyle( color = CodeTheme.colors.textMain, textDecoration = TextDecoration.Underline, ) - val richText = rememberRichText( - text = text, - annotators = listOf(UrlAnnotator(linkStyle)), + // A tombstone carries no link and nothing worth selecting; it is a notice, not a message. + val body = if (isTombstone) { + AnnotatedString(text) + } else { + rememberRichText(text = text, annotators = listOf(UrlAnnotator(linkStyle))) + } + val bodyStyle = CodeTheme.typography.textMedium.copy( + fontWeight = FontWeight.Medium, + fontStyle = if (isTombstone) FontStyle.Italic else FontStyle.Normal, ) - SelectionContainer { + val bodyColor = if (isTombstone) { + CodeTheme.colors.textSecondary + } else { + CodeTheme.colors.textMain + } + + val markerLabel = stringResource(R.string.label_edited) + val markerStyle = CodeTheme.typography.caption + + // The marker is pinned to the bubble's bottom-trailing corner rather than laid out after + // the text, so the body has to leave a hole of exactly the marker's width. An empty + // placeholder in the text flow does that: it sits on the last line where there is room and + // wraps onto its own line where there isn't, without dragging the last word along with it. + // It draws nothing and holds no text, so selection and the accessibility tree see only the + // real marker below. + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val reservation = remember(markerLabel, markerStyle, density) { + with(density) { + (measurer.measure(markerLabel, markerStyle).size.width.toDp() + EDITED_MARKER_GAP).toSp() + } + } + + val laidOut = if (isEdited) { + buildAnnotatedString { + append(body) + appendInlineContent(EDITED_MARKER_SLOT, "\u2007") + } + } else { + body + } + val inlineContent = if (isEdited) { + mapOf( + EDITED_MARKER_SLOT to InlineTextContent( + Placeholder( + width = reservation, + height = 1.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom, + ), + ) { }, + ) + } else { + emptyMap() + } + + val bodyText = @Composable { + Text( + text = laidOut, + inlineContent = inlineContent, + style = bodyStyle, + color = bodyColor, + ) + } + + if (isTombstone) bodyText() else SelectionContainer { bodyText() } + + if (isEdited) { Text( - text = richText, - style = CodeTheme.typography.textMedium.copy( - fontWeight = FontWeight.Medium - ), - color = CodeTheme.colors.textMain, + modifier = Modifier.align(Alignment.BottomEnd), + text = markerLabel, + style = markerStyle, + color = CodeTheme.colors.textSecondary, ) } } @@ -411,6 +504,45 @@ private fun Preview_TextBubble_Incoming() { ) } +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_TextBubble_Edited() { + TextBubble( + text = "Hey! How's it going?", + isFromSelf = true, + position = BubblePosition.Solo, + maxWidth = 300.dp, + isEdited = true, + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_TextBubble_EditedLongMessage() { + TextBubble( + text = "A long enough message that the last line has no room left for the marker, so the reservation wraps onto a line of its own and the bubble grows to fit it.", + isFromSelf = true, + position = BubblePosition.Solo, + maxWidth = 300.dp, + isEdited = true, + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_TextBubble_Tombstone() { + TextBubble( + text = "You deleted this message", + isFromSelf = true, + position = BubblePosition.Solo, + maxWidth = 300.dp, + isTombstone = true, + ) +} + @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt new file mode 100644 index 0000000000..880de8276e --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt @@ -0,0 +1,38 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MessageContent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * Deleting a message must be an in-place update of the row already on screen. The list decides that + * from the key and the content type, so both have to survive the swap from text to tombstone. + */ +class ChatListItemContentTypeTest { + + private val sentAt = Instant.fromEpochSeconds(1_000) + + private fun bubble(content: MessageContent) = ChatListItem.ContentBubble( + messageId = 42, + contentIndex = 0, + content = content, + isFromSelf = true, + timestamp = sentAt, + ) + + @Test + fun `a tombstone keeps the key and content type of the text it replaces`() { + val text = bubble(MessageContent.Text("hello")) + val tombstone = bubble(MessageContent.Deleted(sentAt, deletedBy = null)) + + assertEquals(text.itemKey, tombstone.itemKey) + assertEquals(text.itemContentType, tombstone.itemContentType) + } + + @Test + fun `cash keeps a content type of its own`() { + assertEquals("text-bubble", bubble(MessageContent.Text("hello")).itemContentType) + assertEquals("system-message", bubble(MessageContent.System("joined")).itemContentType) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageBubbleTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageBubbleTest.kt new file mode 100644 index 0000000000..1dc4436d62 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageBubbleTest.kt @@ -0,0 +1,83 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Instant + +/** + * What a deleted or edited message actually renders. The tombstone copy depends on who deleted it, + * and the "Edited" marker has to reach the accessibility tree — it is drawn pinned to the bubble + * corner rather than laid out after the text, so it would be easy to lose. + */ +@RunWith(RobolectricTestRunner::class) +class MessageBubbleTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val sentAt = Instant.fromEpochSeconds(1_000) + + private fun bubble( + content: MessageContent, + isEdited: Boolean = false, + deletedByViewer: Boolean = false, + ) = ChatListItem.ContentBubble( + messageId = 42, + contentIndex = 0, + content = content, + isFromSelf = true, + timestamp = sentAt, + isEdited = isEdited, + deletedByViewer = deletedByViewer, + ) + + private fun setBubble(item: ChatListItem.ContentBubble) { + composeTestRule.setContent { + DesignSystem { + ContentBubble(item = item, position = BubblePosition.Solo) + } + } + } + + @Test + fun `a message the viewer deleted says so`() { + setBubble(bubble(MessageContent.Deleted(sentAt, deletedBy = listOf(1, 2, 3)), deletedByViewer = true)) + + composeTestRule.onNodeWithText("You deleted this message").assertIsDisplayed() + } + + @Test + fun `a message someone else deleted is attributed to no one`() { + setBubble(bubble(MessageContent.Deleted(sentAt, deletedBy = listOf(4, 5, 6)))) + + composeTestRule.onNodeWithText("This message was deleted").assertIsDisplayed() + } + + @Test + fun `an edited message shows the marker alongside its body`() { + setBubble(bubble(MessageContent.Text("hello"), isEdited = true)) + + // The body carries the marker's layout reservation as a trailing placeholder character, + // so its text is "hello" plus that one character. + composeTestRule.onNodeWithText("hello", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Edited").assertIsDisplayed() + } + + @Test + fun `an unedited message has no marker`() { + setBubble(bubble(MessageContent.Text("hello"))) + + composeTestRule.onNodeWithText("hello").assertIsDisplayed() + composeTestRule.onAllNodesWithText("Edited").assertCountEquals(0) + } +} 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 5af1d838b8..0088d4bf0c 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 0000000000..0017411275 --- /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 0000000000..fcaa3eadd0 --- /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 01d2a08fab..ae9f549468 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 0000000000..08d029064a --- /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 0000000000..3d645e813f --- /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 0000000000..a0ff54ab3d --- /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 59f2948878..cdc81c25ee 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 e651693189..215cedf3bf 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,