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