From 3b195c730c2b8614408e804dbeaf4d7910d60fea Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 27 Aug 2026 18:50:48 -0400 Subject: [PATCH] Teach both engines the sticker wire A sticker is a content envelope carrying an id, never an image: sending the picture would cost an upload and a download of bytes both ends already ship with, on every sticker. This is about sixty bytes. Recognised before the text path on both sides, because a sticker is renderable but is NOT text - it draws as a glyph with no bubble, and falling through would render it as an empty message. An unknown pack or id has to degrade to the pack's fallback rather than break, which is what the envelope format exists for. The pack is one JSON manifest read by both clients, so there is a single source of truth for what exists. docs/STICKERS.md says plainly what the first cut is: the glyphs are Unicode, because real sticker art is a design job, not a code one. The protocol, picker, rendering, fallback and tests are real and finished; swapping in artwork is a change to the manifest plus an image loader, not to any of that. It is not a claim to have drawn a sticker set. Emoji are deliberately NOT this. They go into the text of an ordinary message and are indistinguishable from any other character once sent - no new kind, no new field, nothing for the receiver to understand. That picker is a keyboard convenience, not a protocol feature. Co-Authored-By: Claude Opus 5 --- .../main/assets/stickers/flamenet.core.json | 31 +++++++++++ .../main/kotlin/io/flamenet/e2e/Content.kt | 51 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 app/src/main/assets/stickers/flamenet.core.json diff --git a/app/src/main/assets/stickers/flamenet.core.json b/app/src/main/assets/stickers/flamenet.core.json new file mode 100644 index 0000000..d43933d --- /dev/null +++ b/app/src/main/assets/stickers/flamenet.core.json @@ -0,0 +1,31 @@ +{ + "pack": "flamenet.core", + "name": "Flamenet", + "fallback": "๐Ÿ™‚", + "stickers": [ + { "id": "wave", "glyph": "๐Ÿ‘‹", "name": "Wave" }, + { "id": "yes", "glyph": "๐Ÿ‘", "name": "Yes" }, + { "id": "no", "glyph": "๐Ÿ‘Ž", "name": "No" }, + { "id": "laugh", "glyph": "๐Ÿ˜‚", "name": "Laughing" }, + { "id": "love", "glyph": "โค๏ธ", "name": "Love" }, + { "id": "party", "glyph": "๐ŸŽ‰", "name": "Party" }, + { "id": "think", "glyph": "๐Ÿค”", "name": "Thinking" }, + { "id": "cry", "glyph": "๐Ÿ˜ญ", "name": "Crying" }, + { "id": "fire", "glyph": "๐Ÿ”ฅ", "name": "Fire" }, + { "id": "clap", "glyph": "๐Ÿ‘", "name": "Applause" }, + { "id": "sleep", "glyph": "๐Ÿ˜ด", "name": "Sleeping" }, + { "id": "eyes", "glyph": "๐Ÿ‘€", "name": "Looking" }, + { "id": "ok", "glyph": "๐Ÿ‘Œ", "name": "OK" }, + { "id": "pray", "glyph": "๐Ÿ™", "name": "Please" }, + { "id": "strong", "glyph": "๐Ÿ’ช", "name": "Strong" }, + { "id": "coffee", "glyph": "โ˜•", "name": "Coffee" }, + { "id": "rocket", "glyph": "๐Ÿš€", "name": "Rocket" }, + { "id": "bug", "glyph": "๐Ÿ›", "name": "Bug" }, + { "id": "check", "glyph": "โœ…", "name": "Done" }, + { "id": "warning", "glyph": "โš ๏ธ", "name": "Careful" }, + { "id": "wave-bye", "glyph": "๐Ÿซก", "name": "Salute" }, + { "id": "shrug", "glyph": "๐Ÿคท", "name": "Shrug" }, + { "id": "star", "glyph": "โญ", "name": "Star" }, + { "id": "cake", "glyph": "๐ŸŽ‚", "name": "Cake" } + ] +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index fb0eefa..23b1041 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -49,8 +49,31 @@ data class Content( /** Set iff this is a reaction: the emoji, or empty to take one back. */ val reaction: String? = null, val call: CallSignal? = null, + /** Set iff this is a sticker: which pack, and which one in it. */ + val sticker: Sticker? = null, ) { + /** + * A sticker: an id, never an image. + * + * ๐Ÿ”ด Sending the picture would cost an upload and a download of bytes both + * ends already ship with, on every sticker. The receiver draws it from its + * own pack, so this is about sixty bytes on the wire. See docs/STICKERS.md. + * + * โš ๏ธ An unknown pack or id must degrade, never break โ€” the renderer falls + * back rather than showing an empty bubble. + */ + data class Sticker(val pack: String, val id: String) { + fun toJson(): JSONObject = JSONObject().put("pack", pack).put("id", id) + + companion object { + fun fromJson(json: JSONObject) = Sticker( + pack = json.optString("pack"), + id = json.optString("id"), + ) + } + } + /** * A pointer at an earlier message. * @@ -107,6 +130,9 @@ data class Content( const val TEXT = "text" const val REACT = "react" + /** A sticker: renderable, but never as text. See docs/STICKERS.md. */ + const val STICKER = "sticker" + /** A contact's delivery key, so we can be reached sealed (ยง14.4). */ const val DELIVERY_KEY = "delivery_key" @@ -191,6 +217,20 @@ data class Content( fun reaction(emoji: String, about: Reference, groupId: String? = null): String = assemble("react", caption = emoji, groupId = groupId, about = about) + /** + * โš ๏ธ Hand-ordered like the rest: the receive side's marker check + * matches the serialized prefix, so `fnc` has to come first. + */ + fun sticker(pack: String, id: String, groupId: String? = null, + messageId: String? = null): String { + val stk = JSONObject().put("pack", pack).put("id", id) + val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(Kind.STICKER)}") + if (messageId != null) parts += "\"mid\":${quote(messageId)}" + parts += "\"stk\":$stk" + if (groupId != null) parts += "\"gid\":${quote(groupId)}" + return "{" + parts.joinToString(",") + "}" + } + fun callSignal(signal: CallSignal): String { val call = JSONObject().put("id", signal.callId) signal.sdp?.let { call.put("sdp", it) } @@ -252,6 +292,17 @@ data class Content( messageId = messageId, about = about, reaction = caption) } + // A sticker is renderable but is NOT text: it draws as a glyph with + // no bubble, so it has to be recognised here rather than falling + // through to the text path and rendering as an empty message. + if (kind == Kind.STICKER) { + val stk = json.optJSONObject("stk") + if (stk != null) { + return Content("", kind = kind, raw = json, groupId = groupId, + messageId = messageId, sticker = Sticker.fromJson(stk)) + } + } + // Control traffic. Returned with its envelope so the caller can act // on it, and never renderable. //