gilded / client /src /components /Message.jsx
OmegaOne
Upload 41 files
0a8fe79 verified
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 (
<>
<img
src={file_url}
alt={file_name}
className="max-w-lg rounded-lg mt-2 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setImageOpen(true)}
/>
{imageOpen && (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center cursor-pointer" onClick={() => setImageOpen(false)}>
<img src={file_url} alt={file_name} className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
</div>
)}
</>
);
}
// Code file preview
if (isCodeFile(file_name) && codeContent !== null) {
return (
<div className="mt-2 max-w-lg">
<div className="flex items-center justify-between bg-[#1e1e22] rounded-t-lg px-3 py-1.5 text-xs text-gray-400 border border-[#3a3a42] border-b-0">
<span>{file_name}</span>
<a href={file_url} download className="text-[#FFD700] hover:underline">Download</a>
</div>
<pre className="bg-[#111114] rounded-b-lg px-3 py-2 text-xs text-gray-300 overflow-x-auto border border-[#3a3a42] border-t-0">
<code>{codeContent}</code>
</pre>
</div>
);
}
// Generic file card
return (
<div className="mt-2 flex items-center gap-3 bg-[#1e1e22] border border-[#3a3a42] rounded-lg px-4 py-3 max-w-sm">
<svg className="w-8 h-8 text-[#FFD700] flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div className="min-w-0 flex-1">
<a href={file_url} download className="text-[#FFD700] hover:underline text-sm font-medium truncate block">{file_name}</a>
<span className="text-gray-500 text-xs">{formatFileSize(file_size)}</span>
</div>
</div>
);
}
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 (
<div className="flex flex-wrap gap-1 mt-1">
{reactions.map((r, i) => {
const users = r.users ? r.users.split(',') : [];
const count = users.length;
const hasReacted = users.includes(String(userId));
return (
<button
key={`${r.emoji}-${i}`}
onClick={() => toggleReaction(r.emoji)}
className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs transition-colors duration-200 ${
hasReacted
? 'bg-[#FFD700]/20 border border-[#FFD700]/60 text-gray-100'
: 'bg-[#25252a] border border-transparent text-gray-400 hover:bg-[#2f2f35]'
}`}
>
<span>{r.emoji}</span>
<span>{count}</span>
</button>
);
})}
</div>
);
}
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 (
<div
className="relative group flex gap-3 py-1 px-2 -mx-2 rounded hover:bg-[#1e1e22]/50 transition-colors duration-200"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowEmojiPicker(false); }}
>
{/* Avatar */}
<div className="flex-shrink-0 pt-0.5">
{message.avatar ? (
<img src={message.avatar} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-[#3a3a42] flex items-center justify-center text-xs font-bold text-gray-300">
{(message.username || '?')[0].toUpperCase()}
</div>
)}
</div>
{/* Body */}
<div className="flex-1 min-w-0">
{/* Reply reference */}
{message.reply_to && (
<div className="flex items-center gap-1 text-xs text-gray-500 mb-0.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /></svg>
<span className="text-gray-400">replied to a message</span>
</div>
)}
{/* Header */}
<div className="flex items-baseline gap-2">
<span className="font-semibold text-gray-100 text-sm">{message.username || 'Unknown'}</span>
<span className="text-xs text-gray-500">{formatTime(message.created_at)}</span>
{message.edited ? <span className="text-xs text-gray-600">(edited)</span> : null}
</div>
{/* Content */}
{editing ? (
<div className="mt-1">
<textarea
ref={editRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleEditKeyDown}
className="w-full bg-[#25252a] text-gray-100 text-sm rounded px-3 py-2 outline-none resize-none border border-[#3a3a42] focus:border-[#FFD700]/50"
rows={2}
/>
<div className="text-xs text-gray-500 mt-1">
Press <kbd className="px-1 bg-[#25252a] rounded">Enter</kbd> to save · <kbd className="px-1 bg-[#25252a] rounded">Esc</kbd> to cancel
</div>
</div>
) : message.content ? (
<div
className="text-sm text-gray-300 leading-relaxed break-words message-content"
dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }}
/>
) : null}
{/* File attachment */}
{message.file_url && <FilePreview message={message} />}
{/* Reactions */}
<ReactionBar
reactions={message.reactions}
userId={user?.id}
token={token}
messageId={message.id}
/>
</div>
{/* Hover actions */}
{hovered && !editing && (
<div className="absolute -top-3 right-2 flex items-center bg-[#1e1e22] border border-[#3a3a42] rounded-lg shadow-lg overflow-hidden">
<div className="relative">
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="px-2 py-1 text-gray-400 hover:text-[#FFD700] hover:bg-[#25252a] transition-colors duration-200 text-sm"
title="Add reaction"
>
😀
</button>
{showEmojiPicker && (
<div className="absolute top-full right-0 mt-1 bg-[#1e1e22] border border-[#3a3a42] rounded-lg p-2 flex gap-1 shadow-xl z-10">
{EMOJI_QUICK.map(e => (
<button
key={e}
onClick={() => addReaction(e)}
className="hover:bg-[#25252a] rounded px-1.5 py-0.5 transition-colors duration-200"
>
{e}
</button>
))}
</div>
)}
</div>
<button
onClick={() => onReply(message)}
className="px-2 py-1 text-gray-400 hover:text-[#FFD700] hover:bg-[#25252a] transition-colors duration-200"
title="Reply"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /></svg>
</button>
{isOwn && (
<>
<button
onClick={() => { setEditing(true); setEditText(message.content || ''); }}
className="px-2 py-1 text-gray-400 hover:text-[#FFD700] hover:bg-[#25252a] transition-colors duration-200"
title="Edit"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</button>
<button
onClick={() => { if (window.confirm('Delete this message?')) onDelete(message.id); }}
className="px-2 py-1 text-gray-400 hover:text-red-400 hover:bg-[#25252a] transition-colors duration-200"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</>
)}
</div>
)}
</div>
);
}