@@ -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,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