gilded / client /src /components /ChatArea.jsx
OmegaOne
Upload 41 files
0a8fe79 verified
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>
);
}