diff --git a/projects/gp-2/index.html b/projects/gp-2/index.html new file mode 100644 index 0000000..48bef79 --- /dev/null +++ b/projects/gp-2/index.html @@ -0,0 +1,183 @@ + +
+ + + +
diff --git a/projects/gp-2/index.js b/projects/gp-2/index.js new file mode 100644 index 0000000..6aed62f --- /dev/null +++ b/projects/gp-2/index.js @@ -0,0 +1,4 @@ +import './index.html'; +import MegaChat from './megaChat'; + +new MegaChat(); diff --git a/projects/gp-2/megaChat.js b/projects/gp-2/megaChat.js new file mode 100644 index 0000000..f872e6b --- /dev/null +++ b/projects/gp-2/megaChat.js @@ -0,0 +1,92 @@ +import LoginWindow from './ui/loginWindow'; +import MainWindow from './ui/mainWindow'; +import UserName from './ui/userName'; +import UserList from './ui/userList'; +import UserPhoto from './ui/userPhoto'; +import MessageList from './ui/messageList'; +import MessageSender from './ui/messageSender'; +import WSClient from './wsClient'; + +export default class MegaChat { + constructor() { + this.wsClient = new WSClient( + `ws://${location.host}/mega-chat-3/ws`, + this.onMessage.bind(this) + ); + + this.ui = { + loginWindow: new LoginWindow( + document.querySelector('#login'), + this.onLogin.bind(this) + ), + mainWindow: new MainWindow(document.querySelector('#main')), + userName: new UserName(document.querySelector('[data-role=user-name]')), + userList: new UserList(document.querySelector('[data-role=user-list]')), + messageList: new MessageList(document.querySelector('[data-role=messages-list]')), + messageSender: new MessageSender( + document.querySelector('[data-role=message-sender]'), + this.onSend.bind(this) + ), + userPhoto: new UserPhoto( + document.querySelector('[data-role=user-photo]'), + this.onUpload.bind(this) + ), + }; + + this.ui.loginWindow.show(); + } + + onUpload(data) { + this.ui.userPhoto.set(data); + + fetch('/mega-chat-3/upload-photo', { + method: 'post', + body: JSON.stringify({ + name: this.ui.userName.get(), + image: data, + }), + }); + } + + onSend(message) { + this.wsClient.sendTextMessage(message); + this.ui.messageSender.clear(); + } + + async onLogin(name) { + await this.wsClient.connect(); + this.wsClient.sendHello(name); + this.ui.loginWindow.hide(); + this.ui.mainWindow.show(); + this.ui.userName.set(name); + this.ui.userPhoto.set(`/mega-chat-3/photos/${name}.png?t=${Date.now()}`); + } + + onMessage({ type, from, data }) { + console.log(type, from, data); + + if (type === 'hello') { + this.ui.userList.add(from); + this.ui.messageList.addSystemMessage(`${from} вошел в чат`); + } else if (type === 'user-list') { + for (const item of data) { + this.ui.userList.add(item); + } + } else if (type === 'bye-bye') { + this.ui.userList.remove(from); + this.ui.messageList.addSystemMessage(`${from} вышел из чата`); + } else if (type === 'text-message') { + this.ui.messageList.add(from, data.message); + } else if (type === 'photo-changed') { + const avatars = document.querySelectorAll( + `[data-role=user-avatar][data-user=${data.name}]` + ); + + for (const avatar of avatars) { + avatar.style.backgroundImage = `url(/mega-chat-3/photos/${ + data.name + }.png?t=${Date.now()})`; + } + } + } +} diff --git a/projects/gp-2/no-photo.png b/projects/gp-2/no-photo.png new file mode 100644 index 0000000..4de590b Binary files /dev/null and b/projects/gp-2/no-photo.png differ diff --git a/projects/gp-2/photos/.gitkeep b/projects/gp-2/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/projects/gp-2/server/index.js b/projects/gp-2/server/index.js new file mode 100644 index 0000000..c68d421 --- /dev/null +++ b/projects/gp-2/server/index.js @@ -0,0 +1,110 @@ +// не забудьте сделать npm install ;) + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const { Server } = require('ws'); + +function readBody(req) { + return new Promise((resolve, reject) => { + let dataRaw = ''; + + req.on('data', (chunk) => (dataRaw += chunk)); + req.on('error', reject); + req.on('end', () => resolve(JSON.parse(dataRaw))); + }); +} + +const server = http.createServer(async (req, res) => { + try { + if (/\/photos\/.+\.png/.test(req.url)) { + const [, imageName] = req.url.match(/\/photos\/(.+\.png)/) || []; + const fallBackPath = path.resolve(__dirname, '../no-photo.png'); + const filePath = path.resolve(__dirname, '../photos', imageName); + + if (fs.existsSync(filePath)) { + return fs.createReadStream(filePath).pipe(res); + } else { + return fs.createReadStream(fallBackPath).pipe(res); + } + } else if (req.url.endsWith('/upload-photo')) { + const body = await readBody(req); + const name = body.name.replace(/\.\.\/|\//, ''); + const [, content] = body.image.match(/data:image\/.+?;base64,(.+)/) || []; + const filePath = path.resolve(__dirname, '../photos', `${name}.png`); + + if (name && content) { + fs.writeFileSync(filePath, content, 'base64'); + + broadcast(connections, { type: 'photo-changed', data: { name } }); + } else { + return res.end('fail'); + } + } + + res.end('ok'); + } catch (e) { + console.error(e); + res.end('fail'); + } +}); +const wss = new Server({ server }); +const connections = new Map(); + +wss.on('connection', (socket) => { + connections.set(socket, {}); + + socket.on('message', (messageData) => { + const message = JSON.parse(messageData); + let excludeItself = false; + + if (message.type === 'hello') { + excludeItself = true; + connections.get(socket).userName = message.data.name; + sendMessageTo( + { + type: 'user-list', + data: [...connections.values()].map((item) => item.userName).filter(Boolean), + }, + socket + ); + } + + sendMessageFrom(connections, message, socket, excludeItself); + }); + + socket.on('close', () => { + sendMessageFrom(connections, { type: 'bye-bye' }, socket); + connections.delete(socket); + }); +}); + +function sendMessageTo(message, to) { + to.send(JSON.stringify(message)); +} + +function broadcast(connections, message) { + for (const connection of connections.keys()) { + connection.send(JSON.stringify(message)); + } +} + +function sendMessageFrom(connections, message, from, excludeSelf) { + const socketData = connections.get(from); + + if (!socketData) { + return; + } + + message.from = socketData.userName; + + for (const connection of connections.keys()) { + if (connection === from && excludeSelf) { + continue; + } + + connection.send(JSON.stringify(message)); + } +} + +server.listen(8282); diff --git a/projects/gp-2/server/package.json b/projects/gp-2/server/package.json new file mode 100644 index 0000000..5a3d4ba --- /dev/null +++ b/projects/gp-2/server/package.json @@ -0,0 +1,14 @@ +{ + "name": "server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "ws": "^7.3.1" + } +} diff --git a/projects/gp-2/settings.json b/projects/gp-2/settings.json new file mode 100644 index 0000000..fd1cbfe --- /dev/null +++ b/projects/gp-2/settings.json @@ -0,0 +1,14 @@ +{ + "proxy": { + "/mega-chat-3/ws": { + "target": "ws://localhost:8282", + "ws": true + }, + "/mega-chat-3/upload-photo": { + "target": "http://localhost:8282" + }, + "/mega-chat-3/photos": { + "target": "http://localhost:8282" + } + } +} diff --git a/projects/gp-2/ui/loginWindow.js b/projects/gp-2/ui/loginWindow.js new file mode 100644 index 0000000..89727d3 --- /dev/null +++ b/projects/gp-2/ui/loginWindow.js @@ -0,0 +1,30 @@ +export default class LoginWindow { + constructor(element, onLogin) { + this.element = element; + this.onLogin = onLogin; + + const loginNameInput = element.querySelector('[data-role=login-name-input]'); + const submitButton = element.querySelector('[data-role=login-submit]'); + const loginError = element.querySelector('[data-role=login-error]'); + + submitButton.addEventListener('click', () => { + loginError.textContent = ''; + + const name = loginNameInput.value.trim(); + + if (!name) { + loginError.textContent = 'Введите никнейм'; + } else { + this.onLogin(name); + } + }); + } + + show() { + this.element.classList.remove('hidden'); + } + + hide() { + this.element.classList.add('hidden'); + } +} diff --git a/projects/gp-2/ui/mainWindow.js b/projects/gp-2/ui/mainWindow.js new file mode 100644 index 0000000..2b319ef --- /dev/null +++ b/projects/gp-2/ui/mainWindow.js @@ -0,0 +1,13 @@ +export default class MainWindow { + constructor(element) { + this.element = element; + } + + show() { + this.element.classList.remove('hidden'); + } + + hide() { + this.element.classList.add('hidden'); + } +} diff --git a/projects/gp-2/ui/messageList.js b/projects/gp-2/ui/messageList.js new file mode 100644 index 0000000..5d38b6a --- /dev/null +++ b/projects/gp-2/ui/messageList.js @@ -0,0 +1,46 @@ +import { sanitize } from '../utils'; + +export default class MessageList { + constructor(element) { + this.element = element; + } + + add(from, text) { + const date = new Date(); + const hours = String(date.getHours()).padStart(2, 0); + const minutes = String(date.getMinutes()).padStart(2, 0); + const time = `${hours}:${minutes}`; + const item = document.createElement('div'); + + item.classList.add('message-item'); + item.innerHTML = ` +
+
+
+
+
+
${sanitize(from)}
+
${time}
+
+
${sanitize(text)}
+
+ `; + + this.element.append(item); + this.element.scrollTop = this.element.scrollHeight; + } + + addSystemMessage(message) { + const item = document.createElement('div'); + + item.classList.add('message-item', 'message-item-system'); + item.textContent = message; + + this.element.append(item); + this.element.scrollTop = this.element.scrollHeight; + } +} diff --git a/projects/gp-2/ui/messageSender.js b/projects/gp-2/ui/messageSender.js new file mode 100644 index 0000000..f179b3b --- /dev/null +++ b/projects/gp-2/ui/messageSender.js @@ -0,0 +1,19 @@ +export default class MessageSender { + constructor(element, onSend) { + this.onSend = onSend; + this.messageInput = element.querySelector('[data-role=message-input]'); + this.messageSendButton = element.querySelector('[data-role=message-send-button]'); + + this.messageSendButton.addEventListener('click', () => { + const message = this.messageInput.value.trim(); + + if (message) { + this.onSend(message); + } + }); + } + + clear() { + this.messageInput.value = ''; + } +} diff --git a/projects/gp-2/ui/userList.js b/projects/gp-2/ui/userList.js new file mode 100644 index 0000000..d86e601 --- /dev/null +++ b/projects/gp-2/ui/userList.js @@ -0,0 +1,31 @@ +export default class UserList { + constructor(element) { + this.element = element; + this.items = new Set(); + } + + buildDOM() { + const fragment = document.createDocumentFragment(); + + this.element.innerHTML = ''; + + for (const name of this.items) { + const element = document.createElement('div'); + element.classList.add('user-list-item'); + element.textContent = name; + fragment.append(element); + } + + this.element.append(fragment); + } + + add(name) { + this.items.add(name); + this.buildDOM(); + } + + remove(name) { + this.items.delete(name); + this.buildDOM(); + } +} diff --git a/projects/gp-2/ui/userName.js b/projects/gp-2/ui/userName.js new file mode 100644 index 0000000..fe09719 --- /dev/null +++ b/projects/gp-2/ui/userName.js @@ -0,0 +1,14 @@ +export default class UserName { + constructor(element) { + this.element = element; + } + + set(name) { + this.name = name; + this.element.textContent = name; + } + + get() { + return this.name; + } +} diff --git a/projects/gp-2/ui/userPhoto.js b/projects/gp-2/ui/userPhoto.js new file mode 100644 index 0000000..09278c0 --- /dev/null +++ b/projects/gp-2/ui/userPhoto.js @@ -0,0 +1,25 @@ +export default class UserPhoto { + constructor(element, onUpload) { + this.element = element; + this.onUpload = onUpload; + + this.element.addEventListener('dragover', (e) => { + if (e.dataTransfer.items.length && e.dataTransfer.items[0].kind === 'file') { + e.preventDefault(); + } + }); + + this.element.addEventListener('drop', (e) => { + const file = e.dataTransfer.items[0].getAsFile(); + const reader = new FileReader(); + + reader.readAsDataURL(file); + reader.addEventListener('load', () => this.onUpload(reader.result)); + e.preventDefault(); + }); + } + + set(photo) { + this.element.style.backgroundImage = `url(${photo})`; + } +} diff --git a/projects/gp-2/utils.js b/projects/gp-2/utils.js new file mode 100644 index 0000000..658c232 --- /dev/null +++ b/projects/gp-2/utils.js @@ -0,0 +1,16 @@ +const UNSAFE_CHARS_RE = /<|>\/|'|\u2028|\u2029/g; + +const ESCAPED_CHARS = { + '<': '<', + '>': '>', + '"': '"', + "'": '\\u0027', + ' ESCAPED_CHARS[unsafeChar]; + +export function sanitize(string) { + return string.replace(UNSAFE_CHARS_RE, escapeUnsafeChars); +} diff --git a/projects/gp-2/wsClient.js b/projects/gp-2/wsClient.js new file mode 100644 index 0000000..039106a --- /dev/null +++ b/projects/gp-2/wsClient.js @@ -0,0 +1,33 @@ +export default class WSClient { + constructor(url, onMessage) { + this.url = url; + this.onMessage = onMessage; + } + + connect() { + return new Promise((resolve) => { + this.socket = new WebSocket(this.url); + this.socket.addEventListener('open', resolve); + this.socket.addEventListener('message', (e) => { + this.onMessage(JSON.parse(e.data)); + }); + }); + } + + sendHello(name) { + this.sendMessage('hello', { name }); + } + + sendTextMessage(message) { + this.sendMessage('text-message', { message }); + } + + sendMessage(type, data) { + this.socket.send( + JSON.stringify({ + type, + data, + }) + ); + } +}