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,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} />;