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 @@ + + +
+ + +Enter a username to join the chat.
+ + +