From 455cdcd81896ddb58bfc355e6ffacf03f61700c9 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 14:01:19 -0400 Subject: [PATCH] =?UTF-8?q?Edit,=20forward=20and=20pin=20from=20the=20conv?= =?UTF-8?q?ersation=20(=C2=A76.15/=C2=A76.16/=C2=A76.19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PARITY.md | 6 +- .../io/flamenet/messenger/app/ChatScreens.kt | 217 ++++++++++++++++++ .../io/flamenet/messenger/app/MainActivity.kt | 2 +- .../messenger/app/MessengerViewModel.kt | 84 ++++++- .../io/flamenet/messenger/app/Session.kt | 138 ++++++++++- .../messenger/app/EditForwardPinWireTest.kt | 139 +++++++++++ 6 files changed, 568 insertions(+), 18 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt diff --git a/PARITY.md b/PARITY.md index 970fdfa..91f5fc3 100644 --- a/PARITY.md +++ b/PARITY.md @@ -94,15 +94,15 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | History at rest | ✅ encrypted, keyed by person not device | | Session restore across restarts | ✅ credential and device both survive | | Reactions | ✅ transcript carries a `mid`; long-press gives a five-emoji row, one emoji per person | -| Edit a sent message (§6.15) | 🟡 inbound edits now APPLY to the message they name; offering the control is UI work | +| Edit a sent message (§6.15) | ✅ inbound edits apply to the message they name; **Edit** in the long-press menu, own messages only, bubble marked `edited` | | Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences | -| Forwarding (§6.16) | 🟡 `fwd` now reaches the transcript so second-hand words are not drawn as the sender's own; originating needs the UI | +| Forwarding (§6.16) | ✅ `fwd` reaches the transcript and the bubble; **Forward** offers people and groups, and carries no trail back to the source | | Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id | | Mentions (§6.18) | ✅ codec, an `@` picker in the group composer, and ids recorded at pick time rather than re-derived from the text | | Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress | | Search inside messages | ✅ scanned, never indexed | | Search inside messages | ✅ scanned, never indexed — no plaintext index at rest | -| Pinned message (§6.19) | 🟡 inbound pins now stored per thread, sealed in the vault; the banner and the control are UI work | +| Pinned message (§6.19) | ✅ inbound pins stored per thread sealed in the vault; **Pin/Unpin** in the menu and a banner above the thread | | Photos | ✅ send via the system picker, render inline | | Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send | | Stories, and seen-state | ✅ sent per-contact, expired bytes deleted | 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 543469c..81cd221 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1622,6 +1622,8 @@ fun ColumnScope.ChatScreen( avatars: Map = 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 = 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(null) } + var forwarding 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 + // 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, ): 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, + groups: List, + 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") } + }, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 07188dc..b0e1488 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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 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 21c4535..9acf849 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 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 3309158..04b964f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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() diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt new file mode 100644 index 0000000..62ec02e --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt @@ -0,0 +1,139 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.Content +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What the three new controls actually put on the wire (§6.15/§6.16/§6.19). + * + * 🔴 **The privacy rule for a forward is the one worth testing, because it is + * the one a "helpful" change would break.** §6.16 says a forward carries the + * words and nothing about where they came from. 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. So + * the absence of that field is asserted, not just the presence of the flag. + * + * ⚠️ These check the ENVELOPE. The `Session` methods that build and apply them + * need a vault and a keystore, so their behaviour is covered on device; what is + * checkable here is the shape that crosses to the other app, which is where an + * interop bug would live. + */ +class EditForwardPinWireTest { + + private fun parse(json: String): Content = Content.parse(json)!! + + // ---- Forward (§6.16) ------------------------------------------------- + + /** + * 🔴 The whole point of the feature: forwarded, and no trail back. + */ + @Test + fun `a forward carries the words and nothing about their origin`() { + val body = Content.text("passed along", messageId = "mid-new", forwarded = true) + val sent = parse(body) + + assertEquals("passed along", sent.text) + assertTrue(sent.isForwarded, "the fwd flag was lost") + // 🔴 No reference to the original, in any form. + assertNull(sent.about, "a forward is carrying a reference to its source") + assertNull(sent.editOf) + assertNull(sent.pins) + assertTrue(sent.isRenderable, "a forward is a message and must be drawn") + } + + /** + * ⚠️ A forward gets a NEW id, not the original's. Reusing it would make the + * copy a target for edits and reactions aimed at the original — one of + * which would silently rewrite the other. + */ + @Test + fun `a forward is a new message, not the old one re-sent`() { + val first = parse(Content.text("passed along", messageId = "mid-a", forwarded = true)) + val second = parse(Content.text("passed along", messageId = "mid-b", forwarded = true)) + assertTrue(first.messageId != second.messageId) + } + + /** A forward into a group carries the gid, and still no origin. */ + @Test + fun `a group forward carries the group and still no origin`() { + val sent = parse( + Content.groupText("passed along", groupId = "gid-1", + messageId = "mid-new", forwarded = true), + ) + assertEquals("gid-1", sent.groupId) + assertTrue(sent.isForwarded) + assertNull(sent.about, "a group forward is carrying a reference to its source") + } + + // ---- Edit (§6.15) ---------------------------------------------------- + + @Test + fun `an edit names the message it replaces and carries the new wording`() { + val sent = parse(Content.edit("the corrected wording", of = "mid-1")) + assertEquals("mid-1", sent.editOf) + assertEquals("the corrected wording", sent.text) + // 🔴 Never drawn as a bubble of its own — the failure that shipped a + // duplicate beside the typo. + assertFalse(sent.isRenderable) + } + + @Test + fun `an edit in a group names the group`() { + val sent = parse(Content.edit("fixed", of = "mid-1", groupId = "gid-1")) + assertEquals("gid-1", sent.groupId) + assertEquals("mid-1", sent.editOf) + } + + // ---- Pin (§6.19) ----------------------------------------------------- + + /** + * 🔴 An id and never a copy. A copy would go stale the moment the message + * was edited and would outlive it being deleted — a banner quoting words + * that are no longer in the conversation. + */ + @Test + fun `a pin names an id and carries no copy of the message`() { + val sent = parse(Content.pin("mid-1")) + assertEquals("mid-1", sent.pins) + assertTrue(sent.text.isNullOrEmpty(), "a pin is carrying a copy of the message") + assertFalse(sent.isRenderable) + } + + /** Unpinning is the same envelope with an empty id, not a second kind. */ + @Test + fun `taking a pin down is the same envelope with an empty id`() { + val sent = parse(Content.pin("")) + assertEquals("", sent.pins) + assertFalse(sent.isRenderable) + } + + @Test + fun `a pin in a group names the group`() { + val sent = parse(Content.pin("mid-1", groupId = "gid-1")) + assertEquals("gid-1", sent.groupId) + assertEquals("mid-1", sent.pins) + } + + // ---- The three do not collide ---------------------------------------- + + /** + * ⚠️ Each envelope sets exactly one of the three fields. A message that was + * both an edit and a pin would be applied twice by a receiver that checks + * them in sequence, and the order of those checks is not part of the spec. + */ + @Test + fun `each control sets exactly one of the three fields`() { + val edit = parse(Content.edit("x", of = "mid-1")) + assertTrue(edit.editOf != null && edit.pins == null && !edit.isForwarded) + + val pin = parse(Content.pin("mid-1")) + assertTrue(pin.pins != null && pin.editOf == null && !pin.isForwarded) + + val forward = parse(Content.text("x", messageId = "mid-2", forwarded = true)) + assertTrue(forward.isForwarded && forward.editOf == null && forward.pins == null) + } +} -- 2.54.0