fix: stickers and chat UI fix
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user