From 1cda4050723a069fdcc37f925e2e615a37a48e5d Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 15:59:07 -0400 Subject: [PATCH] Say which envelopes are worth interrupting somebody for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay announced every envelope as "New message" because it holds ciphertext and cannot tell. It now takes `silent` from the sender. Marked silent: call answers, call ends, delivery keys, key-log gossip, reactions, avatars โ€” everything that is a layer talking to a layer. A call offer still announces itself, and so does anything a person would see in the thread. `Content.isRenderable` already drew exactly this line for the receive side; this is the same question asked on the way out. โš ๏ธ A plain string is a message: text from an older build carries no envelope at all, and the safe reading of something unrecognised is that a person meant to say it. --- .../io/flamenet/client/Conversations.kt | 23 +++++++++++++++++++ .../kotlin/io/flamenet/client/RelayClient.kt | 14 +++++++++++ 2 files changed, 37 insertions(+) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index ea62792..1b5ee14 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -532,6 +532,28 @@ class Conversations( fun sendPlaintextForGroupInvite(toUserId: Long, text: String): Sent = sendPlaintext(toUserId, text) + /** + * Whether an envelope is worth interrupting somebody for. + * + * ๐Ÿ”ด **Everything this app sends was.** The relay holds ciphertext and + * cannot tell a call answer from a birthday message, so it announced every + * envelope as "New message" โ€” and call answers, call ends, delivery keys, + * key-log gossip, reactions and avatars are all envelopes. One call + * produced a burst of banners on the far end for things nobody sent. + * + * โš ๏ธ A plain string is a message: text from an older build carries no + * envelope at all, and the safe reading of something unrecognised is that a + * person meant to say it. + */ + private fun warrantsAlert(body: String): Boolean { + if (!body.startsWith("""{"fnc":""")) return true + val content = Content.parse(body) + // Only the OFFER announces itself. An answer and a hang-up are the far + // end's half of a conversation already on screen. + content.call?.let { return it.action == Content.CallSignal.OFFER } + return content.isRenderable + } + private fun sendPlaintext(toUserId: Long, text: String): Sent { // ๐Ÿ”ด **The DIRECTORY, not the key route.** `/e2e/keys` pops a one-time // prekey per device and is metered per contact pair โ€” calling it on @@ -696,6 +718,7 @@ class Conversations( try { relay.sendEnvelopes( toUserId, outgoing, identity.deviceId, + silent = !warrantsAlert(text), // โš ๏ธ The key only when there is no capability. Sending both // would let the relay accept on the key when the capability // had been revoked, so it would never learn it was refused diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index a171457..61e7ca4 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -217,6 +217,19 @@ class RelayClient( * revocation is the whole lever ยง21 adds. */ deliveryToken: String? = null, + /** + * Deliver without announcing: protocol traffic, not a message. + * + * ๐Ÿ”ด **The relay announced everything, because it cannot tell.** It + * holds ciphertext, so a call answer, a delivery key and a birthday + * message are the same bytes to it โ€” and every one of them raised + * "New message" on the far end. One call produced a burst of banners + * for things nobody sent. The sender is the only party that knows. + * + * โš ๏ธ Advisory and unauthenticated. A sender that lies suppresses its + * own notifications and nobody else's. + */ + silent: Boolean = false, ): JSONObject = postJson("/e2e/messages", JSONObject() .put("to", toUser) @@ -224,6 +237,7 @@ class RelayClient( if (deliveryToken != null) body.put("delivery_token", deliveryToken) else if (deliveryKey != null) body.put("delivery_key", deliveryKey) else body.put("from_device", fromDevice) + if (silent) body.put("silent", true) } .put("messages", JSONArray().also { array -> envelopes.forEach {