fix: stickers and chat UI fix

This commit is contained in:
gosangam
2026-08-30 03:01:20 +05:30
parent 2b0f966f90
commit c001eeab45
9 changed files with 456 additions and 174 deletions
+112 -13
View File
@@ -1,28 +1,54 @@
"use client";
/**
* The sticker drawer, opened from the composer.
* 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 here the moment you keep it, with no client-side bookkeeping.
* 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 { ResponsiveDialog } from "@/components/chat/responsive-dialog";
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(() => {
@@ -40,21 +66,26 @@ export function StickerPicker({
};
}, []);
return (
<ResponsiveDialog
title="Stickers"
description="Earned through sādhana, or saved from a chat."
onClose={onClose}
>
// 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. Keep your sādhana streak — a sacred reward arrives
every 7 days — or save one someone sends you.
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-4 sm:grid-cols-5">
<div className="grid grid-cols-4 gap-2 p-3 sm:grid-cols-5">
{stickers.map((s) => (
<button
key={s.key}
@@ -78,6 +109,74 @@ export function StickerPicker({
))}
</div>
)}
</ResponsiveDialog>
</>
);
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,
);
}