Messaging
Message Reactions
Emoji reactions on the AES metadata channel — no OTP spend, custom-emoji aware, mutable side-metadata targeting the stable message id.
Overview
Users can react to any message with a unicode emoji or a custom :name: emoji. Unlike a chat message, a reaction spends no keystream: it rides the same AES metadata control channel used for delivery-checks and profile sync, entirely separate from the OTP pad. A reaction is mutable side-metadata — receiving one edits an already-persisted message in place rather than appending to the write-once ciphertext log.
ℹ️ WHY THE META CHANNEL AND NOT THE PAD
Reactions are high-frequency, low-value, and toggle-able. Charging them against the finite one-time pad would burn irreplaceable keystream on ephemeral 👍s. The metadata channel (AES, re-keyable, not budget-bound) is the correct transport — the same reasoning behind delivery-checks and profile updates living there.
Reactions are high-frequency, low-value, and toggle-able. Charging them against the finite one-time pad would burn irreplaceable keystream on ephemeral 👍s. The metadata channel (AES, re-keyable, not budget-bound) is the correct transport — the same reasoning behind delivery-checks and profile updates living there.
How it Works
-
Target = the stable message id:
A reaction frame references its target by
ChatMessage.id. That id is assigned by the sender and transmitted in the message envelope (envelopeJson['id']), so both peers store the same message under the same id — making it a valid cross-peer reaction key. -
Transport frames:
- 1-on-1 —
content_type: 'reaction', encrypted with the peer's one-way metadata key (ChatMetaStore.keyFor), envelope{d: enc}. Mirrorschat_info_update. - Group —
content_type: 'group_reaction', encrypted withSHA256(groupSeed)and sent full-mesh to every member, envelope{group_id, sender_id, d, t}. Mirrorsgroup_member_profile.
{target_id, emoji, op: 'add' | 'remove', v: 1}. - 1-on-1 —
-
Reactor identity:
A reaction is stored under the reactor's id —
userIdfor the local user, the frame sender's key hash for everyone else. This lets the UI mark "your" reaction and, on long-press, resolve names (viagroupMembersMetadatafor groups, the contact name for 1-on-1). - Optimistic + best-effort: Toggling applies locally first (DB + in-memory + rebuild) and then sends the frame. Frames sit in the relay's 24h store-and-forward like any message; a missed reaction is acceptable loss (no ack/retry protocol). If a reaction arrives before its target message (store-and-forward reordering), it is dropped rather than queued.
Persistence (schema v6)
Reactions live in a new reactions TEXT column on the messages table (DB bumped v5 → v6 via ALTER TABLE). The value is JSON: {token: [reactorId, ...]}.
{"👍": ["<userId>"], ":party:": ["<peerKeyHash>"]}
- Plaintext, not master-encrypted: unlike the message body (which stores a master-key copy in
text_encrypted_master), reactions are low-sensitivity — emoji tokens plus key hashes already present in the contacts table — so they are stored plaintext for cheap in-place updates. - Read-modify-write:
mutateMessageReaction(id, token, reactorId, add)reads the row's reactions, applies one add/remove, and writes back. It returns the updated map, ornullwhen the target row is missing (the best-effort drop above). - Clobber guard: because reactions ride a separate channel,
saveMessagepreserves the stored reactions when the in-memory message carries none — otherwise a resync / duplicateINSERT OR REPLACEof the message row would silently wipe reactions accrued since.
UI
- Add: long-press any settled bubble to open
showReactionPicker— a bottom sheet of quick unicode reactions, the chat's custom-emoji pool, and a + that opens the full curated emoji set (reusingEmojiPickerPanel.categories). - Display:
ReactionsRowrenders chips under the bubble, flush to it (inter-message spacing is a trailing spacer, so reactions attach to their bubble, not the one below). Tap a chip to toggle your own reaction; long-press to see who reacted. - Scale: chips scale with the chat text-size setting (
chatTextScale).reactionTokenGlyphnormalises unicode glyphs (rendered at ~0.82× font size) to the same visual box as custom-emoji images.
⚠️ CUSTOM-EMOJI REACTIONS MUST TOLERATE TOMBSTONES
A reaction can point at a
A reaction can point at a
:name: that is later emoji_deleted (see Custom Emojis). When the token no longer resolves in CustomEmojiStore.cachedMap, the chip renders a neutral placeholder glyph and keeps the count — it must never crash or drop the reaction.
Key Files & Symbols
| File Path | Symbol Name | Description |
|---|---|---|
lib/core/state_reactions.dart |
toggleReaction() |
Public entry: optimistic local apply + send the add/remove frame. |
lib/core/state_reactions.dart |
_applyReaction() / _sendReactionFrame() |
DB-authoritative apply (+ in-memory + notify); builds the 1-on-1 reaction / group group_reaction frame. |
lib/core/state_reactions.dart |
_handleReaction() / _handleGroupReaction() |
Inbound handlers, dispatched from state_inbound.dart by content_type. |
lib/core/models.dart |
ChatMessage.reactions |
Map<String, Set<String>> (token → reactor ids) + encodeReactions() / decodeReactions(). |
lib/core/db/wiltkey_db.dart |
mutateMessageReaction() |
Read-modify-write of the reactions column (v6); returns updated map or null if the row is absent. |
.../widgets/reactions.dart |
ReactionsRow / showReactionPicker / reactionTokenGlyph |
Chip row under the bubble, the add-reaction bottom sheet, and the size-normalised token renderer. |
Gotchas & Edge Cases
⚠️ REACTIONS ARE BEST-EFFORT, NOT GUARANTEED
There is no delivery ack or retry for reaction frames. Between the 24h relay TTL and the "drop if the target isn't stored yet" rule, a reaction can be lost. This is an intentional trade — reactions are cosmetic and must never block, retry-storm, or spend pad to guarantee delivery.
There is no delivery ack or retry for reaction frames. Between the 24h relay TTL and the "drop if the target isn't stored yet" rule, a reaction can be lost. This is an intentional trade — reactions are cosmetic and must never block, retry-storm, or spend pad to guarantee delivery.
🛑 DON'T LET A MESSAGE RE-SAVE WIPE REACTIONS
Reactions are synced on a channel independent of the message body. Any code path that re-persists an existing message via
Reactions are synced on a channel independent of the message body. Any code path that re-persists an existing message via
saveMessage (resync, duplicate redelivery) carries an empty in-memory reaction map. saveMessage therefore reads and preserves the stored reactions when the incoming message has none — do not "simplify" this away, or an INSERT OR REPLACE will clobber them.