import { useState, useRef, useEffect } from 'react'; import Avatar from './Avatar.jsx'; import UserProfilePopup from './UserProfilePopup.jsx'; import ImageLightbox from './ImageLightbox.jsx'; import Picker from '@emoji-mart/react'; import data from '@emoji-mart/data'; import './Message.css'; const QUICK_EMOJIS = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ™']; function formatMsgContent(content) { if (!content) return ''; return content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `@${name}`); } export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact }) { const [showActions, setShowActions] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const wrapperRef = useRef(null); const pickerRef = useRef(null); const avatarRef = useRef(null); const [showProfile, setShowProfile] = useState(false); const [lightboxSrc, setLightboxSrc] = useState(null); const [pickerOpensDown, setPickerOpensDown] = useState(false); const isOwn = msg.user_id === currentUser.id; const isDeleted = !!msg.is_deleted; // Deleted messages are filtered out by ChatWindow, but guard here too if (isDeleted) return null; const canDelete = ( msg.user_id === currentUser.id || currentUser.role === 'admin' || (msg.group_owner_id === currentUser.id) ); const prevSameUser = prevMessage && prevMessage.user_id === msg.user_id && new Date(msg.created_at) - new Date(prevMessage.created_at) < 60000; const showDateSep = !prevMessage || new Date(msg.created_at).toDateString() !== new Date(prevMessage.created_at).toDateString(); const reactionMap = {}; for (const r of (msg.reactions || [])) { if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, users: [], hasMe: false }; reactionMap[r.emoji].count++; reactionMap[r.emoji].users.push(r.user_name); if (r.user_id === currentUser.id) reactionMap[r.emoji].hasMe = true; } // Close emoji picker when clicking outside useEffect(() => { if (!showEmojiPicker) return; const handler = (e) => { if (pickerRef.current && !pickerRef.current.contains(e.target)) { setShowEmojiPicker(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showEmojiPicker]); const handleReact = (emoji) => { onReact(msg.id, emoji); setShowEmojiPicker(false); }; const handleTogglePicker = () => { if (!showEmojiPicker && wrapperRef.current) { // If the message is in the top 400px of viewport, open picker downward const rect = wrapperRef.current.getBoundingClientRect(); setPickerOpensDown(rect.top < 400); } setShowEmojiPicker(p => !p); }; const msgUser = { id: msg.user_id, name: msg.user_name, display_name: msg.user_display_name, avatar: msg.user_avatar, role: msg.user_role, status: msg.user_status, hide_admin_tag: msg.user_hide_admin_tag, about_me: msg.user_about_me, }; return ( <> {showDateSep && (
{formatDate(msg.created_at)}
)}
setShowActions(true)} onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }} > {!isOwn && !prevSameUser && (
setShowProfile(p => !p)}>
)} {!isOwn && prevSameUser &&
}
{!isOwn && !prevSameUser && (
{msgUser.display_name || msgUser.name} {msgUser.role === 'admin' && !msgUser.hide_admin_tag && Admin} {msgUser.status !== 'active' && (inactive)}
)} {/* Reply preview */} {msg.reply_to_id && (
{msg.reply_user_display_name || msg.reply_user_name}
{msg.reply_is_deleted ? Deleted message : msg.reply_image_url ? '๐Ÿ“ท Image' : msg.reply_content}
)} {/* Bubble + actions together so actions hover above bubble */}
{/* Actions toolbar โ€” floats above the bubble, aligned to correct side */} {!isDeleted && (showActions || showEmojiPicker) && (
{QUICK_EMOJIS.map(e => ( ))} {canDelete && ( )} {/* Emoji picker anchored to the toolbar */} {showEmojiPicker && (
e.stopPropagation()} > handleReact(e.native)} theme="light" previewPosition="none" skinTonePosition="none" />
)}
)}
{msg.image_url && ( attachment setLightboxSrc(msg.image_url)} /> )} {msg.content && (

)} {msg.link_preview && }

{formatTime(msg.created_at)}
{Object.keys(reactionMap).length > 0 && (
{Object.entries(reactionMap).map(([emoji, { count, users, hasMe }]) => ( ))}
)}
{showProfile && ( setShowProfile(false)} /> )} {lightboxSrc && ( setLightboxSrc(null)} /> )} ); } function LinkPreview({ data: raw }) { let d; try { d = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; } if (!d?.title) return null; return ( {d.image && e.target.style.display = 'none'} />}
{d.siteName && {d.siteName}} {d.title} {d.description && {d.description}}
); } function formatTime(dateStr) { return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function formatDate(dateStr) { const d = new Date(dateStr); const now = new Date(); if (d.toDateString() === now.toDateString()) return 'Today'; const yest = new Date(now); yest.setDate(yest.getDate() - 1); if (d.toDateString() === yest.toDateString()) return 'Yesterday'; return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }); }