v0.3.0
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user