feat(expressions): custom emojis and stickers per space

Emojis and stickers get separate tables on purpose: an emoji sits inside a
sentence and a sticker is the whole message, so their sizes, limits and render
paths differ.

Emoji names are unique per space and restricted to letters, digits and
underscore — :name: has to resolve to one image, and a space or colon inside
the name would make the reference impossible to delimit. Sticker names are
labels picked from a grid, so they accept anything.

Rendering reuses the existing markdown pipeline by rewriting :name: into image
syntax, which needs no plugin. Code spans and fences are left alone: text
someone wrapped in backticks was meant to be shown literally, and turning it
into an image would destroy what they wrote.

Messages carry stickerId with ON DELETE set null — removing a sticker from the
space must not delete the history of everyone who used it. The server refuses a
sticker from another space, which would otherwise leak images between unrelated
servers.

Expressions load once per space and stay in memory: the message renderer
consults the map for every :name: it finds, and a request per lookup would turn
each message into a cascade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 17:57:32 -03:00
co-authored by Claude Opus 5
parent 50a8f12c77
commit 7d003021d1
15 changed files with 5006 additions and 8 deletions
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import { useExpressionStore } from '../../stores/expressionStore';
import { useParams, useNavigate } from 'react-router-dom';
import { SpaceSidebar } from './SpaceSidebar';
import { ChannelSidebar } from './ChannelSidebar';
@@ -217,6 +218,15 @@ export function AppLayout() {
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
useSpotifyActivity();
// Emojis e figurinhas do espaço atual, carregados uma vez por espaço: o
// render de mensagem consulta o mapa a cada `:nome:`, e buscar por mensagem
// viraria uma cascata de requisições.
const expressionSpaceId = useSpaceStore((s) => s.currentSpaceId);
const loadExpressions = useExpressionStore((s) => s.load);
useEffect(() => {
if (expressionSpaceId) void loadExpressions(expressionSpaceId);
}, [expressionSpaceId, loadExpressions]);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);