feat(expressions): pickers and upload for emojis and stickers

Completes the feature: the tables existed but nothing could be put in them.

Space settings gain an Emojis & Stickers panel behind MANAGE_SPACE, with a
512KB ceiling — both are fetched on every message that uses them, so weight
matters more than fidelity. The suggested name is pre-normalised so the common
case needs no typing, and a name collision reports itself distinctly from an
upload failure: the corrective action is different.

Custom emojis join the emoji picker as their own category. They have no native
character, so selecting one inserts :name: — the same text the renderer
resolves back to an image, which also means copying a message yields something
that still reads.

Stickers get a picker tab that only appears inside a space, since that is where
they exist, and send immediately on click: a sticker is the whole message, so
parking it in the composer to await Enter would make no sense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 18:25:10 -03:00
co-authored by Claude Opus 5
parent 7d003021d1
commit 7f08384372
10 changed files with 392 additions and 12 deletions
@@ -1,4 +1,7 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useMemo } from 'react';
import { useSpaceStore } from '../../stores/spaceStore';
import { useExpressionStore } from '../../stores/expressionStore';
import { api } from '../../api/client';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
@@ -16,6 +19,24 @@ interface EmojiPickerProps {
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Emojis próprios do servidor entram como categoria extra do emoji-mart.
// Sem espaço atual (DM) a lista fica vazia e a categoria não aparece.
const spaceId = useSpaceStore((s) => s.currentSpaceId);
const spaceEmojis = useExpressionStore((s) => (spaceId ? s.emojisBySpace.get(spaceId) : undefined));
const customCategories = useMemo(() => {
if (!spaceEmojis?.length) return [];
return [{
id: 'space',
name: 'Servidor',
emojis: spaceEmojis.map((e) => ({
id: e.name,
name: e.name,
keywords: [e.name],
skins: [{ src: api.uploads.url(e.filename) }],
})),
}];
}, [spaceEmojis]);
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
useEffect(() => {
const el = containerRef.current;
@@ -45,6 +66,7 @@ export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps)
<div ref={containerRef} className={wrapperClass}>
<Picker
data={data}
custom={customCategories}
onEmojiSelect={onEmojiSelect}
theme="dark"
set="native"
@@ -1,11 +1,13 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { useT } from '../../i18n';
import { createPortal } from 'react-dom';
import { EmojiPicker } from './EmojiPicker';
import { GifPicker } from './GifPicker';
import { StickerPicker } from './StickerPicker';
import { useUIStore } from '../../stores/uiStore';
import { useDragToClose } from '../../hooks/useDragToClose';
export type InputPopoverTab = 'emoji' | 'gif';
export type InputPopoverTab = 'emoji' | 'gif' | 'sticker';
interface InputPopoverProps {
activeTab: InputPopoverTab;
@@ -15,6 +17,8 @@ interface InputPopoverProps {
anchorRef: React.RefObject<HTMLElement | null>;
gifEnabled: boolean;
onTabChange: (tab: InputPopoverTab) => void;
onStickerSelect: (stickerId: string) => void;
hasStickers?: boolean;
}
interface SharedTabProps {
@@ -53,6 +57,7 @@ function DesktopPopover({
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
anchorRef,
gifEnabled,
onTabChange,
@@ -139,6 +144,7 @@ function DesktopPopover({
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} />}
</div>
</div>
</div>,
@@ -155,6 +161,7 @@ function MobileSheet({
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
gifEnabled,
onTabChange,
availableTabs,
@@ -221,6 +228,7 @@ function MobileSheet({
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} mobile />}
</div>
</div>
</>,
@@ -231,12 +239,17 @@ function MobileSheet({
export function InputPopover(props: InputPopoverProps) {
const isMobile = useUIStore((s) => s.isMobile);
const t = useT();
const availableTabs: { key: InputPopoverTab; label: string }[] = [
{ key: 'emoji', label: 'Emoji' },
];
if (props.gifEnabled) {
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
}
// Figurinha só existe dentro de um servidor; em DM a aba não aparece.
if (props.hasStickers) {
availableTabs.push({ key: 'sticker', label: t('expressions.stickers') });
}
if (isMobile) {
return <MobileSheet {...props} availableTabs={availableTabs} />;
@@ -96,6 +96,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
// Feature flags
const tr = useT();
// Figurinhas pertencem a um servidor; em DM não há o que oferecer.
const stickerSpaceId = useSpaceStore((s) => s.currentSpaceId);
const handleStickerSelect = (stickerId: string) => {
setActivePopover(null);
// Enviada de imediato: a figurinha é a mensagem inteira, então não faz
// sentido acumulá-la no campo de texto esperando um Enter.
void sendMessage(channelId, '', undefined, stickerId);
};
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
@@ -531,21 +541,25 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
};
const handleEmojiSelect = useCallback(
(emoji: { native: string }) => {
(emoji: { native?: string; id?: string }) => {
// Emoji próprio não tem `native`: entra no texto como `:nome:`, que é
// o que o render resolve depois para a imagem.
const inserted = emoji.native ?? (emoji.id ? `:${emoji.id}:` : '');
if (!inserted) return;
const textarea = textareaRef.current;
if (!textarea) {
setDraft(channelId, draftText + emoji.native);
setDraft(channelId, draftText + inserted);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = draftText.slice(0, start);
const after = draftText.slice(end);
const newContent = before + emoji.native + after;
const newContent = before + inserted + after;
setDraft(channelId, newContent);
// Restore cursor position after the emoji
const newCursorPos = start + emoji.native.length;
const newCursorPos = start + inserted.length;
requestAnimationFrame(() => {
textarea.focus();
textarea.selectionStart = newCursorPos;
@@ -768,6 +782,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onClose={() => setActivePopover(null)}
onEmojiSelect={handleEmojiSelect}
onGifSelect={handleGifSelect}
onStickerSelect={handleStickerSelect}
hasStickers={Boolean(stickerSpaceId)}
anchorRef={popoverAnchorRef}
gifEnabled={gifEnabled}
onTabChange={setActivePopover}
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { api } from '../../api/client';
import { useExpressionStore } from '../../stores/expressionStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useT } from '../../i18n';
interface StickerPickerProps {
onStickerSelect: (stickerId: string) => void;
mobile?: boolean;
}
export function StickerPicker({ onStickerSelect, mobile = false }: StickerPickerProps) {
const t = useT();
const spaceId = useSpaceStore((s) => s.currentSpaceId);
const stickers = useExpressionStore((s) => (spaceId ? s.stickersBySpace.get(spaceId) : undefined)) ?? [];
const load = useExpressionStore((s) => s.load);
useEffect(() => {
if (spaceId) void load(spaceId);
}, [spaceId, load]);
// Mesmas dimensões do seletor de GIF, para as abas do popover não pularem de
// tamanho ao alternar.
const rootClass = mobile
? 'flex flex-col flex-1 min-h-0 w-full'
: 'flex flex-col h-[390px] w-[390px]';
return (
<div className={rootClass}>
<div className="px-3 pt-3 pb-2 shrink-0">
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
{t('expressions.pickerTitle')}
</span>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
{stickers.length === 0 ? (
<p className="text-[12px] text-txt-tertiary p-3">{t('expressions.pickerEmpty')}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{stickers.map((sticker) => (
<button
key={sticker.id}
type="button"
onClick={() => onStickerSelect(sticker.id)}
title={sticker.name}
className="aspect-square rounded-lg bg-surface-elevated hover:brightness-125 transition-all flex items-center justify-center p-2"
>
<img
src={api.uploads.url(sticker.filename)}
alt={sticker.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
import { ExpressionsPanel } from './spaceSettingsPanels/ExpressionsPanel';
import { useT } from '../../i18n';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
@@ -270,7 +271,7 @@ export function SpaceSettingsModal() {
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const t = useT();
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats'>('overview');
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats' | 'expressions'>('overview');
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
const isOpen = activeModal === 'spaceSettings';
@@ -339,6 +340,9 @@ export function SpaceSettingsModal() {
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
)}
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
{canManageSpace && (
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
)}
</div>
</div>
@@ -378,6 +382,9 @@ export function SpaceSettingsModal() {
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
)}
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
{canManageSpace && (
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
)}
</div>
</div>
)}
@@ -406,6 +413,7 @@ export function SpaceSettingsModal() {
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
{tab === 'expressions' && canManageSpace && <ExpressionsPanel spaceId={currentSpaceId} />}
</div>
</div>
)}
@@ -0,0 +1,218 @@
import { useEffect, useRef, useState } from 'react';
import { api, type SpaceEmoji } from '../../../api/client';
import { useExpressionStore } from '../../../stores/expressionStore';
import { useTransferStore } from '../../../stores/transferStore';
import { waitForTransferAttachment } from '../../../utils/waitForTransfer';
import { useT } from '../../../i18n';
interface ExpressionsPanelProps {
spaceId: string;
}
/** Emoji e figurinha são carregados a cada mensagem: têm de ser leves. */
const MAX_BYTES = 512 * 1024;
type Kind = 'emoji' | 'sticker';
export function ExpressionsPanel({ spaceId }: ExpressionsPanelProps) {
const t = useT();
const emojis = useExpressionStore((s) => s.emojisBySpace.get(spaceId)) ?? [];
const stickers = useExpressionStore((s) => s.stickersBySpace.get(spaceId)) ?? [];
const setEmojis = useExpressionStore((s) => s.setEmojis);
const setStickers = useExpressionStore((s) => s.setStickers);
const [kind, setKind] = useState<Kind>('emoji');
const [pending, setPending] = useState<File | null>(null);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
Promise.all([api.expressions.emojis(spaceId), api.expressions.stickers(spaceId)])
.then(([e, s]) => {
if (cancelled) return;
setEmojis(spaceId, e.emojis);
setStickers(spaceId, s.stickers);
})
.catch(() => { /* lista vazia é o fallback honesto */ });
return () => { cancelled = true; };
}, [spaceId, setEmojis, setStickers]);
const pickFile = (file: File) => {
setError('');
if (file.size > MAX_BYTES) {
setError(t('expressions.tooLarge'));
return;
}
setPending(file);
// Sugere o nome do arquivo já no formato aceito, para o caso comum não
// exigir digitação nenhuma.
const base = file.name.replace(/\.[^.]+$/, '');
setName(kind === 'emoji' ? base.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 32) : base.slice(0, 32));
};
const cancel = () => {
setPending(null);
setName('');
if (fileRef.current) fileRef.current.value = '';
};
const confirm = async () => {
const file = pending;
const trimmed = name.trim();
if (!file || !trimmed) return;
setBusy(true);
setError('');
try {
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
const { filename } = await waitForTransferAttachment(tid);
if (kind === 'emoji') {
const created = await api.expressions.addEmoji(spaceId, trimmed, filename);
setEmojis(spaceId, [...emojis, created]);
} else {
const created = await api.expressions.addSticker(spaceId, trimmed, filename);
setStickers(spaceId, [...stickers, created]);
}
cancel();
} catch (err) {
// 409 é nome repetido — mensagem específica, porque a ação corretiva é
// outra: mudar o nome, não trocar a imagem.
const conflict = (err as { statusCode?: number } | undefined)?.statusCode === 409;
setError(conflict ? t('expressions.nameTaken') : t('expressions.uploadFailed'));
} finally {
setBusy(false);
}
};
const remove = async (item: SpaceEmoji) => {
const isEmoji = kind === 'emoji';
const previous = isEmoji ? emojis : stickers;
const next = previous.filter((x) => x.id !== item.id);
if (isEmoji) setEmojis(spaceId, next); else setStickers(spaceId, next);
try {
await (isEmoji ? api.expressions.removeEmoji(item.id) : api.expressions.removeSticker(item.id));
} catch {
if (isEmoji) setEmojis(spaceId, previous); else setStickers(spaceId, previous);
}
};
const items = kind === 'emoji' ? emojis : stickers;
return (
<div className="max-w-2xl">
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t('expressions.title')}</h2>
<div className="flex gap-1.5 mb-5">
{(['emoji', 'sticker'] as Kind[]).map((k) => (
<button
key={k}
type="button"
onClick={() => { setKind(k); cancel(); }}
className={`px-2.5 py-1 rounded-full text-[12px] font-medium transition-colors ${
kind === k ? 'bg-accent-primary text-white' : 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
}`}
>
{k === 'emoji' ? t('expressions.emojis') : t('expressions.stickers')}
</button>
))}
</div>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy}
className="px-3 py-1.5 rounded-md text-[13px] font-medium bg-accent-primary text-white hover:brightness-110 disabled:opacity-50"
>
{kind === 'emoji' ? t('expressions.addEmoji') : t('expressions.addSticker')}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickFile(f); }}
/>
{error && <p className="text-[12px] text-txt-danger mt-2">{error}</p>}
{pending && (
<div className="mt-3 p-3 rounded-lg bg-surface-elevated/60">
<label className="block text-[11px] text-txt-tertiary mb-1">{t('expressions.namePrompt')}</label>
<input
type="text"
value={name}
maxLength={32}
autoFocus
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' && name.trim()) void confirm();
if (e.key === 'Escape') cancel();
}}
className="input-search w-full mb-1"
/>
{kind === 'emoji' && (
<p className="text-[11px] text-txt-tertiary mb-2">{t('expressions.nameHintEmoji')}</p>
)}
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => void confirm()}
disabled={busy || !name.trim()}
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-accent-primary text-white disabled:opacity-50"
>
{busy ? t('expressions.uploading') : t('expressions.confirm')}
</button>
<button
type="button"
onClick={cancel}
disabled={busy}
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-interactive-muted text-txt-secondary disabled:opacity-50"
>
{t('expressions.cancel')}
</button>
</div>
</div>
)}
<div className="mt-5">
{items.length === 0 ? (
<p className="text-[13px] text-txt-tertiary">
{kind === 'emoji' ? t('expressions.emptyEmojis') : t('expressions.emptyStickers')}
</p>
) : (
<div className="grid grid-cols-6 gap-2">
{items.map((item) => (
<div key={item.id} className="relative group">
<div className="aspect-square rounded-lg bg-surface-elevated flex items-center justify-center p-1.5">
<img
src={api.uploads.url(item.filename)}
alt={item.name}
title={kind === 'emoji' ? `:${item.name}:` : item.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</div>
<div className="text-[10px] text-txt-tertiary truncate text-center mt-0.5">
{kind === 'emoji' ? `:${item.name}:` : item.name}
</div>
<button
type="button"
onClick={() => void remove(item)}
title={t('expressions.remove')}
aria-label={t('expressions.remove')}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-accent-rose text-white text-[10px] leading-none opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
+20
View File
@@ -56,6 +56,26 @@ export const en = {
'accountMenu.copyId': 'Copy User ID',
'accountMenu.copied': 'Copied',
// Custom emojis and stickers
'expressions.title': 'Emojis & Stickers',
'expressions.emojis': 'Emojis',
'expressions.stickers': 'Stickers',
'expressions.addEmoji': 'Add emoji',
'expressions.addSticker': 'Add sticker',
'expressions.emptyEmojis': 'No custom emojis yet.',
'expressions.emptyStickers': 'No stickers yet.',
'expressions.namePrompt': 'Name',
'expressions.nameHintEmoji': 'Letters, numbers and underscore. Used as :name: in messages.',
'expressions.confirm': 'Add',
'expressions.cancel': 'Cancel',
'expressions.remove': 'Remove',
'expressions.tooLarge': 'Image must be under 512 KB.',
'expressions.uploadFailed': 'Could not add that image. Try another one.',
'expressions.nameTaken': 'That name is already in use in this server.',
'expressions.uploading': 'Uploading…',
'expressions.pickerTitle': 'Stickers',
'expressions.pickerEmpty': 'This server has no stickers yet.',
// Search
'search.placeholder': 'Search messages…',
'search.filters': 'Filters',
+20
View File
@@ -55,6 +55,26 @@ export const ptBR: Partial<Dictionary> = {
'accountMenu.copyId': 'Copiar ID do usuário',
'accountMenu.copied': 'Copiado',
// Emojis e figurinhas
'expressions.title': 'Emojis e figurinhas',
'expressions.emojis': 'Emojis',
'expressions.stickers': 'Figurinhas',
'expressions.addEmoji': 'Adicionar emoji',
'expressions.addSticker': 'Adicionar figurinha',
'expressions.emptyEmojis': 'Nenhum emoji próprio ainda.',
'expressions.emptyStickers': 'Nenhuma figurinha ainda.',
'expressions.namePrompt': 'Nome',
'expressions.nameHintEmoji': 'Letras, números e sublinhado. Usado como :nome: nas mensagens.',
'expressions.confirm': 'Adicionar',
'expressions.cancel': 'Cancelar',
'expressions.remove': 'Remover',
'expressions.tooLarge': 'A imagem precisa ter menos de 512 KB.',
'expressions.uploadFailed': 'Não foi possível adicionar essa imagem. Tente outra.',
'expressions.nameTaken': 'Esse nome já está em uso neste servidor.',
'expressions.uploading': 'Enviando…',
'expressions.pickerTitle': 'Figurinhas',
'expressions.pickerEmpty': 'Este servidor ainda não tem figurinhas.',
// Busca
'search.placeholder': 'Buscar mensagens…',
'search.filters': 'Filtros',
+3 -3
View File
@@ -58,7 +58,7 @@ interface ChatState {
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
clearAllMessages: () => void;
loadMoreMessages: (channelId: string) => Promise<boolean>;
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
sendMessage: (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => Promise<void>;
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
addMessage: (channelId: string, message: MessageWithUser) => void;
@@ -319,7 +319,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
},
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
sendMessage: async (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => {
const replyToId = get().replyTo?.id;
const isDm = isDmChannel(channelId);
const currentUser = useAuthStore.getState().user;
@@ -367,7 +367,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (isDm) {
await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
} else {
await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId, stickerId });
}
// Real message will arrive via WebSocket and replace the temp one
} catch {