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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user