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 (
{replyTo && (
Replying to {replyTo.username}
)}
{file && (
{file.name}
)}
{uploading && (
)}
{error &&
{error}
}
);
}
/* ────────────────────────────────────────────
Sub-component: TypingIndicator
──────────────────────────────────────────── */
function TypingIndicator({ typingUsers }) {
if (!typingUsers || typingUsers.length === 0) return ;
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 (
{label}
);
}
/* ────────────────────────────────────────────
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 Loading…
;
}
return (
{/* Conversation list */}
Direct Messages
{conversations.length === 0 && (
No conversations yet
)}
{conversations.map(convo => {
const other = convo.username || convo.other_username || 'User';
const isActive = activeConvo?.id === convo.id;
return (
);
})}
{/* Message area */}
{!activeConvo ? (
Select a conversation to start messaging
) : (
<>
{activeConvo.username || activeConvo.other_username || 'User'}
{messages.map(msg => (
setReplyTo(m)}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
setReplyTo(null)}
/>
>
)}
);
}
/* ────────────────────────────────────────────
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 ;
}
// ── 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 (
Welcome to {server?.name || 'the server'}
Select a channel to start chatting
);
}
return (
{/* Header */}
#
{channel.name}
{channel.description && (
<>
{channel.description}
>
)}
{/* Messages */}
{loading && messages.length === 0 && (
Loading messages…
)}
{!hasMore && messages.length > 0 && (
Beginning of channel history
)}
{loading && messages.length > 0 && (
Loading older messages…
)}
{messages.map(msg => (
setReplyTo(m)}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
{/* Typing indicator */}
{/* Input */}
setReplyTo(null)}
/>
);
}