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:
co-authored by
Claude Opus 5
parent
1d3d097d7c
commit
6d0fe731b4
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user