Files
backspace/packages/web/src/stores/expressionStore.ts
T
devsyncwrldandClaude Opus 5 7d003021d1 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>
2026-09-01 17:57:32 -03:00

58 lines
2.1 KiB
TypeScript

import { create } from 'zustand';
import { api, type SpaceEmoji, type SpaceSticker } from '../api/client';
/**
* Emojis e figurinhas por espaço.
*
* Carregados uma vez por espaço e mantidos em memória: o render de mensagem
* consulta o mapa a cada `:nome:` encontrado, e uma busca por requisição
* tornaria cada mensagem uma cascata de chamadas.
*/
interface ExpressionState {
emojisBySpace: Map<string, SpaceEmoji[]>;
stickersBySpace: Map<string, SpaceSticker[]>;
loaded: Set<string>;
load: (spaceId: string) => Promise<void>;
emojiByName: (spaceId: string, name: string) => SpaceEmoji | undefined;
setEmojis: (spaceId: string, emojis: SpaceEmoji[]) => void;
setStickers: (spaceId: string, stickers: SpaceSticker[]) => void;
}
export const useExpressionStore = create<ExpressionState>((set, get) => ({
emojisBySpace: new Map(),
stickersBySpace: new Map(),
loaded: new Set(),
load: async (spaceId) => {
if (!spaceId || get().loaded.has(spaceId)) return;
// Marcado antes da resposta: entrar num espaço dispara vários renders, e
// sem isso a mesma busca sairia várias vezes em paralelo.
set((s) => ({ loaded: new Set(s.loaded).add(spaceId) }));
try {
const [{ emojis }, { stickers }] = await Promise.all([
api.expressions.emojis(spaceId),
api.expressions.stickers(spaceId),
]);
set((s) => ({
emojisBySpace: new Map(s.emojisBySpace).set(spaceId, emojis),
stickersBySpace: new Map(s.stickersBySpace).set(spaceId, stickers),
}));
} catch {
// Sem emojis próprios a conversa segue: `:nome:` fica como texto.
set((s) => {
const loaded = new Set(s.loaded); loaded.delete(spaceId);
return { loaded };
});
}
},
emojiByName: (spaceId, name) =>
get().emojisBySpace.get(spaceId)?.find((e) => e.name === name),
setEmojis: (spaceId, emojis) =>
set((s) => ({ emojisBySpace: new Map(s.emojisBySpace).set(spaceId, emojis) })),
setStickers: (spaceId, stickers) =>
set((s) => ({ stickersBySpace: new Map(s.stickersBySpace).set(spaceId, stickers) })),
}));