From 6955468b24987d6d661df1af1e5ed76d7176cb69 Mon Sep 17 00:00:00 2001 From: Heny Date: Tue, 1 Jul 2025 13:28:51 +0300 Subject: [PATCH] Solution --- .github/workflows/test.yml-template | 23 +++++ package-lock.json | 9 +- package.json | 2 +- src.zip | Bin 0 -> 350 bytes src/index.js | 26 +++++- src/models/roomManager.js | 70 +++++++++++++++ src/models/userManager.js | 50 +++++++++++ src/public/index.html | 130 ++++++++++++++++++++++++++++ src/public/js/auth.js | 33 +++++++ src/public/js/main.js | 17 ++++ src/public/js/messages.js | 37 ++++++++ src/public/js/rooms.js | 73 ++++++++++++++++ src/public/js/socket.js | 28 ++++++ src/public/js/ui.js | 69 +++++++++++++++ src/socket/socketHandlers.js | 123 ++++++++++++++++++++++++++ 15 files changed, 684 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/test.yml-template create mode 100644 src.zip create mode 100644 src/models/roomManager.js create mode 100644 src/models/userManager.js create mode 100644 src/public/index.html create mode 100644 src/public/js/auth.js create mode 100644 src/public/js/main.js create mode 100644 src/public/js/messages.js create mode 100644 src/public/js/rooms.js create mode 100644 src/public/js/socket.js create mode 100644 src/public/js/ui.js create mode 100644 src/socket/socketHandlers.js 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..0e17474dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "GPL-3.0", "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.1", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", @@ -1467,10 +1467,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.1", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.1.tgz", + "integrity": "sha512-Tf97p/jZ/ZRsQSPGcZf2FpvxgUCl8DiUOsiDFHj7HBN8gMK0iZOBQEtyqcFsauVUAvPP8Ayo8cAiC12MMp45iQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index 4f64337fe..00f87c96d 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.1", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", diff --git a/src.zip b/src.zip new file mode 100644 index 0000000000000000000000000000000000000000..86373d368eba960a61971a51902df9e1a3abca0b GIT binary patch literal 350 zcmWIWW@Zs#0D(WA-oLi3t#$Yd>QGF1A7I4cJl#o zfHxzP2s19%N`RaQ2LByF6jryhGB85i3^57jYGhk@7!W3HYjlE|gwqdj6It0nwlV?X L93Z_0#9;scA;CZu literal 0 HcmV?d00001 diff --git a/src/index.js b/src/index.js index ad9a93a7c..779eb0d69 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,25 @@ -'use strict'; +/* eslint-disable no-console */ + +const express = require('express'); +const http = require('http'); +const { Server } = require('socket.io'); +const path = require('path'); +const { setupSocketHandlers } = require('./socket/socketHandlers'); + +const app = express(); +const server = http.createServer(app); +const io = new Server(server); + +const PORT = process.env.PORT || 3000; + +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +setupSocketHandlers(io); + +server.listen(PORT, () => { + console.log(`Server listening on port:${PORT}`); +}); diff --git a/src/models/roomManager.js b/src/models/roomManager.js new file mode 100644 index 000000000..8875ec5af --- /dev/null +++ b/src/models/roomManager.js @@ -0,0 +1,70 @@ +const rooms = { + general: { name: 'General', messages: [] }, + tech: { name: 'Technology', messages: [] }, + random: { name: 'Random', messages: [] }, +}; + +function getAllRooms() { + return rooms; +} + +function getRoomsList() { + return Object.entries(rooms).map(([id, data]) => ({ id, name: data.name })); +} + +function getRoom(roomId) { + return rooms[roomId]; +} + +function createRoom(roomName) { + const roomId = `room_${Date.now()}`; + + rooms[roomId] = { name: roomName, messages: [] }; + + return roomId; +} + +function renameRoom(roomId, newName) { + if (rooms[roomId]) { + rooms[roomId].name = newName; + + return true; + } + + return false; +} + +function deleteRoom(roomId) { + if (rooms[roomId] && roomId !== 'general') { + delete rooms[roomId]; + + return true; + } + + return false; +} + +function addMessage(roomId, message) { + if (rooms[roomId]) { + rooms[roomId].messages.push(message); + + return true; + } + + return false; +} + +function roomExists(roomId) { + return !!rooms[roomId]; +} + +module.exports = { + getAllRooms, + getRoomsList, + getRoom, + createRoom, + renameRoom, + deleteRoom, + addMessage, + roomExists, +}; diff --git a/src/models/userManager.js b/src/models/userManager.js new file mode 100644 index 000000000..b97154fc2 --- /dev/null +++ b/src/models/userManager.js @@ -0,0 +1,50 @@ +const users = {}; + +function createUser(socketId, username) { + users[socketId] = { username, currentRoom: null }; +} + +function getUser(socketId) { + return users[socketId]; +} + +function getUserRoom(socketId) { + const user = users[socketId]; + + return user ? user.currentRoom : null; +} + +function setUserRoom(socketId, roomId) { + if (users[socketId]) { + users[socketId].currentRoom = roomId; + + return true; + } + + return false; +} + +function removeUser(socketId) { + const user = users[socketId]; + + if (user) { + delete users[socketId]; + + return user; + } + + return null; +} + +function userExists(socketId) { + return !!users[socketId]; +} + +module.exports = { + createUser, + getUser, + getUserRoom, + setUserRoom, + removeUser, + userExists, +}; diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 000000000..47b3b7687 --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,130 @@ + + + + + + Socket.io Chat + + + + + +
+
+

Enter Your Username

+
+ + +
+
+
+ + + + + + + + diff --git a/src/public/js/auth.js b/src/public/js/auth.js new file mode 100644 index 000000000..f90eebd90 --- /dev/null +++ b/src/public/js/auth.js @@ -0,0 +1,33 @@ +function initializeAuth(state) { + const usernameModal = document.getElementById('username-modal'); + const usernameForm = document.getElementById('username-form'); + const usernameInput = document.getElementById('username-input'); + const displayUsername = document.getElementById('display-username'); + const chatContainer = document.getElementById('chat-container'); + + if (state.username) { + showChatInterface(); + } + + usernameForm.addEventListener('submit', (e) => { + e.preventDefault(); + + const newUsername = usernameInput.value.trim(); + + if (newUsername) { + state.username = newUsername; + window.localStorage.setItem('chat_username', state.username); + showChatInterface(); + } + }); + + function showChatInterface() { + displayUsername.textContent = state.username; + usernameModal.classList.add('hidden'); + chatContainer.classList.remove('hidden'); + chatContainer.classList.add('flex'); + state.socket.emit('set username', state.username); + } +} + +export { initializeAuth }; diff --git a/src/public/js/main.js b/src/public/js/main.js new file mode 100644 index 000000000..3ecafc4c4 --- /dev/null +++ b/src/public/js/main.js @@ -0,0 +1,17 @@ +import { initializeAuth } from './auth.js'; +import { initializeSocket } from './socket.js'; +import { initializeUI } from './ui.js'; + +document.addEventListener('DOMContentLoaded', () => { + const socket = window.io(); + + const state = { + socket, + username: window.localStorage.getItem('chat_username'), + currentRoomId: null, + }; + + initializeAuth(state); + initializeSocket(state); + initializeUI(state); +}); diff --git a/src/public/js/messages.js b/src/public/js/messages.js new file mode 100644 index 000000000..69fb1a353 --- /dev/null +++ b/src/public/js/messages.js @@ -0,0 +1,37 @@ +function appendMessage({ author, text, time }, currentUsername) { + const messages = document.getElementById('messages'); + const messageElement = document.createElement('div'); + const isMyMessage = author === currentUsername; + const formattedTime = new Date(time).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + + messageElement.className = `flex flex-col ${isMyMessage ? 'items-end' : 'items-start'}`; + + messageElement.innerHTML = ` +
+
+

${isMyMessage ? 'You' : author}

+

${formattedTime}

+
+

${text}

+
+ `; + + messages.appendChild(messageElement); + messages.scrollTop = messages.scrollHeight; +} + +function appendSystemMessage(text) { + const messages = document.getElementById('messages'); + const systemMessageElement = document.createElement('div'); + + systemMessageElement.className = 'text-center my-2'; + systemMessageElement.innerHTML = `

${text}

`; + + messages.appendChild(systemMessageElement); + messages.scrollTop = messages.scrollHeight; +} + +export { appendMessage, appendSystemMessage }; diff --git a/src/public/js/rooms.js b/src/public/js/rooms.js new file mode 100644 index 000000000..8b4bba885 --- /dev/null +++ b/src/public/js/rooms.js @@ -0,0 +1,73 @@ +function renderRooms(rooms, state) { + const roomList = document.getElementById('room-list'); + + roomList.innerHTML = ''; + + rooms.forEach((room) => { + const roomElement = document.createElement('button'); + + roomElement.textContent = room.name; + roomElement.dataset.roomId = room.id; + + roomElement.className = `w-full text-left p-2 rounded-md transition duration-200 ${ + room.id === state.currentRoomId + ? 'bg-indigo-600 font-bold' + : 'hover:bg-gray-700' + }`; + roomElement.addEventListener('click', () => joinRoom(room.id, state)); + roomList.appendChild(roomElement); + }); +} + +function joinRoom(roomId, state) { + if (roomId === state.currentRoomId) { + return; + } + + state.socket.emit('join room', roomId, (response) => { + if (response.status === 'ok') { + state.currentRoomId = roomId; + updateCurrentRoomDisplay(roomId); + clearAndLoadMessages(response.messages, state.username); + updateRoomListStyles(roomId); + } else { + window.alert(`Error joining room: ${response.message}`); + } + }); +} + +function updateCurrentRoomDisplay(roomId) { + const currentRoomName = document.getElementById('current-room-name'); + const roomList = document.getElementById('room-list'); + const roomData = Array.from(roomList.children).find( + (el) => el.dataset.roomId === roomId, + ); + + currentRoomName.textContent = roomData ? roomData.textContent : 'Room'; +} + +function clearAndLoadMessages(messages, username) { + const messagesContainer = document.getElementById('messages'); + + messagesContainer.innerHTML = ''; + + messages.forEach((message) => { + import('./messages.js').then(({ appendMessage }) => { + appendMessage(message, username); + }); + }); +} + +function updateRoomListStyles(currentRoomId) { + const roomList = document.getElementById('room-list'); + + Array.from(roomList.children).forEach((el) => { + const isActive = el.dataset.roomId === currentRoomId; + + el.classList.toggle('bg-indigo-600', isActive); + el.classList.toggle('font-bold', isActive); + el.classList.toggle('hover:bg-gray-700', !isActive); + }); +} + +export { renderRooms, joinRoom }; diff --git a/src/public/js/socket.js b/src/public/js/socket.js new file mode 100644 index 000000000..be8a4f885 --- /dev/null +++ b/src/public/js/socket.js @@ -0,0 +1,28 @@ +import { renderRooms, joinRoom } from './rooms.js'; +import { appendMessage, appendSystemMessage } from './messages.js'; + +function initializeSocket(state) { + state.socket.on('rooms list', (rooms) => { + renderRooms(rooms, state); + + if (!state.currentRoomId && rooms.some((r) => r.id === 'general')) { + joinRoom('general', state); + } else if (!state.currentRoomId && rooms.length > 0) { + joinRoom(rooms[0].id, state); + } + }); + + state.socket.on('new message', (message) => { + appendMessage(message, state.username); + }); + + state.socket.on('user joined', (username) => { + appendSystemMessage(`${username} has joined the room.`); + }); + + state.socket.on('user left', (username) => { + appendSystemMessage(`${username} has left the room.`); + }); +} + +export { initializeSocket }; diff --git a/src/public/js/ui.js b/src/public/js/ui.js new file mode 100644 index 000000000..534df9081 --- /dev/null +++ b/src/public/js/ui.js @@ -0,0 +1,69 @@ +import { joinRoom } from './rooms.js'; + +function initializeUI(state) { + const messageForm = document.getElementById('message-form'); + const messageInput = document.getElementById('message-input'); + const createRoomBtn = document.getElementById('create-room-btn'); + const renameRoomBtn = document.getElementById('rename-room-btn'); + const deleteRoomBtn = document.getElementById('delete-room-btn'); + const currentRoomName = document.getElementById('current-room-name'); + + messageForm.addEventListener('submit', (e) => { + e.preventDefault(); + + const messageText = messageInput.value.trim(); + + if (messageText && state.currentRoomId) { + state.socket.emit('send message', messageText); + messageInput.value = ''; + } + }); + + createRoomBtn.addEventListener('click', () => { + const roomName = window.prompt('Enter a name for the new room:'); + + if (roomName) { + state.socket.emit('create room', roomName, (newRoomId) => { + joinRoom(newRoomId, state); + }); + } + }); + + renameRoomBtn.addEventListener('click', () => { + if (!state.currentRoomId || state.currentRoomId === 'general') { + window.alert('You cannot rename the General room.'); + + return; + } + + const newName = window.prompt( + `Enter a new name for the room "${currentRoomName.textContent}":`, + ); + + if (newName) { + state.socket.emit('rename room', { + roomId: state.currentRoomId, + newName, + }); + } + }); + + deleteRoomBtn.addEventListener('click', () => { + if (!state.currentRoomId || state.currentRoomId === 'general') { + window.alert('You cannot delete the General room.'); + + return; + } + + if ( + window.confirm( + `Are you sure you want to delete the room "${currentRoomName.textContent}"?`, + ) + ) { + state.socket.emit('delete room', state.currentRoomId); + state.currentRoomId = null; + } + }); +} + +export { initializeUI }; diff --git a/src/socket/socketHandlers.js b/src/socket/socketHandlers.js new file mode 100644 index 000000000..5de3ff4fd --- /dev/null +++ b/src/socket/socketHandlers.js @@ -0,0 +1,123 @@ +/* eslint-disable no-console */ + +const roomManager = require('../models/roomManager'); +const userManager = require('../models/userManager'); + +function setupSocketHandlers(io) { + io.on('connection', (socket) => { + console.log(`User connected: ${socket.id}`); + + socket.on('set username', (username) => { + userManager.createUser(socket.id, username); + console.log(`User ${socket.id} set username to: ${username}`); + socket.emit('rooms list', roomManager.getRoomsList()); + }); + + socket.on('join room', (roomId, callback) => { + const user = userManager.getUser(socket.id); + + if (!user) { + if (callback) { + callback({ status: 'error', message: 'User not found' }); + } + + return; + } + + const previousRoomId = user.currentRoom; + + if (previousRoomId) { + socket.leave(previousRoomId); + socket.to(previousRoomId).emit('user left', user.username); + } + + if (!roomManager.roomExists(roomId)) { + console.error(`Attempt to join non-existent room: ${roomId}`); + + if (callback) { + callback({ status: 'error', message: 'Room not found' }); + } + + return; + } + + socket.join(roomId); + userManager.setUserRoom(socket.id, roomId); + + const room = roomManager.getRoom(roomId); + + console.log( + `User ${user.username} (${socket.id}) joined room: ${room.name}`, + ); + + socket.to(roomId).emit('user joined', user.username); + + if (callback) { + callback({ + status: 'ok', + messages: room.messages, + }); + } + }); + + socket.on('send message', (messageText) => { + const user = userManager.getUser(socket.id); + + if (!user || !user.currentRoom) { + return; + } + + const roomId = user.currentRoom; + const message = { + author: user.username, + text: messageText, + time: new Date().toISOString(), + }; + + roomManager.addMessage(roomId, message); + io.to(roomId).emit('new message', message); + }); + + socket.on('create room', (roomName, callback) => { + const roomId = roomManager.createRoom(roomName); + + console.log(`Room created: ${roomName} (${roomId})`); + + io.emit('rooms list', roomManager.getRoomsList()); + + if (callback) { + callback(roomId); + } + }); + + socket.on('rename room', ({ roomId, newName }) => { + if (roomManager.renameRoom(roomId, newName)) { + console.log(`Room ${roomId} renamed to: ${newName}`); + io.emit('rooms list', roomManager.getRoomsList()); + } + }); + + socket.on('delete room', (roomId) => { + if (roomManager.deleteRoom(roomId)) { + console.log(`Room deleted: ${roomId}`); + io.emit('rooms list', roomManager.getRoomsList()); + } + }); + + socket.on('disconnect', () => { + const user = userManager.removeUser(socket.id); + + if (user) { + if (user.currentRoom) { + socket.to(user.currentRoom).emit('user left', user.username); + } + + console.log(`User disconnected: ${user.username} (${socket.id})`); + } else { + console.log(`User disconnected: ${socket.id}`); + } + }); + }); +} + +module.exports = { setupSocketHandlers };