183 lines
5.9 KiB
TypeScript
183 lines
5.9 KiB
TypeScript
"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<Sticker[] | null>(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 ? (
|
|
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
|
) : stickers.length === 0 ? (
|
|
<p className="p-4 text-sm text-muted-foreground">
|
|
No stickers yet. Earn through your sādhana streak, or save one when
|
|
someone sends you.
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-4 gap-2 p-3 sm:grid-cols-5">
|
|
{stickers.map((s) => (
|
|
<button
|
|
key={s.key}
|
|
type="button"
|
|
onClick={() => {
|
|
onPick(s);
|
|
onClose();
|
|
}}
|
|
title={s.name}
|
|
aria-label={s.name}
|
|
className="relative aspect-square rounded-lg p-1 transition-colors hover:bg-muted"
|
|
>
|
|
<Image
|
|
src={s.image_url}
|
|
alt=""
|
|
fill
|
|
sizes="80px"
|
|
className="object-contain p-1"
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Drawer open onOpenChange={(o) => !o && onClose()}>
|
|
<DrawerContent className="bg-card">
|
|
<DrawerHeader className="text-left">
|
|
<DrawerTitle>Stickers</DrawerTitle>
|
|
<DrawerDescription>
|
|
Earned through sādhana, or saved from a chat.
|
|
</DrawerDescription>
|
|
</DrawerHeader>
|
|
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
|
|
{grid}
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
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. */}
|
|
<div className="fixed inset-0 z-50" onClick={onClose} aria-hidden />
|
|
<div
|
|
role="dialog"
|
|
aria-label="Stickers"
|
|
style={{ left, ...vertical, width: POPOVER_W, maxHeight: maxH }}
|
|
className="fixed z-[51] flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg"
|
|
>
|
|
<div className="border-b border-border px-3 py-2">
|
|
<p className="text-xs font-medium text-foreground">Stickers</p>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Earned through sādhana, or saved from a chat.
|
|
</p>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">{grid}</div>
|
|
</div>
|
|
</>,
|
|
document.body,
|
|
);
|
|
}
|