import React, { useRef, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { EmojiPicker } from './EmojiPicker'; import { GifPicker } from './GifPicker'; import { useUIStore } from '../../stores/uiStore'; import { useDragToClose } from '../../hooks/useDragToClose'; export type InputPopoverTab = 'emoji' | 'gif'; interface InputPopoverProps { activeTab: InputPopoverTab; onClose: () => void; onEmojiSelect: (emoji: { native: string }) => void; onGifSelect: (url: string) => void; anchorRef: React.RefObject; gifEnabled: boolean; onTabChange: (tab: InputPopoverTab) => void; } 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 (
{availableTabs.map((t) => ( ))}
); } interface DesktopPopoverProps extends InputPopoverProps { availableTabs: { key: InputPopoverTab; label: string }[]; } function DesktopPopover({ activeTab, onClose, onEmojiSelect, onGifSelect, anchorRef, gifEnabled, onTabChange, availableTabs, }: DesktopPopoverProps) { const floatingRef = useRef(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(
{/* Content */}
{activeTab === 'emoji' && } {activeTab === 'gif' && gifEnabled && }
, document.body, ); } interface MobileSheetProps extends InputPopoverProps { availableTabs: { key: InputPopoverTab; label: string }[]; } function MobileSheet({ activeTab, onClose, onEmojiSelect, onGifSelect, 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 */}
{/* Sheet */}
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. */}
{/* Content */}
{activeTab === 'emoji' && } {activeTab === 'gif' && gifEnabled && }
, document.body, ); } export function InputPopover(props: InputPopoverProps) { const isMobile = useUIStore((s) => s.isMobile); const availableTabs: { key: InputPopoverTab; label: string }[] = [ { key: 'emoji', label: 'Emoji' }, ]; if (props.gifEnabled) { availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' }); } if (isMobile) { return ; } return ; }