Initial Commit

This commit is contained in:
2026-03-06 11:54:19 -05:00
parent ee68c4704f
commit 4517746692
36 changed files with 4262 additions and 0 deletions

View File

@@ -0,0 +1,249 @@
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_type !== 'private') ||
(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' });
}