import React, { useState, useRef, useEffect } from 'react';
import { parseMarkdown } from '../utils/markdown';
const CODE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.py', '.html', '.css', '.json', '.md', '.txt', '.sh', '.yml', '.yaml', '.xml', '.sql', '.rb', '.go', '.rs', '.c', '.cpp', '.h', '.java'];
function formatFileSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function formatTime(dateStr) {
const d = new Date(dateStr);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = d.toDateString() === yesterday.toDateString();
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return `${d.toLocaleDateString()} ${time}`;
}
function isCodeFile(fileName) {
if (!fileName) return false;
return CODE_EXTENSIONS.some(ext => fileName.toLowerCase().endsWith(ext));
}
function FilePreview({ message }) {
const { file_url, file_name, file_size, file_type } = message;
const [codeContent, setCodeContent] = useState(null);
const [imageOpen, setImageOpen] = useState(false);
useEffect(() => {
if (isCodeFile(file_name)) {
fetch(file_url).then(r => r.text()).then(text => {
const lines = text.split('\n').slice(0, 20);
setCodeContent(lines.join('\n') + (text.split('\n').length > 20 ? '\n...' : ''));
}).catch(() => {});
}
}, [file_url, file_name]);
// Image preview
if (file_type?.startsWith('image/')) {
return (
<>
setImageOpen(true)}
/>
{imageOpen && (
setImageOpen(false)}>
)}
>
);
}
// Code file preview
if (isCodeFile(file_name) && codeContent !== null) {
return (
);
}
// Generic file card
return (
);
}
function ReactionBar({ reactions, userId, token, messageId }) {
if (!reactions || reactions.length === 0) return null;
const toggleReaction = async (emoji) => {
const reaction = reactions.find(r => r.emoji === emoji);
const users = reaction?.users ? reaction.users.split(',') : [];
const hasReacted = users.includes(String(userId));
try {
if (hasReacted) {
await fetch(`/api/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
} else {
await fetch(`/api/messages/${messageId}/reactions`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji }),
});
}
} catch (err) { console.error(err); }
};
return (
{reactions.map((r, i) => {
const users = r.users ? r.users.split(',') : [];
const count = users.length;
const hasReacted = users.includes(String(userId));
return (
);
})}
);
}
const EMOJI_QUICK = ['๐', 'โค๏ธ', '๐', '๐', '๐ฅ', '๐'];
export default function Message({ message, user, token, onReply, onEdit, onDelete }) {
const [hovered, setHovered] = useState(false);
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(message.content || '');
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const editRef = useRef(null);
const isOwn = user && String(message.user_id) === String(user.id);
useEffect(() => {
if (editing && editRef.current) {
editRef.current.focus();
editRef.current.selectionStart = editRef.current.value.length;
}
}, [editing]);
const saveEdit = () => {
const trimmed = editText.trim();
if (trimmed && trimmed !== message.content) {
onEdit(message.id, trimmed);
}
setEditing(false);
};
const handleEditKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveEdit(); }
if (e.key === 'Escape') { setEditing(false); setEditText(message.content || ''); }
};
const addReaction = async (emoji) => {
setShowEmojiPicker(false);
try {
await fetch(`/api/messages/${message.id}/reactions`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji }),
});
} catch (err) { console.error(err); }
};
return (
setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowEmojiPicker(false); }}
>
{/* Avatar */}
{message.avatar ? (

) : (
{(message.username || '?')[0].toUpperCase()}
)}
{/* Body */}
{/* Reply reference */}
{message.reply_to && (
)}
{/* Header */}
{message.username || 'Unknown'}
{formatTime(message.created_at)}
{message.edited ? (edited) : null}
{/* Content */}
{editing ? (
) : message.content ? (
) : null}
{/* File attachment */}
{message.file_url &&
}
{/* Reactions */}
{/* Hover actions */}
{hovered && !editing && (
{showEmojiPicker && (
{EMOJI_QUICK.map(e => (
))}
)}
{isOwn && (
<>
>
)}
)}
);
}