Files
rosterchirp-dev/frontend/src/components/Message.jsx
2026-03-06 22:37:48 -05:00

250 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' });
}