diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..5800cc59b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(ls C:/Users/Hanna/projects/node/node_chat/.eslint* 2>/dev/null; ls C:/Users/Hanna/projects/node/node_chat/src/public/.eslint* 2>/dev/null; cat C:/Users/Hanna/projects/node/node_chat/package.json | grep -A 20 eslint)", + "Bash(cd C:/Users/Hanna/projects/node/node_chat && node -e \"\nconst WebSocket = require\\('ws'\\);\n\nprocess.env.PORT = 3002;\nrequire\\('./src/index.js'\\);\n\nsetTimeout\\(\\(\\) => {\n const ws = new WebSocket\\('ws://localhost:3002'\\);\n let step = 0;\n ws.on\\('open', \\(\\) => {\n ws.send\\(JSON.stringify\\({ type: 'set_username', username: 'testuser' }\\)\\);\n }\\);\n ws.on\\('message', \\(data\\) => {\n const msg = JSON.parse\\(data\\);\n console.log\\('MSG:', JSON.stringify\\(msg\\)\\);\n if \\(msg.type === 'username_confirmed'\\) {\n // Test: send create_room with empty name \\(simulate the bug\\)\n ws.send\\(JSON.stringify\\({ type: 'create_room', name: '' }\\)\\);\n }\n if \\(msg.type === 'error' || \\(msg.type === 'room_created' && step === 0\\)\\) {\n step++;\n // Now test with actual name\n ws.send\\(JSON.stringify\\({ type: 'create_room', name: 'test' }\\)\\);\n }\n if \\(msg.type === 'room_created' && step === 1\\) {\n setTimeout\\(\\(\\) => process.exit\\(0\\), 100\\);\n }\n }\\);\n}, 200\\);\n\" 2>&1)" + ] + } +} diff --git a/.eslintrc.js b/.eslintrc.js index f44c7a1df..3ff8c2346 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,10 +1,10 @@ module.exports = { extends: '@mate-academy/eslint-config', env: { - jest: true + jest: true, }, rules: { - 'no-proto': 0 + 'no-proto': 0, }, - plugins: ['jest'] + plugins: ['jest'], }; diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 000000000..bb13dfc45 --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,23 @@ +name: Test + +on: + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/package-lock.json b/package-lock.json index ce07e1dca..1c3f71ac5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "hasInstallScript": true, "license": "GPL-3.0", + "dependencies": { + "ws": "^8.18.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", @@ -1467,10 +1470,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.6.tgz", - "integrity": "sha512-b4om/whj4G9emyi84ORE3FRZzCRwRIesr8tJHXa8EvJdOaAPDpzcJ8A0sFfMsWH9NUOVmOwkBtOXDu5eZZ00Ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -8658,6 +8662,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 4f64337fe..56e646b04 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,12 @@ }, "author": "Mate academy", "license": "GPL-3.0", + "dependencies": { + "ws": "^8.18.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", diff --git a/src/index.js b/src/index.js index ad9a93a7c..9bd5a3b36 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,267 @@ 'use strict'; + +const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const { WebSocketServer } = require('ws'); + +const PORT = process.env.PORT || 3000; +const PUBLIC_DIR = path.join(__dirname, 'public'); + +// ─── In-memory state ───────────────────────────────────────────────────────── + +const rooms = new Map(); // roomId → { id, name, messages[] } +const clients = new Map(); // ws → { username, roomId } + +let nextRoomId = 1; + +function createRoom(roomName) { + const id = String(nextRoomId++); + + rooms.set(id, { id, name: roomName, messages: [] }); + + return rooms.get(id); +} + +// Seed a default room so the app is usable right away +createRoom('general'); + +// ─── HTTP server (serves static client files) ──────────────────────────────── + +const MIME = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.ico': 'image/x-icon', +}; + +const server = http.createServer((req, res) => { + const filePath = path.join( + PUBLIC_DIR, + req.url === '/' ? 'index.html' : req.url, + ); + + const ext = path.extname(filePath); + const contentType = MIME[ext] || 'tsxt/plain'; + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not found'); + + return; + } + + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); +}); + +// ─── WebSocket helpers ─────────────────────────────────────────────────────── + +function send(ws, type, payload = {}) { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ type, ...payload })); + } +} + +function broadcast(roomId, type, payload, excludeWs = null) { + for (const [ws, client] of clients) { + if (client.roomId === roomId && ws !== excludeWs) { + send(ws, type, payload); + } + } +} + +function broadcastRoomList() { + // eslint-disable-next-line + const roomList = [...rooms.values()].map(({ id, name }) => ({ + id, + name, + })); + + for (const ws of clients.keys()) { + send(ws, 'room_list', { rooms: roomList }); + } +} + +// ─── Message handlers ──────────────────────────────────────────────────────── + +const handlers = { + set_username(ws, { username }) { + if (!username || !username.trim()) { + send(ws, 'error', { message: 'User name cannot be empty' }); + + return; + } + + clients.get(ws).username = username.trim(); + send(ws, 'username_confirmed', { username: username.trim() }); + + send(ws, 'room_list', { + rooms: [...rooms.values()].map(({ id, name }) => ({ id, name })), + }); + }, + + create_room(ws, { name }) { + if (!name || !name.trim()) { + send(ws, 'error', { message: 'Room name cannot be empty' }); + + return; + } + + const room = createRoom(name.trim()); + + broadcastRoomList(); + + send(ws, 'room_created', { room: { id: room.id, name: room.name } }); + }, + + rename_room(ws, { roomId, name }) { + const room = rooms.get(roomId); + + if (!room) { + send(ws, 'error', { message: 'Room not found' }); + + return; + } + + if (!name || !name.trim()) { + send(ws, 'error', { message: 'Room name cannot be empty' }); + + return; + } + + room.name = name.trim(); + + broadcastRoomList(); + + broadcast(roomId, 'room_renamed', { roomId, name: room.name }); + }, + + join_room(ws, { roomId }) { + const room = rooms.get(roomId); + const client = clients.get(ws); + + if (!room) { + send(ws, 'error', { message: 'Room not found.' }); + + return; + } + + if (!client.username) { + send(ws, 'error', { message: 'Set a username first' }); + + return; + } + + client.roomId = roomId; + + send(ws, 'room_joined', { + room: { id: room.id, name: room.name }, + messages: room.messages, + }); + + broadcast(roomId, 'user_joined', { username: client.username }, ws); + }, + + delete_room(ws, { roomId }) { + if (!rooms.has(roomId)) { + send(ws, 'error', { message: 'Room not found.' }); + + return; + } + + rooms.delete(roomId); + + // Move everyone in that room out + for (const [clientWs, client] of clients) { + if (client.roomId === roomId) { + client.roomId = null; + send(clientWs, 'room_deleted', { roomId }); + } + } + + broadcastRoomList(); + }, + + send_message(ws, { text }) { + const client = clients.get(ws); + + if (!client.roomId) { + send(ws, 'error', { message: 'Join a room first.' }); + + return; + } + + if (!text || !text.trim()) { + send(ws, 'error', { message: 'Message cannot be empty.' }); + + return; + } + + const room = rooms.get(client.roomId); + + if (!room) { + send(ws, 'error', { message: 'Room not found.' }); + + return; + } + + const message = { + id: Date.now(), + author: client.username, + text: text.trim(), + time: new Date().toISOString(), + }; + + room.messages.push(message); + broadcast(client.roomId, 'new_message', { message }); + }, +}; + +// ─── WebSocket server ──────────────────────────────────────────────────────── + +const wss = new WebSocketServer({ server }); + +wss.on('connection', (ws) => { + clients.set(ws, { username: null, roomId: null }); + + ws.on('message', (raw) => { + let parsed; + + try { + parsed = JSON.parse(raw); + } catch { + send(ws, 'error', { message: 'Invalid JSON.' }); + + return; + } + + const { type, ...payload } = parsed; + const handler = handlers[type]; + + if (handler) { + handler(ws, payload); + } else { + send(ws, 'error', { message: `Unknown message type: ${type}` }); + } + }); + + ws.on('close', () => { + const client = clients.get(ws); + + if (client && client.roomId && client.username) { + broadcast(client.roomId, 'user_left', { username: client.username }); + } + + clients.delete(ws); + }); +}); + +// ─── Start ─────────────────────────────────────────────────────────────────── + +server.listen(PORT, () => { + // eslint-disable-next-line + console.log(`Chat server running at http://localhost:${PORT}`); +}); diff --git a/src/public/.eslintrc.js b/src/public/.eslintrc.js new file mode 100644 index 000000000..c38aa9759 --- /dev/null +++ b/src/public/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + env: { + browser: true, + }, +}; diff --git a/src/public/client.js b/src/public/client.js new file mode 100644 index 000000000..5da972094 --- /dev/null +++ b/src/public/client.js @@ -0,0 +1,335 @@ +'use strict'; + +// ── DOM refs ───────────────────────────────────────────────────────────────── +const loginScreen = document.getElementById('login-screen'); +const app = document.getElementById('app'); +const usernameInput = document.getElementById('username-input'); +const loginBtn = document.getElementById('login-btn'); +const userAvatar = document.getElementById('user-avatar'); +const usernameDisplay = document.getElementById('username-display'); +const roomsList = document.getElementById('rooms-list'); +const createRoomBtn = document.getElementById('create-room-btn'); +const roomTitle = document.getElementById('room-title'); +const noRoom = document.getElementById('no-room'); +const messages = document.getElementById('messages'); +const messageForm = document.getElementById('message-form'); +const messageInput = document.getElementById('message-input'); +const sendBtn = document.getElementById('send-btn'); +const connDot = document.getElementById('connection-dot'); +const modalOverlay = document.getElementById('modal-overlay'); +const modalTitleEl = document.getElementById('modal-title'); +const modalInput = document.getElementById('modal-input'); +const modalOk = document.getElementById('modal-ok'); +const modalCancel = document.getElementById('modal-cancel'); +const toast = document.getElementById('toast'); + +// ── State ──────────────────────────────────────────────────────────────────── +let ws = null; +let currentUsername = localStorage.getItem('chat_username') || ''; +let currentRoomId = null; +let knownRooms = []; // [{ id, name }] + +// ── Toast ──────────────────────────────────────────────────────────────────── +let toastTimer = null; + +function showToast(msg) { + toast.textContent = msg; + toast.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.classList.remove('show'), 3500); +} + +// ── Modal helpers ──────────────────────────────────────────────────────────── +let modalResolve = null; + +function openModal(title, defaultValue = '') { + modalTitleEl.textContent = title; + modalInput.value = defaultValue; + modalOverlay.classList.add('visible'); + modalInput.focus(); + modalInput.select(); + + return new Promise((resolve) => { + modalResolve = resolve; + }); +} + +function closeModal(value) { + modalOverlay.classList.remove('visible'); + + if (modalResolve) { + modalResolve(value); + modalResolve = null; + } +} + +modalOk.addEventListener('click', () => closeModal(modalInput.value.trim())); +modalCancel.addEventListener('click', () => closeModal(null)); + +modalInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + closeModal(modalInput.value.trim()); + } + + if (e.key === 'Escape') { + closeModal(null); + } +}); + +// ── WebSocket ──────────────────────────────────────────────────────────────── +function connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + + ws = new WebSocket(`${proto}://${location.host}`); + + ws.addEventListener('open', () => { + connDot.classList.remove('disconnected'); + + if (currentUsername) { + send('set_username', { username: currentUsername }); + } + }); + + ws.addEventListener('close', () => { + connDot.classList.add('disconnected'); + setTimeout(connect, 2000); // auto-reconnect + }); + + ws.addEventListener('message', (evt) => { + const { type, ...payload } = JSON.parse(evt.data); + const handler = messageHandlers[type]; + + if (handler) { + handler(payload); + } + }); +} + +function send(type, payload = {}) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type, ...payload })); + } +} + +// ── Message handlers (server → client) ─────────────────────────────────────── +const messageHandlers = { + username_confirmed({ username }) { + currentUsername = username; + localStorage.setItem('chat_username', username); + userAvatar.textContent = username[0].toUpperCase(); + usernameDisplay.textContent = username; + + loginScreen.style.display = 'none'; + app.classList.add('visible'); + }, + + room_list({ rooms }) { + knownRooms = rooms; + renderRoomList(); + }, + + room_created({ room }) { + send('join_room', { roomId: room.id }); + }, + + room_joined({ room, messages: msgHistory }) { + currentRoomId = room.id; + roomTitle.textContent = '# ' + room.name; + + noRoom.style.display = 'none'; + messages.style.display = 'flex'; + messageForm.style.display = 'flex'; + + messages.innerHTML = ''; + + msgHistory.forEach(renderMessage); + scrollToBottom(); + renderRoomList(); + messageInput.focus(); + }, + + // FIX: server sends { roomId, name } — was destructuring roomName + room_renamed({ roomId, name: roomName }) { + if (roomId === currentRoomId) { + roomTitle.textContent = '# ' + roomName; + addSystemMessage(`Room renamed to "${roomName}"`); + } + }, + + room_deleted({ roomId }) { + if (roomId === currentRoomId) { + currentRoomId = null; + roomTitle.textContent = 'Select a room'; + noRoom.style.display = 'flex'; + messages.style.display = 'none'; + messageForm.style.display = 'none'; + addSystemMessage('This room was deleted.'); + } + }, + + new_message({ message }) { + renderMessage(message); + scrollToBottom(); + }, + + user_joined({ username }) { + addSystemMessage(`${username} joined the room`); + }, + + user_left({ username }) { + addSystemMessage(`${username} left the room`); + }, + + error({ message }) { + showToast(message); + }, +}; + +// ── Rendering ──────────────────────────────────────────────────────────────── +function renderRoomList() { + roomsList.innerHTML = ''; + + knownRooms.forEach(({ id, name: roomName }) => { + const li = document.createElement('li'); + + if (id === currentRoomId) { + li.classList.add('active'); + } + + li.innerHTML = ` + # ${escHtml(roomName)} + + + + + `; + + li.querySelector('.room-name').addEventListener('click', () => { + send('join_room', { roomId: id }); + }); + + li.querySelector('.rename-btn').addEventListener('click', async (e) => { + e.stopPropagation(); + + const newName = await openModal('Rename room', roomName); + + if (newName) { + send('rename_room', { roomId: id, name: newName }); + } + }); + + li.querySelector('.delete-btn').addEventListener('click', async (e) => { + e.stopPropagation(); + + const confirmed = await openModal( + `Delete "${roomName}"? Type "delete" to confirm`, + '', + ); + + if (confirmed === 'delete') { + send('delete_room', { roomId: id }); + } + }); + + roomsList.appendChild(li); + }); +} + +function renderMessage(msg) { + const isOwn = msg.author === currentUsername; + const div = document.createElement('div'); + + div.className = `message ${isOwn ? 'own' : 'other'}`; + + const time = new Date(msg.time).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + + div.innerHTML = ` +
+ + `; + + messages.appendChild(div); +} + +function addSystemMessage(text) { + const div = document.createElement('div'); + + div.className = 'message system'; + div.innerHTML = ``; + messages.appendChild(div); + scrollToBottom(); +} + +function scrollToBottom() { + messages.scrollTop = messages.scrollHeight; +} + +function escHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ── Event listeners ────────────────────────────────────────────────────────── + +// Login +function tryLogin() { + const userName = usernameInput.value.trim(); + + if (!userName) { + return; + } + send('set_username', { username: userName }); +} + +loginBtn.addEventListener('click', tryLogin); +// eslint-disable-next-line +usernameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + tryLogin(); + } +}); + +// Send message +sendBtn.addEventListener('click', () => { + const text = messageInput.value.trim(); + + if (!text) { + return; + } + send('send_message', { text }); + messageInput.value = ''; + messageInput.focus(); +}); + +messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendBtn.click(); + } +}); + +// Create room — FIX: was sending { roomName }, server expects { name } +createRoomBtn.addEventListener('click', async () => { + const roomName = await openModal('New room name'); + + if (roomName) { + send('create_room', { name: roomName }); + } +}); + +// ── Boot ──────────────────────────────────────────────────────────────────── +connect(); + +// Pre-fill username if saved +if (currentUsername) { + usernameInput.value = currentUsername; +} diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 000000000..765f5702d --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,72 @@ + + + + + +Enter a username to start chatting
+ + +Join a room to start chatting
+