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 { channelAuthorizer } from "@/lib/realtime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useVisualViewport } from "@/lib/use-visual-viewport"; import { useVisualViewport } from "@/lib/use-visual-viewport";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { useNotifications } from "@/components/notifications/notifications-provider"; import { useNotifications } from "@/components/notifications/notifications-provider";
import { import {
chat, chat,
@@ -71,6 +72,9 @@ export default function ChatThreadPage() {
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null); const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null); const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
const [forwarding, setForwarding] = useState<ChatMessage | 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()); const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
// user id → when they last typed. Kept as timestamps rather than a boolean // 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, // so the indicator expires on its own: there is no "stopped typing" event,
@@ -295,12 +299,6 @@ export default function ChatThreadPage() {
const deleteMessage = useCallback( const deleteMessage = useCallback(
async (m: ChatMessage, scope: "me" | "everyone") => { async (m: ChatMessage, scope: "me" | "everyone") => {
if (
scope === "everyone" &&
!window.confirm("Delete this message for everyone?")
) {
return;
}
try { try {
await chat.deleteMessage(id, m.id, scope); await chat.deleteMessage(id, m.id, scope);
if (scope === "me") { if (scope === "me") {
@@ -695,7 +693,8 @@ export default function ChatThreadPage() {
// Only the sender may erase a message for everyone. // Only the sender may erase a message for everyone.
onDeleteForEveryone: onDeleteForEveryone:
menu.message.sender_id === user.id menu.message.sender_id === user.id
? () => void deleteMessage(menu.message, "everyone") ? // Ask first — the modal calls deleteMessage on confirm.
() => setConfirmDelete(menu.message)
: undefined, : undefined,
// Reporting your own message is meaningless. // Reporting your own message is meaningless.
onReport: 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 && ( {forwarding && (
<ForwardDialog <ForwardDialog
message={forwarding} message={forwarding}
+53 -24
View File
@@ -13,13 +13,16 @@
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; 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 { LinkPreviewCard } from "@/components/chat/link-preview-card";
import { import {
ComposerAttachments, ComposerAttachments,
type StagedAttachment, type StagedAttachment,
} from "@/components/chat/composer-attachments"; } 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 { compressImage } from "@/lib/compress-image";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -70,7 +73,9 @@ export function ChatComposer({
const [staged, setStaged] = useState<StagedAttachment[]>([]); const [staged, setStaged] = useState<StagedAttachment[]>([]);
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(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); const lookupSeq = useRef(0);
// Grow with the content, up to a cap. // Grow with the content, up to a cap.
@@ -248,23 +253,28 @@ export function ChatComposer({
e.target.value = ""; e.target.value = "";
}} }}
/> />
{/* 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(
// 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 <button
type="button" type="button"
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
disabled={disabled} disabled={disabled}
aria-label="Attach a file" 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" 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-4" /> <Paperclip className="size-5" />
</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> </button>
<textarea <textarea
@@ -281,9 +291,10 @@ export function ChatComposer({
onTyping?.(); onTyping?.();
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
// Enter sends on a physical keyboard; Shift+Enter makes a newline. // Enter sends on a physical keyboard; Shift+Enter makes a
// On touch keyboards Enter must insert a newline instead — there is // newline. On touch keyboards Enter must insert a newline
// no Shift to reach for — so the send button is the only way there. // instead — there is no Shift to reach for — so the send button
// is the only way there.
if (e.key !== "Enter" || e.shiftKey) return; if (e.key !== "Enter" || e.shiftKey) return;
const touch = const touch =
typeof window !== "undefined" && typeof window !== "undefined" &&
@@ -293,16 +304,33 @@ export function ChatComposer({
submit(); submit();
}} }}
className={cn( 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 // 16px on touch devices: iOS Safari zooms the whole page when a
// focused field computes below that, which on a composer means the // focused field computes below that, which on a composer means
// thread jumps every time the keyboard opens. Pointer-based devices // the thread jumps every time the keyboard opens. Pointer-based
// keep the app's 14px chrome. // 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", "text-sm pointer-coarse:text-base",
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40", "placeholder:text-muted-foreground focus:outline-none",
"disabled:opacity-60", "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 <button
type="button" type="button"
onClick={submit} onClick={submit}
@@ -317,13 +345,14 @@ export function ChatComposer({
</button> </button>
</div> </div>
{pickingSticker && ( {stickerAnchor && (
<StickerPicker <StickerPicker
anchor={stickerAnchor}
// Sent on its own, with no text: a sticker IS the message. // Sent on its own, with no text: a sticker IS the message.
onPick={(sticker) => onPick={(sticker) =>
onSend("", undefined, undefined, undefined, sticker.key) onSend("", undefined, undefined, undefined, sticker.key)
} }
onClose={() => setPickingSticker(false)} onClose={() => setStickerAnchor(null)}
/> />
)} )}
</div> </div>
+15 -4
View File
@@ -15,6 +15,7 @@ import { useState } from "react";
import { BellOff, Bell, ShieldBan, ShieldCheck } from "lucide-react"; import { BellOff, Bell, ShieldBan, ShieldCheck } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { relationships } from "@/lib/social"; import { relationships } from "@/lib/social";
import { import {
chat, chat,
@@ -44,6 +45,9 @@ export function ChatProfileHeader({
onChanged: () => void; onChanged: () => void;
}) { }) {
const [busy, setBusy] = useState(false); 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 rel = detail.relationship;
const peer = detail.peer; const peer = detail.peer;
const muted = isMuted(detail); const muted = isMuted(detail);
@@ -64,9 +68,6 @@ export function ChatProfileHeader({
const toggleBlock = async () => { const toggleBlock = async () => {
if (!peer) return; if (!peer) return;
const blocking = !rel?.blocked; const blocking = !rel?.blocked;
if (blocking && !window.confirm(`Block ${peer.name}? They won't be able to message you.`)) {
return;
}
setBusy(true); setBusy(true);
try { try {
if (blocking) await relationships.block(peer.handle); if (blocking) await relationships.block(peer.handle);
@@ -134,7 +135,9 @@ export function ChatProfileHeader({
variant={rel?.blocked ? "outline" : "destructive"} variant={rel?.blocked ? "outline" : "destructive"}
size="sm" size="sm"
disabled={busy} disabled={busy}
onClick={toggleBlock} onClick={() =>
rel?.blocked ? void toggleBlock() : setConfirmBlock(true)
}
> >
{rel?.blocked ? ( {rel?.blocked ? (
<> <>
@@ -148,6 +151,14 @@ export function ChatProfileHeader({
</Button> </Button>
)} )}
</div> </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> </div>
); );
} }
+21 -8
View File
@@ -14,6 +14,7 @@ import { toast } from "sonner";
import { UserAvatar } from "@/components/ui/user-avatar"; import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AddMembersDialog } from "@/components/chat/add-members-dialog"; import { AddMembersDialog } from "@/components/chat/add-members-dialog";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { chat, isGroupAdmin, type ConversationDetail } from "@/lib/chat"; import { chat, isGroupAdmin, type ConversationDetail } from "@/lib/chat";
const ROLE_LABEL: Record<string, string> = { const ROLE_LABEL: Record<string, string> = {
@@ -34,6 +35,9 @@ export function GroupMembersTab({
onChanged: () => void; onChanged: () => void;
}) { }) {
const [busy, setBusy] = useState<string | null>(null); 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 [adding, setAdding] = useState(false);
const iAmAdmin = isGroupAdmin(detail.my_role); const iAmAdmin = isGroupAdmin(detail.my_role);
const iAmOwner = detail.my_role === "owner"; const iAmOwner = detail.my_role === "owner";
@@ -132,14 +136,8 @@ export function GroupMembersTab({
type="button" type="button"
disabled={busy === m.handle} disabled={busy === m.handle}
aria-label={`Remove ${m.name}`} aria-label={`Remove ${m.name}`}
onClick={() => { // Ask first — the modal calls act() on confirm.
if (!window.confirm(`Remove ${m.name} from the group?`)) return; onClick={() => setConfirmRemove(m)}
void act(
m.handle,
() => chat.removeMember(conversationId, m.handle),
`${m.name} was removed`,
);
}}
className="shrink-0 rounded-md p-1.5 text-destructive hover:opacity-80 disabled:opacity-40" className="shrink-0 rounded-md p-1.5 text-destructive hover:opacity-80 disabled:opacity-40"
> >
<UserMinus className="size-4" /> <UserMinus className="size-4" />
@@ -158,6 +156,21 @@ export function GroupMembersTab({
onAdded={onChanged} 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> </div>
); );
} }
+15 -10
View File
@@ -13,6 +13,7 @@ import { toast } from "sonner";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/ui/user-avatar"; import { UserAvatar } from "@/components/ui/user-avatar";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { compressImage } from "@/lib/compress-image"; import { compressImage } from "@/lib/compress-image";
import { import {
chat, chat,
@@ -38,6 +39,7 @@ export function GroupSettingsTab({
const router = useRouter(); const router = useRouter();
const [title, setTitle] = useState(detail.title ?? ""); const [title, setTitle] = useState(detail.title ?? "");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const iAmAdmin = isGroupAdmin(detail.my_role); const iAmAdmin = isGroupAdmin(detail.my_role);
@@ -96,15 +98,6 @@ export function GroupSettingsTab({
}; };
const leave = async () => { 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); setBusy(true);
try { try {
await chat.removeMember(conversationId, selfHandle); await chat.removeMember(conversationId, selfHandle);
@@ -219,7 +212,7 @@ export function GroupSettingsTab({
variant="destructive" variant="destructive"
size="sm" size="sm"
disabled={busy} disabled={busy}
onClick={leave} onClick={() => setConfirmLeave(true)}
> >
<LogOut className="size-4" /> Leave group <LogOut className="size-4" /> Leave group
</Button> </Button>
@@ -230,6 +223,18 @@ export function GroupSettingsTab({
</p> </p>
)} )}
</section> </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> </div>
); );
} }
+11 -1
View File
@@ -316,6 +316,15 @@ export function MessageGroup({
</div> </div>
{m.reactions?.length > 0 && ( {m.reactions?.length > 0 && (
// 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(
"flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
<button <button
type="button" type="button"
// ONE control, not one per emoji. Two different jobs depending // ONE control, not one per emoji. Two different jobs depending
@@ -331,7 +340,7 @@ export function MessageGroup({
} }
className={cn( className={cn(
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs", "-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", mine ? "mr-1" : "ml-1",
m.reactions.some((r) => r.mine) m.reactions.some((r) => r.mine)
? "border-primary/40 bg-primary/10" ? "border-primary/40 bg-primary/10"
: "border-border", : "border-border",
@@ -353,6 +362,7 @@ export function MessageGroup({
</span> </span>
)} )}
</button> </button>
</div>
)} )}
</MessageGestures> </MessageGestures>
))} ))}
+118 -17
View File
@@ -4,20 +4,33 @@
* The emoji row at the top of a message's long-press menu. * 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 * Five quick reactions plus a "+" that opens the full set — a drawer on
* mobile, a modal on desktop, reusing ResponsiveDialog so it behaves like * mobile, a popover anchored to the "+" on desktop. A modal would take over
* every other chat surface. * 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 * 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 * press is one gesture, and two surfaces competing for it would mean neither
* felt reliable. * felt reliable.
*/ */
import { useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { EMOJI_GROUPS } from "@/components/ui/emoji-picker"; 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"; 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. */ /** What a double-tap sends. Matches the server's own default. */
export const HEART_EMOJI = "❤️"; export const HEART_EMOJI = "❤️";
@@ -33,7 +46,12 @@ export function ReactionRow({
/** Picking the current emoji again clears it — the caller decides. */ /** Picking the current emoji again clears it — the caller decides. */
onPick: (emoji: string) => void; 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 ( return (
<> <>
@@ -55,7 +73,11 @@ export function ReactionRow({
))} ))}
<button <button
type="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" aria-label="More reactions"
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground" className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
> >
@@ -63,13 +85,34 @@ export function ReactionRow({
</button> </button>
</div> </div>
{browsing && ( {browsing && <FullSet anchor={browsing} onPick={onPick} onClose={close} />}
<ResponsiveDialog </>
title="Pick a reaction" );
description="Choose any emoji to react with." }
onClose={() => setBrowsing(false)}
> /** The whole emoji set: popover on desktop, drawer on touch. */
<div className="space-y-4 p-4"> 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) => ( {EMOJI_GROUPS.map((group) => (
<section key={group.label}> <section key={group.label}>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
@@ -81,7 +124,7 @@ export function ReactionRow({
key={emoji} key={emoji}
type="button" type="button"
onClick={() => { onClick={() => {
setBrowsing(false); onClose();
onPick(emoji); onPick(emoji);
}} }}
aria-label={`React ${emoji}`} aria-label={`React ${emoji}`}
@@ -94,8 +137,66 @@ export function ReactionRow({
</section> </section>
))} ))}
</div> </div>
</ResponsiveDialog> );
)}
</> 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) { if (!post || attachment.entity_unavailable) {
return ( 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 /> <ImageOff className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
This post is no longer available This post is no longer available
@@ -31,7 +31,11 @@ export function SharedPostCard({ attachment }: { attachment: Attachment }) {
return ( return (
<Link <Link
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`} 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"> <span className="flex items-center gap-2 px-3 py-2">
<UserAvatar <UserAvatar
+112 -13
View File
@@ -1,28 +1,54 @@
"use client"; "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 * 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 * 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 * 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. * with none needs to know how to get one rather than seeing a blank sheet.
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Image from "next/image"; 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"; 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({ export function StickerPicker({
anchor,
onPick, onPick,
onClose, onClose,
}: { }: {
/** Ignored on mobile, which uses a drawer. */
anchor: StickerAnchor | null;
onPick: (sticker: Sticker) => void; onPick: (sticker: Sticker) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const isMobile = useMediaQuery("(max-width: 767px)");
const [stickers, setStickers] = useState<Sticker[] | null>(null); const [stickers, setStickers] = useState<Sticker[] | null>(null);
useEffect(() => { useEffect(() => {
@@ -40,21 +66,26 @@ export function StickerPicker({
}; };
}, []); }, []);
return ( // Desktop only: Escape closes. The drawer handles its own dismissal, and no
<ResponsiveDialog // scroll lock here — a popover shouldn't freeze the thread behind it.
title="Stickers" useEffect(() => {
description="Earned through sādhana, or saved from a chat." if (isMobile) return;
onClose={onClose} const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
> document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isMobile, onClose]);
const grid = (
<>
{stickers === null ? ( {stickers === null ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p> <p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : stickers.length === 0 ? ( ) : stickers.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground"> <p className="p-4 text-sm text-muted-foreground">
No stickers yet. Keep your sādhana streak — a sacred reward arrives No stickers yet. Earn through your sādhana streak, or save one when
every 7 days — or save one someone sends you. someone sends you.
</p> </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) => ( {stickers.map((s) => (
<button <button
key={s.key} key={s.key}
@@ -78,6 +109,74 @@ export function StickerPicker({
))} ))}
</div> </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,
); );
} }