@@ -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" ;
@@ -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