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
+17 -7
View File
@@ -35,6 +35,7 @@ import { toast } from "sonner";
import { channelAuthorizer } from "@/lib/realtime";
import { cn } from "@/lib/utils";
import { useVisualViewport } from "@/lib/use-visual-viewport";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { useNotifications } from "@/components/notifications/notifications-provider";
import {
chat,
@@ -71,6 +72,9 @@ export default function ChatThreadPage() {
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
// Non-null asks before deleting for everyone. Deleting only for yourself is
// reversible by the others still having it, so it goes straight through.
const [confirmDelete, setConfirmDelete] = useState<ChatMessage | null>(null);
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
// user id → when they last typed. Kept as timestamps rather than a boolean
// so the indicator expires on its own: there is no "stopped typing" event,
@@ -295,12 +299,6 @@ export default function ChatThreadPage() {
const deleteMessage = useCallback(
async (m: ChatMessage, scope: "me" | "everyone") => {
if (
scope === "everyone" &&
!window.confirm("Delete this message for everyone?")
) {
return;
}
try {
await chat.deleteMessage(id, m.id, scope);
if (scope === "me") {
@@ -695,7 +693,8 @@ export default function ChatThreadPage() {
// Only the sender may erase a message for everyone.
onDeleteForEveryone:
menu.message.sender_id === user.id
? () => void deleteMessage(menu.message, "everyone")
? // Ask first — the modal calls deleteMessage on confirm.
() => setConfirmDelete(menu.message)
: undefined,
// Reporting your own message is meaningless.
onReport:
@@ -714,6 +713,17 @@ export default function ChatThreadPage() {
/>
)}
<ConfirmModal
open={confirmDelete !== null}
onClose={() => setConfirmDelete(null)}
title="Delete for everyone?"
message="This removes the message from the conversation for all participants. It can't be undone."
confirmLabel="Delete"
onConfirm={async () => {
if (confirmDelete) await deleteMessage(confirmDelete, "everyone");
}}
/>
{forwarding && (
<ForwardDialog
message={forwarding}
+87 -58
View File
@@ -13,13 +13,16 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Paperclip, Smile, SendHorizonal, X } from "lucide-react";
import { Paperclip, Sticker, SendHorizonal, X } from "lucide-react";
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
import {
ComposerAttachments,
type StagedAttachment,
} from "@/components/chat/composer-attachments";
import { StickerPicker } from "@/components/chat/sticker-picker";
import {
StickerPicker,
type StickerAnchor,
} from "@/components/chat/sticker-picker";
import { compressImage } from "@/lib/compress-image";
import { cn } from "@/lib/utils";
import {
@@ -70,7 +73,9 @@ export function ChatComposer({
const [staged, setStaged] = useState<StagedAttachment[]>([]);
const ref = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const [pickingSticker, setPickingSticker] = useState(false);
// Non-null opens the sticker picker AND positions it: the popover hangs
// off the button's viewport rect, which only exists at click time.
const [stickerAnchor, setStickerAnchor] = useState<StickerAnchor | null>(null);
const lookupSeq = useRef(0);
// Grow with the content, up to a cap.
@@ -248,61 +253,84 @@ export function ChatComposer({
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={disabled}
aria-label="Attach a file"
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Paperclip className="size-4" />
</button>
<button
type="button"
onClick={() => setPickingSticker(true)}
disabled={disabled}
aria-label="Send a sticker"
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Smile className="size-4" />
</button>
<textarea
ref={ref}
rows={1}
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
// Fired on every keystroke; the handler throttles itself, so the
// thread hears "still typing" every few seconds rather than per
// character.
onTyping?.();
}}
onKeyDown={(e) => {
// Enter sends on a physical keyboard; Shift+Enter makes a newline.
// On touch keyboards Enter must insert a newline instead — there is
// no Shift to reach for — so the send button is the only way there.
if (e.key !== "Enter" || e.shiftKey) return;
const touch =
typeof window !== "undefined" &&
window.matchMedia("(pointer: coarse)").matches;
if (touch) return;
e.preventDefault();
submit();
}}
{/* One field group: attach on the left, sticker on the right, the
text between them. The border and background live here rather than
on the textarea so all three read as a single control, and
focus-within moves the ring to the group. */}
<div
className={cn(
// 16px on touch devices: iOS Safari zooms the whole page when a
// focused field computes below that, which on a composer means the
// thread jumps every time the keyboard opens. Pointer-based devices
// keep the app's 14px chrome.
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2",
"text-sm pointer-coarse:text-base",
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40",
"disabled:opacity-60",
// Minimal horizontal padding: the icon buttons keep a full-height
// tap target, but sit close to the group's edges and to the text
// rather than floating in their own gutters.
"flex flex-1 items-end gap-0.5 rounded-2xl border border-border bg-muted/40 px-1 py-1",
"focus-within:ring-1 focus-within:ring-primary/40",
disabled && "opacity-60",
)}
/>
>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={disabled}
aria-label="Attach a file"
className="inline-flex h-8 w-8 mr-1 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Paperclip className="size-5" />
</button>
<textarea
ref={ref}
rows={1}
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
// Fired on every keystroke; the handler throttles itself, so the
// thread hears "still typing" every few seconds rather than per
// character.
onTyping?.();
}}
onKeyDown={(e) => {
// Enter sends on a physical keyboard; Shift+Enter makes a
// newline. On touch keyboards Enter must insert a newline
// instead — there is no Shift to reach for — so the send button
// is the only way there.
if (e.key !== "Enter" || e.shiftKey) return;
const touch =
typeof window !== "undefined" &&
window.matchMedia("(pointer: coarse)").matches;
if (touch) return;
e.preventDefault();
submit();
}}
className={cn(
// Bare: the group above owns the frame. Only the type size and
// the growth behaviour belong to the field itself.
"min-w-0 flex-1 resize-none bg-transparent py-1.5",
// 16px on touch devices: iOS Safari zooms the whole page when a
// focused field computes below that, which on a composer means
// the thread jumps every time the keyboard opens. Pointer-based
// devices keep the app's 14px chrome.
"text-sm pointer-coarse:text-base",
"placeholder:text-muted-foreground focus:outline-none",
"disabled:opacity-60",
)}
/>
<button
type="button"
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
// Top-centre of the button: the popover opens upward from here.
setStickerAnchor({ x: r.left + r.width / 2, y: r.top });
}}
disabled={disabled}
aria-label="Send a sticker"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Sticker className="size-5" />
</button>
</div>
<button
type="button"
onClick={submit}
@@ -317,13 +345,14 @@ export function ChatComposer({
</button>
</div>
{pickingSticker && (
{stickerAnchor && (
<StickerPicker
anchor={stickerAnchor}
// Sent on its own, with no text: a sticker IS the message.
onPick={(sticker) =>
onSend("", undefined, undefined, undefined, sticker.key)
}
onClose={() => setPickingSticker(false)}
onClose={() => setStickerAnchor(null)}
/>
)}
</div>
+15 -4
View File
@@ -15,6 +15,7 @@ import { useState } from "react";
import { BellOff, Bell, ShieldBan, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { relationships } from "@/lib/social";
import {
chat,
@@ -44,6 +45,9 @@ export function ChatProfileHeader({
onChanged: () => void;
}) {
const [busy, setBusy] = useState(false);
// Blocking asks first; unblocking is immediate, since it only restores what
// was there before.
const [confirmBlock, setConfirmBlock] = useState(false);
const rel = detail.relationship;
const peer = detail.peer;
const muted = isMuted(detail);
@@ -64,9 +68,6 @@ export function ChatProfileHeader({
const toggleBlock = async () => {
if (!peer) return;
const blocking = !rel?.blocked;
if (blocking && !window.confirm(`Block ${peer.name}? They won't be able to message you.`)) {
return;
}
setBusy(true);
try {
if (blocking) await relationships.block(peer.handle);
@@ -134,7 +135,9 @@ export function ChatProfileHeader({
variant={rel?.blocked ? "outline" : "destructive"}
size="sm"
disabled={busy}
onClick={toggleBlock}
onClick={() =>
rel?.blocked ? void toggleBlock() : setConfirmBlock(true)
}
>
{rel?.blocked ? (
<>
@@ -148,6 +151,14 @@ export function ChatProfileHeader({
</Button>
)}
</div>
<ConfirmModal
open={confirmBlock}
onClose={() => setConfirmBlock(false)}
title={`Block ${peer?.name ?? "this person"}?`}
message="They won't be able to message you, and you won't see their messages. You can unblock them here later."
confirmLabel="Block"
onConfirm={toggleBlock}
/>
</div>
);
}
+21 -8
View File
@@ -14,6 +14,7 @@ import { toast } from "sonner";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { AddMembersDialog } from "@/components/chat/add-members-dialog";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { chat, isGroupAdmin, type ConversationDetail } from "@/lib/chat";
const ROLE_LABEL: Record<string, string> = {
@@ -34,6 +35,9 @@ export function GroupMembersTab({
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
// The member being removed. Non-null asks first.
const [confirmRemove, setConfirmRemove] =
useState<ConversationDetail["participants"][number] | null>(null);
const [adding, setAdding] = useState(false);
const iAmAdmin = isGroupAdmin(detail.my_role);
const iAmOwner = detail.my_role === "owner";
@@ -132,14 +136,8 @@ export function GroupMembersTab({
type="button"
disabled={busy === m.handle}
aria-label={`Remove ${m.name}`}
onClick={() => {
if (!window.confirm(`Remove ${m.name} from the group?`)) return;
void act(
m.handle,
() => chat.removeMember(conversationId, m.handle),
`${m.name} was removed`,
);
}}
// Ask first — the modal calls act() on confirm.
onClick={() => setConfirmRemove(m)}
className="shrink-0 rounded-md p-1.5 text-destructive hover:opacity-80 disabled:opacity-40"
>
<UserMinus className="size-4" />
@@ -158,6 +156,21 @@ export function GroupMembersTab({
onAdded={onChanged}
/>
)}
<ConfirmModal
open={confirmRemove !== null}
onClose={() => setConfirmRemove(null)}
title={`Remove ${confirmRemove?.name ?? "this member"}?`}
message="They'll lose access to the conversation and its history from here on."
confirmLabel="Remove"
onConfirm={async () => {
if (!confirmRemove) return;
await act(
confirmRemove.handle,
() => chat.removeMember(conversationId, confirmRemove.handle),
`${confirmRemove.name} was removed`,
);
}}
/>
</div>
);
}
+15 -10
View File
@@ -13,6 +13,7 @@ import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/ui/user-avatar";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { compressImage } from "@/lib/compress-image";
import {
chat,
@@ -38,6 +39,7 @@ export function GroupSettingsTab({
const router = useRouter();
const [title, setTitle] = useState(detail.title ?? "");
const [busy, setBusy] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const iAmAdmin = isGroupAdmin(detail.my_role);
@@ -96,15 +98,6 @@ export function GroupSettingsTab({
};
const leave = async () => {
if (
!window.confirm(
detail.my_role === "owner"
? "Leave this group? Another admin will become the owner."
: "Leave this group?",
)
) {
return;
}
setBusy(true);
try {
await chat.removeMember(conversationId, selfHandle);
@@ -219,7 +212,7 @@ export function GroupSettingsTab({
variant="destructive"
size="sm"
disabled={busy}
onClick={leave}
onClick={() => setConfirmLeave(true)}
>
<LogOut className="size-4" /> Leave group
</Button>
@@ -230,6 +223,18 @@ export function GroupSettingsTab({
</p>
)}
</section>
<ConfirmModal
open={confirmLeave}
onClose={() => setConfirmLeave(false)}
title="Leave this group?"
message={
detail.my_role === "owner"
? "You own this group. The longest-serving admin becomes the owner in your place."
: "You'll stop receiving messages from this conversation."
}
confirmLabel="Leave"
onConfirm={leave}
/>
</div>
);
}
+43 -33
View File
@@ -316,43 +316,53 @@ export function MessageGroup({
</div>
{m.reactions?.length > 0 && (
<button
type="button"
// ONE control, not one per emoji. Two different jobs depending
// on whether you are in the reaction list already: if you have
// reacted, this retracts it; if you have not, it opens the
// list of who reacted. Per-chip toggling would make "see who
// reacted" unreachable for anyone who had.
onClick={() => onTrailClick(m)}
aria-label={
m.reactions.some((r) => r.mine)
? "Remove your reaction"
: "See who reacted"
}
// Aligned like the bubble it belongs to. The gesture wrapper is
// full-width, so without this the trail sits at the left edge
// while your own bubble is over on the right.
<div
className={cn(
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs",
mine ? "self-end mr-1" : "self-start ml-1",
m.reactions.some((r) => r.mine)
? "border-primary/40 bg-primary/10"
: "border-border",
"flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
{m.reactions.slice(0, MAX_TRAIL_EMOJI).map((r) => (
<span key={r.emoji}>{r.emoji}</span>
))}
{m.reactions.length > MAX_TRAIL_EMOJI && (
<span className="tabular-nums text-muted-foreground">
+{m.reactions.length - MAX_TRAIL_EMOJI}
</span>
)}
{/* Total people, not distinct emoji — "😂❤️ 5" reads as five
<button
type="button"
// ONE control, not one per emoji. Two different jobs depending
// on whether you are in the reaction list already: if you have
// reacted, this retracts it; if you have not, it opens the
// list of who reacted. Per-chip toggling would make "see who
// reacted" unreachable for anyone who had.
onClick={() => onTrailClick(m)}
aria-label={
m.reactions.some((r) => r.mine)
? "Remove your reaction"
: "See who reacted"
}
className={cn(
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs",
mine ? "mr-1" : "ml-1",
m.reactions.some((r) => r.mine)
? "border-primary/40 bg-primary/10"
: "border-border",
)}
>
{m.reactions.slice(0, MAX_TRAIL_EMOJI).map((r) => (
<span key={r.emoji}>{r.emoji}</span>
))}
{m.reactions.length > MAX_TRAIL_EMOJI && (
<span className="tabular-nums text-muted-foreground">
+{m.reactions.length - MAX_TRAIL_EMOJI}
</span>
)}
{/* Total people, not distinct emoji — "😂❤️ 5" reads as five
reactions, which is what it is. */}
{totalReactions(m) > 1 && (
<span className="ml-0.5 tabular-nums text-muted-foreground">
{totalReactions(m)}
</span>
)}
</button>
{totalReactions(m) > 1 && (
<span className="ml-0.5 tabular-nums text-muted-foreground">
{totalReactions(m)}
</span>
)}
</button>
</div>
)}
</MessageGestures>
))}
+140 -39
View File
@@ -4,20 +4,33 @@
* The emoji row at the top of a message's long-press menu.
*
* Five quick reactions plus a "+" that opens the full set — a drawer on
* mobile, a modal on desktop, reusing ResponsiveDialog so it behaves like
* every other chat surface.
* mobile, a popover anchored to the "+" on desktop. A modal would take over
* the screen and hide the message being reacted to, which is the one thing
* that needs to stay visible while choosing.
*
* It sits inside the context menu rather than being its own popover: long
* press is one gesture, and two surfaces competing for it would mean neither
* felt reliable.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { EMOJI_GROUPS } from "@/components/ui/emoji-picker";
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 { cn } from "@/lib/utils";
const POPOVER_W = 320;
const POPOVER_H = 320;
const EDGE_PAD = 8;
/** What a double-tap sends. Matches the server's own default. */
export const HEART_EMOJI = "❤️";
@@ -33,7 +46,12 @@ export function ReactionRow({
/** Picking the current emoji again clears it — the caller decides. */
onPick: (emoji: string) => void;
}) {
const [browsing, setBrowsing] = useState(false);
// Non-null both opens the full set and positions it: the popover hangs off
// the "+" button's rect, which only exists at click time.
const [browsing, setBrowsing] = useState<{ x: number; y: number } | null>(
null,
);
const close = () => setBrowsing(null);
return (
<>
@@ -55,7 +73,11 @@ export function ReactionRow({
))}
<button
type="button"
onClick={() => setBrowsing(true)}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
// Top-centre of the "+": the popover opens upward from here.
setBrowsing({ x: r.left + r.width / 2, y: r.top });
}}
aria-label="More reactions"
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
@@ -63,39 +85,118 @@ export function ReactionRow({
</button>
</div>
{browsing && (
<ResponsiveDialog
title="Pick a reaction"
description="Choose any emoji to react with."
onClose={() => setBrowsing(false)}
>
<div className="space-y-4 p-4">
{EMOJI_GROUPS.map((group) => (
<section key={group.label}>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
<div className="grid grid-cols-8 gap-1">
{group.emojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
setBrowsing(false);
onPick(emoji);
}}
aria-label={`React ${emoji}`}
className="inline-flex size-9 items-center justify-center rounded-md text-lg hover:bg-muted"
>
{emoji}
</button>
))}
</div>
</section>
))}
</div>
</ResponsiveDialog>
)}
{browsing && <FullSet anchor={browsing} onPick={onPick} onClose={close} />}
</>
);
}
/** The whole emoji set: popover on desktop, drawer on touch. */
function FullSet({
anchor,
onPick,
onClose,
}: {
anchor: { x: number; y: number };
onPick: (emoji: string) => void;
onClose: () => void;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
// Desktop only: Escape closes. The drawer handles its own dismissal, and a
// popover deliberately doesn't lock scrolling 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 = (
<div className="space-y-4 p-3">
{EMOJI_GROUPS.map((group) => (
<section key={group.label}>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
<div className="grid grid-cols-8 gap-1">
{group.emojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onClose();
onPick(emoji);
}}
aria-label={`React ${emoji}`}
className="inline-flex size-9 items-center justify-center rounded-md text-lg hover:bg-muted"
>
{emoji}
</button>
))}
</div>
</section>
))}
</div>
);
if (isMobile) {
return (
<Drawer open onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left">
<DrawerTitle>Pick a reaction</DrawerTitle>
<DrawerDescription>
Choose any emoji to react with.
</DrawerDescription>
</DrawerHeader>
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
{grid}
</div>
</DrawerContent>
</Drawer>
);
}
if (typeof document === "undefined") return null;
// Opens upward: the "+" sits in a menu raised over a message, so there is
// usually more room above it than below. Flips down only if that 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-[60]" onClick={onClose} aria-hidden />
<div
role="dialog"
aria-label="Pick a reaction"
style={{ left, ...vertical, width: POPOVER_W, maxHeight: maxH }}
className="fixed z-[61] flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg"
>
<div className="flex-1 overflow-y-auto">{grid}</div>
</div>
</>,
document.body,
);
}
+6 -2
View File
@@ -19,7 +19,7 @@ export function SharedPostCard({ attachment }: { attachment: Attachment }) {
if (!post || attachment.entity_unavailable) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
<div className="flex w-full items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5 sm:max-w-64">
<ImageOff className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="text-sm text-muted-foreground">
This post is no longer available
@@ -31,7 +31,11 @@ export function SharedPostCard({ attachment }: { attachment: Attachment }) {
return (
<Link
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`}
className="block overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/40"
// Capped on wide screens. The bubble allows 78% of the column, which on
// a phone is a sensible ~265px but on desktop is over 500px — and with
// a square image that makes a shared post taller than the viewport.
// Mobile keeps the bubble's own width; only sm+ needs the ceiling.
className="block w-full overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/40 sm:max-w-64"
>
<span className="flex items-center gap-2 px-3 py-2">
<UserAvatar
+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,
);
}