Skip to content

Commit 5d5c8ee

Browse files
committed
fix(chat): stabilize action menu interactions
- keep desktop copy/unsend menu open across hover gaps - render mobile action sheet in portal above input area
1 parent d203078 commit 5d5c8ee

1 file changed

Lines changed: 88 additions & 48 deletions

File tree

app/components/chat/Messages.tsx

Lines changed: 88 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import { Conversation, Message } from "../Chat";
88
import { timeAgo } from "@/app/utils/time";
99
import { type BadgeInfo, getBadgeInfoFromHours } from "@/app/utils/badge";
1010
import { useEffect, useMemo, useRef, useState } from "react";
11+
import { createPortal } from "react-dom";
1112
import Image from "next/image";
1213
import {
14+
faCopy,
1315
faFile,
1416
faPause,
1517
faPlay,
18+
faTrashCan,
1619
} from "@fortawesome/free-solid-svg-icons";
1720
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
1821
import MediaViewerModal, { type MediaViewerPayload } from "./MediaViewerModal";
@@ -41,6 +44,7 @@ export default function Messages({
4144
);
4245
const [activeDesktopMenuMessageId, setActiveDesktopMenuMessageId] = useState<string | null>(null);
4346
const longPressTimerRef = useRef<number | null>(null);
47+
const desktopMenuCloseTimerRef = useRef<number | null>(null);
4448
const [mobileActionMenu, setMobileActionMenu] = useState<{
4549
messageId: string;
4650
text: string;
@@ -72,6 +76,9 @@ export default function Messages({
7276
if (longPressTimerRef.current) {
7377
window.clearTimeout(longPressTimerRef.current);
7478
}
79+
if (desktopMenuCloseTimerRef.current) {
80+
window.clearTimeout(desktopMenuCloseTimerRef.current);
81+
}
7582
};
7683
}, []);
7784

@@ -110,6 +117,25 @@ export default function Messages({
110117
longPressTimerRef.current = null;
111118
}, 450);
112119
};
120+
121+
const openDesktopMenu = (messageId: string) => {
122+
if (desktopMenuCloseTimerRef.current) {
123+
window.clearTimeout(desktopMenuCloseTimerRef.current);
124+
desktopMenuCloseTimerRef.current = null;
125+
}
126+
setActiveDesktopMenuMessageId(messageId);
127+
};
128+
129+
const closeDesktopMenu = (messageId: string) => {
130+
if (desktopMenuCloseTimerRef.current) {
131+
window.clearTimeout(desktopMenuCloseTimerRef.current);
132+
}
133+
// Small delay prevents accidental close while moving pointer to menu.
134+
desktopMenuCloseTimerRef.current = window.setTimeout(() => {
135+
setActiveDesktopMenuMessageId((prev) => (prev === messageId ? null : prev));
136+
desktopMenuCloseTimerRef.current = null;
137+
}, 120);
138+
};
113139
return (
114140
<>
115141
<MediaViewerModal
@@ -187,6 +213,10 @@ export default function Messages({
187213
!msg.text &&
188214
normalizedAttachments.length > 0 &&
189215
normalizedAttachments.every((att) => getAttachmentKind(att) === "audio");
216+
const isFocusedMobileMessage =
217+
mobileActionMenu?.messageId === msg.id;
218+
const shouldBlurForMobileActionMenu =
219+
!!mobileActionMenu && !isFocusedMobileMessage;
190220

191221
return (
192222
<div
@@ -195,6 +225,12 @@ export default function Messages({
195225
isSelf ? "justify-end" : "justify-start"
196226
} ${
197227
activeDesktopMenuMessageId === msg.id ? "z-20" : "z-0"
228+
} ${
229+
shouldBlurForMobileActionMenu
230+
? "md:blur-0 blur-[1.5px] opacity-45 scale-[0.995]"
231+
: "opacity-100 scale-100"
232+
} ${
233+
isFocusedMobileMessage ? "z-30" : ""
198234
}`}
199235
>
200236
{!isSelf && (
@@ -240,12 +276,10 @@ export default function Messages({
240276
} transition-opacity`}
241277
onMouseEnter={() => {
242278
if (!hasDesktopActions) return;
243-
setActiveDesktopMenuMessageId(msg.id);
279+
openDesktopMenu(msg.id);
244280
}}
245281
onMouseLeave={() => {
246-
setActiveDesktopMenuMessageId((prev) =>
247-
prev === msg.id ? null : prev,
248-
);
282+
closeDesktopMenu(msg.id);
249283
}}
250284
>
251285
<div className="relative isolate flex items-center gap-1 rounded-xl border border-white/10 bg-[#0c0c1f]/95 backdrop-blur px-1.5 py-1 shadow-xl">
@@ -318,12 +352,10 @@ export default function Messages({
318352
<div
319353
onMouseEnter={() => {
320354
if (!hasDesktopActions) return;
321-
setActiveDesktopMenuMessageId(msg.id);
355+
openDesktopMenu(msg.id);
322356
}}
323357
onMouseLeave={() => {
324-
setActiveDesktopMenuMessageId((prev) =>
325-
prev === msg.id ? null : prev,
326-
);
358+
closeDesktopMenu(msg.id);
327359
}}
328360
onTouchStart={() => queueLongPressMenu(msg.id, msg.text || "", isSelf)}
329361
onTouchEnd={clearLongPress}
@@ -372,12 +404,10 @@ export default function Messages({
372404
<div
373405
onMouseEnter={() => {
374406
if (!hasDesktopActions) return;
375-
setActiveDesktopMenuMessageId(msg.id);
407+
openDesktopMenu(msg.id);
376408
}}
377409
onMouseLeave={() => {
378-
setActiveDesktopMenuMessageId((prev) =>
379-
prev === msg.id ? null : prev,
380-
);
410+
closeDesktopMenu(msg.id);
381411
}}
382412
className={`${isVoiceOnlyMessage ? "mt-0" : "mt-1.5"} space-y-1.5`}
383413
>
@@ -397,45 +427,55 @@ export default function Messages({
397427
<div ref={bottomRef} />
398428
</div>
399429

400-
{mobileActionMenu && (
401-
<div
402-
className="fixed inset-0 z-[120] bg-black/45 backdrop-blur-[2px] md:hidden flex items-end"
403-
onClick={() => setMobileActionMenu(null)}
404-
>
430+
{mobileActionMenu &&
431+
createPortal(
405432
<div
406-
className="w-full rounded-t-2xl border-t border-white/10 bg-[#0c0c1f] p-4 pb-6 shadow-2xl"
407-
onClick={(event) => event.stopPropagation()}
433+
className="fixed inset-0 z-[9999] md:hidden"
434+
onClick={() => setMobileActionMenu(null)}
408435
>
409-
<div className="w-10 h-1 rounded-full bg-white/20 mx-auto mb-4" />
410-
<div className="space-y-2">
411-
{!!mobileActionMenu.text.trim() && (
412-
<button
413-
type="button"
414-
onClick={() => {
415-
void copyMessageText(mobileActionMenu.text);
416-
setMobileActionMenu(null);
417-
}}
418-
className="w-full text-left rounded-xl px-4 py-3 bg-white/5 border border-white/10 text-gray-100 font-medium"
419-
>
420-
Copy
421-
</button>
422-
)}
423-
{mobileActionMenu.isSelf && onUnsendMessage && (
424-
<button
425-
type="button"
426-
onClick={() => {
427-
void onUnsendMessage(mobileActionMenu.messageId);
428-
setMobileActionMenu(null);
429-
}}
430-
className="w-full text-left rounded-xl px-4 py-3 bg-red-500/10 border border-red-500/20 text-red-300 font-medium"
431-
>
432-
Unsend
433-
</button>
434-
)}
436+
<div className="absolute inset-0 bg-black/25" />
437+
<div
438+
className="absolute inset-x-0 bottom-0 border-t border-white/10 bg-[#0c0c1f]/96 backdrop-blur-xl px-3 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.9rem)] shadow-2xl rounded-t-[28px]"
439+
onClick={(event) => event.stopPropagation()}
440+
>
441+
<div className="mx-auto w-full max-w-sm flex items-stretch gap-2">
442+
{!!mobileActionMenu.text.trim() && (
443+
<button
444+
type="button"
445+
onClick={() => {
446+
void copyMessageText(mobileActionMenu.text);
447+
setMobileActionMenu(null);
448+
}}
449+
className="flex-1 rounded-2xl px-2 py-2 text-gray-100 hover:bg-white/10 active:scale-[0.98] transition flex flex-col items-center justify-center gap-1"
450+
>
451+
<FontAwesomeIcon
452+
icon={faCopy}
453+
className="w-4 h-4 text-indigo-300"
454+
/>
455+
<span className="text-[12px] font-semibold leading-tight">Copy</span>
456+
</button>
457+
)}
458+
{mobileActionMenu.isSelf && onUnsendMessage && (
459+
<button
460+
type="button"
461+
onClick={() => {
462+
void onUnsendMessage(mobileActionMenu.messageId);
463+
setMobileActionMenu(null);
464+
}}
465+
className="flex-1 rounded-2xl px-2 py-2 text-red-200 hover:bg-white/10 active:scale-[0.98] transition flex flex-col items-center justify-center gap-1"
466+
>
467+
<FontAwesomeIcon
468+
icon={faTrashCan}
469+
className="w-4 h-4 text-red-300"
470+
/>
471+
<span className="text-[12px] font-semibold leading-tight">Unsend</span>
472+
</button>
473+
)}
474+
</div>
435475
</div>
436-
</div>
437-
</div>
438-
)}
476+
</div>,
477+
document.body,
478+
)}
439479
</>
440480
);
441481
}

0 commit comments

Comments
 (0)