This commit is contained in:
2026-03-09 14:36:19 -04:00
parent f37fe0086f
commit 42ad779750
40 changed files with 1928 additions and 593 deletions

View File

@@ -4,16 +4,34 @@ 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 '';
return content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `<span class="mention">@${name}</span>`);
// First handle @mentions
let html = content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `<span class="mention">@${name}</span>`);
// 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 `<a href="${trimmed}" target="_blank" rel="noopener noreferrer" class="msg-link">${trimmed}</a>${trailing}`;
});
return html;
}
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact }) {
// 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 }) {
const [showActions, setShowActions] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const wrapperRef = useRef(null);
@@ -25,21 +43,36 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
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 = prevMessage && prevMessage.user_id === msg.user_id &&
prevMessage.type !== 'system' && msg.type !== 'system' &&
parseTS(msg.created_at) - parseTS(prevMessage.created_at) < 60000;
const canDelete = !msg.is_deleted && (
msg.user_id === currentUser.id ||
currentUser.role === 'admin' ||
msg.group_owner_id === currentUser.id
);
// 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();
// System messages render as a simple centred notice
if (isSystem) {
return (
<>
{showDateSep && (
<div className="date-separator"><span>{formatDate(msg.created_at)}</span></div>
)}
<div className="system-message">{msg.content}</div>
</>
);
}
const reactionMap = {};
for (const r of (msg.reactions || [])) {
@@ -66,6 +99,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
setShowEmojiPicker(false);
};
const handleCopy = () => {
if (!msg.content) return;
navigator.clipboard.writeText(msg.content).catch(() => {});
};
const handleTogglePicker = () => {
if (!showEmojiPicker && wrapperRef.current) {
// If the message is in the top 400px of viewport, open picker downward
@@ -97,11 +135,15 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
<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)}>
<div
ref={avatarRef}
style={{ cursor: 'pointer', borderRadius: '50%', transition: 'box-shadow 0.15s' }}
onClick={() => setShowProfile(p => !p)}
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
<Avatar user={msgUser} size="sm" className="msg-avatar" />
</div>
)}
@@ -133,7 +175,10 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
{/* Bubble + actions together so actions hover above bubble */}
<div className="msg-bubble-wrap">
<div className="msg-bubble-with-actions">
<div className="msg-bubble-with-actions"
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
>
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
{!isDeleted && (showActions || showEmojiPicker) && (
<div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}>
@@ -146,6 +191,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
<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>
{msg.content && (
<button className="btn-icon action-btn" onClick={handleCopy} title="Copy text">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></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>
@@ -165,7 +215,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
</div>
)}
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}`}>
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}${!msg.image_url && isEmojiOnly(msg.content) ? ' emoji-only' : ''}`}>
{msg.image_url && (
<img
src={msg.image_url}
@@ -175,10 +225,12 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
/>
)}
{msg.content && (
<p
className="msg-text"
dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }}
/>
isEmojiOnly(msg.content) && !msg.image_url
? <p className="msg-text emoji-msg">{msg.content}</p>
: <p
className="msg-text"
dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }}
/>
)}
{msg.link_preview && <LinkPreview data={msg.link_preview} />}
</div>
@@ -209,6 +261,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
user={msgUser}
anchorEl={avatarRef.current}
onClose={() => setShowProfile(false)}
onDirectMessage={onDirectMessage}
/>
)}
{lightboxSrc && (
@@ -236,11 +289,11 @@ function LinkPreview({ data: raw }) {
}
function formatTime(dateStr) {
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return parseTS(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatDate(dateStr) {
const d = new Date(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);