Skip to content

Commit 417d2db

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 417d2db

1 file changed

Lines changed: 116 additions & 52 deletions

File tree

app/components/chat/Messages.tsx

Lines changed: 116 additions & 52 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";
@@ -40,7 +43,9 @@ export default function Messages({
4043
null,
4144
);
4245
const [activeDesktopMenuMessageId, setActiveDesktopMenuMessageId] = useState<string | null>(null);
46+
const desktopMenuOpenTimerRef = useRef<number | null>(null);
4347
const longPressTimerRef = useRef<number | null>(null);
48+
const desktopMenuCloseTimerRef = useRef<number | null>(null);
4449
const [mobileActionMenu, setMobileActionMenu] = useState<{
4550
messageId: string;
4651
text: string;
@@ -72,9 +77,21 @@ export default function Messages({
7277
if (longPressTimerRef.current) {
7378
window.clearTimeout(longPressTimerRef.current);
7479
}
80+
if (desktopMenuOpenTimerRef.current) {
81+
window.clearTimeout(desktopMenuOpenTimerRef.current);
82+
}
83+
if (desktopMenuCloseTimerRef.current) {
84+
window.clearTimeout(desktopMenuCloseTimerRef.current);
85+
}
7586
};
7687
}, []);
7788

89+
const visibleDesktopMenuMessageId =
90+
activeDesktopMenuMessageId &&
91+
messages.some((message) => message.id === activeDesktopMenuMessageId)
92+
? activeDesktopMenuMessageId
93+
: null;
94+
7895
const copyMessageText = async (messageText: string) => {
7996
const trimmed = (messageText || "").trim();
8097
if (!trimmed) {
@@ -110,6 +127,36 @@ export default function Messages({
110127
longPressTimerRef.current = null;
111128
}, 450);
112129
};
130+
131+
const openDesktopMenu = (messageId: string) => {
132+
if (desktopMenuOpenTimerRef.current) {
133+
window.clearTimeout(desktopMenuOpenTimerRef.current);
134+
}
135+
if (desktopMenuCloseTimerRef.current) {
136+
window.clearTimeout(desktopMenuCloseTimerRef.current);
137+
desktopMenuCloseTimerRef.current = null;
138+
}
139+
// Delay open slightly for cleaner hover UX on desktop.
140+
desktopMenuOpenTimerRef.current = window.setTimeout(() => {
141+
setActiveDesktopMenuMessageId(messageId);
142+
desktopMenuOpenTimerRef.current = null;
143+
}, 180);
144+
};
145+
146+
const closeDesktopMenu = (messageId: string) => {
147+
if (desktopMenuOpenTimerRef.current) {
148+
window.clearTimeout(desktopMenuOpenTimerRef.current);
149+
desktopMenuOpenTimerRef.current = null;
150+
}
151+
if (desktopMenuCloseTimerRef.current) {
152+
window.clearTimeout(desktopMenuCloseTimerRef.current);
153+
}
154+
// Small delay prevents accidental close while moving pointer to menu.
155+
desktopMenuCloseTimerRef.current = window.setTimeout(() => {
156+
setActiveDesktopMenuMessageId((prev) => (prev === messageId ? null : prev));
157+
desktopMenuCloseTimerRef.current = null;
158+
}, 120);
159+
};
113160
return (
114161
<>
115162
<MediaViewerModal
@@ -122,7 +169,7 @@ export default function Messages({
122169
className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3 space-y-1 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-white/30"
123170
id="chat-container"
124171
>
125-
{activeDesktopMenuMessageId && (
172+
{visibleDesktopMenuMessageId && (
126173
<div className="hidden md:block fixed inset-0 z-10 pointer-events-none bg-black/20 backdrop-blur-[2px]" />
127174
)}
128175
{showScrollBtn && (
@@ -187,14 +234,24 @@ export default function Messages({
187234
!msg.text &&
188235
normalizedAttachments.length > 0 &&
189236
normalizedAttachments.every((att) => getAttachmentKind(att) === "audio");
237+
const isFocusedMobileMessage =
238+
mobileActionMenu?.messageId === msg.id;
239+
const shouldBlurForMobileActionMenu =
240+
!!mobileActionMenu && !isFocusedMobileMessage;
190241

191242
return (
192243
<div
193244
key={idx}
194245
className={`group relative flex gap-2.5 items-end transition-colors ${
195246
isSelf ? "justify-end" : "justify-start"
196247
} ${
197-
activeDesktopMenuMessageId === msg.id ? "z-20" : "z-0"
248+
visibleDesktopMenuMessageId === msg.id ? "z-20" : "z-0"
249+
} ${
250+
shouldBlurForMobileActionMenu
251+
? "md:blur-0 blur-[1.5px] opacity-45 scale-[0.995]"
252+
: "opacity-100 scale-100"
253+
} ${
254+
isFocusedMobileMessage ? "z-30" : ""
198255
}`}
199256
>
200257
{!isSelf && (
@@ -234,18 +291,16 @@ export default function Messages({
234291
className={`hidden md:flex absolute z-20 -top-8 ${
235292
isSelf ? "right-0" : "left-0"
236293
} ${
237-
activeDesktopMenuMessageId === msg.id
294+
visibleDesktopMenuMessageId === msg.id
238295
? "opacity-100"
239296
: "opacity-0 pointer-events-none"
240297
} transition-opacity`}
241298
onMouseEnter={() => {
242299
if (!hasDesktopActions) return;
243-
setActiveDesktopMenuMessageId(msg.id);
300+
openDesktopMenu(msg.id);
244301
}}
245302
onMouseLeave={() => {
246-
setActiveDesktopMenuMessageId((prev) =>
247-
prev === msg.id ? null : prev,
248-
);
303+
closeDesktopMenu(msg.id);
249304
}}
250305
>
251306
<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">
@@ -262,7 +317,10 @@ export default function Messages({
262317
{isSelf && onUnsendMessage && (
263318
<button
264319
type="button"
265-
onClick={() => void onUnsendMessage(msg.id)}
320+
onClick={() => {
321+
setActiveDesktopMenuMessageId(null);
322+
void onUnsendMessage(msg.id);
323+
}}
266324
className="px-2 py-1 rounded-lg text-[11px] text-red-300 hover:bg-red-500/15 transition"
267325
title="Unsend message"
268326
>
@@ -318,12 +376,10 @@ export default function Messages({
318376
<div
319377
onMouseEnter={() => {
320378
if (!hasDesktopActions) return;
321-
setActiveDesktopMenuMessageId(msg.id);
379+
openDesktopMenu(msg.id);
322380
}}
323381
onMouseLeave={() => {
324-
setActiveDesktopMenuMessageId((prev) =>
325-
prev === msg.id ? null : prev,
326-
);
382+
closeDesktopMenu(msg.id);
327383
}}
328384
onTouchStart={() => queueLongPressMenu(msg.id, msg.text || "", isSelf)}
329385
onTouchEnd={clearLongPress}
@@ -372,12 +428,10 @@ export default function Messages({
372428
<div
373429
onMouseEnter={() => {
374430
if (!hasDesktopActions) return;
375-
setActiveDesktopMenuMessageId(msg.id);
431+
openDesktopMenu(msg.id);
376432
}}
377433
onMouseLeave={() => {
378-
setActiveDesktopMenuMessageId((prev) =>
379-
prev === msg.id ? null : prev,
380-
);
434+
closeDesktopMenu(msg.id);
381435
}}
382436
className={`${isVoiceOnlyMessage ? "mt-0" : "mt-1.5"} space-y-1.5`}
383437
>
@@ -397,45 +451,55 @@ export default function Messages({
397451
<div ref={bottomRef} />
398452
</div>
399453

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-
>
454+
{mobileActionMenu &&
455+
createPortal(
405456
<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()}
457+
className="fixed inset-0 z-[9999] md:hidden"
458+
onClick={() => setMobileActionMenu(null)}
408459
>
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-
)}
460+
<div className="absolute inset-0 bg-black/25" />
461+
<div
462+
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]"
463+
onClick={(event) => event.stopPropagation()}
464+
>
465+
<div className="mx-auto w-full max-w-sm flex items-stretch gap-2">
466+
{!!mobileActionMenu.text.trim() && (
467+
<button
468+
type="button"
469+
onClick={() => {
470+
void copyMessageText(mobileActionMenu.text);
471+
setMobileActionMenu(null);
472+
}}
473+
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"
474+
>
475+
<FontAwesomeIcon
476+
icon={faCopy}
477+
className="w-4 h-4 text-indigo-300"
478+
/>
479+
<span className="text-[12px] font-semibold leading-tight">Copy</span>
480+
</button>
481+
)}
482+
{mobileActionMenu.isSelf && onUnsendMessage && (
483+
<button
484+
type="button"
485+
onClick={() => {
486+
void onUnsendMessage(mobileActionMenu.messageId);
487+
setMobileActionMenu(null);
488+
}}
489+
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"
490+
>
491+
<FontAwesomeIcon
492+
icon={faTrashCan}
493+
className="w-4 h-4 text-red-300"
494+
/>
495+
<span className="text-[12px] font-semibold leading-tight">Unsend</span>
496+
</button>
497+
)}
498+
</div>
435499
</div>
436-
</div>
437-
</div>
438-
)}
500+
</div>,
501+
document.body,
502+
)}
439503
</>
440504
);
441505
}

0 commit comments

Comments
 (0)