@@ -8,11 +8,14 @@ import { Conversation, Message } from "../Chat";
88import { timeAgo } from "@/app/utils/time" ;
99import { type BadgeInfo , getBadgeInfoFromHours } from "@/app/utils/badge" ;
1010import { useEffect , useMemo , useRef , useState } from "react" ;
11+ import { createPortal } from "react-dom" ;
1112import Image from "next/image" ;
1213import {
14+ faCopy ,
1315 faFile ,
1416 faPause ,
1517 faPlay ,
18+ faTrashCan ,
1619} from "@fortawesome/free-solid-svg-icons" ;
1720import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" ;
1821import 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