VNOX-Client/docs/05-features/custom-emoji-stickers.md

12 KiB
Raw Blame History

Custom Emoji, Stickers & GIF

Target phase: 1.3 (emoji picker) / 2 (custom emoji, stickers, GIF) Depends on: Phase 1.2 (guild system, permissions)


Overview

Three related but distinct features:

Feature Size Format Scope Storage
Custom Emoji 128×128 max PNG, GIF, WebP Per-guild Server filesystem
Stickers 512×512 max PNG, GIF, WebP, Lottie† Per-guild + per-user Server filesystem
GIF Picker External GIF (Tenor/Giphy API) Per-server config Proxy via server

† Lottie — future, not Phase 2.

Difference from unicode reactions

Unicode emoji reactions (message_reactions table, Phase 1.3) use single-codepoint emoji (👍, 🔥, 😂). Custom emoji extend this with server-hosted images referenced as :emoji_name: in messages.


Storage & Schema

Filesystem layout

data/
├── guilds/
│   └── {guild_id}/
│       ├── emojis/
│       │   ├── kappa.png
│       │   ├── pogchamp.gif
│       │   └── ...
│       └── stickers/
│           ├── wave.png
│           └── ...
├── users/
│   └── {user_id}/
│       └── stickers/       # personal stickers
│           └── ...
└── emoji_cache/            # client-side cache (per client, not server)

Database tables

guild_emojis

CREATE TABLE guild_emojis (
  id TEXT PRIMARY KEY,              -- UUID
  guild_id TEXT NOT NULL,           -- FK guilds
  name TEXT NOT NULL,               -- :name: reference (lowercase, alphanumeric + underscore)
  filename TEXT NOT NULL,           -- on-disk filename (id.png)
  content_type TEXT NOT NULL,       -- image/png, image/gif, image/webp
  file_size INTEGER NOT NULL,       -- bytes
  width INTEGER NOT NULL,           -- px
  height INTEGER NOT NULL,          -- px
  is_animated BOOLEAN NOT NULL,     -- true for GIF
  uploaded_by TEXT NOT NULL,        -- FK users
  created_at TIMESTAMP NOT NULL,
  UNIQUE(guild_id, name)
);

CREATE INDEX idx_guild_emojis_guild ON guild_emojis(guild_id);

guild_stickers

CREATE TABLE guild_stickers (
  id TEXT PRIMARY KEY,
  guild_id TEXT NOT NULL,
  name TEXT NOT NULL,
  description TEXT,
  filename TEXT NOT NULL,
  content_type TEXT NOT NULL,
  file_size INTEGER NOT NULL,
  width INTEGER NOT NULL,
  height INTEGER NOT NULL,
  uploaded_by TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL,
  UNIQUE(guild_id, name)
);

CREATE INDEX idx_guild_stickers_guild ON guild_stickers(guild_id);

user_stickers

CREATE TABLE user_stickers (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  name TEXT NOT NULL,
  filename TEXT NOT NULL,
  content_type TEXT NOT NULL,
  file_size INTEGER NOT NULL,
  width INTEGER NOT NULL,
  height INTEGER NOT NULL,
  created_at TIMESTAMP NOT NULL,
  UNIQUE(user_id, name)
);

CREATE INDEX idx_user_stickers_user ON user_stickers(user_id);

Limits (configurable in server config)

[limits.emojis]
max_per_guild = 150          # default: 150 static + animated combined
max_static_per_guild = 100
max_animated_per_guild = 50
max_file_size_kb = 256       # per emoji
max_dimension = 128          # px, both width and height

[limits.stickers]
max_per_guild = 50
max_per_user = 25
max_file_size_kb = 512       # per sticker
max_dimension = 512

Protocol

New packet types

0x0070  EMOJI_SYNC         server → client, full emoji list for guild
0x0071  EMOJI_ADD           client → server, admin upload
0x0072  EMOJI_DELETE        client → server, admin delete
0x0073  EMOJI_UPDATE        server → client, delta update (single emoji added/removed)

0x0074  STICKER_SYNC        server → client, full sticker list
0x0075  STICKER_SEND        client → server, send sticker in channel
0x0076  STICKER_UPLOAD      client → server, upload sticker
0x0077  STICKER_DELETE      client → server, delete sticker

0x0078  EMOJI_DATA          client → server, request emoji image bytes
0x0079  EMOJI_DATA_RESP     server → client, image bytes (lazy-load, not in sync)

0x0080  GIF_SEARCH          client → server, proxy search to Tenor/Giphy
0x0081  GIF_TRENDING        client → server, trending GIFs

EMOJI_SYNC (0x0070)

Server sends on guild join. Client caches locally.

{
  "guild_id": "uuid",
  "emojis": [
    {
      "id": "uuid",
      "name": "kappa",
      "content_type": "image/png",
      "is_animated": false,
      "width": 128,
      "height": 128,
      "file_size": 4096
    }
  ]
}

Image bytes are NOT included — client lazy-loads via EMOJI_DATA + EMOJI_DATA_RESP.

EMOJI_ADD (0x0071)

Admin uploads new emoji. Requires MANAGE_EMOJIS permission.

{
  "guild_id": "uuid",
  "name": "pogchamp",
  "data": "<base64>"
}

Server validates:

  • Name: lowercase alphanumeric + underscore, 2-32 chars
  • Image: PNG/GIF/WebP, max 256KB, max 128×128
  • Count: under guild limit

Response: new emoji object (same shape as in EMOJI_SYNC), server broadcasts EMOJI_UPDATE to guild.

EMOJI_DELETE (0x0072)

{
  "guild_id": "uuid",
  "emoji_id": "uuid"
}

Server removes file + DB row, broadcasts EMOJI_UPDATE with deleted: true.

EMOJI_DATA / EMOJI_DATA_RESP (0x0078/0x0079)

Lazy-load pattern — client requests image bytes when first rendering an emoji.

Request:

{
  "emoji_id": "uuid"
}

Response:

{
  "emoji_id": "uuid",
  "content_type": "image/png",
  "data": "<base64>"
}

Client caches image bytes on disk in emoji_cache/{emoji_id}.{ext}.

STICKER_SEND (0x0075)

Stickers are sent as a separate message type (not inline). The sticker reference is embedded in a chat message.

{
  "channel_id": "uuid",
  "sticker_id": "uuid",
  "sticker_type": "guild"  // "guild" | "user"
}

Gateway creates a message with content_type: "sticker" and content: {sticker_id, sticker_type}.

GIF_SEARCH (0x0080)

Client requests GIF search via server (server proxies to Tenor/Giphy so API key stays server-side).

{
  "query": "cat dancing",
  "limit": 20,
  "offset": 0
}

Response:

{
  "results": [
    {
      "id": "tenor_12345",
      "url": "https://media.tenor.com/...",
      "preview_url": "https://media.tenor.com/.../tiny.gif",
      "width": 498,
      "height": 280,
      "title": "Cat Dancing"
    }
  ]
}

GIF config in server config.toml:

[gif]
enabled = true
provider = "tenor"           # "tenor" | "giphy"
api_key = "TENOR_API_KEY"
max_results = 50

When enabled = false the GIF picker is hidden in all clients.


Client Integration

Message format with custom emoji

Messages reference custom emoji as <:emoji_name:emoji_id> inline in text content:

User: check out this <:kappa:abc123> and <:pog:def456>

Client renders by looking up emoji_id in local cache, falling back to lazy-load via EMOJI_DATA_RESP.

Unicode emoji (👍) render as-is via system font.

Emoji picker

New widget: client/src/ui/chat/emoji_picker.rs

┌─────────────────────────────┐
│ [Search emoji...        🔍] │
│─────────────────────────────│
│ Favorites  │ Custom  │ GIF  │  ← tabs
│────────────┴─────────┴──────│
│ 👍 😂 🔥 💯 🙏 😎          │
│ 🎉 ✨ 😭 💀 👀 🫡          │
│ 😈 🐱 💪 🚀 🫶 🔒          │
│─────────────────────────────│
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐   │
│ │Kappa│ │Pog │ │Hmm │ │Clap│  │  ← custom (rendered as images)
│ └───┘ └───┘ └───┘ └───┘   │
└─────────────────────────────┘

Sticker panel

New widget: client/src/ui/chat/sticker_panel.rs

  • Grid of sticker thumbnails
  • Tabs: Guild stickers / My stickers
  • Click to send as sticker message (not inline)
  • Send button shows sticker preview before sending

GIF picker

New widget: client/src/ui/chat/gif_picker.rs

  • Search bar at top
  • Trending grid when empty query
  • Results grid with animated previews
  • Click to send GIF as embedded image in chat message

Local cache

%APPDATA%/vnox/cache/
├── emojis/{emoji_id}.{ext}     # custom emoji images
├── stickers/{sticker_id}.{ext}  # sticker images
└── gifs/{gif_id}.gif           # recently used GIFs (LRU, max 50)

Cache invalidation: client re-requests on version mismatch (server increments emoji_version per guild).


Admin UX

Permissions

Permission Bit Description
MANAGE_EMOJIS 12 Upload/delete guild emojis
MANAGE_STICKERS 13 Upload/delete guild stickers

These fit into the existing u128 permission bitmask (bits 12-13 are available).

Emoji management panel

Accessible from guild settings (admin only):

┌──────────────────────────────────────────────────┐
│ Guild Emojis                          [Upload]   │
│──────────────────────────────────────────────────│
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌────────┐│
│ │Kappa │ │ Pog  │ │ Hmm  │ │ Clap │ │ (free) ││
│ │ 4KB  │ │ 12KB │ │ 8KB  │ │ 16KB │ │        ││
│ │ [✕]  │ │ [✕]  │ │ [✕]  │ │ [✕]  │ │        ││
│ └──────┘ └──────┘ └──────┘ └──────┘ └────────┘│
│──────────────────────────────────────────────────│
│ 4/150 emojis used                    [Save order]│
└──────────────────────────────────────────────────┘

Upload flow:

  1. Admin clicks Upload → file dialog (PNG/GIF/WebP)
  2. Client validates: ≤256KB, ≤128×128, valid format
  3. Client sends EMOJI_ADD with base64 data
  4. Server validates + stores + broadcasts EMOJI_UPDATE

Sticker management

Same pattern as emoji management, separate tab in guild settings.

Personal stickers managed in user settings (no admin required).


Federation Considerations (Phase 3)

Emoji visibility across nodes

When two nodes federate:

  1. Each node maintains its own emoji set
  2. Guild emoji metadata synced via federation protocol
  3. Image bytes fetched on-demand (lazy, same as client)
  4. Reference format includes origin node: <:emoji_name:emoji_id@node>

Cross-node sticker send

  1. Sticker reference sent as {sticker_id, origin_node}
  2. Receiving node fetches sticker metadata + image from origin node
  3. Caches locally for subsequent renders

GIF proxy

GIF search always goes through the user's home node. The server proxies to Tenor/Giphy — no federation needed for GIF results (they're just URLs).


Migration Path

Phase 1.3 — Unicode reactions only

  • message_reactions table (already designed)
  • Unicode emoji picker in client
  • No custom emoji, no stickers, no GIF

Phase 2 — Custom emoji

  • Add guild_emojis table
  • Add packet types 0x0070–0x0073, 0x0078–0x0079
  • Client emoji picker with custom tab
  • Admin upload/delete UI
  • No federation support yet

Phase 2 — Stickers

  • Add guild_stickers + user_stickers tables
  • Add packet types 0x0074–0x0077
  • Sticker panel in client
  • New message content_type: "sticker"

Phase 2 — GIF picker

  • Add Tenor/Giphy config to gateway
  • Add packet types 0x0080–0x0081
  • GIF picker widget in client
  • Server-side proxy (API key protection)

Phase 3 — Federation

  • Cross-node emoji/sticker sync
  • @node suffix in references