Files
backspace/packages/web/src/components/modals/spaceSettingsPanels/ExpressionsPanel.tsx
T
devsyncwrldandClaude Opus 5 7f08384372 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>
2026-09-01 18:25:10 -03:00

219 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}