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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user