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 { 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}
|
||||||
|
|||||||
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user