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
+29
View File
@@ -75,6 +75,17 @@ import type {
} from '@backspace/shared';
import type { AuditEvent } from '@backspace/shared/src/audit.js';
export interface SpaceEmoji {
id: string;
spaceId: string;
name: string;
filename: string;
uploaderId: string | null;
createdAt: number;
}
export type SpaceSticker = SpaceEmoji;
export interface SoundboardSound {
id: string;
spaceId: string;
@@ -321,6 +332,15 @@ export class BackspaceApiClient {
space: (spaceId: string, days: number) => Promise<SpaceStats>;
};
readonly expressions: {
emojis: (spaceId: string) => Promise<{ emojis: SpaceEmoji[] }>;
addEmoji: (spaceId: string, name: string, filename: string) => Promise<SpaceEmoji>;
removeEmoji: (id: string) => Promise<void>;
stickers: (spaceId: string) => Promise<{ stickers: SpaceSticker[] }>;
addSticker: (spaceId: string, name: string, filename: string) => Promise<SpaceSticker>;
removeSticker: (id: string) => Promise<void>;
};
readonly pins: {
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
pin: (messageId: string) => Promise<{ success: boolean }>;
@@ -757,6 +777,15 @@ export class BackspaceApiClient {
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
};
this.expressions = {
emojis: (spaceId) => request<{ emojis: SpaceEmoji[] }>('GET', `/spaces/${spaceId}/emojis`),
addEmoji: (spaceId, name, filename) => request<SpaceEmoji>('POST', `/spaces/${spaceId}/emojis`, { name, filename }),
removeEmoji: (id) => request<void>('DELETE', `/emojis/${id}`),
stickers: (spaceId) => request<{ stickers: SpaceSticker[] }>('GET', `/spaces/${spaceId}/stickers`),
addSticker: (spaceId, name, filename) => request<SpaceSticker>('POST', `/spaces/${spaceId}/stickers`, { name, filename }),
removeSticker: (id) => request<void>('DELETE', `/stickers/${id}`),
};
this.pins = {
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
@@ -1,4 +1,5 @@
import React from 'react';
import { isCustomEmojiAlt } from '../../utils/customEmoji';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Highlight, themes } from 'prism-react-renderer';
@@ -186,7 +187,22 @@ function buildComponents(): Components {
hr: () => <hr className="border-border-soft my-2" />,
// Images (in markdown content — not attachments)
img: ({ src, alt }) => (
img: ({ src, alt }) => {
// Emoji próprio do espaço chega como imagem markdown com alt `:nome:`.
// Renderiza em tamanho de texto e inline, para caber no meio da frase em
// vez de virar um bloco como uma imagem comum.
if (isCustomEmojiAlt(alt ?? undefined)) {
return (
<img
src={src}
alt={alt ?? ''}
title={alt ?? ''}
className="inline-block align-text-bottom w-[22px] h-[22px] object-contain"
loading="lazy"
/>
);
}
return (
<div className="mt-1 max-w-[400px]">
<img
src={src}
@@ -197,7 +213,8 @@ function buildComponents(): Components {
crossOrigin="anonymous"
/>
</div>
),
);
},
// Tables (GFM)
table: ({ children }) => (
+36 -2
View File
@@ -1,4 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useExpressionStore } from '../../stores/expressionStore';
import { renderCustomEmojis } from '../../utils/customEmoji';
import { api } from '../../api/client';
import { useT } from '../../i18n';
import { createPortal } from 'react-dom';
@@ -233,6 +235,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
return () => clearTimeout(confirmDeleteTimeout.current);
}, []);
// Emojis próprios do espaço são resolvidos antes do markdown; sem espaço
// (DM, por exemplo) o texto passa intacto.
const emojiSpaceId = useSpaceStore((s) => s.currentSpaceId);
const emojiLookup = useExpressionStore((s) => s.emojiByName);
const contentWithEmojis = useMemo(() => {
if (!message.content || !emojiSpaceId) return message.content ?? '';
return renderCustomEmojis(
message.content,
(name) => emojiLookup(emojiSpaceId, name),
(filename) => api.uploads.url(filename),
);
}, [message.content, emojiSpaceId, emojiLookup]);
const stickerId = (message as MessageWithUser).stickerId;
const sticker = useExpressionStore((s) =>
stickerId && emojiSpaceId
? s.stickersBySpace.get(emojiSpaceId)?.find((k) => k.id === stickerId)
: undefined,
);
const isGifOnly = isGifOnlyMessage(message.content);
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
// sourceUrl: the original URL for context menu Copy/Open Link actions
@@ -528,7 +550,19 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
<>
{message.content && (
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
<MarkdownRenderer content={message.content} />
{sticker ? (
// Figurinha ocupa a mensagem inteira, sem moldura de
// anexo: é o conteúdo, não um arquivo acompanhando texto.
<img
src={api.uploads.url(sticker.filename)}
alt={sticker.name}
title={sticker.name}
className="w-[160px] h-[160px] object-contain rounded-lg"
loading="lazy"
/>
) : (
<MarkdownRenderer content={contentWithEmojis} />
)}
{message.editedAt && (
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
)}
@@ -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);
@@ -0,0 +1,57 @@
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) })),
}));
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { renderCustomEmojis, isCustomEmojiAlt } from './customEmoji';
import type { SpaceEmoji } from '../api/client';
const emoji = (name: string): SpaceEmoji => ({
id: '1', spaceId: 's', name, filename: `${name}.png`, uploaderId: null, createdAt: 0,
});
const lookup = (n: string) => (n === 'trollface' ? emoji('trollface') : undefined);
const urlOf = (f: string) => `/api/uploads/${f}`;
describe('renderCustomEmojis', () => {
it('troca um emoji conhecido por imagem', () => {
expect(renderCustomEmojis('olha :trollface: isso', lookup, urlOf))
.toBe('olha ![:trollface:](/api/uploads/trollface.png) isso');
});
it('deixa nome desconhecido como texto', () => {
expect(renderCustomEmojis('nada :inexistente: aqui', lookup, urlOf))
.toBe('nada :inexistente: aqui');
});
it('não mexe em código em linha', () => {
// Quem escreveu entre crases quis mostrar o texto literal.
expect(renderCustomEmojis('use `:trollface:` assim', lookup, urlOf))
.toBe('use `:trollface:` assim');
});
it('não mexe em bloco de código', () => {
const src = '```\n:trollface:\n```';
expect(renderCustomEmojis(src, lookup, urlOf)).toBe(src);
});
it('devolve o texto intacto quando não há dois-pontos', () => {
expect(renderCustomEmojis('sem nada', lookup, urlOf)).toBe('sem nada');
});
});
describe('isCustomEmojiAlt', () => {
it('reconhece o alt de emoji e ignora imagem comum', () => {
expect(isCustomEmojiAlt(':trollface:')).toBe(true);
expect(isCustomEmojiAlt('foto de praia')).toBe(false);
expect(isCustomEmojiAlt(undefined)).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { SpaceEmoji } from '../api/client';
/**
* Troca `:nome:` pela sintaxe de imagem do markdown, para o renderizador
* existente desenhar o emoji próprio sem precisar de plugin.
*
* Trechos de código são preservados: `:nome:` dentro de crase é texto que a
* pessoa quis mostrar literalmente, e virar imagem ali seria destruir o que ela
* escreveu.
*/
export function renderCustomEmojis(
content: string,
lookup: (name: string) => SpaceEmoji | undefined,
urlOf: (filename: string) => string,
): string {
if (!content.includes(':')) return content;
// Divide preservando blocos cercados (```) e código em linha (`) —
// os delimitadores ficam nos pedaços ímpares e passam intactos.
const parts = content.split(/(```[\s\S]*?```|`[^`]*`)/g);
return parts
.map((part, i) => {
if (i % 2 === 1) return part;
return part.replace(/:([a-z0-9_]{2,32}):/gi, (whole, name: string) => {
const emoji = lookup(name.toLowerCase());
if (!emoji) return whole;
// O alt conserva `:nome:` para que copiar a mensagem devolva o texto
// original, e é por ele que o renderizador reconhece o emoji.
return `![:${emoji.name}:](${urlOf(emoji.filename)})`;
});
})
.join('');
}
/** O renderizador usa isto para dar tamanho de emoji em vez de imagem normal. */
export function isCustomEmojiAlt(alt: string | undefined): boolean {
return !!alt && /^:[a-z0-9_]{2,32}:$/i.test(alt);
}