"use client"; /** * The sticker picker: a popover anchored to the composer's sticker button on * desktop, a bottom drawer on mobile. * * A popover rather than a modal on desktop because picking a sticker is a * quick aside from typing — a modal takes over the screen and hides the * thread you're replying to. On touch, where there is nothing to anchor to * and the keyboard is already occupying the lower half, the drawer is the * right shape. * * The collection is whatever the server says it is — rewards earned through * sādhana plus stickers saved from a chat — so a sticker you were sent * appears the moment you keep it, with no client-side bookkeeping. * * Empty is a real state worth explaining: stickers are earned, so someone * with none needs to know how to get one rather than seeing a blank sheet. */ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import Image from "next/image"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { chat, type Sticker } from "@/lib/chat"; /** Where the popover hangs from — the sticker button's viewport rect. */ export type StickerAnchor = { x: number; y: number }; const POPOVER_W = 320; const POPOVER_H = 300; const EDGE_PAD = 8; export function StickerPicker({ anchor, onPick, onClose, }: { /** Ignored on mobile, which uses a drawer. */ anchor: StickerAnchor | null; onPick: (sticker: Sticker) => void; onClose: () => void; }) { const isMobile = useMediaQuery("(max-width: 767px)"); const [stickers, setStickers] = useState(null); useEffect(() => { let cancelled = false; chat .stickers() .then((s) => { if (!cancelled) setStickers(s); }) .catch(() => { if (!cancelled) setStickers([]); }); return () => { cancelled = true; }; }, []); // Desktop only: Escape closes. The drawer handles its own dismissal, and no // scroll lock here — a popover shouldn't freeze the thread behind it. useEffect(() => { if (isMobile) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [isMobile, onClose]); const grid = ( <> {stickers === null ? (

Loading…

) : stickers.length === 0 ? (

No stickers yet. Earn through your sādhana streak, or save one when someone sends you.

) : (
{stickers.map((s) => ( ))}
)} ); if (isMobile) { return ( !o && onClose()}> Stickers Earned through sādhana, or saved from a chat.
{grid}
); } if (typeof document === "undefined" || !anchor) return null; // Opens UPWARD by default: the composer sits at the bottom of the viewport, // so there is rarely room below it. Flips down only if the anchor is high // enough that upward would clip. let left = anchor.x - POPOVER_W / 2; if (left + POPOVER_W > window.innerWidth - EDGE_PAD) { left = window.innerWidth - POPOVER_W - EDGE_PAD; } if (left < EDGE_PAD) left = EDGE_PAD; // Room above the button, less a small gap and the viewport edge. const roomAbove = anchor.y - 8 - EDGE_PAD; // Open upward when there is any reasonable room, which by the composer // there almost always is. const openUp = roomAbove > 160; // Anchored by whichever edge faces the button, so the panel hugs it // regardless of how tall its contents actually are. Positioning by top // minus a MAX height floats it away whenever the content is shorter. const vertical = openUp ? { bottom: window.innerHeight - anchor.y + 8 } : { top: anchor.y + 8 }; // Never taller than the space it has. const maxH = Math.min( POPOVER_H, openUp ? roomAbove : window.innerHeight - anchor.y - 8 - EDGE_PAD, ); return createPortal( <> {/* Catches the outside click. Transparent — a popover dims nothing. */}

Stickers

Earned through sādhana, or saved from a chat.

{grid}
, document.body, ); }