From 75d7113ad3f8f43d92b9f8b665406e24de8effad Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 7 Sep 2026 15:45:51 -0400 Subject: [PATCH] Compose a reply (android#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receiving half already worked. `Content.Reference` was in use for reactions, an inbound reply was recognised and rendered, and `Session.Line` already had an `about` field to hold one. What was missing was any way to START one: no Reply in the long-press menu, no composer state, and `Session.send` had nowhere to put a reference. So this is narrow, as the ticket said: `Session.send(replyTo:)` passes the reference to `Content.text(about =)` and records it locally, a Reply item sits above Forward in the menu, and a dismissible strip above the composer says who is being answered. ๐Ÿ”ด **The preview travels with the reply, and dropping it to save bytes is not a free optimisation.** A recipient who joined the thread later or cleared their history has never seen the message being answered โ€” "> [missing]" is worse than showing what was said. A test says so rather than a comment. โš ๏ธ **Visible before it is sent.** A reply that only appears in the sent message is a mode nobody can see they are in, and the commonest way to answer the wrong message is not to know you were answering one. โš ๏ธ **1:1 only, and the menu item is ABSENT rather than disabled in a group.** The group send path does not carry a reply yet; accepting one there and dropping it would be a half-feature that looks whole. The same reasoning the file already applies to Edit and Pin on messages written before ids existed. Three wire tests. One of them pins that a message which is NOT a reply still goes out as a bare body โ€” adding this must not have turned every plain message into an envelope an older peer reads differently. โš ๏ธ Not included, and its own piece: tapping the quote to jump to the message it names. That needs a scroll-to-id in the transcript list. 158 tests, 0 failures. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/ChatScreens.kt | 64 ++++++++++++++++++- .../messenger/app/MessengerViewModel.kt | 15 ++++- .../io/flamenet/messenger/app/Session.kt | 15 ++++- .../flamenet/messenger/app/ReplyWireTest.kt | 57 +++++++++++++++++ 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/ReplyWireTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index cb03ab2..d2bd6ae 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -80,6 +80,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import io.flamenet.client.IdentityTrust +import io.flamenet.e2e.Content import io.flamenet.e2e.ContactCard import io.flamenet.e2e.SafetyNumber import io.flamenet.e2e.Vault @@ -1812,6 +1813,8 @@ fun ColumnScope.ChatScreen( // the worst possible way to lose a correction. var editing by remember(chat.threadId) { mutableStateOf(null) } var forwarding by remember(chat.threadId) { mutableStateOf(null) } + /** The message the next send answers (android#16). */ + var replying by remember(chat.threadId) { mutableStateOf(null) } // ๐Ÿ”ด The pinned banner, above the messages: a pin nobody can see is a pin // nobody uses. Resolved from the id against the lines on screen, so a @@ -1980,6 +1983,17 @@ fun ColumnScope.ChatScreen( modifier = Modifier.testTag("pin-message"), ) } + // โš ๏ธ Needs an id to point at, like Edit and Pin โ€” and a + // 1:1 thread, because the group send path does not carry a + // reply yet. Absent rather than disabled where it cannot + // work: a greyed-out row invites a tap and teaches nothing. + if (line.id != null && line.text.isNotBlank() && chat.peerId != null) { + DropdownMenuItem( + text = { Text("Reply") }, + onClick = { replying = line; menuOpen = false }, + modifier = Modifier.testTag("reply-message"), + ) + } if (line.text.isNotBlank()) { DropdownMenuItem( text = { Text("Forward") }, @@ -2118,11 +2132,59 @@ fun ColumnScope.ChatScreen( // โš ๏ธ Only the ones still named in the text. Picking somebody and then // deleting their name would otherwise still ring them โ€” from their side, a // notification about a message that does not mention them. + // โš ๏ธ Above the composer and dismissible. A reply that is only visible in + // the sent message is a mode nobody can see they are in โ€” the commonest way + // to answer the wrong message is not to know you were answering one. + replying?.let { line -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Space.xs) + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.SurfaceAlt) + .padding(horizontal = Space.sm, vertical = Space.xs) + .testTag("replying-to"), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + if (line.mine) "Replying to yourself" else "Replying to ${chat.title}", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + Text( + line.text, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + KitIcon.Close, + contentDescription = "Stop replying", + tint = Palette.SecondaryText, + modifier = Modifier + .clickable { replying = null } + .testTag("cancel-reply"), + ) + } + } + val sendDraft: () -> Unit = { val named = pendingMentions .filter { draft.contains("@" + memberLabel(it, buddies)) } .map { it.toInt() } - model.sendMessage(draft, named) + // ๐Ÿ”ด The preview is the sender's own copy of the quoted words, and it + // travels with the reply. A recipient who joined later or cleared + // their history has never seen the message being answered, and + // "> [missing]" is worse than showing what was said. + val answering = replying?.let { line -> + line.id?.let { id -> + Content.Reference(id, line.author, line.text.take(140)) + } + } + model.sendMessage(draft, named, replyTo = answering) + replying = null // โš ๏ธ `setDraft`, not `draft = ""`. Clearing only the composer leaves // the stored copy behind, and the message comes back next time as a // draft of something already sent. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 9456c32..7988f56 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -11,6 +11,7 @@ import io.flamenet.client.RelayClient import io.flamenet.client.RelayAccounts import io.flamenet.client.MessengerApi import io.flamenet.client.RelayException +import io.flamenet.e2e.Content import io.flamenet.e2e.AccountKdf import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Ed25519 @@ -3394,7 +3395,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } - fun sendMessage(text: String, mentions: List = emptyList()) = viewModelScope.launch { + /** + * @param replyTo the message this one answers (android#16), or null. + * + * โš ๏ธ Only the 1:1 path carries it today. Groups and federated addresses + * take a different send, and wiring a reply through those without a way to + * render the quote in a group transcript would be a half-feature that looks + * whole โ€” the menu is hidden there rather than accepting a reply and + * dropping it. + */ + fun sendMessage(text: String, mentions: List = emptyList(), + replyTo: Content.Reference? = null) = viewModelScope.launch { val current = session ?: return@launch val chat = signedIn()?.openChat ?: return@launch if (text.isBlank()) return@launch @@ -3425,7 +3436,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // // โš ๏ธ Best-effort. Every failure path ends as a message with a // plain URL in it, which is a perfectly good thing to be. - else current.send(chat.peerId!!, text, linkPreviewFor(text)) + else current.send(chat.peerId!!, text, linkPreviewFor(text), replyTo = replyTo) } } result.onSuccess { sent -> diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 2b1227c..27ccf61 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -526,6 +526,16 @@ class Session( link: Content.LinkPreview? = null, /** A timer for this one message, in seconds (ยง6.17); 0 for none. */ lifetimeSeconds: Int = 0, + /** + * The message this one answers (android#16). + * + * โš ๏ธ **The preview inside it is the sender's own copy of the quoted + * words, and it has to travel.** A recipient who joined the thread + * later, or cleared their history, has never seen the message being + * answered โ€” a reply rendering as "> [missing]" is worse than one that + * shows what was said. + */ + replyTo: Content.Reference? = null, ): Conversations.Sent { // ๐Ÿ”ด **Every message gets an id now, so this is always an envelope.** // It used to be the raw body byte for byte, which is cheaper on the @@ -534,7 +544,7 @@ class Session( // both engines read a bare body as text, so an older peer is // unaffected โ€” see `isUnknownControl`. val mid = java.util.UUID.randomUUID().toString() - val plaintext = Content.text(text, messageId = mid, link = link, + val plaintext = Content.text(text, messageId = mid, about = replyTo, link = link, lifetimeSeconds = lifetimeSeconds) val sent = conversations.send(peerUserId, plaintext) val at = System.currentTimeMillis() @@ -542,7 +552,8 @@ class Session( // recipient's phone would be the opposite of the promise: the person // who set it would be the one still holding the message. record(Vault.threadForUser(peerUserId), text, mine = true, at = at, - link = link, messageId = mid, lifetimeSeconds = lifetimeSeconds, + link = link, messageId = mid, about = replyTo, + lifetimeSeconds = lifetimeSeconds, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) // ios#16 โ€” every send's outcome, not just this one's. `Sent.sealed` // answers "was that sealed"; the record answers "how often does this diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ReplyWireTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ReplyWireTest.kt new file mode 100644 index 0000000..f9e8157 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ReplyWireTest.kt @@ -0,0 +1,57 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.Content +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Starting a reply, on the wire (android#16). + * + * โš ๏ธ **The receiving half already worked** โ€” `InboundControlTest` has "a reply + * is still renderable", and `Content.Reference` was in use for reactions. What + * was missing was any way to START one: no Reply in the menu, no composer + * state, and `Session.send` had nowhere to put a reference. So these tests are + * about the outbound envelope, which is the half that did not exist. + */ +class ReplyWireTest { + + private fun parse(json: String) = Content.parse(json) + + @Test + fun `a reply names the message it answers`() { + val about = Content.Reference("mid-42", author = 7, preview = "the original words") + val wire = Content.text("answering that", messageId = "mid-99", about = about) + val parsed = parse(wire) + assertEquals("mid-99", parsed.messageId) + val ref = assertNotNull(parsed.about, "the reply carries no reference") + assertEquals("mid-42", ref.messageId) + assertEquals(7L, ref.author) + } + + /** + * ๐Ÿ”ด **The preview travels, and this is why.** A recipient who joined the + * thread later, or cleared their history, has never seen the message being + * answered. A reply rendering as "> [missing]" is worse than one that shows + * what was said โ€” so the sender's own copy of the quoted words goes with + * it, and dropping the field to save bytes is not a free optimisation. + */ + @Test + fun `the quoted words travel with the reply`() { + val about = Content.Reference("mid-42", author = 7, preview = "the original words") + val ref = assertNotNull(parse(Content.text("hi", messageId = "m", about = about)).about) + assertEquals("the original words", ref.preview) + } + + /** + * โš ๏ธ A message with nothing extra to carry still goes out as raw bytes, so + * an older peer reads it as text. Adding a reply must not have changed + * that for messages that are not replies. + */ + @Test + fun `a message that is not a reply is still a bare body`() { + assertEquals("just text", Content.text("just text")) + assertTrue(parse(Content.text("just text")).about == null) + } +}