diff --git a/.eslintrc.js b/.eslintrc.js index f44c7a1df..80067bcd4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,10 +1,18 @@ module.exports = { extends: '@mate-academy/eslint-config', env: { - jest: true + jest: true, }, rules: { - 'no-proto': 0 + 'no-proto': 0, }, - plugins: ['jest'] + plugins: ['jest'], + overrides: [ + { + files: ['src/client/**/*.js'], + env: { + browser: true, + }, + }, + ], }; 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..bf3f32bfd 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.21.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", @@ -59,6 +62,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -1467,10 +1471,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", @@ -1536,7 +1541,6 @@ "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz", "integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==", "dev": true, - "peer": true, "engines": { "node": ">= 18" } @@ -1565,7 +1569,6 @@ "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", "dev": true, - "peer": true, "dependencies": { "@octokit/types": "^13.0.0", "universal-user-agent": "^7.0.2" @@ -1579,7 +1582,6 @@ "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz", "integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==", "dev": true, - "peer": true, "dependencies": { "@octokit/request": "^9.0.0", "@octokit/types": "^13.0.0", @@ -1593,8 +1595,7 @@ "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@octokit/plugin-paginate-rest": { "version": "2.21.3", @@ -1656,7 +1657,6 @@ "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz", "integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==", "dev": true, - "peer": true, "dependencies": { "@octokit/endpoint": "^10.0.0", "@octokit/request-error": "^6.0.1", @@ -1672,7 +1672,6 @@ "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.4.tgz", "integrity": "sha512-VpAhIUxwhWZQImo/dWAN/NpPqqojR6PSLgLYAituLM6U+ddx9hCioFGwBr5Mi+oi5CLeJkcAs3gJ0PYYzU6wUg==", "dev": true, - "peer": true, "dependencies": { "@octokit/types": "^13.0.0" }, @@ -1860,7 +1859,6 @@ "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz", "integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==", "dev": true, - "peer": true, "dependencies": { "@octokit/openapi-types": "^22.2.0" } @@ -2203,6 +2201,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2624,8 +2623,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", - "dev": true, - "peer": true + "dev": true }, "node_modules/brace-expansion": { "version": "1.1.11", @@ -2668,6 +2666,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001640", "electron-to-chromium": "^1.4.820", @@ -3381,6 +3380,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3436,6 +3436,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3526,6 +3527,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -3603,6 +3605,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz", "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", "dev": true, + "peer": true, "dependencies": { "eslint-plugin-es": "^3.0.0", "eslint-utils": "^2.0.0", @@ -3653,6 +3656,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-4.3.1.tgz", "integrity": "sha512-bY2sGqyptzFBDLh/GMbAxfdJC+b0f23ME63FOE4+Jao0oZ3E1LEwFtWJX/1pGMJLiTtrSSern2CRM/g+dfc0eQ==", "dev": true, + "peer": true, "engines": { "node": ">=6" } @@ -3676,6 +3680,7 @@ "url": "https://feross.org/support" } ], + "peer": true, "peerDependencies": { "eslint": ">=5.0.0" } @@ -5044,6 +5049,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7376,6 +7382,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8307,8 +8314,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", - "dev": true, - "peer": true + "dev": true }, "node_modules/universalify": { "version": "2.0.1", @@ -8658,6 +8664,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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..5bfa456c3 100644 --- a/package.json +++ b/package.json @@ -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", @@ -26,5 +26,8 @@ }, "mateAcademy": { "projectType": "javascript" + }, + "dependencies": { + "ws": "^8.21.0" } } diff --git a/src/client/app.js b/src/client/app.js new file mode 100644 index 000000000..320b245ce --- /dev/null +++ b/src/client/app.js @@ -0,0 +1,291 @@ +/* eslint-env browser */ +'use strict'; + +const { MessageType, USERNAME_STORAGE_KEY } = window.ChatConstants; + +const authPanel = document.getElementById('auth-panel'); +const chatPanel = document.getElementById('chat-panel'); +const usernameForm = document.getElementById('username-form'); +const usernameInput = document.getElementById('username-input'); +const authError = document.getElementById('auth-error'); +const currentUserEl = document.getElementById('current-user'); +const changeUsernameBtn = document.getElementById('change-username-btn'); +const roomsList = document.getElementById('rooms-list'); +const roomTitle = document.getElementById('room-title'); +const roomActions = document.getElementById('room-actions'); +const createRoomBtn = document.getElementById('create-room-btn'); +const renameRoomBtn = document.getElementById('rename-room-btn'); +const deleteRoomBtn = document.getElementById('delete-room-btn'); +const messagesList = document.getElementById('messages-list'); +const messageForm = document.getElementById('message-form'); +const messageInput = document.getElementById('message-input'); + +let socket = null; +let username = null; +let currentRoomId = null; +let rooms = []; +const messagesByRoom = new Map(); + +function getWsUrl() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + + return `${protocol}//${window.location.host}`; +} + +function connect() { + socket = new WebSocket(getWsUrl()); + + socket.addEventListener('open', () => { + const saved = localStorage.getItem(USERNAME_STORAGE_KEY); + + if (saved) { + setUsernameOnServer(saved); + } + }); + + socket.addEventListener('message', (evt) => { + handleServerMessage(JSON.parse(evt.data)); + }); + + socket.addEventListener('close', () => { + setTimeout(connect, 2000); + }); +} + +function send(payload) { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(payload)); + } +} + +function showAuthError(message) { + authError.textContent = message; + authError.hidden = !message; +} + +function setUsernameOnServer(userName) { + send({ type: MessageType.SET_USERNAME, username: userName }); +} + +function showChat() { + authPanel.hidden = true; + chatPanel.hidden = false; + currentUserEl.textContent = username; +} + +function showAuth() { + authPanel.hidden = false; + chatPanel.hidden = true; + usernameInput.value = localStorage.getItem(USERNAME_STORAGE_KEY) || ''; + usernameInput.focus(); +} + +function formatTime(iso) { + return new Date(iso).toLocaleString(); +} + +function renderMessage(message) { + const li = document.createElement('li'); + const article = document.createElement('article'); + + article.className = 'message'; + + const meta = document.createElement('div'); + + meta.className = 'message-meta'; + meta.innerHTML = `${escapeHtml(message.author)} ยท ${formatTime(message.time)}`; + + const text = document.createElement('p'); + + text.className = 'message-text'; + text.textContent = message.text; + + article.append(meta, text); + li.append(article); + + return li; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + + div.textContent = text; + + return div.innerHTML; +} + +function renderMessages(roomId) { + messagesList.innerHTML = ''; + + const messages = messagesByRoom.get(roomId) || []; + + for (const message of messages) { + messagesList.append(renderMessage(message)); + } + + messagesList.scrollTop = messagesList.scrollHeight; +} + +function appendMessage(roomId, message) { + if (!messagesByRoom.has(roomId)) { + messagesByRoom.set(roomId, []); + } + + const list = messagesByRoom.get(roomId); + + if (list.some((item) => item.id === message.id)) { + return; + } + + list.push(message); + + if (roomId === currentRoomId) { + messagesList.append(renderMessage(message)); + messagesList.scrollTop = messagesList.scrollHeight; + } +} + +function renderRooms() { + roomsList.innerHTML = ''; + + for (const room of rooms) { + const li = document.createElement('li'); + + li.textContent = room.name; + li.dataset.roomId = room.id; + + if (room.id === currentRoomId) { + li.classList.add('active'); + roomTitle.textContent = room.name; + } + + li.addEventListener('click', () => { + send({ type: MessageType.JOIN_ROOM, roomId: room.id }); + }); + + roomsList.append(li); + } + + const current = rooms.find((r) => r.id === currentRoomId); + + roomActions.hidden = !current || current.isDefault; +} + +function handleServerMessage(data) { + switch (data.type) { + case MessageType.USERNAME_SET: + username = data.username; + currentRoomId = data.roomId; + rooms = data.rooms || rooms; + localStorage.setItem(USERNAME_STORAGE_KEY, username); + showAuthError(''); + showChat(); + break; + + case MessageType.ROOM_HISTORY: + messagesByRoom.set(data.roomId, data.messages); + currentRoomId = data.roomId; + renderMessages(currentRoomId); + renderRooms(); + break; + + case MessageType.MESSAGE: + appendMessage(data.roomId, data.message); + break; + + case MessageType.ROOMS_UPDATED: + rooms = data.rooms; + renderRooms(); + break; + + case MessageType.ERROR: + if (chatPanel.hidden) { + showAuthError(data.message); + } else { + alert(data.message); + } + break; + + default: + break; + } +} + +usernameForm.addEventListener('submit', (evt) => { + evt.preventDefault(); + + const userName = usernameInput.value.trim(); + + if (!userName) { + return; + } + + showAuthError(''); + setUsernameOnServer(userName); +}); + +changeUsernameBtn.addEventListener('click', () => { + localStorage.removeItem(USERNAME_STORAGE_KEY); + username = null; + showAuth(); +}); + +messageForm.addEventListener('submit', (evt) => { + evt.preventDefault(); + + const text = messageInput.value.trim(); + + if (!text || !currentRoomId) { + return; + } + + send({ + type: MessageType.SEND_MESSAGE, + roomId: currentRoomId, + text, + }); + + messageInput.value = ''; +}); + +createRoomBtn.addEventListener('click', () => { + const roomName = prompt('Room name:'); + + if (roomName?.trim()) { + send({ type: MessageType.CREATE_ROOM, name: roomName.trim() }); + } +}); + +renameRoomBtn.addEventListener('click', () => { + const room = rooms.find((r) => r.id === currentRoomId); + + if (!room) { + return; + } + + const roomName = prompt('New room name:', room.name); + + if (roomName?.trim()) { + send({ + type: MessageType.RENAME_ROOM, + roomId: currentRoomId, + name: roomName.trim(), + }); + } +}); + +deleteRoomBtn.addEventListener('click', () => { + if (!confirm('Delete this room?')) { + return; + } + + send({ type: MessageType.DELETE_ROOM, roomId: currentRoomId }); +}); + +const savedUsername = localStorage.getItem(USERNAME_STORAGE_KEY); + +if (savedUsername) { + usernameInput.value = savedUsername; +} + +connect(); diff --git a/src/client/index.html b/src/client/index.html new file mode 100644 index 000000000..dcd5ec16c --- /dev/null +++ b/src/client/index.html @@ -0,0 +1,75 @@ + + + + + + Node Chat + + + +
+
+

Node Chat

+

Enter a username to join the chat.

+
+ + +
+ +
+ + +
+ + + + diff --git a/src/client/protocol.js b/src/client/protocol.js new file mode 100644 index 000000000..917b5d689 --- /dev/null +++ b/src/client/protocol.js @@ -0,0 +1,19 @@ +/* eslint-env browser */ +'use strict'; + +window.ChatConstants = { + MessageType: { + SET_USERNAME: 'setUsername', + USERNAME_SET: 'usernameSet', + SEND_MESSAGE: 'sendMessage', + MESSAGE: 'message', + CREATE_ROOM: 'createRoom', + RENAME_ROOM: 'renameRoom', + JOIN_ROOM: 'joinRoom', + DELETE_ROOM: 'deleteRoom', + ROOMS_UPDATED: 'roomsUpdated', + ROOM_HISTORY: 'roomHistory', + ERROR: 'error', + }, + USERNAME_STORAGE_KEY: 'username', +}; diff --git a/src/client/styles.css b/src/client/styles.css new file mode 100644 index 000000000..8aa76ae96 --- /dev/null +++ b/src/client/styles.css @@ -0,0 +1,244 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + background: #0f1419; + color: #e7e9ea; + min-height: 100vh; +} + +#app { + max-width: 960px; + margin: 0 auto; + padding: 1.5rem; +} + +.panel { + background: #1a2332; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +#auth-panel { + max-width: 400px; + margin: 4rem auto; + text-align: center; +} + +#auth-panel h1 { + margin: 0 0 0.5rem; + font-size: 1.75rem; +} + +#auth-panel p { + color: #8b98a5; + margin: 0 0 1.25rem; +} + +form { + display: flex; + gap: 0.5rem; +} + +input[type='text'] { + flex: 1; + padding: 0.65rem 0.85rem; + border: 1px solid #38444d; + border-radius: 8px; + background: #0f1419; + color: inherit; + font-size: 1rem; +} + +input:focus { + outline: 2px solid #1d9bf0; + border-color: transparent; +} + +button { + padding: 0.65rem 1rem; + border: none; + border-radius: 8px; + background: #1d9bf0; + color: #fff; + font-weight: 600; + cursor: pointer; +} + +button:hover { + background: #1a8cd8; +} + +.link-btn { + background: transparent; + color: #1d9bf0; + font-weight: 500; + padding: 0.25rem 0.5rem; +} + +.link-btn:hover { + background: rgba(29, 155, 240, 0.15); +} + +.danger { + background: #f4212e; +} + +.danger:hover { + background: #dc1e2a; +} + +.error { + color: #f4212e; + margin-top: 0.75rem; + font-size: 0.9rem; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #38444d; +} + +#current-user { + font-weight: 600; +} + +.layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 1rem; + min-height: 420px; +} + +.sidebar { + border-right: 1px solid #38444d; + padding-right: 0.75rem; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.sidebar-header h2 { + margin: 0; + font-size: 0.95rem; + color: #8b98a5; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +#create-room-btn { + width: 28px; + height: 28px; + padding: 0; + font-size: 1.25rem; + line-height: 1; +} + +#rooms-list { + list-style: none; + margin: 0; + padding: 0; +} + +#rooms-list li { + padding: 0.5rem 0.65rem; + border-radius: 6px; + cursor: pointer; + margin-bottom: 2px; +} + +#rooms-list li:hover { + background: #243447; +} + +#rooms-list li.active { + background: #1d9bf0; + color: #fff; +} + +.room-actions { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.75rem; +} + +.room-actions button { + font-size: 0.85rem; +} + +.chat-main { + display: flex; + flex-direction: column; + min-height: 0; +} + +#room-title { + margin: 0 0 0.75rem; + font-size: 1.1rem; +} + +#messages-list { + flex: 1; + list-style: none; + margin: 0 0 1rem; + padding: 0; + overflow-y: auto; + max-height: 320px; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.message { + background: #243447; + border-radius: 8px; + padding: 0.6rem 0.75rem; +} + +.message-meta { + font-size: 0.8rem; + color: #8b98a5; + margin-bottom: 0.25rem; +} + +.message-meta strong { + color: #1d9bf0; +} + +.message-text { + margin: 0; + word-break: break-word; +} + +@media (max-width: 640px) { + .layout { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: none; + border-bottom: 1px solid #38444d; + padding-right: 0; + padding-bottom: 0.75rem; + } +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 000000000..dcbd6c157 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,19 @@ +'use strict'; + +const MessageType = { + SET_USERNAME: 'setUsername', + USERNAME_SET: 'usernameSet', + SEND_MESSAGE: 'sendMessage', + MESSAGE: 'message', + CREATE_ROOM: 'createRoom', + RENAME_ROOM: 'renameRoom', + JOIN_ROOM: 'joinRoom', + DELETE_ROOM: 'deleteRoom', + ROOMS_UPDATED: 'roomsUpdated', + ROOM_HISTORY: 'roomHistory', + ERROR: 'error', +}; + +const USERNAME_STORAGE_KEY = 'username'; + +module.exports = { MessageType, USERNAME_STORAGE_KEY }; diff --git a/src/index.js b/src/index.js index ad9a93a7c..6805bb175 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,7 @@ 'use strict'; + +const { createServer } = require('./server/createServer'); + +const PORT = Number(process.env.PORT) || 3000; + +createServer(PORT).start(); diff --git a/src/server/chatStore.js b/src/server/chatStore.js new file mode 100644 index 000000000..5e7ab08f1 --- /dev/null +++ b/src/server/chatStore.js @@ -0,0 +1,90 @@ +'use strict'; + +const { randomUUID } = require('crypto'); + +const DEFAULT_ROOM_NAME = 'General'; + +function createMessage(author, text) { + return { + id: randomUUID(), + author, + time: new Date().toISOString(), + text, + }; +} + +function createRoom(name) { + return { + id: randomUUID(), + name: name.trim(), + messages: [], + }; +} + +class ChatStore { + constructor() { + this.rooms = new Map(); + + const general = createRoom(DEFAULT_ROOM_NAME); + + general.name = DEFAULT_ROOM_NAME; + this.rooms.set(general.id, general); + this.defaultRoomId = general.id; + } + + getRoomsList() { + return [...this.rooms.values()].map(({ id, name }) => ({ + id, + name, + isDefault: id === this.defaultRoomId, + })); + } + + getRoom(roomId) { + return this.rooms.get(roomId); + } + + addRoom(name) { + const room = createRoom(name); + + this.rooms.set(room.id, room); + + return room; + } + + renameRoom(roomId, name) { + const room = this.rooms.get(roomId); + + if (!room) { + return null; + } + + room.name = name.trim(); + + return room; + } + + deleteRoom(roomId) { + if (roomId === this.defaultRoomId) { + return false; + } + + return this.rooms.delete(roomId); + } + + addMessage(roomId, author, text) { + const room = this.rooms.get(roomId); + + if (!room) { + return null; + } + + const message = createMessage(author, text); + + room.messages.push(message); + + return message; + } +} + +module.exports = { ChatStore, DEFAULT_ROOM_NAME }; diff --git a/src/server/createServer.js b/src/server/createServer.js new file mode 100644 index 000000000..7516c976d --- /dev/null +++ b/src/server/createServer.js @@ -0,0 +1,68 @@ +'use strict'; + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { WebSocketServer } = require('ws'); +const { ChatStore } = require('./chatStore'); +const { attachWebSocket } = require('./wsHandler'); + +const CLIENT_DIR = path.join(__dirname, '../client'); + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', +}; + +function serveStatic(req, res) { + const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0]; + const filePath = path.join( + CLIENT_DIR, + urlPath.replace(/^\/+/, '').replace(/\.\./g, '') || 'index.html', + ); + + if (!filePath.startsWith(CLIENT_DIR)) { + res.writeHead(403); + res.end('Forbidden'); + + return; + } + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not found'); + + return; + } + + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); +} + +function createServer(port = 3000) { + const store = new ChatStore(); + const clients = new Set(); + + const server = http.createServer(serveStatic); + const wss = new WebSocketServer({ server }); + + attachWebSocket({ wss, clients, store }); + + return { + start() { + server.listen(port); + }, + server, + wss, + store, + clients, + }; +} + +module.exports = { createServer }; diff --git a/src/server/wsHandler.js b/src/server/wsHandler.js new file mode 100644 index 000000000..4c2ba0b50 --- /dev/null +++ b/src/server/wsHandler.js @@ -0,0 +1,263 @@ +'use strict'; + +const { MessageType } = require('../constants'); + +function send(ws, payload) { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify(payload)); + } +} + +function sendError(ws, message) { + send(ws, { type: MessageType.ERROR, message }); +} + +function broadcast(clients, payload, exceptWs = null) { + for (const client of clients) { + if (client.ws !== exceptWs) { + send(client.ws, payload); + } + } +} + +function broadcastToRoom(clients, roomId, payload, exceptWs = null) { + for (const client of clients) { + if (client.roomId === roomId && client.ws !== exceptWs) { + send(client.ws, payload); + } + } +} + +function handleSetUsername(client, clients, store, data) { + const username = data.username?.trim(); + + if (!username) { + sendError(client.ws, 'Username is required'); + + return; + } + + const taken = [...clients].some( + (other) => other !== client && other.username === username, + ); + + if (taken) { + sendError(client.ws, 'Username is already taken'); + + return; + } + + client.username = username; + + if (!client.roomId) { + client.roomId = store.defaultRoomId; + } + + send(client.ws, { + type: MessageType.USERNAME_SET, + username, + roomId: client.roomId, + rooms: store.getRoomsList(), + }); + + sendRoomHistory(client, store); + broadcastRooms(clients, store); +} + +function sendRoomHistory(client, store) { + const room = store.getRoom(client.roomId); + + if (!room) { + return; + } + + send(client.ws, { + type: MessageType.ROOM_HISTORY, + roomId: room.id, + messages: room.messages, + }); +} + +function broadcastRooms(clients, store) { + broadcast(clients, { + type: MessageType.ROOMS_UPDATED, + rooms: store.getRoomsList(), + }); +} + +function handleSendMessage(client, clients, store, data) { + if (!client.username) { + sendError(client.ws, 'Set a username first'); + + return; + } + + const text = data.text?.trim(); + + if (!text) { + return; + } + + const roomId = data.roomId || client.roomId; + const message = store.addMessage(roomId, client.username, text); + + if (!message) { + sendError(client.ws, 'Room not found'); + + return; + } + + const payload = { + type: MessageType.MESSAGE, + roomId, + message, + }; + + broadcastToRoom(clients, roomId, payload); +} + +function handleCreateRoom(client, clients, store, data) { + if (!client.username) { + sendError(client.ws, 'Set a username first'); + + return; + } + + const name = data.name?.trim(); + + if (!name) { + sendError(client.ws, 'Room name is required'); + + return; + } + + const room = store.addRoom(name); + + client.roomId = room.id; + sendRoomHistory(client, store); + broadcastRooms(clients, store); +} + +function handleRenameRoom(client, clients, store, data) { + if (!client.username) { + sendError(client.ws, 'Set a username first'); + + return; + } + + const name = data.name?.trim(); + + if (!name) { + sendError(client.ws, 'Room name is required'); + + return; + } + + const room = store.renameRoom(data.roomId, name); + + if (!room) { + sendError(client.ws, 'Room not found'); + + return; + } + + broadcastRooms(clients, store); +} + +function handleJoinRoom(client, clients, store, data) { + if (!client.username) { + sendError(client.ws, 'Set a username first'); + + return; + } + + const room = store.getRoom(data.roomId); + + if (!room) { + sendError(client.ws, 'Room not found'); + + return; + } + + client.roomId = room.id; + sendRoomHistory(client, store); +} + +function handleDeleteRoom(client, clients, store, data) { + if (!client.username) { + sendError(client.ws, 'Set a username first'); + + return; + } + + const deleted = store.deleteRoom(data.roomId); + + if (!deleted) { + sendError(client.ws, 'Cannot delete this room'); + + return; + } + + for (const other of clients) { + if (other.roomId === data.roomId) { + other.roomId = store.defaultRoomId; + sendRoomHistory(other, store); + } + } + + broadcastRooms(clients, store); +} + +function handleMessage(client, clients, store, raw) { + let data; + + try { + data = JSON.parse(raw); + } catch { + sendError(client.ws, 'Invalid message format'); + + return; + } + + switch (data.type) { + case MessageType.SET_USERNAME: + handleSetUsername(client, clients, store, data); + break; + case MessageType.SEND_MESSAGE: + handleSendMessage(client, clients, store, data); + break; + case MessageType.CREATE_ROOM: + handleCreateRoom(client, clients, store, data); + break; + case MessageType.RENAME_ROOM: + handleRenameRoom(client, clients, store, data); + break; + case MessageType.JOIN_ROOM: + handleJoinRoom(client, clients, store, data); + break; + case MessageType.DELETE_ROOM: + handleDeleteRoom(client, clients, store, data); + break; + default: + sendError(client.ws, 'Unknown message type'); + } +} + +function attachWebSocket({ wss, clients, store }) { + wss.on('connection', (ws) => { + const client = { ws, username: null, roomId: null }; + + clients.add(client); + + ws.on('message', (raw) => { + handleMessage(client, clients, store, raw.toString()); + }); + + ws.on('close', () => { + clients.delete(client); + broadcastRooms(clients, store); + }); + }); +} + +module.exports = { attachWebSocket };