├── .gitignore ├── nodemon.json ├── screencaptures ├── chatroom.png └── homepage.png ├── app.json ├── webpack.config.js ├── .eslintrc.json ├── public ├── chatroom │ └── index.html ├── homepage │ └── index.html ├── js │ ├── homepage.js │ └── chatroom.js └── css │ └── style.css ├── package.json ├── src ├── homepage.js ├── escape-html.js └── chatroom.js ├── README.md └── app.js /.gitignore: -------------------------------------------------------------------------------- 1 | _* 2 | .DS_Store 3 | node_modules 4 | .env -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "app.js" 4 | ] 5 | } -------------------------------------------------------------------------------- /screencaptures/chatroom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liam-ilan/sendverse/HEAD/screencaptures/chatroom.png -------------------------------------------------------------------------------- /screencaptures/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liam-ilan/sendverse/HEAD/screencaptures/homepage.png -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sendverse chatting app", 3 | "description": "A chatting app based on socket.io and Express", 4 | "repository": "https://github.com/liam-ilan/sendverse" 5 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: { 5 | chatroom: './src/chatroom.js', 6 | homepage: './src/homepage.js' 7 | }, 8 | output: { 9 | filename: '[name].js', 10 | path: path.resolve(__dirname, 'public/js/') 11 | }, 12 | watch: true, 13 | watchOptions: { 14 | ignored: ['../app.js', 'node_modules'] 15 | } 16 | }; -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "extends": "airbnb-base", 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2018 15 | }, 16 | "rules": { 17 | } 18 | } -------------------------------------------------------------------------------- /public/chatroom/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | (ツ) 6 | 7 | 8 | 9 | 10 |
11 | 12 |
Copy Link
13 |
14 | 15 | Say 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/homepage/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | (ツ) 6 | 7 | 8 | 9 | 10 |
11 | 12 |
Enter
13 |
14 |
15 | Or enter: 16 | The Lobby 17 |
18 |
online: -
19 |
20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sendverse", 3 | "version": "1.0.0", 4 | "description": "A public chatting room for everyone.", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js", 8 | "lint": "npx eslint ./src --fix", 9 | "dev": "npx nodemon app.js & npx webpack --config webpack.config.js" 10 | }, 11 | "author": "Liam Ilan", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "eslint": "^5.16.0", 15 | "eslint-config-airbnb-base": "^13.1.0", 16 | "eslint-plugin-import": "^2.17.3", 17 | "nodemon": "^1.19.1", 18 | "webpack": "^4.35.0", 19 | "webpack-cli": "^3.3.5" 20 | }, 21 | "dependencies": { 22 | "express": "^4.17.1", 23 | "socket.io": "^2.2.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/homepage.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * sendverse 3 | * Copyright(c) 2019 Liam Ilan 4 | * MIT Licensed 5 | */ 6 | 7 | const roomName = document.getElementById('room-name'); 8 | const enter = document.getElementById('enter'); 9 | const onlineCount = document.getElementById('online-count'); 10 | 11 | // request josn function 12 | async function reqJson(url, method = 'GET', data = null) { 13 | // set fetch options 14 | const options = { 15 | method, 16 | headers: { 17 | 'Content-Type': 'application/json; charset=utf-8', 18 | }, 19 | }; 20 | 21 | // add body if content exists 22 | if (data) { 23 | options.body = JSON.stringify(data); 24 | } 25 | 26 | // await the fetch from the url 27 | const res = await window.fetch(url, options); 28 | 29 | // await until the json promise is resolved 30 | const json = await res.json(); 31 | return json; 32 | } 33 | 34 | async function putCount() { 35 | const countJSON = await reqJson('/data/count'); 36 | onlineCount.innerHTML = `Online: ${countJSON.online}`; 37 | } 38 | 39 | enter.addEventListener('click', () => { 40 | window.location.href += encodeURIComponent(roomName.value); 41 | }); 42 | 43 | document.addEventListener('keypress', (e) => { 44 | if (e.key === 'Enter') { 45 | e.preventDefault(); 46 | window.location.href += encodeURIComponent(roomName.value); 47 | } 48 | }); 49 | 50 | putCount(); 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sendverse 2 | An anonymous chatting server you can deploy for your friends. 3 | See it in action: 4 | https://sendverse.herokuapp.com/ 5 | 6 | #### The software 7 | This repo includes the server side and frontend software required to run your own chatting app. The software is written in javascript. It uses Express.js to serve files, and socket.io, for the the realtime communication between the server and client. 8 | 9 | ![](./screencaptures/homepage.png) 10 | ![](./screencaptures/chatroom.png) 11 | 12 | ##### Contents 13 | In this repo, you will find the server-side software, *app.js*, and the frontend, deployable software, located in the *public* directory. The frontend src files are located in the *src* directory. 14 | 15 | #### Deploy your own 16 | *To clone this repo:* 17 | ``` bash 18 | git clone https://github.com/liam-ilan/sendverse.git 19 | ``` 20 | 21 | ##### Running the app 22 | *To start up a local server on port 3000:* 23 | ``` bash 24 | npm start 25 | ``` 26 | 27 | *Or you can:* 28 | 29 | 30 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 31 | 32 | #### Development 33 | *Start up a localhost on port 3000 for development with Nodemon and Webpack:* 34 | 35 | ``` bash 36 | npm run dev 37 | ``` 38 | 39 | *Lint javascript:* 40 | ``` bash 41 | npm run lint 42 | ``` 43 | 44 | #### Author 45 | I'm Liam Ilan, a 13 year old software developer who is never working, but always playing around. -------------------------------------------------------------------------------- /public/js/homepage.js: -------------------------------------------------------------------------------- 1 | !function(e){var n={};function t(o){if(n[o])return n[o].exports;var r=n[o]={i:o,l:!1,exports:{}};return e[o].call(r.exports,r,r.exports,t),r.l=!0,r.exports}t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:o})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(t.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var r in e)t.d(o,r,function(n){return e[n]}.bind(null,r));return o},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="",t(t.s=0)}([function(e,n){ 2 | /*! 3 | * sendverse 4 | * Copyright(c) 2019 Liam Ilan 5 | * MIT Licensed 6 | */ 7 | const t=document.getElementById("room-name"),o=document.getElementById("enter"),r=document.getElementById("online-count");o.addEventListener("click",()=>{window.location.href+=encodeURIComponent(t.value)}),document.addEventListener("keypress",e=>{"Enter"===e.key&&(e.preventDefault(),window.location.href+=encodeURIComponent(t.value))}),async function(){const e=await async function(e,n="GET",t=null){const o={method:n,headers:{"Content-Type":"application/json; charset=utf-8"}};t&&(o.body=JSON.stringify(t));const r=await window.fetch(e,o);return await r.json()}("/data/count");r.innerHTML=`Online: ${e.online}`}()}]); -------------------------------------------------------------------------------- /src/escape-html.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /*! 3 | * escape-html 4 | * Copyright(c) 2012-2013 TJ Holowaychuk 5 | * Copyright(c) 2015 Andreas Lubbe 6 | * Copyright(c) 2015 Tiancheng "Timothy" Gu 7 | * MIT Licensed 8 | */ 9 | 10 | 11 | /** 12 | * Module variables. 13 | * @private 14 | */ 15 | 16 | const matchHtmlRegExp = /["'&<>]/; 17 | 18 | /** 19 | * Module exports. 20 | * @public 21 | */ 22 | 23 | // module.exports = escapeHtml 24 | 25 | /** 26 | * Escape special characters in the given string of text. 27 | * 28 | * @param {string} string The string to escape for inserting into HTML 29 | * @return {string} 30 | * @public 31 | */ 32 | 33 | export default function escapeHtml(string) { 34 | const str = `${string}`; 35 | const match = matchHtmlRegExp.exec(str); 36 | 37 | if (!match) { 38 | return str; 39 | } 40 | 41 | let escape; 42 | let html = ''; 43 | let index = 0; 44 | let lastIndex = 0; 45 | 46 | for (index = match.index; index < str.length; index++) { 47 | switch (str.charCodeAt(index)) { 48 | case 34: // " 49 | escape = '"'; 50 | break; 51 | case 38: // & 52 | escape = '&'; 53 | break; 54 | case 39: // ' 55 | escape = '''; 56 | break; 57 | case 60: // < 58 | escape = '<'; 59 | break; 60 | case 62: // > 61 | escape = '>'; 62 | break; 63 | default: 64 | continue; 65 | } 66 | 67 | if (lastIndex !== index) { 68 | html += str.substring(lastIndex, index); 69 | } 70 | 71 | lastIndex = index + 1; 72 | html += escape; 73 | } 74 | 75 | return lastIndex !== index 76 | ? html + str.substring(lastIndex, index) 77 | : html; 78 | } 79 | /* eslint-enable */ 80 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | // require packages 2 | const express = require('express'); 3 | let io = require('socket.io'); 4 | 5 | // current count of online people 6 | let currentCount = 0; 7 | 8 | // get our port 9 | const port = process.env.PORT || 3000; 10 | 11 | // make an app 12 | const app = express({ static: true }); 13 | app.use('/', express.static('public')); 14 | 15 | // redirect trailing slash 16 | app.use((req, res, next) => { 17 | if (req.url.substr(-1) === '/' && (req.url.split('/').length - 1 > 1)) res.redirect(301, req.url.slice(0, -1)); 18 | else next(); 19 | }); 20 | 21 | // keep list of active namespaces so we know if a given name needs to be created 22 | const namespaces = []; 23 | 24 | // on a connection function 25 | function connection(socket) { 26 | // add to count 27 | currentCount += 1; 28 | 29 | // when the client diconnects 30 | socket.on('disconnect', () => { 31 | // remove 1 from count 32 | if (currentCount !== 0) { 33 | currentCount -= 1; 34 | } 35 | }); 36 | 37 | // on every message sent from THIS socket 38 | socket.on('message', (data) => { 39 | // set up data 40 | const newData = { 41 | message: data.message, 42 | color: data.color, 43 | }; 44 | 45 | // validate 46 | if (newData.color.charAt(0) !== '#') { return { status: 'error' }; } 47 | if (newData.color.length !== 4) { return { status: 'error' }; } 48 | if (Number.isNaN(parseInt(newData.color.substring(1), 16))) { return { status: 'error' }; } 49 | 50 | // emit the message to everyone but the client 51 | socket.broadcast.emit('message', newData); 52 | 53 | return { status: 'success' }; 54 | }); 55 | } 56 | 57 | // namespace paths 58 | app.get('/:namespace', (req, res) => { 59 | let { namespace } = req.params; 60 | namespace = namespace.toLowerCase(); 61 | 62 | // if the namespace is not in the list, add it 63 | if (namespaces.indexOf(namespace) === -1) { 64 | namespaces.push(namespace); 65 | io.of(`/${namespace}`).on('connection', connection); 66 | } 67 | 68 | // serve 69 | res.sendFile(`${__dirname}/public/chatroom/index.html`); 70 | }); 71 | 72 | // main path 73 | app.get('/', (req, res) => { 74 | res.sendFile(`${__dirname}/public/homepage/index.html`); 75 | }); 76 | 77 | // get count 78 | app.get('/data/count', (req, res) => { 79 | res.json({ online: currentCount }); 80 | }); 81 | 82 | // listen on the port 83 | const server = app.listen(port); 84 | 85 | // setup sockets 86 | io = io(server); 87 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | body, html{ 2 | font-family: Arial, Helvetica, sans-serif; 3 | padding: 0px; 4 | margin: 0px; 5 | box-sizing: border-box; 6 | } 7 | 8 | ::placeholder{ 9 | color: #ccc; 10 | } 11 | 12 | main{ 13 | position: absolute; 14 | top: 50%; 15 | left: 50%; 16 | transform: translate(-50%, -50%); 17 | } 18 | 19 | main a { 20 | color: #3f89ff; 21 | } 22 | 23 | .bubble{ 24 | display: block; 25 | padding: 25px; 26 | margin: 20px; 27 | font-size: 48px; 28 | border-radius: 30px 30px 30px 0px; 29 | word-wrap: break-word; 30 | width: 75%; 31 | border-bottom: 2px solid #ccc; 32 | border-left: 2px solid #ccc; 33 | } 34 | 35 | .my-bubble{ 36 | margin-left: auto; 37 | margin-right: 20px; 38 | border-radius: 30px 30px 0px 30px; 39 | } 40 | 41 | #history{ 42 | position: fixed; 43 | top: 0; 44 | bottom: 120px; 45 | left: 0; 46 | right: 0; 47 | overflow-y: scroll; 48 | background-color: #efefef; 49 | } 50 | 51 | #input-space{ 52 | position: fixed; 53 | min-height: 100px; 54 | left: 0; 55 | right: 0; 56 | bottom: 0; 57 | background-color: #f9f9f9; 58 | text-align: center; 59 | padding-top: 20px; 60 | padding-bottom: 10px; 61 | } 62 | 63 | #input{ 64 | border: 3px solid #ccc; 65 | width: 70%; 66 | font-size: 48px; 67 | display: inline-block; 68 | margin-right: 20px; 69 | outline: none; 70 | text-align: left; 71 | padding: 12px; 72 | border-radius: 10px; 73 | } 74 | 75 | 76 | #send-button{ 77 | font-size: 48px; 78 | display: inline-block; 79 | text-align: center; 80 | background-color: #3f89ff; 81 | padding: 15px; 82 | border-radius: 10px; 83 | color: white; 84 | } 85 | 86 | #share{ 87 | font-size: 40px; 88 | z-index: 100; 89 | position: absolute; 90 | display: inline-block; 91 | padding: 10px; 92 | top: 10px; 93 | right: 10px; 94 | background-color: #ccc; 95 | border-radius: 10px; 96 | opacity: 0.5; 97 | } 98 | 99 | #share:hover{ 100 | background-color: #bdbdbd; 101 | opacity: 1; 102 | } 103 | 104 | .input-shape{ 105 | position: relative; 106 | border: 10px solid #3f89ff; 107 | width: 300px; 108 | height: 90px; 109 | font-size: 50px; 110 | text-align: center; 111 | line-height: 90px; 112 | border-radius: 10px; 113 | outline: none; 114 | } 115 | 116 | #room-name{ 117 | color: #bbb; 118 | margin: auto; 119 | display: block; 120 | margin-top: 30px; 121 | } 122 | 123 | #enter{ 124 | margin: auto; 125 | color: #fff; 126 | background-color: #3f89ff; 127 | margin-top: 15px; 128 | } 129 | 130 | #lobby-section{ 131 | text-align: center; 132 | font-size: 24px; 133 | line-height: 40px; 134 | margin-top: 15px; 135 | } 136 | 137 | #online-count{ 138 | text-align: center; 139 | font-size: 24px; 140 | } 141 | -------------------------------------------------------------------------------- /public/js/chatroom.js: -------------------------------------------------------------------------------- 1 | !function(e){var t={};function n(o){if(t[o])return t[o].exports;var r=t[o]={i:o,l:!1,exports:{}};return e[o].call(r.exports,r,r.exports,n),r.l=!0,r.exports}n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)n.d(o,r,function(t){return e[t]}.bind(null,r));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=1)}([,function(e,t,n){"use strict";n.r(t); 2 | /*! 3 | * escape-html 4 | * Copyright(c) 2012-2013 TJ Holowaychuk 5 | * Copyright(c) 2015 Andreas Lubbe 6 | * Copyright(c) 2015 Tiancheng "Timothy" Gu 7 | * MIT Licensed 8 | */ 9 | const o=/["'&<>]/; 10 | /*! 11 | * sendverse 12 | * Copyright(c) 2019 Liam Ilan 13 | * MIT Licensed 14 | */ 15 | const r=io.connect(window.location.href.toLowerCase()),a=document.getElementById("share"),c=document.getElementById("input"),i=document.getElementById("send-button"),l=document.getElementById("history"),s=`#${Math.floor(4095*Math.random()).toString(16).padStart(3,"0")}`;function d(e,t,n=!1){const r=document.createElement("div");r.className=`bubble ${n?"my-bubble":""}`,r.innerHTML=function(e){const t=`${e}`,n=o.exec(t);if(!n)return t;let r,a="",c=0,i=0;for(c=n.index;c10?"black":"white"}(e),l.appendChild(r),l.scrollTop=l.scrollHeight}function u(){const e={message:c.innerText,color:s};""!==e.message&&(d(e.color,c.innerText,!0),r.emit("message",e),c.innerText="")}i.addEventListener("click",u),c.addEventListener("paste",e=>{e.preventDefault();const t=(e.originalEvent||e).clipboardData.getData("text/plain");document.execCommand("insertHTML",!1,t)}),c.addEventListener("keypress",e=>{"Enter"===e.key&&(e.preventDefault(),u())}),a.addEventListener("click",()=>{const e=document.createElement("textarea");if(e.contentEditable="true",e.readOnly="false",e.innerHTML=window.location.href,document.body.appendChild(e),navigator.platform.match(/iPhone|iPod|iPad/)){const t=document.createRange();t.selectNodeContents(e);const n=window.getSelection();n.removeAllRanges(),n.addRange(t),e.setSelectionRange(0,999999)}else e.select();document.execCommand("copy"),document.body.removeChild(e),a.innerHTML="Copied",window.setTimeout(()=>{a.innerHTML="Copy Link"},1e3)}),r.on("message",e=>{d(e.color,e.message)})}]); -------------------------------------------------------------------------------- /src/chatroom.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * sendverse 3 | * Copyright(c) 2019 Liam Ilan 4 | * MIT Licensed 5 | */ 6 | 7 | /* global io */ 8 | /* Main */ 9 | import escapeHtml from './escape-html'; 10 | 11 | const socket = io.connect(window.location.href.toLowerCase()); 12 | 13 | const share = document.getElementById('share'); 14 | const input = document.getElementById('input'); 15 | const sendButton = document.getElementById('send-button'); 16 | const history = document.getElementById('history'); 17 | 18 | // color of your messages. Each participant has their color chosen at random. 19 | const ourColor = `#${Math.floor(Math.random() * 4095).toString(16).padStart(3, '0')}`; 20 | 21 | // calculate the color the font should be depending on a background color 22 | // for a hex with a length of 4 23 | function fontColor(color) { 24 | const r = parseInt(color.charAt(1), 16); 25 | const g = parseInt(color.charAt(2), 16); 26 | const b = parseInt(color.charAt(3), 16); 27 | return Math.max(r, g, b) > 10 ? 'black' : 'white'; 28 | } 29 | 30 | // add a bubble 31 | function addBubble(color, message, myBubble = false) { 32 | const bubble = document.createElement('div'); 33 | bubble.className = `bubble ${myBubble ? 'my-bubble' : ''}`; 34 | 35 | // escape everything, before putting it in the UI 36 | bubble.innerHTML = escapeHtml(message); 37 | bubble.style.backgroundColor = color; 38 | bubble.style.color = fontColor(color); 39 | 40 | history.appendChild(bubble); 41 | 42 | // scroll down to keep recent messages in view 43 | history.scrollTop = history.scrollHeight; 44 | } 45 | 46 | // post a message 47 | function postMessage() { 48 | const data = { message: input.innerText, color: ourColor }; 49 | 50 | if (data.message !== '') { 51 | addBubble(data.color, input.innerText, true); 52 | 53 | // emit the message 54 | socket.emit('message', data); 55 | input.innerText = ''; 56 | } 57 | } 58 | 59 | // when you click the sendbutton, post the message 60 | sendButton.addEventListener('click', postMessage); 61 | 62 | // remove styling from copy paste 63 | input.addEventListener('paste', (e) => { 64 | // cancel paste 65 | e.preventDefault(); 66 | 67 | // get text representation of clipboard 68 | const text = (e.originalEvent || e).clipboardData.getData('text/plain'); 69 | 70 | // insert text manually 71 | document.execCommand('insertHTML', false, text); 72 | }); 73 | 74 | // prevent enter from adding a line break in input so that Enter sends message. 75 | input.addEventListener('keypress', (e) => { 76 | if (e.key === 'Enter') { 77 | e.preventDefault(); 78 | postMessage(e); 79 | } 80 | }); 81 | 82 | // Copy Link functionality. 83 | share.addEventListener('click', () => { 84 | const element = document.createElement('textarea'); // set temp element. 85 | element.contentEditable = 'true'; 86 | element.readOnly = 'false'; 87 | element.innerHTML = window.location.href; // this is what we want copied. 88 | document.body.appendChild(element); 89 | 90 | if (navigator.platform.match(/iPhone|iPod|iPad/)) { 91 | const range = document.createRange(); 92 | range.selectNodeContents(element); 93 | 94 | const selection = window.getSelection(); 95 | selection.removeAllRanges(); 96 | selection.addRange(range); 97 | 98 | element.setSelectionRange(0, 999999); 99 | } else { 100 | element.select(); 101 | } 102 | 103 | document.execCommand('copy'); 104 | document.body.removeChild(element); // cleanup. 105 | 106 | // button UI 107 | share.innerHTML = 'Copied'; 108 | window.setTimeout(() => { share.innerHTML = 'Copy Link'; }, 1000); 109 | }); 110 | 111 | // Listen to server events. 112 | socket.on('message', (data) => { 113 | addBubble(data.color, data.message); 114 | }); 115 | --------------------------------------------------------------------------------