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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 18:50:48 -04:00
co-authored by Claude Opus 5
parent 1d3d097d7c
commit 6d0fe731b4
5 changed files with 226 additions and 0 deletions
+31
View File
@@ -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" }
]
}
+42
View File
@@ -54,6 +54,20 @@ enum E2EAttachment {
/// Set iff this is the sender's profile picture. Control traffic: it
/// carries an attachment and must never become a bubble.
var isAvatar: Bool = false
/// Set iff this is a sticker. Renderable, but never as text.
var sticker: StickerRef? = nil
}
/// 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.
struct StickerRef: Codable, Hashable {
let pack: String
let id: String
}
/// A pointer at an earlier message.
@@ -106,6 +120,7 @@ enum E2EAttachment {
let att: Att? // present iff the kind carries a blob
let mid: String? // this message's stable id (§6.9)
let re: Reference? // what it replies to or reacts to (§6.9)
let stk: StickerRef? // sticker only (docs/STICKERS.md)
struct Call: Codable {
let id: String
@@ -215,6 +230,25 @@ enum E2EAttachment {
mid: nil, re: reference)
}
/// Build the ratchet plaintext for a sticker.
///
/// ⚠️ Hand-ordered like the rest: the receive side's marker check matches
/// the serialized prefix, so `fnc` has to come first.
static func encodeSticker(pack: String, id: String,
gid: String? = nil, mid: String? = nil) throws -> String {
let stk = StickerRef(pack: pack, id: id)
let stkJSON = String(decoding: try JSONEncoder().encode(stk), as: UTF8.self)
var parts = ["\"fnc\":1", "\"kind\":\"sticker\""]
if let mid {
parts.append("\"mid\":\(String(decoding: try JSONEncoder().encode(mid), as: UTF8.self))")
}
parts.append("\"stk\":\(stkJSON)")
if let gid {
parts.append("\"gid\":\(String(decoding: try JSONEncoder().encode(gid), as: UTF8.self))")
}
return "{" + parts.joined(separator: ",") + "}"
}
/// Build the ratchet plaintext for a call signal (§6.8).
static func encodeCallSignal(_ signal: CallSignal) throws -> String {
let call = Envelope.Call(id: signal.callID, sdp: signal.sdp, reason: signal.reason)
@@ -272,6 +306,14 @@ enum E2EAttachment {
mid: env.mid, re: re, reaction: env.caption ?? "")
}
// A sticker is renderable but is NOT text: it draws as a glyph with no
// bubble, so it is recognised here rather than falling through to the
// text path and rendering as an empty message.
if env.kind == "sticker", let stk = env.stk {
return Content(text: "", attachment: nil, gid: env.gid,
mid: env.mid, sticker: stk)
}
// Call signalling: never rendered as a message (§6.8).
if let action = CallSignal.Action(rawValue: env.kind), let call = env.call {
return Content(text: "", attachment: nil, gid: nil,
@@ -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" }
]
}
@@ -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.
//
+71
View File
@@ -0,0 +1,71 @@
# Stickers — the wire, and what a pack is
One document because both clients have to agree, and because the last protocol
bug this project shipped was a client and a relay disagreeing about a field
name with nothing to check it against.
## The wire
A sticker is a content envelope (§6.6) like any other, so it travels through
the ratchet and the relay sees only ciphertext:
```json
{"fnc":1,"kind":"sticker","stk":{"pack":"flamenet.core","id":"wave"},"gid":"..."}
```
- `pack` — which set the id belongs to. Namespaced so a second pack cannot
collide with the first.
- `id` — the sticker within it.
- `gid` — present only in a group, exactly as a text message carries it.
🔴 **An id, not an image.** The obvious design sends the picture, and it is
wrong twice: every sticker would cost an upload and a download of bytes both
ends already have, and a set of a few dozen would multiply that by every
message. The receiver draws it from the pack it already ships with, so a
sticker message is about sixty bytes on the wire.
⚠️ **Which means an unknown id has to degrade, not break.** A pack the receiver
does not have — an older build, a future pack — must render as *something*.
Both clients fall back to the pack's `fallback` glyph and, failing that, to a
neutral placeholder. A sticker must never render as an empty bubble or as raw
JSON; that is the failure this envelope format exists to prevent.
## Never a bubble of text
`sticker` is renderable, but it is **not** text: it draws at 96pt with no
bubble behind it, because a sticker in a grey rounded rectangle reads as a
broken image. It carries no `caption` — a sticker with words on it is a
different feature.
## The pack
`Resources/stickers/flamenet.core.json` — read by both clients, so there is one
source of truth for what exists:
```json
{
"pack": "flamenet.core",
"name": "Flamenet",
"fallback": "🙂",
"stickers": [ {"id": "wave", "glyph": "👋", "name": "Wave"} ]
}
```
⚠️ **`glyph` is deliberately a Unicode character, and this is the honest part
of the first cut.** Real sticker art is a design job, not a code one. Drawing
the pack from glyphs means the protocol, the picker, the rendering, the
fallback and the tests are all real and finished, and swapping in artwork later
is a change to this file plus an image loader — not a change to any of the
above. What it is *not* is a claim to have drawn a sticker set.
A later pack that ships images adds `"image": "wave.png"` beside `glyph`;
clients prefer the image and keep the glyph as the fallback for a client that
cannot load it.
## Emoji are not stickers
Emoji 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
receiving end to understand. The picker is a keyboard convenience, not a
protocol feature. Stickers are their own kind precisely because they are not
text and must not be rendered as text.