Edit, forward and pin from the conversation (§6.15/§6.16/§6.19)

The outbound half of the three controls whose codecs were already back. The
long-press menu gains Edit, Pin/Unpin and Forward; the thread gains a pinned
banner; a bubble says `edited` and `Forwarded`.

🔴 An edit that silently replaces the words is worse than the typo. The reader
has no way to tell a correction from their own misremembering, and no way to
know the message they replied to is not the message that is there now. So the
bubble is marked, on both ends, and the edit sheet says plainly that everyone in
the conversation sees the new wording and a mark on their copy.

🔴 A forward carries the words and NOTHING about where they came from — no
author, no thread, no quote — and the test asserts the ABSENCE of a reference
rather than only the presence of the flag. The obvious "improvement", carrying
a reference so the reader can see the original, would republish a private
message's author to somebody who was never in that conversation. It also gets a
NEW message id: reusing the original's would make the copy a target for edits
and reactions aimed at the original, one of which would silently rewrite the
other.

⚠️ Only your own message can be edited, checked in `Session` and not only in
the menu. The receiving side enforces the same rule independently, so neither
end is trusting the other. Anyone may PIN, by contrast: a pin is a shared
banner rather than a permission, and restricting it to the author would mean the
useful message in a group is exactly the one nobody but its writer can pin.

⚠️ Edits and pins are applied locally first and rolled back if the send fails,
the same shape `react` already uses. Waiting on a round trip to see your own
correction appear makes it feel broken on a slow connection; leaving one that
exists only on this phone is worse, because you would believe you had fixed it.

⚠️ A forward does not carry an attachment yet. Re-sending a blob means
re-uploading it under a new token, and passing on the ORIGINAL token would hand
the new recipient a capability to a file in a conversation they are not part of.
Text only until that upload exists.

⚠️ The pinned banner resolves the id against the lines on screen, so a pinned
message that was since deleted shows nothing rather than a stale copy. Clearing
a thread clears its pin.

Two things found while wiring it: `Content.text` has no `groupId` parameter, so
a group forward needs `groupText` — a compile error rather than a silent drop.
And the edit/forward state is hoisted above the message list, because a row's
own `remember` dies when it scrolls out and an edit box vanishing mid-typing is
the worst way to lose a correction.

9 more unit tests. App builds, every JVM suite green. The three PARITY rows are
now ✅.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 14:01:19 -04:00
co-authored by Claude Opus 5
parent e0420a00c6
commit 455cdcd818
6 changed files with 568 additions and 18 deletions
@@ -1622,6 +1622,8 @@ fun ColumnScope.ChatScreen(
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
/** Ours, so the mention list never offers you yourself. */
myUserId: Long = 0L,
/** Somewhere to forward to (§6.16); groups as well as people. */
groups: List<io.flamenet.client.RelayClient.Group> = emptyList(),
) {
// ⚠️ Keyed on the thread, so switching conversations re-reads rather than
// carrying one person's half-written message into somebody else's composer.
@@ -1741,6 +1743,62 @@ fun ColumnScope.ChatScreen(
// because a timer nobody can see is a timer nobody trusts.
if (chat.peerId != null) ConversationStatusLine(model, chat)
// §6.15/§6.19 — which message a sheet is currently about. Hoisted above the
// list because a row's own `remember` dies when it scrolls out, and an edit
// box that vanished mid-typing because the message scrolled away would be
// 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 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
// pinned message that was since deleted shows nothing rather than a stale
// copy of words that are gone.
chat.pinnedMessageId?.let { pinnedId ->
chat.lines.firstOrNull { it.id == pinnedId }?.let { pinned ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
.background(Palette.Accent.copy(alpha = 0.10f))
.padding(horizontal = 12.dp, vertical = 8.dp)
.testTag("pinned-banner"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
Text("Pinned", fontSize = 11.sp, color = Palette.SecondaryText)
Text(
pinned.text.take(80),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
editing?.let { line ->
EditMessageSheet(
line = line,
onDismiss = { editing = null },
onSave = { text -> model.editMessage(line, chat, text); editing = null },
)
}
forwarding?.let { line ->
ForwardSheet(
line = line,
buddies = buddies,
groups = groups,
onDismiss = { forwarding = null },
onPick = { peerId, group ->
model.forwardMessage(line.text, peerId, group)
forwarding = null
},
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
@@ -1822,6 +1880,32 @@ fun ColumnScope.ChatScreen(
onClick = { clipboard.setText(AnnotatedString(line.text)); menuOpen = false },
)
}
// ⚠️ Every control below needs an id to point at, so all
// of them are absent — not disabled — on a message written
// before ids existed. A greyed-out row invites a tap and
// teaches nothing.
if (line.id != null && line.mine && line.text.isNotBlank()) {
DropdownMenuItem(
text = { Text("Edit") },
onClick = { editing = line; menuOpen = false },
modifier = Modifier.testTag("edit-message"),
)
}
if (line.id != null) {
val isPinned = chat.pinnedMessageId == line.id
DropdownMenuItem(
text = { Text(if (isPinned) "Unpin" else "Pin") },
onClick = { model.pinMessage(line, chat, !isPinned); menuOpen = false },
modifier = Modifier.testTag("pin-message"),
)
}
if (line.text.isNotBlank()) {
DropdownMenuItem(
text = { Text("Forward") },
onClick = { forwarding = line; menuOpen = false },
modifier = Modifier.testTag("forward-message"),
)
}
DropdownMenuItem(
text = { Text(if (line.mine) "Unsend" else "Delete for me") },
onClick = { model.deleteMessage(line); menuOpen = false },
@@ -2165,6 +2249,23 @@ private fun Bubble(
modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp),
) {
Text(time, fontSize = 10.sp, color = Palette.SecondaryText)
// 🔴 **An edit says so (§6.15).** Silently replacing the words is
// worse than the typo: the reader has no way to tell a correction
// from their own misremembering, and no way to know the message
// they replied to is not the message that is there now.
if (line.editedAt != null) {
Text("edited", fontSize = 10.sp, color = Palette.SecondaryText,
modifier = Modifier.testTag("edited-marker"))
}
// ⚠️ **THAT it was forwarded, never from whom (§6.16).** Second-hand
// words drawn as the sender's own is the thing this prevents;
// naming the original author would be a different feature and a
// worse one, because it republishes somebody who was never in this
// conversation.
if (line.forwarded) {
Text("Forwarded", fontSize = 10.sp, color = Palette.SecondaryText,
modifier = Modifier.testTag("forwarded-marker"))
}
// 🔴 **A message nobody received must not look like one that was
// received.** That sameness is the whole of what makes a ghost
// message possible; every specific cause behind one is downstream
@@ -3733,3 +3834,119 @@ internal fun memberLabel(
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
): String = buddies.firstOrNull { it.userId == id }
?.let { it.displayName.ifBlank { it.handle } } ?: "user $id"
/**
* Change the wording of a message already sent (§6.15).
*
* ⚠️ **Opens with the current text already in it.** An edit box that starts
* empty is a retype, and somebody correcting one word in a long message would
* rather not. The cursor sits at the end for the same reason.
*
* ⚠️ Saving nothing is refused rather than treated as a delete. Emptying the
* box is the shape of a slip, and "Unsend" is right there in the same menu for
* anybody who meant it.
*/
@Composable
private fun EditMessageSheet(
line: Session.Line,
onDismiss: () -> Unit,
onSave: (String) -> Unit,
) {
var text by remember(line.id) { mutableStateOf(line.text) }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit message") },
text = {
Column {
androidx.compose.material3.OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth().testTag("edit-field"),
singleLine = false,
)
Spacer(Modifier.height(Space.xs))
// 🔴 Says what the other side will see. An edit is not a quiet
// repair: the recipient's bubble changes and is marked, and
// somebody about to correct a typo should know that before they
// do it rather than discover it afterwards.
Text(
"Everyone in this conversation sees the new wording, and their "
+ "copy is marked as edited.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { onSave(text) },
enabled = text.isNotBlank() && text != line.text,
modifier = Modifier.testTag("edit-save"),
) { Text("Save") }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
/**
* Pass a message's words on to another conversation (§6.16).
*
* 🔴 **The list is people and groups, and the message travels without its
* author.** What is sent is the text with `fwd` set — no original thread, no
* name, no quote. A forward that named its source would republish a private
* message's author to somebody who was never in that conversation, which is
* the one thing §6.16 exists to prevent.
*/
@Composable
private fun ForwardSheet(
line: Session.Line,
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
groups: List<io.flamenet.client.RelayClient.Group>,
onDismiss: () -> Unit,
onPick: (Long?, io.flamenet.client.RelayClient.Group?) -> Unit,
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Forward to") },
text = {
Column(modifier = Modifier.testTag("forward-list")) {
Text(
// ⚠️ States the cost plainly. "Forwarded" is all the
// recipient is told, and somebody passing on a message
// should know their own name is what travels with it.
"This sends the words on their own. Whoever receives it sees "
+ "that it was forwarded, and sees it from you.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
groups.forEach { group ->
Text(
group.name.ifBlank { "Group" },
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(null, group) }
.padding(vertical = 10.dp)
.testTag("forward-group-${group.id}"),
)
}
buddies.forEach { buddy ->
Text(
buddy.displayName.ifBlank { buddy.handle },
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(buddy.userId, null) }
.padding(vertical = 10.dp)
.testTag("forward-to-${buddy.userId}"),
)
}
}
},
confirmButton = {},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -233,7 +233,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
val chat = current.openChat
if (chat != null) {
ChatScreen(model, chat, current.error, current.buddies, current.avatars,
myUserId = current.userId)
myUserId = current.userId, groups = current.groups)
} else {
// 🔴 **The content area is weighted, and the tab bar is
// not.** These used to be plain siblings, so the tab bar
@@ -247,6 +247,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* number that points at somebody else.
*/
val address: String? = null,
/**
* The message pinned in this conversation (§6.19), or null.
*
* ⚠️ An id, resolved against [lines] at draw time. Holding a copy
* of the message here would go stale the moment it was edited and
* would outlive it being deleted — a banner quoting words that are
* no longer in the thread.
*/
val pinnedMessageId: String? = null,
/**
* What we believe about the other side's identity keys (§19).
*
@@ -712,6 +721,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
disappearSeconds = current.conversations.disappearSeconds(peerId),
replyReminderAt = current.replyReminder(peerId),
scheduled = current.scheduledFor(peerId),
pinnedMessageId = current.pinnedMessage(thread),
),
// Opening the thread is what marks it read. Nothing is sent to
// the relay for this — read state is local, because a read
@@ -750,7 +760,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
update {
it.copy(
openChat = Phase.Chat(
thread, card.address, current.history(thread), address = card.address),
thread, card.address, current.history(thread), address = card.address,
pinnedMessageId = current.pinnedMessage(thread)),
unread = it.unread - thread,
error = null,
)
@@ -857,7 +868,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val thread = Vault.threadForGroup(group.id)
update {
it.copy(
openChat = Phase.Chat(thread, group.name, current.history(thread), group = group),
openChat = Phase.Chat(thread, group.name, current.history(thread), group = group,
pinnedMessageId = current.pinnedMessage(thread)),
unread = it.unread - thread,
)
}
@@ -2035,6 +2047,74 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* the tap registers immediately and a failed send puts back what was
* there. All this has to do is re-read the thread afterwards.
*/
/**
* Change the wording of a message already sent (§6.15).
*
* ⚠️ Refreshes the open chat from the transcript afterwards, like
* `reactToMessage` — the edit is applied to storage first and the screen
* reads from storage, so without this the correction lands and is invisible
* until the next poll.
*/
fun editMessage(line: Session.Line, chat: Phase.Chat, newText: String) =
viewModelScope.launch {
val current = session ?: return@launch
val mid = line.id ?: return@launch
withContext(Dispatchers.IO) {
runCatching {
current.editMessage(chat.threadId, mid, newText,
peerUserId = chat.peerId, group = chat.group)
}
}
update {
it.copy(openChat = it.openChat?.copy(
lines = current.history(chat.threadId),
pinnedMessageId = current.pinnedMessage(chat.threadId)))
}
}
/** Pin a message, or take the pin down by pinning the empty id (§6.19). */
fun pinMessage(line: Session.Line, chat: Phase.Chat, pin: Boolean) =
viewModelScope.launch {
val current = session ?: return@launch
val mid = line.id ?: return@launch
withContext(Dispatchers.IO) {
runCatching {
current.pinMessage(chat.threadId, if (pin) mid else "",
peerUserId = chat.peerId, group = chat.group)
}
}
update {
it.copy(openChat = it.openChat?.copy(
lines = current.history(chat.threadId),
pinnedMessageId = current.pinnedMessage(chat.threadId)))
}
}
/**
* Pass a message's words on to another conversation (§6.16).
*
* 🔴 Takes the TEXT, not the line. A forward carries the words and nothing
* about where they came from, and a signature that accepted the whole line
* would make it easy to start carrying the author "just for context" —
* which is the one thing §6.16 exists to prevent.
*/
fun forwardMessage(text: String, toPeerUserId: Long?, toGroup: RelayClient.Group?) =
viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) {
runCatching { current.forwardMessage(text, toPeerUserId, toGroup) }
}
// ⚠️ The forward lands in ANOTHER thread, so the chat on screen is
// not the one that changed. Only the open chat is re-read, which is
// correct: reloading the destination would be reloading a screen
// nobody is looking at.
update {
it.copy(openChat = it.openChat?.let { chat ->
chat.copy(lines = current.history(chat.threadId))
})
}
}
fun reactToMessage(emoji: String, line: Session.Line, chat: Phase.Chat) =
viewModelScope.launch {
val current = session ?: return@launch
@@ -738,18 +738,7 @@ class Session(
if (!applyReaction(threadId, messageId, userId, emoji)) return false
val reference = Content.Reference(messageId, author, "")
val body = Content.reaction(emoji, reference, groupId = group?.id)
val sent = runCatching {
when {
// ⚠️ Pairwise, like every group message: the relay does no
// fan-out, so a group reaction is one send per member.
group != null -> group.memberIds
.filter { it != userId }
.map { conversations.send(it, body).reachedSomeone }
.any { it }
peerUserId != null -> conversations.send(peerUserId, body).reachedSomeone
else -> false
}
}.getOrDefault(false)
val sent = sendToThread(body, peerUserId, group)
if (!sent) {
applyReaction(threadId, messageId, userId, previous)
return false
@@ -757,6 +746,131 @@ class Session(
return true
}
/**
* Fan one control envelope out to a thread, exactly as `react` does.
*
* ⚠️ Pairwise for a group, because the relay does no fan-out — one send per
* member. Factored out of `react` rather than copied into three new
* callers: three copies of "which sends does this thread need" is three
* places for a group to be handled as a 1:1.
*/
private fun sendToThread(
body: String,
peerUserId: Long?,
group: RelayClient.Group?,
): Boolean = runCatching {
when {
group != null -> group.memberIds
.filter { it != userId }
.map { conversations.send(it, body).reachedSomeone }
.any { it }
peerUserId != null -> conversations.send(peerUserId, body).reachedSomeone
else -> false
}
}.getOrDefault(false)
/**
* Change the wording of a message already sent (§6.15).
*
* 🔴 **Only your own, and the check is here rather than only in the UI.** A
* menu that hides the control is a convenience; this is the rule. The
* receiving side enforces the same thing (`applyEdit` refuses an edit from
* anybody but the author), so neither end is trusting the other.
*
* ⚠️ **Applied locally first, then sent, and rolled back if the send
* fails** — the same shape as `react`. Waiting on a round trip to see your
* own correction appear makes it feel broken on a slow connection, and
* leaving a correction that exists only on this phone is worse: you would
* believe you had fixed it.
*/
fun editMessage(
threadId: String,
messageId: String,
newText: String,
peerUserId: Long?,
group: RelayClient.Group?,
): Boolean {
val line = history(threadId).firstOrNull { it.id == messageId } ?: return false
if (!line.mine) return false
if (newText.isBlank()) return false
val previous = line.text
if (!applyEdit(threadId, messageId, userId, newText)) return false
val body = Content.edit(newText, of = messageId, groupId = group?.id)
if (!sendToThread(body, peerUserId, group)) {
// Put the old wording back. `applyEdit` stamps `edited`, so this
// leaves the flag behind — deliberately: the message DID change on
// this device and briefly showed the new words, and pretending
// otherwise would be a second lie on top of the failed send.
applyEdit(threadId, messageId, userId, previous)
return false
}
return true
}
/**
* Pin a message in a thread, or take the pin down with an empty id (§6.19).
*
* ⚠️ **Anyone in the conversation may pin.** A pin is a shared banner, not
* a permission — restricting it to the author would mean the useful message
* in a group is exactly the one nobody but its writer can pin.
*/
fun pinMessage(
threadId: String,
messageId: String,
peerUserId: Long?,
group: RelayClient.Group?,
): Boolean {
val previous = pinnedMessage(threadId)
applyPin(threadId, messageId)
val body = Content.pin(messageId, groupId = group?.id)
if (!sendToThread(body, peerUserId, group)) {
applyPin(threadId, previous.orEmpty())
return false
}
return true
}
/**
* Pass somebody's words on to another conversation (§6.16).
*
* 🔴 **The forward carries the words and NOT where they came from.** No
* author, no original thread, no quote — that is the whole point of §6.16,
* and it is why this sends a plain text envelope with `fwd` set rather than
* a reply that references the original. A forward that named its source
* would republish a private message's author to somebody who was never in
* that conversation.
*
* ⚠️ An attachment is deliberately not carried yet. Re-sending a blob means
* re-uploading it under a new token, and a forward that silently shared the
* ORIGINAL token would hand the new recipient a capability to a file in a
* conversation they are not part of.
*/
fun forwardMessage(
text: String,
toPeerUserId: Long?,
toGroup: RelayClient.Group?,
): Boolean {
if (text.isBlank()) return false
val mid = java.util.UUID.randomUUID().toString()
// ⚠️ Two builders, because a group message carries its gid inside the
// envelope and a 1:1 one has no field for it. `Content.text` takes no
// groupId at all — passing one is a compile error rather than a silent
// drop, which is how this was caught.
val body = if (toGroup != null) {
Content.groupText(text, groupId = toGroup.id, messageId = mid,
forwarded = true)
} else {
Content.text(text, messageId = mid, forwarded = true)
}
if (!sendToThread(body, toPeerUserId, toGroup)) return false
val thread = toGroup?.let { Vault.threadForGroup(it.id) }
?: toPeerUserId?.let { Vault.threadForUser(it) }
?: return true
record(thread, text, mine = true, messageId = mid, forwarded = true,
delivery = Delivery.DELIVERED)
return true
}
private fun enqueue(peerUserId: Long, text: String, at: Long,
thread: String? = null) {
val pending = vault.outbox()