Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef, useCallback } from 'react'; | |
| import Message from './Message'; | |
| import { parseMarkdown } from '../utils/markdown'; | |
| /* ──────────────────────────────────────────── | |
| Helper: debounce | |
| ──────────────────────────────────────────── */ | |
| function useDebouncedCallback(fn, delay) { | |
| const timer = useRef(null); | |
| return useCallback((...args) => { | |
| if (timer.current) return; | |
| fn(...args); | |
| timer.current = setTimeout(() => { timer.current = null; }, delay); | |
| }, [fn, delay]); | |
| } | |
| /* ──────────────────────────────────────────── | |
| Sub-component: MessageInput | |
| ──────────────────────────────────────────── */ | |
| function MessageInput({ token, channelId, socket, socketEvent, replyTo, onClearReply, isDm }) { | |
| const [text, setText] = useState(''); | |
| const [file, setFile] = useState(null); | |
| const [uploading, setUploading] = useState(false); | |
| const [uploadProgress, setUploadProgress] = useState(0); | |
| const [error, setError] = useState(''); | |
| const textareaRef = useRef(null); | |
| const fileRef = useRef(null); | |
| const emitTyping = useDebouncedCallback(() => { | |
| if (!socket) return; | |
| if (isDm) { | |
| socket.emit('dm-typing', { conversationId: channelId }); | |
| } else { | |
| socket.emit('typing', { channelId }); | |
| } | |
| }, 2000); | |
| const autoGrow = () => { | |
| const ta = textareaRef.current; | |
| if (!ta) return; | |
| ta.style.height = 'auto'; | |
| const maxH = 5 * 24; // ~5 lines | |
| ta.style.height = Math.min(ta.scrollHeight, maxH) + 'px'; | |
| }; | |
| const handleChange = (e) => { | |
| setText(e.target.value); | |
| autoGrow(); | |
| emitTyping(); | |
| }; | |
| const uploadFile = async (fileObj) => { | |
| const form = new FormData(); | |
| form.append('file', fileObj); | |
| setUploading(true); | |
| setUploadProgress(0); | |
| setError(''); | |
| try { | |
| // Use XMLHttpRequest for progress | |
| return await new Promise((resolve, reject) => { | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open('POST', '/api/upload'); | |
| xhr.setRequestHeader('Authorization', `Bearer ${token}`); | |
| xhr.upload.onprogress = (e) => { | |
| if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100)); | |
| }; | |
| xhr.onload = () => { | |
| if (xhr.status >= 200 && xhr.status < 300) { | |
| resolve(JSON.parse(xhr.responseText)); | |
| } else { | |
| reject(new Error('Upload failed')); | |
| } | |
| }; | |
| xhr.onerror = () => reject(new Error('Upload failed')); | |
| xhr.send(form); | |
| }); | |
| } catch (err) { | |
| setError(err.message); | |
| return null; | |
| } finally { | |
| setUploading(false); | |
| setUploadProgress(0); | |
| } | |
| }; | |
| const send = async () => { | |
| const content = text.trim(); | |
| if (!content && !file) return; | |
| setError(''); | |
| let fileData = null; | |
| if (file) { | |
| fileData = await uploadFile(file); | |
| if (!fileData) return; | |
| setFile(null); | |
| if (fileRef.current) fileRef.current.value = ''; | |
| } | |
| const body = { content: content || '' }; | |
| if (replyTo) body.replyTo = replyTo.id; | |
| if (fileData) { | |
| body.file_url = fileData.url; | |
| body.file_name = fileData.name; | |
| body.file_size = fileData.size; | |
| body.file_type = fileData.type; | |
| } | |
| const endpoint = isDm | |
| ? `/api/dms/${channelId}/messages` | |
| : `/api/channels/${channelId}/messages`; | |
| try { | |
| const res = await fetch(endpoint, { | |
| method: 'POST', | |
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| }); | |
| if (!res.ok) throw new Error('Failed to send message'); | |
| setText(''); | |
| if (onClearReply) onClearReply(); | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = 'auto'; | |
| } | |
| } catch (err) { | |
| setError(err.message); | |
| } | |
| }; | |
| const handleKeyDown = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| send(); | |
| } | |
| }; | |
| const handleFileSelect = (e) => { | |
| if (e.target.files?.[0]) setFile(e.target.files[0]); | |
| }; | |
| return ( | |
| <div className="px-4 pb-4"> | |
| {replyTo && ( | |
| <div className="flex items-center gap-2 px-3 py-1.5 mb-1 bg-[#25252a] rounded-t-lg text-sm text-gray-400"> | |
| <span>Replying to <span className="text-[#FFD700] font-medium">{replyTo.username}</span></span> | |
| <button onClick={onClearReply} className="ml-auto text-gray-500 hover:text-gray-300 transition-colors duration-200">✕</button> | |
| </div> | |
| )} | |
| {file && ( | |
| <div className="flex items-center gap-2 px-3 py-1.5 mb-1 bg-[#25252a] rounded-t-lg text-sm text-gray-400"> | |
| <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg> | |
| <span className="truncate">{file.name}</span> | |
| <button onClick={() => { setFile(null); if (fileRef.current) fileRef.current.value = ''; }} className="ml-auto text-gray-500 hover:text-gray-300 transition-colors duration-200">✕</button> | |
| </div> | |
| )} | |
| {uploading && ( | |
| <div className="h-1 mb-1 bg-[#25252a] rounded overflow-hidden"> | |
| <div className="h-full bg-[#FFD700] transition-all duration-300" style={{ width: `${uploadProgress}%` }} /> | |
| </div> | |
| )} | |
| <div className="flex items-end gap-2 bg-[#25252a] rounded-lg px-3 py-2"> | |
| <button | |
| onClick={() => fileRef.current?.click()} | |
| className="p-1 text-gray-400 hover:text-gray-200 transition-colors duration-200 flex-shrink-0 mb-0.5" | |
| title="Upload file" | |
| > | |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg> | |
| </button> | |
| <input ref={fileRef} type="file" className="hidden" onChange={handleFileSelect} /> | |
| <textarea | |
| ref={textareaRef} | |
| value={text} | |
| onChange={handleChange} | |
| onKeyDown={handleKeyDown} | |
| rows={1} | |
| placeholder={isDm ? 'Send a message…' : `Message #${''}`} | |
| className="flex-1 bg-transparent text-gray-100 placeholder-gray-500 resize-none outline-none text-sm leading-6 max-h-[120px]" | |
| /> | |
| <button | |
| onClick={send} | |
| disabled={(!text.trim() && !file) || uploading} | |
| className="p-1 text-[#FFD700] hover:text-yellow-300 disabled:text-gray-600 transition-colors duration-200 flex-shrink-0 mb-0.5" | |
| > | |
| <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" /></svg> | |
| </button> | |
| </div> | |
| {error && <p className="text-red-400 text-xs mt-1">{error}</p>} | |
| </div> | |
| ); | |
| } | |
| /* ──────────────────────────────────────────── | |
| Sub-component: TypingIndicator | |
| ──────────────────────────────────────────── */ | |
| function TypingIndicator({ typingUsers }) { | |
| if (!typingUsers || typingUsers.length === 0) return <div className="h-6 px-4" />; | |
| const names = typingUsers.map(u => u.username); | |
| const label = names.length === 1 | |
| ? `${names[0]} is typing` | |
| : names.length === 2 | |
| ? `${names[0]} and ${names[1]} are typing` | |
| : `${names[0]} and ${names.length - 1} others are typing`; | |
| return ( | |
| <div className="h-6 px-4 flex items-center gap-1 text-xs text-gray-400"> | |
| <span className="flex gap-0.5"> | |
| <span className="typing-dot w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> | |
| <span className="typing-dot w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> | |
| <span className="typing-dot w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> | |
| </span> | |
| <span>{label}</span> | |
| </div> | |
| ); | |
| } | |
| /* ──────────────────────────────────────────── | |
| Sub-component: DMView (when server is null) | |
| ──────────────────────────────────────────── */ | |
| function DMView({ token, user, socket }) { | |
| const [conversations, setConversations] = useState([]); | |
| const [activeConvo, setActiveConvo] = useState(null); | |
| const [messages, setMessages] = useState([]); | |
| const [loading, setLoading] = useState(true); | |
| const [typingUsers, setTypingUsers] = useState([]); | |
| const [replyTo, setReplyTo] = useState(null); | |
| const messagesEndRef = useRef(null); | |
| const typingTimers = useRef({}); | |
| // Fetch conversations | |
| useEffect(() => { | |
| (async () => { | |
| try { | |
| const res = await fetch('/api/dms', { | |
| headers: { 'Authorization': `Bearer ${token}` }, | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setConversations(data.conversations || data); | |
| } | |
| } catch (err) { | |
| console.error('Failed to load DMs', err); | |
| } finally { | |
| setLoading(false); | |
| } | |
| })(); | |
| }, [token]); | |
| // Join / leave DM room | |
| useEffect(() => { | |
| if (!socket || !activeConvo) return; | |
| socket.emit('join-dm', activeConvo.id); | |
| return () => socket.emit('leave-dm', activeConvo.id); | |
| }, [socket, activeConvo?.id]); | |
| // Fetch messages for active convo | |
| useEffect(() => { | |
| if (!activeConvo) return; | |
| (async () => { | |
| try { | |
| const res = await fetch(`/api/dms/${activeConvo.id}/messages`, { | |
| headers: { 'Authorization': `Bearer ${token}` }, | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setMessages(data.messages || data); | |
| } | |
| } catch (err) { | |
| console.error('Failed to load DM messages', err); | |
| } | |
| })(); | |
| }, [activeConvo?.id, token]); | |
| // Scroll to bottom | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }, [messages]); | |
| // Socket listeners | |
| useEffect(() => { | |
| if (!socket) return; | |
| const onNewDm = (msg) => { | |
| if (activeConvo && msg.conversation_id === activeConvo.id) { | |
| setMessages(prev => [...prev, msg]); | |
| } | |
| }; | |
| const onDmTyping = ({ userId, username, conversationId }) => { | |
| if (!activeConvo || conversationId !== activeConvo.id || userId === user.id) return; | |
| setTypingUsers(prev => { | |
| if (prev.find(u => u.userId === userId)) return prev; | |
| return [...prev, { userId, username }]; | |
| }); | |
| if (typingTimers.current[userId]) clearTimeout(typingTimers.current[userId]); | |
| typingTimers.current[userId] = setTimeout(() => { | |
| setTypingUsers(prev => prev.filter(u => u.userId !== userId)); | |
| }, 3000); | |
| }; | |
| socket.on('new-dm-message', onNewDm); | |
| socket.on('user-dm-typing', onDmTyping); | |
| return () => { | |
| socket.off('new-dm-message', onNewDm); | |
| socket.off('user-dm-typing', onDmTyping); | |
| }; | |
| }, [socket, activeConvo?.id, user?.id]); | |
| const handleEdit = async (messageId, content) => { | |
| try { | |
| const res = await fetch(`/api/messages/${messageId}`, { | |
| method: 'PUT', | |
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ content }), | |
| }); | |
| if (res.ok) { | |
| setMessages(prev => prev.map(m => m.id === messageId ? { ...m, content, edited: 1 } : m)); | |
| } | |
| } catch (err) { console.error(err); } | |
| }; | |
| const handleDelete = async (messageId) => { | |
| try { | |
| const res = await fetch(`/api/messages/${messageId}`, { | |
| method: 'DELETE', | |
| headers: { 'Authorization': `Bearer ${token}` }, | |
| }); | |
| if (res.ok) { | |
| setMessages(prev => prev.filter(m => m.id !== messageId)); | |
| } | |
| } catch (err) { console.error(err); } | |
| }; | |
| if (loading) { | |
| return <div className="flex-1 flex items-center justify-center text-gray-500">Loading…</div>; | |
| } | |
| return ( | |
| <div className="flex flex-1 h-full overflow-hidden"> | |
| {/* Conversation list */} | |
| <div className="w-60 bg-[#111114] border-r border-[#2f2f35] flex flex-col"> | |
| <div className="h-12 flex items-center px-4 font-semibold text-gray-100 border-b border-[#2f2f35]"> | |
| Direct Messages | |
| </div> | |
| <div className="flex-1 overflow-y-auto py-2"> | |
| {conversations.length === 0 && ( | |
| <p className="text-gray-500 text-sm px-4 py-2">No conversations yet</p> | |
| )} | |
| {conversations.map(convo => { | |
| const other = convo.username || convo.other_username || 'User'; | |
| const isActive = activeConvo?.id === convo.id; | |
| return ( | |
| <button | |
| key={convo.id} | |
| onClick={() => { setActiveConvo(convo); setMessages([]); setReplyTo(null); }} | |
| className={`w-full flex items-center gap-3 px-4 py-2 text-left transition-colors duration-200 ${ | |
| isActive ? 'bg-[#25252a] text-gray-100' : 'text-gray-400 hover:bg-[#1e1e22] hover:text-gray-200' | |
| }`} | |
| > | |
| <div className="w-8 h-8 rounded-full bg-[#3a3a42] flex items-center justify-center text-xs font-bold text-gray-300 flex-shrink-0"> | |
| {convo.avatar ? ( | |
| <img src={convo.avatar} alt="" className="w-8 h-8 rounded-full object-cover" /> | |
| ) : ( | |
| other[0]?.toUpperCase() | |
| )} | |
| </div> | |
| <span className="truncate text-sm">{other}</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| {/* Message area */} | |
| <div className="flex-1 flex flex-col bg-[#0e0e10]"> | |
| {!activeConvo ? ( | |
| <div className="flex-1 flex items-center justify-center text-gray-500"> | |
| Select a conversation to start messaging | |
| </div> | |
| ) : ( | |
| <> | |
| <div className="h-12 flex items-center px-4 border-b border-[#2f2f35]"> | |
| <span className="font-semibold text-gray-100">{activeConvo.username || activeConvo.other_username || 'User'}</span> | |
| </div> | |
| <div className="flex-1 overflow-y-auto px-4 py-4 space-y-1"> | |
| {messages.map(msg => ( | |
| <Message | |
| key={msg.id} | |
| message={msg} | |
| user={user} | |
| token={token} | |
| onReply={(m) => setReplyTo(m)} | |
| onEdit={handleEdit} | |
| onDelete={handleDelete} | |
| /> | |
| ))} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| <TypingIndicator typingUsers={typingUsers} /> | |
| <MessageInput | |
| token={token} | |
| channelId={activeConvo.id} | |
| socket={socket} | |
| isDm | |
| replyTo={replyTo} | |
| onClearReply={() => setReplyTo(null)} | |
| /> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| /* ──────────────────────────────────────────── | |
| Main: ChatArea | |
| ──────────────────────────────────────────── */ | |
| export default function ChatArea({ server, channel, token, user, socket, onToggleMemberList, showMemberList }) { | |
| const [messages, setMessages] = useState([]); | |
| const [loading, setLoading] = useState(false); | |
| const [hasMore, setHasMore] = useState(true); | |
| const [typingUsers, setTypingUsers] = useState([]); | |
| const [replyTo, setReplyTo] = useState(null); | |
| const messagesEndRef = useRef(null); | |
| const containerRef = useRef(null); | |
| const typingTimers = useRef({}); | |
| const prevScrollHeight = useRef(0); | |
| const isInitialLoad = useRef(true); | |
| // ── DM mode ── | |
| if (!server) { | |
| return <DMView token={token} user={user} socket={socket} />; | |
| } | |
| // ── Channel mode ── | |
| // Fetch messages | |
| const fetchMessages = useCallback(async (before) => { | |
| if (!channel) return; | |
| setLoading(true); | |
| try { | |
| let url = `/api/channels/${channel.id}/messages?limit=50`; | |
| if (before) url += `&before=${before}`; | |
| const res = await fetch(url, { | |
| headers: { 'Authorization': `Bearer ${token}` }, | |
| }); | |
| if (!res.ok) throw new Error('Failed to fetch messages'); | |
| const data = await res.json(); | |
| const fetched = data.messages || []; | |
| if (fetched.length < 50) setHasMore(false); | |
| if (before) { | |
| setMessages(prev => [...fetched, ...prev]); | |
| } else { | |
| setMessages(fetched); | |
| isInitialLoad.current = true; | |
| } | |
| } catch (err) { | |
| console.error(err); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [channel?.id, token]); | |
| // Join channel & load messages on channel change | |
| useEffect(() => { | |
| if (!channel) return; | |
| setMessages([]); | |
| setHasMore(true); | |
| setReplyTo(null); | |
| setTypingUsers([]); | |
| fetchMessages(); | |
| if (socket) socket.emit('join-channel', channel.id); | |
| return () => { | |
| if (socket) socket.emit('leave-channel', channel.id); | |
| }; | |
| }, [channel?.id, socket]); | |
| // Scroll to bottom on initial load or new messages | |
| useEffect(() => { | |
| if (isInitialLoad.current && messages.length > 0) { | |
| messagesEndRef.current?.scrollIntoView(); | |
| isInitialLoad.current = false; | |
| } | |
| }, [messages]); | |
| // Scroll to bottom on new message (only if already near bottom) | |
| const scrollToBottomIfNeeded = useCallback(() => { | |
| const c = containerRef.current; | |
| if (!c) return; | |
| const isNearBottom = c.scrollHeight - c.scrollTop - c.clientHeight < 150; | |
| if (isNearBottom) { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| }, []); | |
| // Socket events | |
| useEffect(() => { | |
| if (!socket || !channel) return; | |
| const onNewMessage = (msg) => { | |
| if (msg.channel_id !== channel.id) return; | |
| setMessages(prev => [...prev, msg]); | |
| setTimeout(scrollToBottomIfNeeded, 50); | |
| }; | |
| const onEdited = ({ id, content, edited }) => { | |
| setMessages(prev => prev.map(m => m.id === id ? { ...m, content, edited } : m)); | |
| }; | |
| const onDeleted = ({ id }) => { | |
| setMessages(prev => prev.filter(m => m.id !== id)); | |
| }; | |
| const onReactionAdded = ({ messageId, emoji, userId }) => { | |
| setMessages(prev => prev.map(m => { | |
| if (m.id !== messageId) return m; | |
| const reactions = [...(m.reactions || [])]; | |
| const idx = reactions.findIndex(r => r.emoji === emoji); | |
| if (idx >= 0) { | |
| const users = reactions[idx].users ? reactions[idx].users.split(',') : []; | |
| if (!users.includes(String(userId))) users.push(String(userId)); | |
| reactions[idx] = { ...reactions[idx], users: users.join(',') }; | |
| } else { | |
| reactions.push({ emoji, users: String(userId) }); | |
| } | |
| return { ...m, reactions }; | |
| })); | |
| }; | |
| const onReactionRemoved = ({ messageId, emoji, userId }) => { | |
| setMessages(prev => prev.map(m => { | |
| if (m.id !== messageId) return m; | |
| let reactions = [...(m.reactions || [])]; | |
| const idx = reactions.findIndex(r => r.emoji === emoji); | |
| if (idx >= 0) { | |
| let users = reactions[idx].users ? reactions[idx].users.split(',') : []; | |
| users = users.filter(uid => uid !== String(userId)); | |
| if (users.length === 0) { | |
| reactions.splice(idx, 1); | |
| } else { | |
| reactions[idx] = { ...reactions[idx], users: users.join(',') }; | |
| } | |
| } | |
| return { ...m, reactions }; | |
| })); | |
| }; | |
| const onTyping = ({ userId, username, channelId }) => { | |
| if (channelId !== channel.id || userId === user?.id) return; | |
| setTypingUsers(prev => { | |
| if (prev.find(u => u.userId === userId)) return prev; | |
| return [...prev, { userId, username }]; | |
| }); | |
| if (typingTimers.current[userId]) clearTimeout(typingTimers.current[userId]); | |
| typingTimers.current[userId] = setTimeout(() => { | |
| setTypingUsers(prev => prev.filter(u => u.userId !== userId)); | |
| }, 3000); | |
| }; | |
| socket.on('new-message', onNewMessage); | |
| socket.on('message-edited', onEdited); | |
| socket.on('message-deleted', onDeleted); | |
| socket.on('reaction-added', onReactionAdded); | |
| socket.on('reaction-removed', onReactionRemoved); | |
| socket.on('user-typing', onTyping); | |
| return () => { | |
| socket.off('new-message', onNewMessage); | |
| socket.off('message-edited', onEdited); | |
| socket.off('message-deleted', onDeleted); | |
| socket.off('reaction-added', onReactionAdded); | |
| socket.off('reaction-removed', onReactionRemoved); | |
| socket.off('user-typing', onTyping); | |
| }; | |
| }, [socket, channel?.id, user?.id, scrollToBottomIfNeeded]); | |
| // Infinite scroll | |
| const handleScroll = () => { | |
| const c = containerRef.current; | |
| if (!c || loading || !hasMore) return; | |
| if (c.scrollTop < 100) { | |
| prevScrollHeight.current = c.scrollHeight; | |
| const oldest = messages[0]; | |
| if (oldest) { | |
| fetchMessages(oldest.id).then(() => { | |
| requestAnimationFrame(() => { | |
| if (containerRef.current) { | |
| containerRef.current.scrollTop = containerRef.current.scrollHeight - prevScrollHeight.current; | |
| } | |
| }); | |
| }); | |
| } | |
| } | |
| }; | |
| const handleEdit = async (messageId, content) => { | |
| try { | |
| const res = await fetch(`/api/messages/${messageId}`, { | |
| method: 'PUT', | |
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ content }), | |
| }); | |
| if (res.ok) { | |
| setMessages(prev => prev.map(m => m.id === messageId ? { ...m, content, edited: 1 } : m)); | |
| } | |
| } catch (err) { console.error(err); } | |
| }; | |
| const handleDelete = async (messageId) => { | |
| try { | |
| const res = await fetch(`/api/messages/${messageId}`, { | |
| method: 'DELETE', | |
| headers: { 'Authorization': `Bearer ${token}` }, | |
| }); | |
| if (res.ok) { | |
| setMessages(prev => prev.filter(m => m.id !== messageId)); | |
| } | |
| } catch (err) { console.error(err); } | |
| }; | |
| if (!channel) { | |
| return ( | |
| <div className="flex-1 flex items-center justify-center bg-[#0e0e10] text-gray-500"> | |
| <div className="text-center"> | |
| <h2 className="text-2xl font-bold text-gray-300 mb-2">Welcome to {server?.name || 'the server'}</h2> | |
| <p>Select a channel to start chatting</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="flex-1 flex flex-col bg-[#0e0e10] min-w-0"> | |
| {/* Header */} | |
| <div className="h-12 flex items-center px-4 border-b border-[#FFD700]/20 flex-shrink-0"> | |
| <span className="text-gray-400 mr-1 font-mono">#</span> | |
| <span className="font-semibold text-gray-100">{channel.name}</span> | |
| {channel.description && ( | |
| <> | |
| <div className="w-px h-5 bg-[#3a3a42] mx-3" /> | |
| <span className="text-gray-500 text-sm truncate">{channel.description}</span> | |
| </> | |
| )} | |
| <div className="ml-auto"> | |
| <button | |
| onClick={onToggleMemberList} | |
| className={`p-1.5 rounded transition-colors duration-200 ${ | |
| showMemberList ? 'text-[#FFD700]' : 'text-gray-400 hover:text-gray-200' | |
| }`} | |
| title="Toggle member list" | |
| > | |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Messages */} | |
| <div | |
| ref={containerRef} | |
| onScroll={handleScroll} | |
| className="flex-1 overflow-y-auto px-4 py-4 space-y-1" | |
| > | |
| {loading && messages.length === 0 && ( | |
| <div className="flex items-center justify-center py-8 text-gray-500">Loading messages…</div> | |
| )} | |
| {!hasMore && messages.length > 0 && ( | |
| <div className="text-center text-gray-600 text-sm py-4">Beginning of channel history</div> | |
| )} | |
| {loading && messages.length > 0 && ( | |
| <div className="text-center text-gray-500 text-sm py-2">Loading older messages…</div> | |
| )} | |
| {messages.map(msg => ( | |
| <Message | |
| key={msg.id} | |
| message={msg} | |
| user={user} | |
| token={token} | |
| onReply={(m) => setReplyTo(m)} | |
| onEdit={handleEdit} | |
| onDelete={handleDelete} | |
| /> | |
| ))} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* Typing indicator */} | |
| <TypingIndicator typingUsers={typingUsers} /> | |
| {/* Input */} | |
| <MessageInput | |
| token={token} | |
| channelId={channel.id} | |
| socket={socket} | |
| replyTo={replyTo} | |
| onClearReply={() => setReplyTo(null)} | |
| /> | |
| </div> | |
| ); | |
| } | |