Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@
# Node
node_modules

# Build
client/dist

# MacOS
.DS_Store
12 changes: 12 additions & 0 deletions client/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Node Chat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
331 changes: 331 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { io } from 'socket.io-client';

const socket = io('http://localhost:3005');
const USERNAME_KEY = 'node_chat_username';

export default function App() {
const [username, setUsername] = useState(
localStorage.getItem(USERNAME_KEY) || '',
);
const [draftUsername, setDraftUsername] = useState(username);
const [rooms, setRooms] = useState([]);
const [currentRoom, setCurrentRoom] = useState(null);
const [messages, setMessages] = useState([]);
const [messageText, setMessageText] = useState('');
const [newRoomName, setNewRoomName] = useState('');
const [renameValue, setRenameValue] = useState('');
const [error, setError] = useState('');
const [toast, setToast] = useState(null);
const messageEndRef = useRef(null);

const showToast = (message, type = 'info') => {
setToast({ message, type });
};

useEffect(() => {
socket.on('roomList', setRooms);
socket.on('roomJoined', (room) => {
setCurrentRoom({ id: room.id, name: room.name });
setMessages(room.messages || []);
setRenameValue(room.name);
});
socket.on('roomCreated', (roomId) => {
showToast('Room created', 'success');
joinRoom(roomId);
});
socket.on('newMessage', (message) => {
setMessages((prev) => [...prev, message]);
});
socket.on('systemMessage', (message) => {
setMessages((prev) => [...prev, message]);
});
socket.on('roomRenamed', (room) => {
setCurrentRoom((prev) => (prev && prev.id === room.id ? room : prev));
setRenameValue(room.name);
});
socket.on('roomDeleted', (roomId) => {
setRooms((prev) => prev.filter((room) => room.id !== roomId));
setCurrentRoom((prev) => (prev?.id === roomId ? null : prev));
setMessages((prev) =>
prev.concat({
author: 'System',
text: 'Current room was deleted',
time: new Date().toISOString(),
}),
);
showToast('Room deleted', 'warning');
});
socket.on('error', (message) => {
setError(message);
showToast(message, 'error');
});

return () => {
socket.off('roomList');
socket.off('roomJoined');
socket.off('roomCreated');
socket.off('newMessage');
socket.off('systemMessage');
socket.off('roomRenamed');
socket.off('roomDeleted');
socket.off('error');
};
}, []);

useEffect(() => {
if (username) {
socket.emit('setUsername', username);
}
}, [username]);

useEffect(() => {
messageEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);

useEffect(() => {
if (!toast) {
return;
}

const id = setTimeout(() => {
setToast(null);
}, 3000);

return () => clearTimeout(id);
}, [toast]);

const roomName = useMemo(() => {
if (!currentRoom) {
return 'No room selected';
}

return currentRoom.name;
}, [currentRoom]);

const saveUsername = (event) => {
event.preventDefault();

const cleaned = draftUsername.trim();

if (!cleaned) {
setError('Username is required');

return;
}

setError('');
localStorage.setItem(USERNAME_KEY, cleaned);
setUsername(cleaned);
};

const joinRoom = (roomId) => {
setError('');
socket.emit('joinRoom', roomId);
};

const sendMessage = (event) => {
event.preventDefault();

const cleaned = messageText.trim();

if (!cleaned || !currentRoom) {
return;
}

socket.emit('sendMessage', cleaned);
setMessageText('');
};

const createRoom = (event) => {
event.preventDefault();
const cleaned = newRoomName.trim();

if (!cleaned) {
return;
}

if (rooms.some((r) => r.name === cleaned)) {
showToast('Room already exists', 'warning');
return;
}

socket.emit('createRoom', cleaned, (response) => {
if (!response?.ok) {
const message = response?.error || 'Failed to create room';
setError(message);
showToast(message, 'error');
return;
}

showToast('Room created', 'success');
joinRoom(response.roomId);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server-side 'createRoom' handler doesn't use an acknowledgment callback, so this callback function will never be executed. The logic within this callback is effectively dead code. Fortunately, the necessary logic is correctly implemented in the 'roomCreated' event listener. Consider removing this callback from the socket.emit call.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed.


setNewRoomName('');
showToast('Creating room...', 'info');
};

const renameRoom = (event) => {
event.preventDefault();

if (!currentRoom) {
return;
}

const cleaned = renameValue.trim();

if (!cleaned) {
return;
}

if (rooms.some((r) => r.name === cleaned)) {
showToast('Room already exists', 'warning');
return;
}

socket.emit('renameRoom', { roomId: currentRoom.id, newName: cleaned });
showToast('Room renamed', 'success');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This success toast is displayed immediately after sending the request to the server, without waiting for confirmation. If an error occurs on the server (e.g., the new room name is invalid or already exists), the user will see a success message followed by an error message. It would be better to move this toast into the socket.on('roomRenamed', ...) event handler to ensure it only appears after the server confirms the action was successful.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved toast to relative handler.

};

const deleteRoom = () => {
if (!currentRoom) {
return;
}

socket.emit('deleteRoom', currentRoom.id);
showToast('Room delete requested', 'warning');
};

const resetUsername = () => {
localStorage.removeItem(USERNAME_KEY);
setUsername('');
setDraftUsername('');
setCurrentRoom(null);
setMessages([]);
setError('');
};

if (!username) {
return (
<main className="auth-screen">
<form onSubmit={saveUsername} className="auth-card">
<h1>Welcome to Node Chat</h1>
<p>Create your username to continue</p>
<label htmlFor="username">Username</label>
<input
id="username"
value={draftUsername}
onChange={(e) => setDraftUsername(e.target.value)}
placeholder="Enter username"
autoFocus
/>
<button type="submit">Continue</button>
{error && <div className="error">{error}</div>}
</form>
</main>
);
}

return (
<>
{toast && (
<div className={`toast toast-${toast.type}`}>{toast.message}</div>
)}
<div className="layout">
<aside className="sidebar">
<h2>Rooms</h2>

<div className="panel">
<strong>{username}</strong>
<button type="button" onClick={resetUsername}>
Change user
</button>
</div>

<form onSubmit={createRoom} className="panel">
<label htmlFor="new-room">Create Room</label>
<input
id="new-room"
value={newRoomName}
onChange={(e) => setNewRoomName(e.target.value)}
placeholder="Room name"
/>
<button type="submit">Create</button>
</form>

<div className="rooms-list">
{rooms.map((room) => (
<button
key={room.id}
type="button"
className={`room-button ${currentRoom?.id === room.id ? 'active' : ''}`}
onClick={() => joinRoom(room.id)}
>
{room.name}
</button>
))}
</div>
</aside>

<main className="chat">
<header className="chat-header">
<div>
<h1>{roomName}</h1>
<p>
{username
? `Signed in as ${username}`
: 'Set username to start'}
</p>
</div>

{currentRoom && (
<div className="room-actions">
<form onSubmit={renameRoom}>
<input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="New room name"
/>
<button type="submit">Rename</button>
</form>
<button type="button" className="danger" onClick={deleteRoom}>
Delete
</button>
</div>
)}
</header>

{error && <p className="error">{error}</p>}

<section className="messages">
{messages.map((message, index) => (
<article key={`${message.time}-${index}`} className="message">
<div className="meta">
<strong>{message.author}</strong>
<time>{new Date(message.time).toLocaleTimeString()}</time>
</div>
<p>{message.text}</p>
</article>
))}
<div ref={messageEndRef} />
</section>

<form onSubmit={sendMessage} className="send-form">
<input
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder={
currentRoom ? 'Write a message' : 'Join a room first'
}
disabled={!currentRoom || !username}
/>
<button type="submit" disabled={!currentRoom || !username}>
Send
</button>
</form>
</main>
</div>
</>
);
}
10 changes: 10 additions & 0 deletions client/src/main.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';

createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
Loading
Loading