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>
259 lines
8.7 KiB
TypeScript
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} />;
|
|
}
|