Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |