From a6cb19e89ed3f672a3ac349fccf99d81794078a1 Mon Sep 17 00:00:00 2001 From: Vlad Possin Date: Fri, 25 Dec 2020 21:50:21 +0600 Subject: [PATCH] gp-2 --- projects/gp-2/index.html | 183 ++++++++++++++++++++++++++++++ projects/gp-2/index.js | 4 + projects/gp-2/megaChat.js | 92 +++++++++++++++ projects/gp-2/no-photo.png | Bin 0 -> 2089 bytes projects/gp-2/photos/.gitkeep | 0 projects/gp-2/server/index.js | 110 ++++++++++++++++++ projects/gp-2/server/package.json | 14 +++ projects/gp-2/settings.json | 14 +++ projects/gp-2/ui/loginWindow.js | 30 +++++ projects/gp-2/ui/mainWindow.js | 13 +++ projects/gp-2/ui/messageList.js | 46 ++++++++ projects/gp-2/ui/messageSender.js | 19 ++++ projects/gp-2/ui/userList.js | 31 +++++ projects/gp-2/ui/userName.js | 14 +++ projects/gp-2/ui/userPhoto.js | 25 ++++ projects/gp-2/utils.js | 16 +++ projects/gp-2/wsClient.js | 33 ++++++ 17 files changed, 644 insertions(+) create mode 100644 projects/gp-2/index.html create mode 100644 projects/gp-2/index.js create mode 100644 projects/gp-2/megaChat.js create mode 100644 projects/gp-2/no-photo.png create mode 100644 projects/gp-2/photos/.gitkeep create mode 100644 projects/gp-2/server/index.js create mode 100644 projects/gp-2/server/package.json create mode 100644 projects/gp-2/settings.json create mode 100644 projects/gp-2/ui/loginWindow.js create mode 100644 projects/gp-2/ui/mainWindow.js create mode 100644 projects/gp-2/ui/messageList.js create mode 100644 projects/gp-2/ui/messageSender.js create mode 100644 projects/gp-2/ui/userList.js create mode 100644 projects/gp-2/ui/userName.js create mode 100644 projects/gp-2/ui/userPhoto.js create mode 100644 projects/gp-2/utils.js create mode 100644 projects/gp-2/wsClient.js 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 0000000000000000000000000000000000000000..4de590b82484a1cc107a0ee5cca991a34d0f8f67 GIT binary patch literal 2089 zcmV+^2-f$BP)P)t-sfPsVm z|NnY>efaqJ<>lq^@$v2L?TwC*h>45D#Kotmse^=u%*@T)+}w9~dY71*tgWu1qolsR zzq7QplarO3oSwM2xz*Ly(b3XrdNT$900)^#L_t(|+U(rfj-x;jMA6JBHiKEc;Q#;9 zSy~!tq#3z8BM{YyBm2c~SH`#&!2BrAlKh+{`8iASbC%@iEXmJVlAp6AKW9mP%r}?H zs`yYQ-^%7C)p~tS^Z3*K=X;)>m$mdQY+F)1ANR)a7i-4$np|$flD=&Wun*m@*T?(! zhjZ^@8m*f)q zlF0!30*r<8^d+wb_C>(UN&1rVX0WfrQaVFlQp~UrOiYTtq^e<2kID(+k~G4?P?t`S zmZTXL*P$a)v?WjD;9faFSJF2yN{2_`1XW2IVJyt06GSBojMiatf~F)5rZ8NbASsz( z5<}q(zNBhQ1NYJie92-mPbbhNO)#Bq^&q~as4bH(g)Mo( zl+b$!Uy@pJN$VkeN!gj!kUWAf=?Bxo)g$DJ4VUHHh{$g z$dad-9iya$*;$fT$#j^KZGIlQyD#5SB~@c`7~f5`zRL&_tq0I0 zPng`|0dz?}m|9nOj5!4+hT0?elKCSggK0clvJxfC7M;E%HMtyIY>7W%ISf?bTEGY+y7zYheg+%^F>jZYFf`D86JhrMC1SzT|>TPQq+SnqW$({8RGZ z`yF2X-nS^hr%JxX(Z!T-mcx`>&XRu{_8UR=!(mEbN=@kjbcue04)u;D^-<<0)k9h` z?N?H^IK~K4yd*KK{wev-ew*H-_>y9Tso-9Cm^o`g7N;&jNzv@SS<05^uZ8Sh9-u2p z*!9vbzC=Iv!p#Zh0*fZ(k(@x3P;+4YJ&ooH)rejG74;>4*=fk7FDYy6Fbd{N^lL1r zv$$LKmkLH%oMkRDVV_$W^(6&UGF+WzuF^6oPLP)r-3}{{(h~lU9%FKzxy;(_`IYb` z{9`eY)l*7-TN`4gNHZ5|vBS`Dladw0f(pxA2^NrL$vu#QF~^ceq-4(PSy4<$a!NiW zt<&@+O=!00EPY8rq~r&8=eE>33`0?Q`I9twWB9wjYtt2N%~DndZz1jBIC=&cqxRO zaFwGPhqTR$pG3a>P!~}!--H~J?2Dg7-AlT0WkH5FcQ?yR5+EF5kxx z6^$!>s&AQ}M2;Yo|Ns7lP(`EaeZr*{8%cQMZWTcV;R_EjyA@5ijb|j$eY3yw^bRK= z?M*}~kWeAPM>+bQWQ5IG;&&Dl^5 zVCss<3fW1tFQ*E)+!L-QAX<>Gj{X4Ieh4t^Bz0dA@XQd1{;V70{EP{zrqKw&P9lxB z4X#%*hGF)w!TKMoWd|D9)mn0r=z0tsSI54{4pGkZLaq<6-Kr!Li7NUE^r+k_jt9tL z4vZHP$spLSsMATb?=6sUg|I1*^xtk==PT2XYZ6IaIzrl|;ezNt&vkqNl`HG#NTeFj zAZ6-6+*t>I5Z*5c4a!x^Ya~+n8K7r`ZSG21M7u#+8W~MBdLqJD%#`feidOgC39f=pPF2xd^o@_#Bh07PgIV)7aRXF(^GTwM z-T}m>%A7ssVlwPsHUeq~`A)J}0zVI*S;O3AP~b)04<>%6=B3d^!_*1 z?re6m47_)O7D8w~&HRy*GOxW?EUu}iwbM8%jf?BNHC~ZJm#yAK=D|nK^vYxrs~Fqf z7DvI(HO2cADRe0{n*6!uK6UB0R?{PLXMR>JuAgYhz0`vq00000000000Dx1zO2~=T T^W3gU00000NkvXXu0mjf=+yHe literal 0 HcmV?d00001 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, + }) + ); + } +}