Files
backspace/packages/web/src/components/chat/InputPopover.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

259 lines
8.7 KiB
TypeScript

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' | 'sticker';
interface InputPopoverProps {
activeTab: InputPopoverTab;
onClose: () => void;
onEmojiSelect: (emoji: { native: string }) => void;
onGifSelect: (url: string) => void;
anchorRef: React.RefObject<HTMLElement | null>;
gifEnabled: boolean;
onTabChange: (tab: InputPopoverTab) => void;
onStickerSelect: (stickerId: string) => void;
hasStickers?: boolean;
}
interface SharedTabProps {
activeTab: InputPopoverTab;
availableTabs: { key: InputPopoverTab; label: string }[];
onTabChange: (tab: InputPopoverTab) => void;
}
function TabBar({ activeTab, availableTabs, onTabChange }: SharedTabProps) {
if (availableTabs.length <= 1) return null;
return (
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1">
{availableTabs.map((t) => (
<button
key={t.key}
onClick={() => onTabChange(t.key)}
className={`px-3 py-1 rounded-md text-[13px] font-medium transition-colors ${
activeTab === t.key
? 'bg-interactive-selected text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`}
>
{t.label}
</button>
))}
</div>
);
}
interface DesktopPopoverProps extends InputPopoverProps {
availableTabs: { key: InputPopoverTab; label: string }[];
}
function DesktopPopover({
activeTab,
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
anchorRef,
gifEnabled,
onTabChange,
availableTabs,
}: DesktopPopoverProps) {
const floatingRef = useRef<HTMLDivElement>(null);
// Position above the anchor
const updatePosition = useCallback(() => {
const anchor = anchorRef.current;
const floating = floatingRef.current;
if (!anchor || !floating) return;
const anchorRect = anchor.getBoundingClientRect();
const floatingRect = floating.getBoundingClientRect();
const vw = window.innerWidth;
let left = anchorRect.right - floatingRect.width;
let top = anchorRect.top - floatingRect.height - 8;
// Flip below if no room above
if (top < 8) {
top = anchorRect.bottom + 8;
}
// Clamp horizontal
left = Math.max(8, Math.min(left, vw - floatingRect.width - 8));
floating.style.top = `${top}px`;
floating.style.left = `${left}px`;
}, [anchorRef]);
useEffect(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [updatePosition, activeTab]);
// Re-position after the picker renders (it may change height)
useEffect(() => {
const frame = requestAnimationFrame(updatePosition);
return () => cancelAnimationFrame(frame);
}, [activeTab, updatePosition]);
// Click outside to close
useEffect(() => {
const handler = (e: MouseEvent) => {
const floating = floatingRef.current;
const anchor = anchorRef.current;
if (!floating) return;
if (floating.contains(e.target as Node)) return;
if (anchor && anchor.contains(e.target as Node)) return;
onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose, anchorRef]);
// Escape to close
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
return createPortal(
<div
ref={floatingRef}
className="fixed z-[300] animate-slide-up"
style={{ top: -9999, left: -9999 }}
>
<div className="glass rounded-xl overflow-hidden flex flex-col w-fit max-h-[435px]">
<TabBar activeTab={activeTab} availableTabs={availableTabs} onTabChange={onTabChange} />
{/* Content */}
<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>,
document.body,
);
}
interface MobileSheetProps extends InputPopoverProps {
availableTabs: { key: InputPopoverTab; label: string }[];
}
function MobileSheet({
activeTab,
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
gifEnabled,
onTabChange,
availableTabs,
}: MobileSheetProps) {
// Escape to close (parity with desktop)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
// Drag-down-to-close. Only the handle + tab-bar area receives the touch;
// the picker grids manage their own scrolling and must not be hijacked.
// `hasInteracted` flips true on the first touchstart and stays true — we
// use it to suppress the `animate-slide-up-sheet` keyframe from re-running
// during snap-back / close-out, which would otherwise fight the inline
// transform the hook is animating.
const { sheetStyle, handleProps, hasInteracted } = useDragToClose({ onClose });
return createPortal(
<>
{/* Backdrop — single tap (mousedown OR touchstart) closes */}
<div
className="fixed inset-0 z-[300] bg-black/30"
onMouseDown={onClose}
onTouchStart={onClose}
/>
{/* Sheet */}
<div
className={`fixed left-0 right-0 z-[301] rounded-t-2xl glass-modal flex flex-col ${
hasInteracted ? '' : 'animate-slide-up-sheet'
}`}
style={{
// Sit at the bottom of the visible viewport. On iOS 16.4+ the
// `keyboard-inset-height` env var lifts us above the soft keyboard;
// on older iOS the 100dvh-based MobileShell layout already shrinks
// the visual viewport when the keyboard is open, so bottom:0 lands
// just above the keyboard naturally.
bottom: 'env(keyboard-inset-height, 0px)',
paddingBottom: 'env(safe-area-inset-bottom)',
maxHeight: 'min(60dvh, 60vh)',
...sheetStyle,
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
{/* Drag handle + tab bar — both belong to the "header" drag area.
Spreading `handleProps` here means the user can grab anywhere in
this top region (handle pill, padding around it, tab buttons'
interstitial space) to dismiss; tab buttons themselves still
receive their own clicks because clicks aren't blocked, only
vertical drag past the dead-zone is. */}
<div {...handleProps} className="shrink-0 touch-none">
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1" />
<TabBar activeTab={activeTab} availableTabs={availableTabs} onTabChange={onTabChange} />
</div>
{/* Content */}
<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>
</>,
document.body,
);
}
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} />;
}
return <DesktopPopover {...props} availableTabs={availableTabs} />;
}