250 lines
10 KiB
JavaScript
250 lines
10 KiB
JavaScript
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) => `<span class="mention">@${name}</span>`);
|
||
}
|
||
|
||
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 && (
|
||
<div className="date-separator">
|
||
<span>{formatDate(msg.created_at)}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
ref={wrapperRef}
|
||
className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`}
|
||
onMouseEnter={() => setShowActions(true)}
|
||
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
|
||
>
|
||
{!isOwn && !prevSameUser && (
|
||
<div ref={avatarRef} style={{ cursor: 'pointer' }} onClick={() => setShowProfile(p => !p)}>
|
||
<Avatar user={msgUser} size="sm" className="msg-avatar" />
|
||
</div>
|
||
)}
|
||
{!isOwn && prevSameUser && <div style={{ width: 32, flexShrink: 0 }} />}
|
||
|
||
<div className="message-body">
|
||
{!isOwn && !prevSameUser && (
|
||
<div className="msg-name">
|
||
{msgUser.display_name || msgUser.name}
|
||
{msgUser.role === 'admin' && !msgUser.hide_admin_tag && <span className="role-badge role-admin" style={{ marginLeft: 6 }}>Admin</span>}
|
||
{msgUser.status !== 'active' && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--text-tertiary)' }}>(inactive)</span>}
|
||
</div>
|
||
)}
|
||
|
||
{/* Reply preview */}
|
||
{msg.reply_to_id && (
|
||
<div className="reply-preview">
|
||
<div className="reply-bar" />
|
||
<div>
|
||
<div className="reply-name">{msg.reply_user_display_name || msg.reply_user_name}</div>
|
||
<div className="reply-text">
|
||
{msg.reply_is_deleted ? <em style={{ color: 'var(--text-tertiary)' }}>Deleted message</em>
|
||
: msg.reply_image_url ? '📷 Image'
|
||
: msg.reply_content}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bubble + actions together so actions hover above bubble */}
|
||
<div className="msg-bubble-wrap">
|
||
<div className="msg-bubble-with-actions">
|
||
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
|
||
{!isDeleted && (showActions || showEmojiPicker) && (
|
||
<div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}>
|
||
{QUICK_EMOJIS.map(e => (
|
||
<button key={e} className="quick-emoji" onClick={() => handleReact(e)} title={e}>{e}</button>
|
||
))}
|
||
<button className="btn-icon action-btn" onClick={handleTogglePicker} title="More reactions">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||
</button>
|
||
<button className="btn-icon action-btn" onClick={() => onReply(msg)} title="Reply">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
||
</button>
|
||
{canDelete && (
|
||
<button className="btn-icon action-btn danger" onClick={() => onDelete(msg.id)} title="Delete">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* Emoji picker anchored to the toolbar */}
|
||
{showEmojiPicker && (
|
||
<div
|
||
className={`emoji-picker-wrap ${isOwn ? 'picker-left' : 'picker-right'} ${pickerOpensDown ? 'picker-down' : ''}`}
|
||
ref={pickerRef}
|
||
onMouseDown={e => e.stopPropagation()}
|
||
>
|
||
<Picker data={data} onEmojiSelect={(e) => handleReact(e.native)} theme="light" previewPosition="none" skinTonePosition="none" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}`}>
|
||
{msg.image_url && (
|
||
<img
|
||
src={msg.image_url}
|
||
alt="attachment"
|
||
className="msg-image"
|
||
onClick={() => setLightboxSrc(msg.image_url)}
|
||
/>
|
||
)}
|
||
{msg.content && (
|
||
<p
|
||
className="msg-text"
|
||
dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }}
|
||
/>
|
||
)}
|
||
{msg.link_preview && <LinkPreview data={msg.link_preview} />}
|
||
</div>
|
||
</div>
|
||
|
||
<span className="msg-time">{formatTime(msg.created_at)}</span>
|
||
</div>
|
||
|
||
{Object.keys(reactionMap).length > 0 && (
|
||
<div className="reactions">
|
||
{Object.entries(reactionMap).map(([emoji, { count, users, hasMe }]) => (
|
||
<button
|
||
key={emoji}
|
||
className={`reaction-btn ${hasMe ? 'active' : ''}`}
|
||
onClick={() => onReact(msg.id, emoji)}
|
||
title={hasMe ? `${users.join(', ')} · Click to remove` : users.join(', ')}
|
||
>
|
||
{emoji} <span className="reaction-count">{count}</span>
|
||
{hasMe && <span className="reaction-remove" title="Remove reaction">×</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{showProfile && (
|
||
<UserProfilePopup
|
||
user={msgUser}
|
||
anchorEl={avatarRef.current}
|
||
onClose={() => setShowProfile(false)}
|
||
/>
|
||
)}
|
||
{lightboxSrc && (
|
||
<ImageLightbox src={lightboxSrc} onClose={() => 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 (
|
||
<a href={d.url} target="_blank" rel="noopener noreferrer" className="link-preview">
|
||
{d.image && <img src={d.image} alt="" className="link-preview-img" onError={e => e.target.style.display = 'none'} />}
|
||
<div className="link-preview-content">
|
||
{d.siteName && <span className="link-site">{d.siteName}</span>}
|
||
<span className="link-title">{d.title}</span>
|
||
{d.description && <span className="link-desc">{d.description}</span>}
|
||
</div>
|
||
</a>
|
||
);
|
||
}
|
||
|
||
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' });
|
||
}
|