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.

How it Works

  1. 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.
  2. Transport frames:
    • 1-on-1content_type: 'reaction', encrypted with the peer's one-way metadata key (ChatMetaStore.keyFor), envelope {d: enc}. Mirrors chat_info_update.
    • Groupcontent_type: 'group_reaction', encrypted with SHA256(groupSeed) and sent full-mesh to every member, envelope {group_id, sender_id, d, t}. Mirrors group_member_profile.
    The decrypted payload is {target_id, emoji, op: 'add' | 'remove', v: 1}.
  3. Reactor identity: A reaction is stored under the reactor's id — userId for 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 (via groupMembersMetadata for groups, the contact name for 1-on-1).
  4. 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, or null when the target row is missing (the best-effort drop above).
  • Clobber guard: because reactions ride a separate channel, saveMessage preserves the stored reactions when the in-memory message carries none — otherwise a resync / duplicate INSERT OR REPLACE of 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 (reusing EmojiPickerPanel.categories).
  • Display: ReactionsRow renders 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). reactionTokenGlyph normalises 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 :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.
🛑 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 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.