Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5,690 changes: 2,840 additions & 2,850 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"license": "GPL-3.0",
"devDependencies": {
"@mate-academy/eslint-config": "latest",
"@mate-academy/scripts": "^1.8.6",
"@mate-academy/scripts": "^2.1.3",
"eslint": "^8.57.0",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-node": "^11.1.0",
Expand All @@ -26,5 +26,10 @@
},
"mateAcademy": {
"projectType": "javascript"
},
"dependencies": {
"cors": "^2.8.6",
"express": "^5.2.1",
"ws": "^8.20.0"
}
}
250 changes: 250 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
<!doctype html>
<html>
<head>
<title>JS Chat - WebSocket</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
display: flex;
height: 100vh;
margin: 0;
background: #f0f2f5;
}
#auth-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
#chat-screen {
display: none;
width: 100%;
height: 100%;
}
.sidebar {
width: 260px;
background: #ffffff;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
}
.main-chat {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.messages-area {
flex-grow: 1;
overflow-y: auto;
padding: 20px;
background: #fff;
}
.input-area {
padding: 20px;
background: #fff;
border-top: 1px solid #ddd;
display: flex;
gap: 10px;
}
.room-item {
padding: 12px;
border-bottom: 1px solid #eee;
cursor: pointer;
display: flex;
justify-content: space-between;
}
.room-item:hover {
background: #f5f5f5;
}
.room-item.active {
background: #e7f3ff;
color: #1877f2;
font-weight: bold;
}
.message {
margin-bottom: 15px;
padding: 10px;
border-radius: 8px;
background: #f1f0f0;
max-width: 80%;
}
.msg-meta {
font-size: 11px;
color: #65676b;
margin-bottom: 4px;
}
</style>
</head>
<body>
<div id="auth-screen">
<h1>Введіть ваш Username</h1>
<input type="text" id="usernameInput" placeholder="Мій нікнейм..." />
<button onclick="login()" style="margin-top: 10px; padding: 10px 20px">
Увійти
</button>
</div>

<div id="chat-screen">
<div class="sidebar">
<div style="padding: 15px; border-bottom: 1px solid #ddd">
<button onclick="createNewRoom()">+ Нова кімната</button>
</div>
<div id="rooms-list"></div>
</div>
<div class="main-chat">
<div
style="
padding: 15px;
background: #fff;
border-bottom: 1px solid #ddd;
font-weight: bold;
"
id="active-room-title"
>
General
</div>
<div class="messages-area" id="messages-area"></div>
<div class="input-area">
<input
type="text"
id="msgInput"
style="flex-grow: 1"
placeholder="Напишіть щось..."
/>
<button onclick="sendMsg()">Відправити</button>
</div>
</div>
</div>

<script>
let socket;
let currentRoom = 'general';
let myUser = localStorage.getItem('chat_username');

if (myUser) {
initChat();
}

function login() {
const val = document.getElementById('usernameInput').value.trim();
if (val) {
localStorage.setItem('chat_username', val);
myUser = val;
initChat();
}
}

function initChat() {
document.getElementById('auth-screen').style.display = 'none';
document.getElementById('chat-screen').style.display = 'flex';

socket = new WebSocket('ws://localhost:8080');

socket.onopen = () => {
socket.send(
JSON.stringify({
type: 'JOIN_ROOM',
roomId: currentRoom,
username: myUser,
}),
);
};

socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'ROOMS_LIST') updateRoomsUI(data.rooms);
if (data.type === 'ROOM_HISTORY') renderHistory(data.messages);
if (data.type === 'MESSAGE') appendMessage(data.message);
if (data.type === 'ROOM_DELETED') switchRoom('general');
};
}

function updateRoomsUI(rooms) {
const list = document.getElementById('rooms-list');
list.innerHTML = '';
rooms.forEach((r) => {
const div = document.createElement('div');
div.className = `room-item ${r.id === currentRoom ? 'active' : ''}`;
div.innerHTML = `<span>${r.name}</span>`;

const btnContainer = document.createElement('div');
if (r.id !== 'general') {
const ren = document.createElement('button');
ren.innerText = 'R';
ren.onclick = (e) => {
e.stopPropagation();
renameRoom(r.id);
};
const del = document.createElement('button');
del.innerText = 'D';
del.onclick = (e) => {
e.stopPropagation();
deleteRoom(r.id);
};
btnContainer.append(ren, del);
}
div.append(btnContainer);
div.onclick = () => switchRoom(r.id);
list.append(div);
});
}

function switchRoom(id) {
currentRoom = id;
socket.send(
JSON.stringify({ type: 'JOIN_ROOM', roomId: id, username: myUser }),
);
document.getElementById('active-room-title').innerText = id;
}

function renderHistory(msgs) {
const area = document.getElementById('messages-area');
area.innerHTML = '';
msgs.forEach(appendMessage);
}

function appendMessage(m) {
const area = document.getElementById('messages-area');
const div = document.createElement('div');
div.className = 'message';
const time = new Date(m.time).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
div.innerHTML = `<div class="msg-meta">${m.author} • ${time}</div><div>${m.text}</div>`;
area.append(div);
area.scrollTop = area.scrollHeight;
}

function sendMsg() {
const input = document.getElementById('msgInput');
if (input.value.trim()) {
socket.send(
JSON.stringify({ type: 'NEW_MESSAGE', text: input.value }),
);
input.value = '';
}
}

function createNewRoom() {
const n = prompt('Назва кімнати:');
if (n)
socket.send(JSON.stringify({ type: 'CREATE_ROOM', roomName: n }));
}

function renameRoom(id) {
const n = prompt('Нова назва:');
if (n)
socket.send(
JSON.stringify({ type: 'RENAME_ROOM', roomId: id, newName: n }),
);
}

function deleteRoom(id) {
if (confirm('Видалити?'))
socket.send(JSON.stringify({ type: 'DELETE_ROOM', roomId: id }));
}
</script>
</body>
</html>
103 changes: 103 additions & 0 deletions src/handlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/* eslint-disable no-console */
const { rooms } = require('./store');
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 project currently lacks the client-side implementation required by the task: UI for typing a username, saving it to localStorage, sending it to the server, and room UI (create/rename/join/delete). These are mandatory according to the description and must be added.

const { broadcastRooms, broadcastToRoom } = require('./utils');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In src/index.js you require './src/websocket' from a file that is already in src; this will resolve to src/src/websocket.js and fail. Change the path to './websocket' (or correct relative path) so Node can load the websocket module.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incorrect require path: index.js is inside src, but this imports './src/websocket' which resolves to src/src/websocket and will likely throw MODULE_NOT_FOUND. Change to require('./websocket') or the correct relative path to the websocket module.

function handleMessage(ws, wss, messageAsString) {
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 repository contains server-side code only. The task requires both client and server and specifically that the username is typed by the user and saved in localStorage (checklist items #1, #3, #4 and #15). Add the client implementation that sends the username to the server and stores it in localStorage.

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 implements room operations and history broadcasting, which is good, but the task requires that the username be saved in localStorage (client-side). Make sure the client saves the username in localStorage and re-sends it when reconnecting/joining a room so the server can set ws.username correctly.

let data;

try {
data = JSON.parse(messageAsString);
} catch (e) {
console.error('Malformed JSON received');

return;
}

switch (data.type) {
case 'JOIN_ROOM':
if (!data.username) {
return;
}

ws.username = data.username;
ws.roomId = data.roomId || 'general';

let isNewRoom = false;

if (!rooms[ws.roomId]) {
rooms[ws.roomId] = { id: ws.roomId, name: ws.roomId, messages: [] };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When you auto-create a room here, call broadcastRooms(wss) immediately after creating the room so other connected clients receive the updated room list right away (instead of relying on an isNewRoom flag and broadcasting later).

isNewRoom = true;
}

ws.send(
JSON.stringify({
type: 'ROOM_HISTORY',
roomId: ws.roomId,
messages: rooms[ws.roomId].messages,
}),
);

if (isNewRoom) {
broadcastRooms(wss);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Right now you call broadcastRooms after sending ROOM_HISTORY (line 41). That delays notifying other clients. Move the broadcastRooms call into the creation block (immediately after creating rooms[ws.roomId]).

}

break;

case 'NEW_MESSAGE':
if (ws.username && ws.roomId && rooms[ws.roomId]) {
const newMessage = {
id: Date.now().toString(),
author: ws.username,
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 message author is set to ws.username here, but ws.username may be undefined if the client hasn't properly joined/identified. Validate that ws.username exists (or reject the message) before creating/persisting a new message to ensure every message has an author as required.

time: new Date().toISOString(),
text: data.text,
};

rooms[ws.roomId].messages.push(newMessage);

broadcastToRoom(wss, ws.roomId, {
type: 'MESSAGE',
roomId: ws.roomId,
message: newMessage,
});
}
break;

case 'CREATE_ROOM':
const newRoomId = 'room_' + Date.now();

rooms[newRoomId] = { id: newRoomId, name: data.roomName, messages: [] };
broadcastRooms(wss);
break;

case 'RENAME_ROOM':
if (rooms[data.roomId] && data.roomId !== 'general') {
rooms[data.roomId].name = data.newName;
broadcastRooms(wss);
}
break;

case 'DELETE_ROOM':
if (rooms[data.roomId] && data.roomId !== 'general') {
delete rooms[data.roomId];

wss.clients.forEach((client) => {
if (client.roomId === data.roomId) {
client.roomId = 'general';
client.send(JSON.stringify({ type: 'ROOM_DELETED' }));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When a room is deleted you set moving clients' roomId to 'general' and send a generic ROOM_DELETED message. Consider including the new room id and/or immediately sending the ROOM_HISTORY for 'general' so clients have enough info to update UI and show previous messages as required.


client.send(
JSON.stringify({
type: 'ROOM_HISTORY',
roomId: 'general',
messages: rooms['general'].messages,
}),
);
}
});
Comment on lines +83 to +96
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When deleting a room you set affected clients' roomId to 'general' and send a ROOM_DELETED event, but you do not send the ROOM_HISTORY for the 'general' room to those clients. The requirement states new users (and users moved to a room) should see previous messages in the room (checklist item #12). Consider sending the 'ROOM_HISTORY' with rooms['general'].messages to moved clients.

broadcastRooms(wss);
}
break;
}
}

module.exports = { handleMessage };
14 changes: 13 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
'use strict';
/* eslint-disable no-console */
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 overall repo is missing client-side implementation files (UI and client JS). The task explicitly requires both client and server; without client code you can't meet requirements like saving username to localStorage, letting users create/rename/join/delete rooms from the UI, and showing previous messages to new users.

const http = require('http');
const { setupWebSocket } = require('./websocket');

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 repository contains only server-side code (handlers, websocket, utils, store). The task requires a client as well (UI to type username, send it to server, and save username in localStorage). Without client-side code, checklist items #1, #2, #3 and #15 are not fulfilled — add a client that sends JOIN_ROOM/CREATE_ROOM/etc. and saves the username in localStorage.

const server = http.createServer();

setupWebSocket(server);

const PORT = 8080;

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 stores ws.username from the incoming JOIN_ROOM message here, which is fine server-side. The task requires the username to be saved in localStorage on the client — ensure you implement client-side code that saves the username to localStorage and sends it with the JOIN_ROOM message.

server.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`);
});
5 changes: 5 additions & 0 deletions src/store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const rooms = {
general: { id: 'general', name: 'General', messages: [] },
};
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 require is incorrect given this file is already in src. Requiring './src/websocket' will try to load src/src/websocket and fail with MODULE_NOT_FOUND. Change to require('./websocket') (or adjust path relative to this file).

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 require is incorrect given this file is already in src. Requiring './src/websocket' will try to load src/src/websocket and fail with MODULE_NOT_FOUND. Change to require('./websocket') (or adjust path relative to this file).


module.exports = { rooms };
Loading
Loading