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 { parseTS } from '../utils/api.js'; import './Message.css'; const QUICK_EMOJIS = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ™']; function formatMsgContent(content) { if (!content) return ''; // First handle @mentions let html = content.replace(/@\[([^\]]+)\]/g, (_, name) => `@${name}`); // Then linkify bare URLs (not already inside a tag) html = html.replace(/(https?:\/\/[^\s<>"]+)/g, (url) => { // Trim trailing punctuation that's unlikely to be part of the URL const trimmed = url.replace(/[.,!?;:)\]]+$/, ''); const trailing = url.slice(trimmed.length); return `${trimmed}${trailing}`; }); return html; } // Detect emoji-only messages for large rendering function isEmojiOnly(str) { if (!str || str.length > 12) return false; const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u; return emojiRegex.test(str.trim()); } export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect, onlineUserIds = new Set() }) { const [showActions, setShowActions] = useState(false); const [showOptionsMenu, setShowOptionsMenu] = useState(false); const longPressTimer = useRef(null); const optionsMenuRef = useRef(null); 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; const isSystem = msg.type === 'system'; // These must be computed before any early returns that reference them const showDateSep = !prevMessage || parseTS(msg.created_at).toDateString() !== parseTS(prevMessage.created_at).toDateString(); const prevSameUser = !showDateSep && prevMessage && prevMessage.user_id === msg.user_id && prevMessage.type !== 'system' && msg.type !== 'system'; const canDelete = !msg.is_deleted && ( msg.user_id === currentUser.id || currentUser.role === 'admin' || msg.group_owner_id === currentUser.id ); // 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]); // Close options menu on outside click useEffect(() => { if (!showOptionsMenu) return; const close = (e) => { if (optionsMenuRef.current && !optionsMenuRef.current.contains(e.target)) { setShowOptionsMenu(false); } }; document.addEventListener('mousedown', close); document.addEventListener('touchstart', close); return () => { document.removeEventListener('mousedown', close); document.removeEventListener('touchstart', close); }; }, [showOptionsMenu]); const handleReact = (emoji) => { onReact(msg.id, emoji); setShowEmojiPicker(false); }; const handleCopy = () => { if (!msg.content) return; navigator.clipboard.writeText(msg.content).catch(() => {}); }; const handleTogglePicker = () => { if (!showEmojiPicker && wrapperRef.current) { const rect = wrapperRef.current.getBoundingClientRect(); setPickerOpensDown(rect.top < 400); } setShowEmojiPicker(p => !p); }; // Long press for mobile action menu (DMs only) const handleTouchStart = () => { if (!isDirect) return; longPressTimer.current = setTimeout(() => setShowOptionsMenu(true), 500); }; const handleTouchEnd = () => { if (longPressTimer.current) clearTimeout(longPressTimer.current); }; // Deleted messages are filtered out by ChatWindow, but guard here too if (isDeleted) return null; // System messages render as a simple centred notice if (isSystem) { return ( <> {showDateSep && (
{formatDate(msg.created_at)}
)}
{msg.content}
); } 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; } 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, allow_dm: msg.user_allow_dm, }; return ( <> {showDateSep && (
{formatDate(msg.created_at)}
)}
{!isOwn && !prevSameUser && (
setShowProfile(p => !p)} onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'} onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} > {!!(onlineUserIds instanceof Set ? onlineUserIds.has(Number(msg.user_id)) : false) && ( )}
)} {!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 */}
setShowActions(true)} onMouseLeave={() => { if (!showEmojiPicker && !showOptionsMenu) setShowActions(false); }} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchEnd} onContextMenu={isDirect ? (e => { e.preventDefault(); setShowOptionsMenu(true); }) : undefined} > {/* Actions toolbar โ€” floats above the bubble, aligned to correct side */} {!isDeleted && (showActions || showEmojiPicker) && (
{QUICK_EMOJIS.map(e => ( ))} {msg.content && ( )} {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 && ( isEmojiOnly(msg.content) && !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)} onDirectMessage={onDirectMessage} /> )} {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 parseTS(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function formatDate(dateStr) { const d = parseTS(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' }); }