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