UI & Customization

Custom Emojis & Metadata Budgets

Discord-like syntax formatting, WebP serialization, tombstone deletions, and pad-relative limits.

Overview

Wiltkey supports user-generated custom emojis, invoked in chats using the :name: format. Emojis are encoded as base64-encoded WebP bytes and synced to peers over standard message lanes. Because keystream bytes cannot be reused or reclaimed, custom emojis are restricted by a soft metadata budget, and deleting an emoji results in a cryptographic tombstone.

How it Works

  1. Inline Syncing: Custom emojis are not sent via a side-channel. Defining an emoji writes an emoji_def message to our outgoing lane. Deleting an emoji writes an emoji_delete tombstone message. These consume pad bytes and propagate via standard resync sweeps. Upon receipt, they are parsed and merged into the local CustomEmojiStore rather than rendered as chat bubbles.
  2. Pad-Relative Metadata Budget: To prevent custom emojis from exhausting a device's storage, the client computes a soft metadata budget proportional to the total pad size:
    budgetBytes = totalPadBytes * 0.02 (Capped at 1MB)
    If a pad is too small to yield at least 50KB (customEmojiThreshold), custom emojis are disabled.
  3. Tombstone Deletion ("Junk Data"): When deleting an emoji, the image bytes are cleared in memory, but a dead slot marked deleted = true remains. The size of the deleted image is stashed in reservedBytes:
    approxBytes = deleted ? reservedBytes : imageB64.length
    This stashed size continues to count against the metadata budget. The tombstone's creation timestamp is bumped to the deletion time, ensuring it wins the union-merge and propagates to the peer.
  4. Union-Merge & Trimming: During sync, mergeAll merges incoming emoji lists. If the merged list exceeds the metadata budget, the oldest emojis (sorted by createdAtMs) are permanently trimmed until the list fits the budget.

Interactive Emoji Editor

To let users easily clean backgrounds and adjust orientations, Wiltkey includes an interactive, multi-stage emoji editing suite:

  • Background Erasing (Alpha Capture): Toggling Erase BG mode locks the InteractiveViewer viewport gestures and captures direct drawing strokes. The editor routes touch coordinates to an unrotated canvas, painting lines using BlendMode.clear inside a canvas.saveLayer() isolated boundary to erase pixels.
    To visualize transparency, a theme-matched dark/light checkerboard background is rendered underneath the workspace. Placing this checkerboard outside the RepaintBoundary ensures the final captured WebP retains its transparency channel without storing checkerboard pixels.
  • Refined Rotations: Supports arbitrary orientation adjustments via Transform.rotate. Users can rotate in fast 90° steps via the quick-rotate button or slide a micro-rotation slider (from -180° to +180°) with real-time degree updates.
  • Step-by-Step Undo History: All edit operations are recorded as a stack of EditStep snapshots containing the canvas rotation and cloned lists of EraseStroke points. Dragging sliders only commits a new history state upon release (via onChangeEnd), grouping continuous sliders updates into a single undo step.

Stickers

A sticker is any emoji — a unicode glyph or a custom :name: emoji — sent large and on its own by pressing and holding it in the picker (a tap still inserts it into the composer as before). It deliberately reuses the existing message pipeline rather than introducing a new content type or a dedicated payload slot, so it costs no extra OTP/keystream budget and inherits encryption, delivery receipts, and resync unchanged.

  • Charge-to-send feedback: a hold no longer fires instantly. The pressed emoji lifts off into a root Overlay where it swells, spits themed sparks, and fills a circular ring gauge over ~720 ms. When the ring completes it bursts (a heavy haptic, an expanding shockwave ring, and a shower of particles) and only then is the sticker sent. Releasing before the burst cancels cleanly; a near-instant release still counts as a plain tap-insert. This is self-contained in _ChargeSticker: one Ticker drives both the charge curve and a small px/second particle simulation (_Spark + _ChargePainter), and the send path is untouched beyond the completion callback.
  • Wire format: the message body is the bare emoji/token prefixed with an internal control-character sentinel (stk) that cannot occur in user-typed text. wrapSticker() adds it; stickerPayload() detects and strips it. The frame travels as ordinary text (1-on-1) or group_message content.
  • Rendering: on decrypt, the bubble checks stickerPayload() before the normal jumbo-emoji path. A custom token resolves to a large (~104px) image; a unicode glyph renders as ~64px text. In 1-on-1 the sticker is bubble-less (transparent fill/border, faint timestamp); in group chats it stays inside the bubble so the sender tint/name is preserved.
  • Discoverability: the picker shows a thin footer hint (chatStickerHint), and escalating haptics (a light grab on press, selection ticks as it charges, a heavy thump on burst) confirm the gesture and its progress.

Key Files & Symbols

File Path Symbol Name Description
lib/core/custom_emoji.dart CustomEmoji Represents an emoji name, WebP string, timestamp, and tombstone state.
lib/core/custom_emoji.dart CustomEmojiStore Handles local storage (SharedPreferences) and union-merges.
lib/core/custom_emoji.dart wrapSticker() / stickerPayload() Sentinel-marker codec for stickers — wraps/detects an emoji sent for big, bubble-less display over the normal text path.
.../widgets/emoji_picker_panel.dart EmojiPickerPanel.onSendSticker Charge-complete callback on a picker emoji; fires after a press-and-hold fully charges (see _ChargeSticker) to send it as a sticker instead of inserting it.
lib/core/chat_metadata.dart ChatMetaStore.budgetFor() Computes the pad-relative metadata budget: 2% of the pad, capped at 1MB.
lib/core/state_emoji.dart defineEmoji() / deleteChatEmoji() Writes emoji creation/tombstone messages into the outgoing lane.
lib/features/groups/presentation/emoji_creator_screen.dart EmojiCreatorScreen Interactive creator screen that supports cropping, rotation, erasing, and undo steps.
lib/features/groups/presentation/emoji_creator_screen.dart EraseStroke / EditStep Model classes representing custom brush strokes and undo steps.
lib/features/groups/presentation/emoji_creator_screen.dart ErasePainter / CheckerboardPainter Custom painters for rendering transparency erase blend modes and preview layouts.

Gotchas & Edge Cases

🛑 TOMBSTONES DO NOT RECLAIM SPACE
Because the keystream bytes utilized to transmit the WebP image cannot be reused, deleting a custom emoji does not reclaim metadata budget bytes. It freezes the cost in place as a tombstone. This prevents a malicious peer from continuously adding and deleting emojis to exhaust the receiver's budget.
⚠️ ERASE REPAINTS ARE LISTENABLE-DRIVEN, NOT REFERENCE-COMPARED
Active erase strokes mutate the current stroke's points list in place, so the list reference doesn't change between frames. A reference-comparing shouldRepaint therefore misses mid-stroke updates (the line only appears on the next gesture). The painter instead repaints via a repaint Listenable (a ticker bumped on each onPanUpdate), which also avoids a full-tree setState per pointer move — the source of the earlier drawing lag. Don't "optimize" ErasePainter.shouldRepaint back into a reference compare.