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}
+87 -58
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,61 +253,84 @@ export function ChatComposer({
e.target.value = ""; e.target.value = "";
}} }}
/> />
<button {/* One field group: attach on the left, sticker on the right, the
type="button" text between them. The border and background live here rather than
onClick={() => fileRef.current?.click()} on the textarea so all three read as a single control, and
disabled={disabled} focus-within moves the ring to the group. */}
aria-label="Attach a file" <div
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();
}}
className={cn( className={cn(
// 16px on touch devices: iOS Safari zooms the whole page when a // Minimal horizontal padding: the icon buttons keep a full-height
// focused field computes below that, which on a composer means the // tap target, but sit close to the group's edges and to the text
// thread jumps every time the keyboard opens. Pointer-based devices // rather than floating in their own gutters.
// keep the app's 14px chrome. "flex flex-1 items-end gap-0.5 rounded-2xl border border-border bg-muted/40 px-1 py-1",
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2", "focus-within:ring-1 focus-within:ring-primary/40",
"text-sm pointer-coarse:text-base", disabled && "opacity-60",
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus: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 <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>
); );
} }
+43 -33
View File
@@ -316,43 +316,53 @@ export function MessageGroup({
</div> </div>
{m.reactions?.length > 0 && ( {m.reactions?.length > 0 && (
<button // Aligned like the bubble it belongs to. The gesture wrapper is
type="button" // full-width, so without this the trail sits at the left edge
// ONE control, not one per emoji. Two different jobs depending // while your own bubble is over on the right.
// on whether you are in the reaction list already: if you have <div
// 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( className={cn(
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs", "flex w-full",
mine ? "self-end mr-1" : "self-start ml-1", mine ? "justify-end" : "justify-start",
m.reactions.some((r) => r.mine)
? "border-primary/40 bg-primary/10"
: "border-border",
)} )}
> >
{m.reactions.slice(0, MAX_TRAIL_EMOJI).map((r) => ( <button
<span key={r.emoji}>{r.emoji}</span> type="button"
))} // ONE control, not one per emoji. Two different jobs depending
{m.reactions.length > MAX_TRAIL_EMOJI && ( // on whether you are in the reaction list already: if you have
<span className="tabular-nums text-muted-foreground"> // reacted, this retracts it; if you have not, it opens the
+{m.reactions.length - MAX_TRAIL_EMOJI} // list of who reacted. Per-chip toggling would make "see who
</span> // reacted" unreachable for anyone who had.
)} onClick={() => onTrailClick(m)}
{/* Total people, not distinct emoji — "😂❤️ 5" reads as five 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. */} reactions, which is what it is. */}
{totalReactions(m) > 1 && ( {totalReactions(m) > 1 && (
<span className="ml-0.5 tabular-nums text-muted-foreground"> <span className="ml-0.5 tabular-nums text-muted-foreground">
{totalReactions(m)} {totalReactions(m)}
</span> </span>
)} )}
</button> </button>
</div>
)} )}
</MessageGestures> </MessageGestures>
))} ))}
+140 -39
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,39 +85,118 @@ 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)}
>
<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>
)}
</> </>
); );
} }
/** 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) { 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,
); );
} }