Skip to content

Commit b4da114

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 b4da114

1 file changed

Lines changed: 117 additions & 49 deletions

File tree

app/components/chat/Messages.tsx

Lines changed: 117 additions & 49 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,25 @@ 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+
useEffect(() => {
90+
if (!activeDesktopMenuMessageId) return;
91+
const stillExists = messages.some(
92+
(message) => message.id === activeDesktopMenuMessageId,
93+
);
94+
if (!stillExists) {
95+
setActiveDesktopMenuMessageId(null);
96+
}
97+
}, [activeDesktopMenuMessageId, messages]);
98+
7899
const copyMessageText = async (messageText: string) => {
79100
const trimmed = (messageText || "").trim();
80101
if (!trimmed) {
@@ -110,6 +131,36 @@ export default function Messages({
110131
longPressTimerRef.current = null;
111132
}, 450);
112133
};
134+
135+
const openDesktopMenu = (messageId: string) => {
136+
if (desktopMenuOpenTimerRef.current) {
137+
window.clearTimeout(desktopMenuOpenTimerRef.current);
138+
}
139+
if (desktopMenuCloseTimerRef.current) {
140+
window.clearTimeout(desktopMenuCloseTimerRef.current);
141+
desktopMenuCloseTimerRef.current = null;
142+
}
143+
// Delay open slightly for cleaner hover UX on desktop.
144+
desktopMenuOpenTimerRef.current = window.setTimeout(() => {
145+
setActiveDesktopMenuMessageId(messageId);
146+
desktopMenuOpenTimerRef.current = null;
147+
}, 180);
148+
};
149+
150+
const closeDesktopMenu = (messageId: string) => {
151+
if (desktopMenuOpenTimerRef.current) {
152+
window.clearTimeout(desktopMenuOpenTimerRef.current);
153+
desktopMenuOpenTimerRef.current = null;
154+
}
155+
if (desktopMenuCloseTimerRef.current) {
156+
window.clearTimeout(desktopMenuCloseTimerRef.current);
157+
}
158+
// Small delay prevents accidental close while moving pointer to menu.
159+
desktopMenuCloseTimerRef.current = window.setTimeout(() => {
160+
setActiveDesktopMenuMessageId((prev) => (prev === messageId ? null : prev));
161+
desktopMenuCloseTimerRef.current = null;
162+
}, 120);
163+
};
113164
return (
114165
<>
115166
<MediaViewerModal
@@ -187,6 +238,10 @@ export default function Messages({
187238
!msg.text &&
188239
normalizedAttachments.length > 0 &&
189240
normalizedAttachments.every((att) => getAttachmentKind(att) === "audio");
241+
const isFocusedMobileMessage =
242+
mobileActionMenu?.messageId === msg.id;
243+
const shouldBlurForMobileActionMenu =
244+
!!mobileActionMenu && !isFocusedMobileMessage;
190245

191246
return (
192247
<div
@@ -195,6 +250,12 @@ export default function Messages({
195250
isSelf ? "justify-end" : "justify-start"
196251
} ${
197252
activeDesktopMenuMessageId === msg.id ? "z-20" : "z-0"
253+
} ${
254+
shouldBlurForMobileActionMenu
255+
? "md:blur-0 blur-[1.5px] opacity-45 scale-[0.995]"
256+
: "opacity-100 scale-100"
257+
} ${
258+
isFocusedMobileMessage ? "z-30" : ""
198259
}`}
199260
>
200261
{!isSelf && (
@@ -240,12 +301,10 @@ export default function Messages({
240301
} transition-opacity`}
241302
onMouseEnter={() => {
242303
if (!hasDesktopActions) return;
243-
setActiveDesktopMenuMessageId(msg.id);
304+
openDesktopMenu(msg.id);
244305
}}
245306
onMouseLeave={() => {
246-
setActiveDesktopMenuMessageId((prev) =>
247-
prev === msg.id ? null : prev,
248-
);
307+
closeDesktopMenu(msg.id);
249308
}}
250309
>
251310
<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 +321,10 @@ export default function Messages({
262321
{isSelf && onUnsendMessage && (
263322
<button
264323
type="button"
265-
onClick={() => void onUnsendMessage(msg.id)}
324+
onClick={() => {
325+
setActiveDesktopMenuMessageId(null);
326+
void onUnsendMessage(msg.id);
327+
}}
266328
className="px-2 py-1 rounded-lg text-[11px] text-red-300 hover:bg-red-500/15 transition"
267329
title="Unsend message"
268330
>
@@ -318,12 +380,10 @@ export default function Messages({
318380
<div
319381
onMouseEnter={() => {
320382
if (!hasDesktopActions) return;
321-
setActiveDesktopMenuMessageId(msg.id);
383+
openDesktopMenu(msg.id);
322384
}}
323385
onMouseLeave={() => {
324-
setActiveDesktopMenuMessageId((prev) =>
325-
prev === msg.id ? null : prev,
326-
);
386+
closeDesktopMenu(msg.id);
327387
}}
328388
onTouchStart={() => queueLongPressMenu(msg.id, msg.text || "", isSelf)}
329389
onTouchEnd={clearLongPress}
@@ -372,12 +432,10 @@ export default function Messages({
372432
<div
373433
onMouseEnter={() => {
374434
if (!hasDesktopActions) return;
375-
setActiveDesktopMenuMessageId(msg.id);
435+
openDesktopMenu(msg.id);
376436
}}
377437
onMouseLeave={() => {
378-
setActiveDesktopMenuMessageId((prev) =>
379-
prev === msg.id ? null : prev,
380-
);
438+
closeDesktopMenu(msg.id);
381439
}}
382440
className={`${isVoiceOnlyMessage ? "mt-0" : "mt-1.5"} space-y-1.5`}
383441
>
@@ -397,45 +455,55 @@ export default function Messages({
397455
<div ref={bottomRef} />
398456
</div>
399457

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

0 commit comments

Comments
 (0)