Merge branch 'feat/compose-a-reply' into 'main'

Compose a reply (android#16)

Closes #16

See merge request paulhitt/flamenet-messenger-android!90
This commit was merged in pull request #117.
This commit is contained in:
Paul Hitt
2026-09-07 21:35:57 +00:00
4 changed files with 146 additions and 5 deletions
@@ -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<Session.Line?>(null) }
var forwarding by remember(chat.threadId) { mutableStateOf<Session.Line?>(null) }
/** The message the next send answers (android#16). */
var replying by remember(chat.threadId) { mutableStateOf<Session.Line?>(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.
@@ -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<Int> = 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<Int> = 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 ->
@@ -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
@@ -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)
}
}