diff --git a/CHANGELOG.md b/CHANGELOG.md index 10e5bc3..457bb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2019-05-23 + +### Added + +- socket.io for real time communication +- compression and minify +- auto replacing image links with images in the chat +- auto replacing urls to urls with link in the chat +- message editing and deleting *(undo your mistakes)* +- changelog to `bingo-create` + +### Changed + +- frontend to use socket.io instead of graphql for refreshing +- use of socket.io for toggeling binogo fields +- button behaviour on `bingo-create` to respond to the situation *(whatever that means)* + +### Removed + +- graphql frontend functions to send messages and refresh + +### Fixed + +- error message when loading `bingo-create` +- chat doesn't scroll down when an image is send *(r/mildlyinfuriating)* +- some style issues + ## [0.1.1] - 2019-05-21 ### Fixed diff --git a/app.js b/app.js index cebbece..195a2f9 100644 --- a/app.js +++ b/app.js @@ -1,9 +1,12 @@ const createError = require('http-errors'), - express = require('express'), path = require('path'), + express = require('express'), cookieParser = require('cookie-parser'), logger = require('morgan'), compileSass = require('express-compile-sass'), + minify = require('express-minify'), + compression = require('compression'), + uglifyEs = require('uglify-es'), session = require('express-session'), pgSession = require('connect-pg-simple')(session), fsx = require('fs-extra'), @@ -20,6 +23,9 @@ const createError = require('http-errors'), changelogRouter = require('./routes/changelog'), bingoRouter = require('./routes/bingo'); +let app = require('express')(), + server = require('http').Server(app), + io = require('socket.io')(server); async function init() { // grapql default resolver @@ -36,15 +42,19 @@ async function init() { // database setup let pgPool = globals.pgPool; await pgPool.query(fsx.readFileSync('./sql/init.sql', 'utf-8')); - await bingoRouter.init(); - let app = express(); + let bingoIo = io.of('/bingo'); + await bingoRouter.init(bingoIo, io); // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'pug'); app.set('trust proxy', 1); + app.use(compression()); + app.use(minify({ + uglifyJsModule: uglifyEs + })); app.use(logger('dev')); app.use(express.json()); app.use(express.urlencoded({extended: false})); @@ -98,7 +108,7 @@ async function init() { res.status(err.status || 500); res.render('error'); }); - return app; + return [app, server]; } module.exports = init; diff --git a/bin/www b/bin/www index f10421a..57ec0e5 100644 --- a/bin/www +++ b/bin/www @@ -6,7 +6,6 @@ const appInit = require('../app'); const debug = require('debug')('whooshy:server'); -const http = require('http'); const yaml = require('js-yaml'); const fsx = require('fs-extra'); @@ -27,15 +26,9 @@ try { let port = normalizePort(process.env.PORT || settings.port || '3000'); -appInit().then((app) => { +appInit().then(([app, server]) => { app.set('port', port); - /** - * Create HTTP server. - */ - - let server = http.createServer(app); - /** * Listen on provided port, on all network interfaces. */ diff --git a/lib/globals.js b/lib/globals.js index e4d7c0a..9fe4ae3 100644 --- a/lib/globals.js +++ b/lib/globals.js @@ -1,10 +1,12 @@ const utils = require('./utils'), + fsx = require('fs-extra'), pg = require('pg'); const settings = utils.readSettings('.'); Object.assign(exports, { settings: settings, + changelog: fsx.readFileSync('CHANGELOG.md', 'utf-8'), pgPool: new pg.Pool({ host: settings.postgres.host, port: settings.postgres.port, @@ -14,7 +16,7 @@ Object.assign(exports, { }), cookieInfo: { headline: 'This website uses cookies', - content: 'This website uses cookies to store your session data (like for bingo).', + content: "This website uses cookies to store your session data. No data is permanently stored.", onclick: 'acceptCookies()', id: 'cookie-container', button: 'All right!' diff --git a/package-lock.json b/package-lock.json index 3522a8c..69abc15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,11 @@ "integrity": "sha512-HJ7CfNHrfJLlNTzIEUTj43LNWGkqpRLxm3YjAlcD0ACydk9XynzYsCBHxut+iqt+1aBXkx9UP/w/ZqMr13XIzg==", "dev": true }, + "after": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", + "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=" + }, "ajv": { "version": "6.10.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz", @@ -219,6 +224,11 @@ "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=" }, + "arraybuffer.slice": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", + "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==" + }, "asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -258,6 +268,11 @@ "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=" }, + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -347,6 +362,11 @@ "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==" }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -402,6 +422,16 @@ } } }, + "base64-arraybuffer": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", + "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=" + }, + "base64id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", + "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=" + }, "basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -418,11 +448,24 @@ "tweetnacl": "^0.14.3" } }, + "better-assert": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", + "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=", + "requires": { + "callsite": "1.0.0" + } + }, "binary-extensions": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==" }, + "blob": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", + "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==" + }, "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", @@ -566,6 +609,11 @@ } } }, + "callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=" + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -752,11 +800,58 @@ "delayed-stream": "~1.0.0" } }, + "commander": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", + "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==" + }, + "component-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", + "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=" + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, + "component-inherit": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", + "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=" + }, + "compressible": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.17.tgz", + "integrity": "sha512-BGHeLCK1GV7j1bSmQQAi26X+GgWcTjLr/0tzSvMCl3LH1w1IJ4PFSPoV5316b30cneTziC+B1a+3OjoSUcQYmw==", + "requires": { + "mime-db": ">= 1.40.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1043,6 +1138,74 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, + "engine.io": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.3.2.tgz", + "integrity": "sha512-AsaA9KG7cWPXWHp5FvHdDWY3AMWeZ8x+2pUVLcn71qE5AtAzgGbxuclOytygskw8XGmiQafTmnI9Bix3uihu2w==", + "requires": { + "accepts": "~1.3.4", + "base64id": "1.0.0", + "cookie": "0.3.1", + "debug": "~3.1.0", + "engine.io-parser": "~2.1.0", + "ws": "~6.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "engine.io-client": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.3.2.tgz", + "integrity": "sha512-y0CPINnhMvPuwtqXfsGuWE8BB66+B6wTtCofQDRecMQPYX3MYUZXFNKDhdrSe3EVjgOu4V3rxdeqN/Tr91IgbQ==", + "requires": { + "component-emitter": "1.2.1", + "component-inherit": "0.0.3", + "debug": "~3.1.0", + "engine.io-parser": "~2.1.1", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "ws": "~6.1.0", + "xmlhttprequest-ssl": "~1.5.4", + "yeast": "0.1.2" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "engine.io-parser": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.1.3.tgz", + "integrity": "sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==", + "requires": { + "after": "0.8.2", + "arraybuffer.slice": "~0.0.7", + "base64-arraybuffer": "0.1.5", + "blob": "0.0.5", + "has-binary2": "~1.0.2" + } + }, "entities": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", @@ -1618,6 +1781,32 @@ } } }, + "express-minify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/express-minify/-/express-minify-1.0.0.tgz", + "integrity": "sha512-04/iYxB79jGeNZBBkbAW7L7FMG4Wtu78F1SayXIKiJD6MfqYnOI3DD8no7QOntgedYCdYUpj+Skg8QWR/2WnMQ==", + "requires": { + "clean-css": "^4.1.7", + "on-headers": "^1.0.1", + "uglify-js": "^3.0.28" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "uglify-js": { + "version": "3.5.14", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.5.14.tgz", + "integrity": "sha512-dgyjIw8KFK6AyVl5vm2tEqPewv5TKGEiiVFLI1LbF+oHua/Njd8tZk3lIbF1AWU1rNdEg7scaceADb4zqCcWXg==", + "requires": { + "commander": "~2.20.0", + "source-map": "~0.6.1" + } + } + } + }, "express-session": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.16.1.tgz", @@ -2579,6 +2768,26 @@ "ansi-regex": "^2.0.0" } }, + "has-binary2": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz", + "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", + "requires": { + "isarray": "2.0.1" + }, + "dependencies": { + "isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=" + } + } + }, + "has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=" + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -2706,6 +2915,11 @@ "repeating": "^2.0.0" } }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3639,6 +3853,11 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, + "object-component": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz", + "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=" + }, "object-copy": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", @@ -3814,6 +4033,22 @@ "error-ex": "^1.2.0" } }, + "parseqs": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", + "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", + "requires": { + "better-assert": "~1.0.0" + } + }, + "parseuri": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", + "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", + "requires": { + "better-assert": "~1.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4833,6 +5068,90 @@ } } }, + "socket.io": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.2.0.tgz", + "integrity": "sha512-wxXrIuZ8AILcn+f1B4ez4hJTPG24iNgxBBDaJfT6MsyOhVYiTXWexGoPkd87ktJG8kQEcL/NBvRi64+9k4Kc0w==", + "requires": { + "debug": "~4.1.0", + "engine.io": "~3.3.1", + "has-binary2": "~1.0.2", + "socket.io-adapter": "~1.1.0", + "socket.io-client": "2.2.0", + "socket.io-parser": "~3.3.0" + } + }, + "socket.io-adapter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz", + "integrity": "sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs=" + }, + "socket.io-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.2.0.tgz", + "integrity": "sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA==", + "requires": { + "backo2": "1.0.2", + "base64-arraybuffer": "0.1.5", + "component-bind": "1.0.0", + "component-emitter": "1.2.1", + "debug": "~3.1.0", + "engine.io-client": "~3.3.1", + "has-binary2": "~1.0.2", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "object-component": "0.0.3", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "socket.io-parser": "~3.3.0", + "to-array": "0.1.4" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "socket.io-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.0.tgz", + "integrity": "sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==", + "requires": { + "component-emitter": "1.2.1", + "debug": "~3.1.0", + "isarray": "2.0.1" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=" + } + } + }, "source-map": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", @@ -5402,6 +5721,11 @@ "os-tmpdir": "~1.0.2" } }, + "to-array": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", + "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=" + }, "to-fast-properties": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", @@ -5526,6 +5850,27 @@ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, + "uglify-es": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz", + "integrity": "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ==", + "requires": { + "commander": "~2.13.0", + "source-map": "~0.6.1" + }, + "dependencies": { + "commander": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.13.0.tgz", + "integrity": "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, "uglify-js": { "version": "2.8.29", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", @@ -5777,6 +6122,19 @@ "mkdirp": "^0.5.1" } }, + "ws": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz", + "integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==", + "requires": { + "async-limiter": "~1.0.0" + } + }, + "xmlhttprequest-ssl": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", + "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=" + }, "xtend": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", @@ -5817,6 +6175,11 @@ "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=" } } + }, + "yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=" } } } diff --git a/package.json b/package.json index 1df0a93..fe732bd 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "start": "node ./bin/www" }, "dependencies": { + "compression": "^1.7.4", "connect-pg-simple": "5.0.0", "cookie-parser": "1.4.4", "debug": "4.1.1", "express": "4.17.0", "express-compile-sass": "latest", "express-graphql": "0.8.0", + "express-minify": "^1.0.0", "express-session": "latest", "fs-extra": "8.0.1", "graphql": "14.3.0", @@ -25,7 +27,9 @@ "morgan": "1.9.1", "node-sass": "4.12.0", "pg": "7.11.0", - "pug": "2.0.3" + "pug": "2.0.3", + "socket.io": "^2.2.0", + "uglify-es": "^3.3.9" }, "devDependencies": { "eslint": "^5.16.0", diff --git a/public/javascripts/bingo-web.js b/public/javascripts/bingo-web.js index 3f95969..5d54fed 100644 --- a/public/javascripts/bingo-web.js +++ b/public/javascripts/bingo-web.js @@ -1,5 +1,344 @@ /* eslint-disable no-unused-vars, no-undef */ +class BingoGraphqlHelper { + + /** + * Sets the username for a user + * @param username {String} - the username + * @returns {Promise} + */ + static async setUsername(username) { + username = username.replace(/^\s+|\s+$/g, ''); + let uname = username.replace(/[\n\tšŸ‘‘šŸŒŸ]|^\s+|\s+$/gu, ''); + if (uname.length === username.length) { + let response = await postGraphqlQuery(` + mutation($username:String!) { + bingo { + setUsername(username: $username) { + id + username + } + } + }`, {username: username}, '/graphql?g=' + getLobbyParam()); + if (response.status === 200) { + return response.data.bingo.setUsername.username; + } else { + if (response.errors) + showError(response.errors[0].message); + else + showError(`Failed to submit username.`); + + console.error(response); + return false; + } + } else { + showError(`Your username contains illegal characters (${username.replace(uname, '')}).`); + } + } + + /** + * Creates a lobby via the graphql endpoint + * @returns {Promise} + */ + static async createLobby() { + let response = await postGraphqlQuery(` + mutation { + bingo { + createLobby { + id + } + } + } + `); + if (response.status === 200 && response.data.bingo.createLobby) { + insertParam('g', response.data.bingo.createLobby.id); + return true; + } else { + showError('Failed to create Lobby. HTTP ERROR: ' + response.status); + console.error(response); + return false; + } + } + + /** + * Leaves a lobby via the graphql endpoint + * @returns {Promise} + */ + static async leaveLobby() { + let response = await postGraphqlQuery(` + mutation($lobbyId:ID!){ + bingo { + mutateLobby(id:$lobbyId) { + leave + } + } + } + `, {lobbyId: getLobbyParam()}); + if (response.status === 200) { + insertParam('g', ''); + } else { + showError('Failed to leave lobby'); + console.error(response); + } + } + + /** + * Kicks a player + * @param pid + * @returns {Promise} + */ + static async kickPlayer(pid) { + let response = await postGraphqlQuery(` + mutation ($lobbyId: ID!, $playerId:ID!) { + bingo { + mutateLobby(id: $lobbyId) { + kickPlayer(pid: $playerId) { + id + } + } + } + } + `, {lobbyId: getLobbyParam(), playerId: pid}); + if (response.status === 200) { + let kickId = response.data.bingo.mutateLobby.kickPlayer.id; + document.querySelector(`.playerEntryContainer[b-pid='${kickId}'`).remove(); + } else { + showError('Failed to kick player!'); + console.error(response); + } + } + + /** + * Loads information about the rounds winner and the round stats. + * @returns {Promise} + */ + static async loadWinnerInfo() { + let response = await postGraphqlQuery(` + query($lobbyId:ID!) { + bingo { + lobby(id:$lobbyId) { + currentRound { + status + winner { + id + username + } + start + finish + } + } + } + }`, {lobbyId: getLobbyParam()}); + if (response.status === 200) { + let roundInfo = response.data.bingo.lobby.currentRound; + if (roundInfo.winner) + displayWinner(roundInfo); + else + window.location.reload(); + + } else { + console.error(response); + showError('Failed to get round information'); + } + } + + /** + * Loads the lobby wors in the words element via graphql. + * @returns {Promise} + */ + static async loadLobbyWords() { + let response = await postGraphqlQuery(` + query($lobbyId:ID!){ + bingo { + lobby(id:$lobbyId) { + words { + content + } + } + } + }`, {lobbyId: getLobbyParam()}); + + if (response.status === 200) { + let wordContainer = document.querySelector('#bingo-words'); + + if (wordContainer) + wordContainer.innerHTML = ` + ${response.data.bingo.lobby.words.map(x => x.content).join('')}`; + } else { + showError('Failed to load words.'); + } + } + + /** + * Sets the settings of the lobby + * @param words + * @param gridSize + * @returns {Promise} + */ + static async setLobbySettings(words, gridSize) { + gridSize = Number(gridSize); + let response = await postGraphqlQuery(` + mutation ($lobbyId: ID!, $words: [String!]!, $gridSize:Int!) { + bingo { + mutateLobby(id: $lobbyId) { + setWords(words: $words) { + words { + content + } + } + setGridSize(gridSize: $gridSize) { + gridSize + } + } + } + } + `, {lobbyId: getLobbyParam(), words: words, gridSize: gridSize}); + if (response.status === 200) { + return response.data.bingo.mutateLobby.setWords.words; + } else { + console.error(response); + if (response.errors) + showError(response.errors[0].message); + else + showError('Error when submitting lobby settings.'); + } + } + + /** + * Refreshes the bingo chat + * @returns {Promise} + */ + static async refreshChat() { + try { + let response = await postGraphqlQuery(` + query($lobbyId:ID!){ + bingo { + player { + id + } + lobby(id:$lobbyId) { + messages { + id + type + htmlContent + content + author { + id + username + } + } + } + } + }`, {lobbyId: getLobbyParam()}); + if (response.status === 200) { + let messages = response.data.bingo.lobby.messages; + for (let message of messages) + if (!document.querySelector(`.chatMessage[msg-id="${message.id}"]`)) + addChatMessage(message, response.data.bingo.player.id); + } else { + showError('Failed to refresh messages'); + console.error(response); + } + } catch (err) { + showError('Failed to refresh messages'); + console.error(err); + } + } +} + +class ChatInput { + constructor(element) { + this.element = element; + this.mode = 0; + this.user = 0; + this.editId = null; + } + + + /** + * Sends a message to the chat + * @returns {Promise} + */ + async sendChatMessage() { + if (this.element.value && this.element.value.length > 0) { + let message = this.element.value; + this.element.value = ''; + + if (this.mode === 0) { + if (/^\/\.*/g.test(message)) + await executeCommand(message); + else + socket.emit('message', message); + } else { + socket.emit('messageEdit', message, this.editId); + this.setNormal(); + } + } else if (this.mode === 1) { + socket.emit('messageDelete', this.editId); + this.setNormal(); + } + } + + /** + * Returns the last message + * @param [before] {Number} - last message before a specific id + * @param [after] {Number} - last message after a specific id + * @returns {Element|*} + * @private + */ + _getMessage(before, after) { + let messages = [...document.querySelectorAll(`.chatMessage[msg-pid='${this.user}']`)]; + let message = null; + + if (before) + message = messages.filter(x => Number(x.getAttribute('msg-id') < before)).slice(-1); + else if (after) + message = messages.filter(x => Number(x.getAttribute('msg-id') > after)); + else + message = messages.slice(-1); + if (message.length > 0) + return message[0]; + } + + setEdit(after) { + let message = null; + let lastMessage = document.querySelector(`.chatMessage[msg-id='${this.editId}']`); + if (this.mode === 0 && !after) { + this.mode = 1; + message = this._getMessage(); + } else if (after && this.mode === 1) { + message = this._getMessage(null, this.editId); + } else if (this.mode === 1) { + message = this._getMessage(this.editId); + } + if (message) { + message.classList.add('selected'); + if (lastMessage) + lastMessage.classList.remove('selected'); + + this.element.value = message.getAttribute('msg-raw'); + this.editId = Number(message.getAttribute('msg-id')); + let chatContent = document.querySelector('#chat-content'); + chatContent.scrollTop = message.offsetTop; + } else { + this.setNormal(); + } + } + + setNormal() { + if (this.mode !== 0) { + this.element.value = ''; + this.mode = 0; + let lastMessage = document.querySelector(`.chatMessage[msg-id='${this.editId}']`); + if (lastMessage) + lastMessage.classList.remove('selected'); + this.editId = null; + let chatContent = document.querySelector('#chat-content'); + chatContent.scrollTop = chatContent.scrollHeight; + } + } +} + /** * Returns the value of the url-param 'g' * @returns {string} @@ -8,19 +347,7 @@ function getLobbyParam() { let matches = window.location.href.match(/\??&?g=(\d+)/); if (matches) return matches[1]; - else - return ''; -} - -/** - * REturns the value of the r url param - * @returns {string} - */ -function getRoundParam() { - let matches = window.location.href.match(/\??&?r=(\d+)/); - if (matches) - return matches[1]; - else + else return ''; } @@ -48,46 +375,13 @@ async function submitUsername() { let username = unameInput.value; if (username.length > 1 && username.length <= 30) { - return await setUsername(username); + return await BingoGraphqlHelper.setUsername(username); } else { showError('You need to provide a username (min. 2 characters, max. 30)!'); return false; } } -/** - * Sets the username for a user - * @param username {String} - the username - * @returns {Promise} - */ -async function setUsername(username) { - username = username.replace(/^\s+|\s+$/g, ''); - let uname = username.replace(/[\n\tšŸ‘‘šŸŒŸ]|^\s+|\s+$/gu, ''); - if (uname.length === username.length) { - let response = await postGraphqlQuery(` - mutation($username:String!) { - bingo { - setUsername(username: $username) { - id - username - } - } - }`, {username: username}, '/graphql?g='+getLobbyParam()); - if (response.status === 200) { - return response.data.bingo.setUsername.username; - } else { - if (response.errors) - showError(response.errors[0].message); - else - showError(`Failed to submit username.`); - console.error(response); - return false; - } - } else { - showError(`Your username contains illegal characters (${username.replace(uname, '')}).`); - } -} - /** * Function that displays the ping in the console. * @returns {Promise} @@ -120,25 +414,8 @@ async function joinLobby() { * @returns {Promise} */ async function createLobby() { - if (await submitUsername()) { - let response = await postGraphqlQuery(` - mutation { - bingo { - createLobby { - id - } - } - } - `); - if (response.status === 200 && response.data.bingo.createLobby) { - insertParam('g', response.data.bingo.createLobby.id); - return true; - } else { - showError('Failed to create Lobby. HTTP ERROR: ' + response.status); - console.error(response); - return false; - } - } + if (await submitUsername()) + await BingoGraphqlHelper.createLobby(); } /** @@ -146,21 +423,7 @@ async function createLobby() { * @returns {Promise} */ async function leaveLobby() { - let response = await postGraphqlQuery(` - mutation($lobbyId:ID!){ - bingo { - mutateLobby(id:$lobbyId) { - leave - } - } - } - `, {lobbyId: getLobbyParam()}); - if (response.status === 200) { - insertParam('g', ''); - } else { - showError('Failed to leave lobby'); - console.error(response); - } + await BingoGraphqlHelper.leaveLobby(); } /** @@ -169,24 +432,7 @@ async function leaveLobby() { * @returns {Promise} */ async function kickPlayer(pid) { - let response = await postGraphqlQuery(` - mutation ($lobbyId: ID!, $playerId:ID!) { - bingo { - mutateLobby(id: $lobbyId) { - kickPlayer(pid: $playerId) { - id - } - } - } - } - `, {lobbyId: getLobbyParam(), playerId: pid}); - if (response.status === 200) { - let kickId = response.data.bingo.mutateLobby.kickPlayer.id; - document.querySelector(`.playerEntryContainer[b-pid='${kickId}'`).remove(); - } else { - showError('Failed to kick player!'); - console.error(response); - } + await BingoGraphqlHelper.kickPlayer(pid); } /** @@ -197,11 +443,12 @@ async function executeCommand(message) { function reply(content) { addChatMessage({content: content, htmlContent: content, type: 'INFO'}); } + let jsStyle = document.querySelector('#js-style'); message = message.replace(/\s+$/g, ''); let command = /(\/\w+) ?(.*)?/g.exec(message); if (command && command.length >= 2) { - switch(command[1]) { + switch (command[1]) { case '/help': reply(`
Commands:
@@ -228,7 +475,7 @@ async function executeCommand(message) { break; case '/username': if (command[2]) { - let uname = await setUsername(command[2]); + let uname = await BingoGraphqlHelper.setUsername(command[2]); reply(`Your username is ${uname} now.`); } else { reply('You need to provide a username'); @@ -243,89 +490,18 @@ async function executeCommand(message) { } } -/** - * Sends a message to the chat - * @returns {Promise} - */ -async function sendChatMessage() { - let messageInput = document.querySelector('#chat-input'); - if (messageInput.value && messageInput.value.length > 0) { - let message = messageInput.value; - messageInput.value = ''; - if (/^\/\.*/g.test(message)) { - await executeCommand(message); - } else { - let response = await postGraphqlQuery(` - mutation($lobbyId:ID!, $message:String!){ - bingo { - mutateLobby(id:$lobbyId) { - sendMessage(message:$message) { - id - htmlContent - type - author { - username - } - } - } - } - }`, {message: message, lobbyId: getLobbyParam()}); - if (response.status === 200) { - addChatMessage(response.data.bingo.mutateLobby.sendMessage); - } else { - messageInput.value = message; - console.error(response); - showError('Error when sending message.'); - } - } - } -} - -/** - * Sets the words for the lobby - * @param words - * @param gridSize - * @returns {Promise} - */ -async function setLobbySettings(words, gridSize) { - gridSize = Number(gridSize); - let response = await postGraphqlQuery(` - mutation ($lobbyId: ID!, $words: [String!]!, $gridSize:Int!) { - bingo { - mutateLobby(id: $lobbyId) { - setWords(words: $words) { - words { - content - } - } - setGridSize(gridSize: $gridSize) { - gridSize - } - } - } - } - `, {lobbyId: getLobbyParam(), words: words, gridSize: gridSize}); - if (response.status === 200) { - return response.data.bingo.mutateLobby.setWords.words; - } else { - console.error(response); - if (response.errors) - showError(response.errors[0].message); - else - showError('Error when submitting lobby settings.'); - } -} - /** * Starts a new round of bingo * @returns {Promise} */ async function startRound() { + let roundStart = document.querySelector('#button-round-start'); let textinput = document.querySelector('#input-bingo-words'); let words = getLobbyWords(); if (words.length > 0) { + roundStart.setAttribute('class', 'pending'); let gridSize = document.querySelector('#input-grid-size').value || 3; - let resultWords = await setLobbySettings(words, gridSize); + let resultWords = await BingoGraphqlHelper.setLobbySettings(words, gridSize); if (resultWords) { textinput.value = resultWords.map(x => x.content).join('\n'); let response = await postGraphqlQuery(` @@ -345,6 +521,7 @@ async function startRound() { console.error(response); showError('Error when starting round.'); } + roundStart.setAttribute('class', ''); } } else { throw new Error('No words provided.'); @@ -372,31 +549,8 @@ async function submitFieldToggle(wordPanel) { let column = Number(wordPanel.getAttribute('b-column')); let wordClass = wordPanel.getAttribute('class'); wordPanel.setAttribute('class', wordClass + ' pending'); - let response = await postGraphqlQuery(` - mutation($lobbyId:ID!, $row:Int!, $column:Int!){ - bingo { - mutateLobby(id:$lobbyId) { - toggleGridField(location:{row:$row, column:$column}) { - submitted - grid { - bingo - } - } - } - } - }`, {lobbyId: getLobbyParam(), row: row, column: column}); - wordPanel.setAttribute('class', wordClass); - if (response.status === 200) { - wordPanel.setAttribute('b-sub', response.data.bingo.mutateLobby.toggleGridField.submitted); - if (response.data.bingo.mutateLobby.toggleGridField.grid.bingo) - document.querySelector('#container-bingo-button').setAttribute('class', ''); - else - document.querySelector('#container-bingo-button').setAttribute('class', 'hidden'); - } else { - console.error(response); - showError('Error when submitting field toggle'); - } + socket.emit('fieldToggle', {row: row, column: column}); } /** @@ -425,7 +579,7 @@ async function setRoundFinished() { /** * Submits bingo - * @returns {Promise} + * @returns {Promise} */ async function submitBingo() { let response = await postGraphqlQuery(` @@ -446,26 +600,42 @@ async function submitBingo() { }`, {lobbyId: getLobbyParam()}); if (response.status === 200 && response.data.bingo.mutateLobby.submitBingo) { - let round = response.data.bingo.mutateLobby.submitBingo; - displayWinner(round); + return true; } else { console.error(response); showError('Failed to submit bingo'); } } +async function onInputKeypress(e) { + switch (e.which) { + case 13: + await chatInput.sendChatMessage(); + break; + case 38: + chatInput.setEdit(); + break; + case 27: + chatInput.setNormal(); + break; + case 40: + chatInput.setEdit(true); + } +} + /** * Displays the winner of the game in a popup. - * @param roundInfo {Object} - the round object as returned by graphql + * @param winner {Object} - the round object as returned by graphql */ -function displayWinner(roundInfo) { - let name = roundInfo.winner.username; +function displayWinner(winner, isPlayer) { + let name = winner.username; let winnerDiv = document.createElement('div'); let greyoverDiv = document.createElement('div'); winnerDiv.setAttribute('class', 'popup'); winnerDiv.innerHTML = ` -

${name} has won!

- +

${name} has won!

+

${isPlayer? 'Congratulations!':'And you lost. How does this make you feel?'}

+
`; greyoverDiv.setAttribute('class', 'greyover'); document.body.append(greyoverDiv); @@ -502,67 +672,45 @@ async function statusWrap(func) { indicator.setAttribute('status', 'idle'); }, 1000); } catch (err) { - showError(err? err.message : 'Unknown error'); + showError(err ? err.message : 'Unknown error'); } } -/** - * Loads information about the rounds winner and the round stats. - * @returns {Promise} - */ -async function loadWinnerInfo() { - let response = await postGraphqlQuery(` - query($lobbyId:ID!) { - bingo { - lobby(id:$lobbyId) { - currentRound { - status - winner { - id - username - } - start - finish - } - } - } - }`, {lobbyId: getLobbyParam()}); - if (response.status === 200) { - let roundInfo = response.data.bingo.lobby.currentRound; - if (roundInfo.winner) - displayWinner(roundInfo); - else - window.location.reload(); - } else { - console.error(response); - showError('Failed to get round information'); - } -} /** * Adds a message to the chat * @param messageObject {Object} - the message object returned by graphql - * @param player {Number} - the id of the player + * @param [player] {Number} - the id of the player */ function addChatMessage(messageObject, player) { let msgSpan = document.createElement('span'); msgSpan.setAttribute('class', 'chatMessage'); msgSpan.setAttribute('msg-type', messageObject.type); msgSpan.setAttribute('msg-id', messageObject.id); + msgSpan.setAttribute('msg-raw', messageObject.content); - if (messageObject.type === "USER") + if (messageObject.type === "USER") { msgSpan.innerHTML = ` ${messageObject.author.username}: ${messageObject.htmlContent}`; - else - msgSpan.innerHTML = ` - ${messageObject.htmlContent}`; + msgSpan.setAttribute('msg-pid', messageObject.author.id); + } else { + msgSpan.innerHTML = `${messageObject.htmlContent}`; + } - if (messageObject.author && messageObject.author.id !== player) + + if (messageObject.type === 'USER' && messageObject.author && messageObject.author.id !== player) spawnNotification(messageObject.content, messageObject.author.username); + let chatContent = document.querySelector('#chat-content'); chatContent.appendChild(msgSpan); chatContent.scrollTop = chatContent.scrollHeight; // auto-scroll to bottom + + let msgImg = msgSpan.querySelector('img'); + if (msgImg) + msgImg.onload = () => { + chatContent.scrollTop = chatContent.scrollHeight; + }; } /** @@ -577,231 +725,157 @@ function addPlayer(player, options) { if (options.isAdmin && player.id !== options.admin) playerContainer.innerHTML = ``; + playerContainer.innerHTML += `${player.username}`; if (player.id === options.admin) playerContainer.innerHTML += " šŸ‘‘"; + document.querySelector('#player-list').appendChild(playerContainer); } /** - * Refreshes the bingo chat - * @returns {Promise} + * Returns the current player id + * @returns {Promise<*>} */ -async function refreshChat() { - try { - let response = await postGraphqlQuery(` - query($lobbyId:ID!){ - bingo { - player { - id - } - lobby(id:$lobbyId) { - messages { - id - type - htmlContent - content - author { - id - username - } - } - } - } - }`, {lobbyId: getLobbyParam()}); - if (response.status === 200) { - let messages = response.data.bingo.lobby.messages; - for (let message of messages) - if (!document.querySelector(`.chatMessage[msg-id="${message.id}"]`)) - addChatMessage(message, response.data.bingo.player.id); - } else { - showError('Failed to refresh messages'); - console.error(response); +async function getPlayerInfo() { + let result = await postGraphqlQuery(` + query ($lobbyId:ID!) { + bingo { + player { + id + username } - } catch (err) { - showError('Failed to refresh messages'); - console.error(err); - } -} - -/** - * Refreshes the player list - * @returns {Promise} - */ -async function refreshPlayers() { - try { - let response = await postGraphqlQuery(` - query ($lobbyId: ID!) { - bingo { - player { - id - } - lobby(id: $lobbyId) { - players { - id - username - wins(lobbyId: $lobbyId) - } - admin { - id - } - } + lobby(id:$lobbyId) { + id + admin { + id } } - `, {lobbyId: getLobbyParam()}); - if (response.status === 200) { - let players = response.data.bingo.lobby.players; - let adminId = response.data.bingo.lobby.admin.id; - let isAdmin = response.data.bingo.player.id === adminId; - for (let player of players) - if (!document.querySelector(`.playerEntryContainer[b-pid="${player.id}"]`)) - addPlayer(player, {admin: adminId, isAdmin: isAdmin}); - } else { - showError('Failed to refresh players'); - console.error(response); - } - } catch (err) { - showError('Failed to refresh players'); - console.error(err); + } + }`, {lobbyId: getLobbyParam()}); + if (result.status === 200) { + let bingoData = result.data.bingo; + return { + id: bingoData.player.id, + username: bingoData.player.username, + isAdmin: bingoData.lobby.admin.id === bingoData.player.id + }; + } else { + showError('Failed to fetch player Id'); + console.error(result); } } /** - * Removes players that are not existent in the player array - * @param players {Array} - player id response of graphql + * Initializes all socket events + * @param data */ -function removeLeftPlayers(players) { - for (let playerEntry of document.querySelectorAll('.playerEntryContainer')) - if (!players.find(x => (x.id === playerEntry.getAttribute('b-pid')))) - playerEntry.remove(); -} +function initSocketEvents(data) { + let playerId = data.id; + let indicator = document.querySelector('#status-indicator'); + indicator.setAttribute('status', 'error'); -/** - * Refreshes if a player-refresh is needed. - * Removes players that are not in the lobby anyomre. - * @param players - */ -function checkPlayerRefresh(players) { - let playerRefresh = false; - removeLeftPlayers(players); - for (let player of players) - if (!document.querySelector(`.playerEntryContainer[b-pid="${player.id}"]`)) - playerRefresh = true; - if (playerRefresh) - statusWrap(refreshPlayers); -} + socket.on('connect', () => { + indicator.setAttribute('socket-status', 'connected'); + }); -/** - * Checks if messages need to be refreshed and does it if it needs to. - * @param messages - */ -function checkMessageRefresh(messages) { - let messageRefresh = false; - for (let message of messages) - if (!document.querySelector(`.chatMessage[msg-id="${message.id}"]`)) - messageRefresh = true; - if (messageRefresh) - statusWrap(refreshChat); -} + socket.on('reconnect', async () => { + indicator.setAttribute('socket-status', 'connected'); + await BingoGraphqlHelper.refreshChat(); + }); -/** - * refreshes the lobby and calls itself with a timeout - * @returns {Promise} - */ -async function refreshLobby() { - try { - let response = await postGraphqlQuery(` - query($lobbyId:ID!){ - bingo { - lobby(id:$lobbyId) { - players { - id - } - messages { - id - } - currentRound { - id - status - } - words { - content - } - } - } - }`, {lobbyId: getLobbyParam()}); - if (response.status === 200) { - let {players, messages, currentRound} = response.data.bingo.lobby; - checkPlayerRefresh(players); - checkMessageRefresh(messages); - let wordContainer = document.querySelector('#bingo-words'); + socket.on('disconnect', () => { + indicator.setAttribute('socket-status', 'disconnected'); + showError('Disconnected from socket!'); + }); - if (wordContainer) - wordContainer.innerHTML = ` - ${response.data.bingo.lobby.words.map(x => x.content).join('')}`; + socket.on('reconnecting', () => { + indicator.setAttribute('socket-status', 'reconnecting'); + }); - if (currentRound && currentRound.status === 'ACTIVE' && Number(currentRound.id) !== Number(getRoundParam())) { - insertParam('r', currentRound.id); - spawnNotification('The round started!', 'Bingo'); - } + socket.on('error', (error) => { + showError(`Socket Error: ${JSON.stringify(error)}`); + }); + + socket.on('userError', (error) => { + showError(error); + }); + socket.on('message', msg => { + addChatMessage(msg, playerId); + }); + + socket.on('messageEdit', msg => { + let message = document.querySelector(`.chatMessage[msg-id='${msg.id}']`); + message.setAttribute('msg-raw', msg.content); + message.querySelector('.chatMessageContent').innerHTML = msg.htmlContent; + let chatContent = document.querySelector('#chat-content'); + let msgImg = message.querySelector('img'); + if (msgImg) + msgImg.onload = () => { + chatContent.scrollTop = chatContent.scrollHeight; + }; + }); + + socket.on('messageDelete', msgId => { + document.querySelector(`.chatMessage[msg-id='${msgId}'`).remove(); + }); + + socket.on('statusChange', (status, winner) => { + if (status === 'FINISHED' && winner) { + if (document.querySelector('#container-bingo-round')) + displayWinner(winner, winner.id === Number(playerId)); } else { - showError('Failed to refresh lobby'); - console.error(response); + window.location.reload(); } - } catch (err) { - showError('Failed to refresh lobby'); - console.error(err); - } finally { - setTimeout(refreshLobby, 1000); - } + }); + + socket.on('playerJoin', (playerObject) => { + addPlayer(playerObject, data); + }); + + socket.on('playerLeave', (playerId) => { + document.querySelector(`.playerEntryContainer[b-pid='${playerId}']`).remove(); + }); + + socket.on('usernameChange', (playerObject) => { + document.querySelector(`.playerEntryContainer[b-pid='${playerObject.id}'] .playerNameSpan`).innerText = playerObject.username; + let msgUsernames = document.querySelectorAll(`.chatMessage[msg-pid='${playerObject.id}'] .chatUsername`); + for (let element of msgUsernames) + element.innerText = `${playerObject.username}: `; + }); + + socket.on('wordsChange', async () => { + try { + await BingoGraphqlHelper.loadLobbyWords(); + } catch (err) { + showError('Failed to load new lobby words.'); + } + }); + + socket.on('fieldChange', (field) => { + let wordPanel = document.querySelector(`.bingoWordPanel[b-row='${field.row}'][b-column='${field.column}']`); + wordPanel.setAttribute('b-sub', field.submitted); + wordPanel.setAttribute('class', 'bingoWordPanel'); + if (field.bingo) + document.querySelector('#container-bingo-button').setAttribute('class', ''); + else + document.querySelector('#container-bingo-button').setAttribute('class', 'hidden'); + }); } /** - * Checks the status of the lobby and the current round. - * @returns {Promise} + * Initializes the lobby refresh with sockets or graphql */ -async function refreshRound() { - let roundOver = false; - try { - let response = await postGraphqlQuery(` - query($lobbyId:ID!) { - bingo { - lobby(id:$lobbyId) { - players { - id - } - messages { - id - } - currentRound { - id - status - } - } - } - }`, {lobbyId: getLobbyParam()}); - if (response.status === 200) { - let {players, messages, currentRound} = response.data.bingo.lobby; - - checkPlayerRefresh(players); - checkMessageRefresh(messages); - if (!currentRound || currentRound.status === "FINISHED") { - roundOver = true; - await loadWinnerInfo(); - } - } else { - showError('Failed to refresh round'); - console.error(response); - } - } catch (err) { - showError('Failed to refresh round'); - console.error(err); - } finally { - if (!roundOver) - setTimeout(refreshRound, 1000); - } +function initRefresh() { + getPlayerInfo().then((data) => { + socket = new SimpleSocket(`/bingo/${getLobbyParam()}`, {playerId: data.id}); + initSocketEvents(data); + chatInput.user = data.id; + }); + let chatContent = document.querySelector('#chat-content'); + chatContent.scrollTop = chatContent.scrollHeight; } window.addEventListener("unhandledrejection", function (promiseRejectionEvent) { @@ -811,12 +885,14 @@ window.addEventListener("unhandledrejection", function (promiseRejectionEvent) { // prevent ctrl + s window.addEventListener("keydown", async (e) => { - if (e.which === 83 && (navigator.platform.match("Mac") ? e.metaKey : e.ctrlKey)) { + if (e.which === 83 && (navigator.platform.match("Mac") ? e.metaKey : e.ctrlKey) && document.querySelector('#input-bingo-words')) { e.preventDefault(); - if (document.querySelector('#input-bingo-words')) { - let gridSize = document.querySelector('#input-grid-size').value || 3; - await statusWrap(async () => await setLobbySettings(getLobbyWords(), gridSize)); - } + let gridSize = document.querySelector('#input-grid-size').value || 3; + await statusWrap(async () => await BingoGraphqlHelper.setLobbySettings(getLobbyWords(), gridSize)); + } + if ([40, 38, 27].includes(e.which) && e.target === document.querySelector('#chat-Input')) { + e.preventDefault(); + await onInputKeypress(e); } }, false); @@ -829,4 +905,8 @@ window.onload = async () => { showError(err.message); } } + chatInput = new ChatInput(document.querySelector('#chat-input')); }; + +let socket = null; +let chatInput = null; diff --git a/public/javascripts/common.js b/public/javascripts/common.js index c74fd23..f5cdcb0 100644 --- a/public/javascripts/common.js +++ b/public/javascripts/common.js @@ -1,5 +1,47 @@ /* eslint-disable no-unused-vars, no-undef */ +/** + * A simple WebSocket + */ +class SimpleSocket { + /** + * Constructor + * @param url + * @param emitContext + */ + constructor(url, emitContext) { + this.socket = io.connect(url); + this.context = emitContext; + } + + /** + * Wrapper for the emit function + * @param event + * @param data + * @param callback + */ + emit(event, data, callback) { + this.socket.emit(event, this.context, data, callback); + } + + /** + * Wrapper for on event function + * @param event + * @param callback + */ + on(event, callback) { + this.socket.on(event, callback); + } + + /** + * Returns if the socket is connected + * @returns {*|boolean} + */ + get connected() { + return this.socket.connected; + } +} + /** * HTTP POST to an url with a post body * @param url {String} - the url to post to @@ -122,9 +164,15 @@ async function indicateStatus(func, indicatorSelector) { statusIndicator.setAttribute('status', 'success'); else statusIndicator.setAttribute('status', 'error'); + setTimeout(() => { + statusIndicator.setAttribute('status', ''); + }, 1000); } catch (err) { console.error(err); statusIndicator.setAttribute('status', 'error'); + setTimeout(() => { + statusIndicator.setAttribute('status', ''); + }, 1000); } } diff --git a/public/stylesheets/sass/bingo/style.sass b/public/stylesheets/sass/bingo/style.sass index 8ab627d..d154b6a 100644 --- a/public/stylesheets/sass/bingo/style.sass +++ b/public/stylesheets/sass/bingo/style.sass @@ -1,31 +1,46 @@ @import ../mixins @import ../vars -//@media(max-device-width: 641px) +@media(max-device-height: 500px) + div#container-winner + margin: 5% auto !important + +@media(max-device-width: 600px) + div#container-bingo-lobby + grid-template: 0 10% 85% 5% / 0 0 0 100% 0 !important + + div#container-lobby-settings + display: none !important + + div#container-players + display: none !important + //@media(min-device-width: 641px) .popup - @include default-element position: fixed - display: grid - height: calc(50% - 1rem) - width: calc(40% - 1rem) - top: 25% - left: 30% - text-align: center - vertical-align: middle - padding: 1rem + top: 0 + left: 0 + height: 100% + width: 100% z-index: 1000 - grid-template: 60% 40% / 100% - h1 - @include gridPosition(1, 2, 1, 1) - button - margin: 1rem - font-size: 2rem - @include gridPosition(2, 3, 1, 1) + #container-winner + @include default-element + position: relative + margin: 20% auto + text-align: center + width: 50% + + h1 + @include gridPosition(1, 2, 1, 1) + + button + margin: 1rem + font-size: 2rem + @include gridPosition(2, 3, 1, 1) .greyover width: 100% @@ -74,8 +89,12 @@ height: auto border-radius: 0.2em +.chatMessage.selected + background-color: lighten($primary, 15%) + #container-chat height: calc(100% - 2px) + position: relative #chat-content height: calc(100% - 3rem) width: calc(100% - 2px) @@ -222,29 +241,55 @@ #container-bingo-create display: grid - grid-template: 5% 10% 10% 70% 5% /10% 80% 10% + grid-template: 5% 45% 45% 5% /5% 35% 5% 50% 5% height: 100% width: 100% #username-form @include gridPosition(2, 3, 2, 3) margin: auto + position: relative + + h2 + text-align: center + margin: 0 0 1rem 0 + + > * + margin: auto + width: 100% + - .statusIndicator - height: 1em - width: 1em - display: inline-block - margin: auto 1em + #submit-username[status='success'] + background-color: $success + transition-duration: 1s - #input-username - margin: 0 0 0 3em + #submit-username[status='pending'] + transition-duration: 1s + background-color: $pending + animation-name: pulse-opacity + animation-duration: 4s + animation-iteration-count: infinite #lobby-form - @include gridPosition(3, 5, 2, 3) - margin: 10% auto + @include gridPosition(3, 4, 2, 3) + margin: auto + width: 100% button width: 100% + margin: auto + + button.inactive + background-color: lighten($primary, 10%) + + #changelog + @include gridPosition(2, 4, 4, 5) + height: 100% + width: calc(100% - 2rem) + overflow-y: auto + margin: 0 0.5rem + padding: 0 0.5rem + box-shadow: inset 0 0 1rem darken($primary, 10%) #container-bingo-lobby @include fillWindow @@ -256,7 +301,7 @@ #lobby-title @include gridPosition(2, 3, 2, 5) - margin: auto + margin: 0.5rem auto #container-players @include gridPosition(3, 4, 2, 3) diff --git a/public/stylesheets/sass/classes.sass b/public/stylesheets/sass/classes.sass index 6172c8d..cc4e442 100644 --- a/public/stylesheets/sass/classes.sass +++ b/public/stylesheets/sass/classes.sass @@ -7,14 +7,6 @@ .hidden display: None !important -.popup - height: 60% - width: 40% - z-index: 1000 - position: fixed - top: 20% - left: 30% - .grid display: grid @@ -45,6 +37,21 @@ animation-duration: 5s animation-iteration-count: infinite +.socketStatusIndicator[socket-status='connected'] + background-color: $success + +.socketStatusIndicator[socket-status='reconnecting'] + background-color: mix($pending, $error) + animation-name: pulse-opacity + animation-duration: 5s + animation-iteration-count: infinite + +.socketStatusIndicator[socket-status='disconnected'] + background-color: $error + animation-name: pulse-opacity + animation-duration: 2s + animation-iteration-count: infinite + .pending background-color: $pending !important animation-name: pulse-opacity diff --git a/routes/bingo.js b/routes/bingo.js index 26d195d..978bfe7 100644 --- a/routes/bingo.js +++ b/routes/bingo.js @@ -4,7 +4,10 @@ const express = require('express'), mdEmoji = require('markdown-it-emoji'), mdMark = require('markdown-it-mark'), mdSmartarrows = require('markdown-it-smartarrows'), - md = require('markdown-it')() + md = require('markdown-it')({ + linkify: true, + typographer: true + }) .use(mdEmoji) .use(mdMark) .use(mdSmartarrows), @@ -12,6 +15,7 @@ const express = require('express'), globals = require('../lib/globals'); let pgPool = globals.pgPool; +let sockets = {}; /** * Class to manage the bingo data in the database. @@ -138,7 +142,16 @@ class BingoDataManager { * @returns {Promise<*>} */ async updateLobbyExpiration(lobbyId) { - return await this._queryDatabase(this.queries.updateLobbyExpire.sql, [lobbyId]); + return await this._queryFirstResult(this.queries.updateLobbyExpire.sql, [lobbyId]); + } + + /** + * Returns all lobby ids + * @returns {Promise<*>} + */ + async getLobbyIds() { + let results = await this._queryAllResults(this.queries.getLobbyIds.sql, []); + return results.map(x => x.id); } /** @@ -430,6 +443,34 @@ class BingoDataManager { return await this._queryFirstResult(this.queries.addUserMessage.sql, [playerId, lobbyId, messageContent]); } + /** + * Edits a message + * @param messageId {Number} - the id of the message + * @param messageContent {String} - the new content of the message + * @returns {Promise<*>} + */ + async editMessage(messageId, messageContent) { + return await this._queryFirstResult(this.queries.editMessage.sql, [messageId, messageContent]); + } + + /** + * Deletes a message + * @param messageId {Number} - the id of the message + * @returns {Promise<*>} + */ + async deleteMessage(messageId) { + return await this._queryFirstResult(this.queries.deleteMessage.sql, [messageId]); + } + + /** + * Returns the data of a message + * @param messageId {Number} - the id of the message + * @returns {Promise<*>} + */ + async getMessageData(messageId) { + return await this._queryFirstResult(this.queries.getMessageData.sql, [messageId]); + } + /** * Adds a message of type "INFO" to the lobby * @param lobbyId {Number} - the id of the lobby @@ -650,12 +691,12 @@ class GridWrapper { let gridField = new GridFieldWrapper(result); let username = await (await this.player()).username(); let word = await gridField.word.content(); + let lobbyWrapper = await this.lobby(); + if (gridField.submitted) - await bdm.addInfoMessage(this.lobbyId, - `${username} toggled "${word}"`); + await lobbyWrapper.addInfoMessage(`${username} toggled "${word}"`); else - await bdm.addInfoMessage(this.lobbyId, - `${username} untoggled "${word}"`); + await lobbyWrapper.addInfoMessage(`${username} untoggled "${word}"`); return gridField; } } @@ -668,7 +709,7 @@ class MessageWrapper { constructor(row) { this.id = row.id; this.content = row.content; - this.htmlContent = md.renderInline(this.content); + this.htmlContent = md.renderInline(preMarkdownParse(this.content)); this.author = new PlayerWrapper(row.player_id); this.lobby = new LobbyWrapper(row.lobby_id); this.type = row.type; @@ -881,6 +922,7 @@ class RoundWrapper { let updateResult = await bdm.setRoundWinner(this.id, winnerId); if (updateResult) await this.setFinished(); + (await this.lobby()).socket.emit('statusChange', 'FINISHED', await resolvePlayer(new PlayerWrapper(winnerId))); return true; } } @@ -894,6 +936,7 @@ class LobbyWrapper { */ constructor(id, row) { this.id = id; + this.socket = sockets[id]; this._infoLoaded = false; if (row) this._assignProperties(row); @@ -907,7 +950,7 @@ class LobbyWrapper { */ async _loadLobbyInfo(force) { if (!this._infoLoaded && !force) { - let row = await bdm.getLobbyInfo(this.id); + let row = await bdm.updateLobbyExpiration(this.id); this._assignProperties(row); } } @@ -928,6 +971,14 @@ class LobbyWrapper { } } + /** + * Emits an event is a socket exists for the lobby + */ + emit() { + if (this.socket) + this.socket.emit(...arguments); + } + /** * Returns if the lobby exists (based on one loaded attribute) * @returns {Promise} @@ -1061,6 +1112,7 @@ class LobbyWrapper { await this._createRound(); await this._createGrids(); await this.setRoundStatus('ACTIVE'); + this.emit('statusChange', 'ACTIVE'); } } @@ -1115,6 +1167,7 @@ class LobbyWrapper { await this.addWord(word); for (let word of removedWords) await this.removeWord(word.id); + this.emit('wordsChange'); } } @@ -1144,6 +1197,16 @@ class LobbyWrapper { }; } + /** + * Adds an info message and emits the message event. + * @param message {String} - the info messages content + * @returns {Promise} + */ + async addInfoMessage(message) { + let result = await bdm.addInfoMessage(this.id, message); + this.emit('message', await resolveMessage(new MessageWrapper(result))); + } + /** * Adds a player to the lobby. * @param playerId @@ -1151,8 +1214,10 @@ class LobbyWrapper { */ async addPlayer(playerId) { await bdm.addPlayerToLobby(playerId, this.id); - let username = await new PlayerWrapper(playerId).username(); - await bdm.addInfoMessage(this.id, `${username} joined.`); + let playerWrapper = new PlayerWrapper(playerId); + this.emit('playerJoin', await resolvePlayer(playerWrapper)); + let username = await playerWrapper.username(); + await this.addInfoMessage(`${username} joined.`); await this._loadLobbyInfo(true); } @@ -1164,7 +1229,8 @@ class LobbyWrapper { async removePlayer(playerId) { await bdm.removePlayerFromLobby(playerId, this.id); let username = await new PlayerWrapper(playerId).username(); - await bdm.addInfoMessage(this.id, `${username} left.`); + this.emit('playerLeave', playerId); + await this.addInfoMessage(`${username} left.`); await this._loadLobbyInfo(true); } @@ -1185,7 +1251,8 @@ class LobbyWrapper { async setRoundStatus(status) { let currentRound = await this.currentRound(); await currentRound.updateStatus(status); - await bdm.addInfoMessage(this.id, `Admin set round status to ${status}`); + await this.addInfoMessage(`Admin set round status to ${status}`); + this.emit('statusChange', status); if (status === 'FINISHED') await bdm.clearGrids(this.id); @@ -1333,6 +1400,28 @@ function checkBingo(fg) { return diagonalBingo || verticalCheck || horizontalCheck; } +/** + * Parses the message and replaces all links with markdown-links and images with markdown-images. + * @param message {String} - the raw message + */ +function preMarkdownParse(message) { + let linkMatch = /(^|[^(])https?:\/\/((([\w-]+\.)+[\w-]+)(\S*))([^)]|$)/g; + let imageMatch = /.*\.(\w+)/g; + let imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg']; + let links = message.match(linkMatch); + + if (links) + for (let link of links) { + let linkGroups = linkMatch.exec(link); + let imgGroups = imageMatch.exec(link); + + if (imgGroups && imgGroups[1] && imageExtensions.includes(imgGroups[1])) + message = message.replace(link, `![${linkGroups[1]}](${link})`); + } + + return message; +} + /** * Gets player data for a lobby * @param lobbyWrapper @@ -1395,6 +1484,53 @@ async function getGridData(lobbyId, playerId) { return {fields: fieldGrid, bingo: await grid.bingo()}; } +/** + * Resolves a message wrapper object + * @param msgWrapper + * @returns {Promise<{author: {id: (*|MessageWrapper.author.id), username: String}, id: *, content: *, timestamp: Timestamp | * | number, htmlContent: *}>} + */ +async function resolveMessage(msgWrapper) { + return { + id: msgWrapper.id, + type: msgWrapper.type, + content: msgWrapper.content, + timestamp: msgWrapper.timestamp, + htmlContent: msgWrapper.htmlContent, + author: { + id: msgWrapper.author.id, + username: await msgWrapper.author.username() + } + }; +} + +/** + * Resolves a player wrapper object + * @param playerWrapper + * @param lobbyId + * @returns {Promise<{wins: PlayerWrapper.wins, id: *, username: (String|*)}>} + */ +async function resolvePlayer(playerWrapper, lobbyId) { + return { + id: playerWrapper.id, + username: await playerWrapper.username(), + wins: await playerWrapper.wins({lobbyId: lobbyId}) + }; +} + +/** + * Resolves a fieldWrapper object + * @param fieldWrapper + * @returns {Promise<{submitted: (Object.submitted|*), column: *, bingo: boolean, row: (*)}>} + */ +async function resolveGridField(fieldWrapper) { + return { + row: fieldWrapper.row, + column: fieldWrapper.column, + submitted: fieldWrapper.submitted, + bingo: await fieldWrapper.grid.bingo() + }; +} + /** * Returns resolved message data. * @param lobbyId @@ -1405,239 +1541,313 @@ async function getMessageData(lobbyId) { let messages = await lobbyWrapper.messages({limit: 20}); let msgReturn = []; for (let message of messages) - msgReturn.push(Object.assign(message, {username: await message.author.username()})); + msgReturn.push(Object.assign(message, { + playerId: message.author.id, + username: await message.author.username() + })); return msgReturn; } // -- Router stuff +/** + * Creates a lobby socket if none exists. + * @param io + * @param lobbyId + */ +function createSocketIfNotExist(io, lobbyId) { + if (!sockets[lobbyId]) { + let lobbySocket = io.of(`/bingo/${lobbyId}`); + sockets[lobbyId] = lobbySocket; + + lobbySocket.on('connection', (socket) => { + socket.on('message', async (context, message) => { + try { + let result = await bdm.addUserMessage(lobbyId, context.playerId, message); + let messageWrapper = new MessageWrapper(result); + lobbySocket.emit('message', await resolveMessage(messageWrapper)); + } catch (err) { + console.error(err); + } + }); + socket.on('messageEdit', async (context, message, messageId) => { + try { + let row = await bdm.getMessageData(messageId); + if (row.player_id === Number(context.playerId)) { + let result = await bdm.editMessage(messageId, message); + let messageWrapper = new MessageWrapper(result); + lobbySocket.emit('messageEdit', await resolveMessage(messageWrapper)); + } else { + socket.emit('userError', "You are only allowed to edit your messages."); + } + } catch (err) { + console.error(err); + } + }); + socket.on('messageDelete', async (context, messageId) => { + try { + let row = await bdm.getMessageData(messageId); + + if (row.player_id === Number(context.playerId)) { + await bdm.deleteMessage(messageId); + lobbySocket.emit('messageDelete', messageId); + } + } catch (err) { + console.error(err); + } + }); + socket.on('fieldToggle', async (context, location) => { + let {row, column} = location; + let result = await (await (new PlayerWrapper(context.playerId)).grid({lobbyId: lobbyId})) + .toggleField(row, column); + socket.emit('fieldChange', await resolveGridField(result)); + }); + }); + } +} + let bdm = new BingoDataManager(pgPool); -router.init = async () => { +router.init = async (bingoIo, io) => { await bdm.init(); -}; -router.use(async (req, res, next) => { - if (req.session.bingoPlayerId) - await bdm.updatePlayerExpiration(req.session.bingoPlayerId); - next(); -}); + for (let id of await bdm.getLobbyIds()) + createSocketIfNotExist(io, id); -router.get('/', async (req, res) => { - let playerId = req.session.bingoPlayerId; - let info = req.session.acceptedCookies? null: globals.cookieInfo; - let lobbyWrapper = new LobbyWrapper(req.query.g); - let playerWrapper = new PlayerWrapper(playerId); + router.use(async (req, res, next) => { + if (req.session.bingoPlayerId) + await bdm.updatePlayerExpiration(req.session.bingoPlayerId); + next(); + }); - if (playerId && await playerWrapper.exists() && req.query.g && await lobbyWrapper.exists()) { - let lobbyId = req.query.g; - - if (!(await lobbyWrapper.roundActive() && await playerWrapper.hasGrid(lobbyId))) { - if (!await lobbyWrapper.hasPlayer(playerId)) - await lobbyWrapper.addPlayer(playerId); - let playerData = await getPlayerData(lobbyWrapper); - let words = await getWordsData(lobbyWrapper); - let admin = await lobbyWrapper.admin(); - res.render('bingo/bingo-lobby', { - players: playerData, - isAdmin: (playerId === admin.id), - adminId: admin.id, - words: words, - wordString: words.join('\n'), - gridSize: await lobbyWrapper.gridSize(), - info: info, - messages: await getMessageData(lobbyId) - }); - } else { - if (await lobbyWrapper.hasPlayer(playerId) && await playerWrapper.hasGrid(lobbyId)) { + router.get('/', async (req, res) => { + let playerId = req.session.bingoPlayerId; + let info = req.session.acceptedCookies? null: globals.cookieInfo; + let lobbyWrapper = new LobbyWrapper(req.query.g); + let playerWrapper = new PlayerWrapper(playerId); + + if (playerId && await playerWrapper.exists() && req.query.g && await lobbyWrapper.exists()) { + let lobbyId = req.query.g; + createSocketIfNotExist(io, lobbyId); + + if (!(await lobbyWrapper.roundActive() && await playerWrapper.hasGrid(lobbyId))) { + if (!await lobbyWrapper.hasPlayer(playerId)) + await lobbyWrapper.addPlayer(playerId); let playerData = await getPlayerData(lobbyWrapper); - let grid = await getGridData(lobbyId, playerId); + let words = await getWordsData(lobbyWrapper); let admin = await lobbyWrapper.admin(); - res.render('bingo/bingo-round', { + res.render('bingo/bingo-lobby', { players: playerData, - grid: grid, isAdmin: (playerId === admin.id), adminId: admin.id, + words: words, + wordString: words.join('\n'), + gridSize: await lobbyWrapper.gridSize(), info: info, messages: await getMessageData(lobbyId) }); } else { - res.redirect('/bingo'); + if (await lobbyWrapper.hasPlayer(playerId) && await playerWrapper.hasGrid(lobbyId)) { + let playerData = await getPlayerData(lobbyWrapper); + let grid = await getGridData(lobbyId, playerId); + let admin = await lobbyWrapper.admin(); + res.render('bingo/bingo-round', { + players: playerData, + grid: grid, + isAdmin: (playerId === admin.id), + adminId: admin.id, + info: info, + messages: await getMessageData(lobbyId) + }); + } else { + res.redirect('/bingo'); + } } + } else { + res.render('bingo/bingo-create', { + info: info, + username: await playerWrapper.username(), + changelog: md.render(globals.changelog), + primaryJoin: (req.query.g && await lobbyWrapper.exists()) + }); } - } else { - res.render('bingo/bingo-create', { - info: info, - username: await playerWrapper.username() - }); - } -}); + }); -router.graphqlResolver = async (req, res) => { - let playerId = req.session.bingoPlayerId; - if (playerId) - await bdm.updatePlayerExpiration(playerId); + router.graphqlResolver = async (req, res) => { + let playerId = req.session.bingoPlayerId; + if (playerId) + await bdm.updatePlayerExpiration(playerId); - return { - // queries - lobby: async ({id}) => { - await bdm.updateLobbyExpiration(id); - return new LobbyWrapper(id); - }, - player: ({id}) => { - if (id) - return new PlayerWrapper(id); - else + return { + // queries + lobby: async ({id}) => { + await bdm.updateLobbyExpiration(id); + return new LobbyWrapper(id); + }, + player: ({id}) => { + if (id) + return new PlayerWrapper(id); + else if (playerId) return new PlayerWrapper(playerId); else res.status(400); - }, - // mutations - setUsername: async ({username}) => { - username = replaceTagSigns(username.substring(0, 30)).replace(/[\n\tšŸ‘‘šŸŒŸ]|^\s+|\s+$/gu, ''); // only allow 30 characters - if (username.length > 0) { - let playerWrapper = new PlayerWrapper(playerId); - - if (!playerId || !(await playerWrapper.exists())) { - req.session.bingoPlayerId = (await bdm.addPlayer(username)).id; - playerId = req.session.bingoPlayerId; - } else { - let oldName = await playerWrapper.username(); - await bdm.updatePlayerUsername(playerId, username); - if (req.query.g) - await bdm.addInfoMessage(req.query.g, `${oldName} changed username to ${username}`); - } - return new PlayerWrapper(playerId); - } else { - res.status(400); - return new GraphQLError('Username too short!'); - } - }, - createLobby: async({gridSize}) => { - if (playerId) - if (gridSize > 0 && gridSize < 10) { - let result = await bdm.createLobby(playerId, gridSize); - return new LobbyWrapper(result.id); - } else { - res.status(413); - } - res.status(400); - }, - mutateLobby: async ({id}) => { - let lobbyId = id; - await bdm.updateLobbyExpiration(lobbyId); - let lobbyWrapper = new LobbyWrapper(lobbyId); - return { - join: async () => { - if (playerId) { - await lobbyWrapper.addPlayer(playerId); - return lobbyWrapper; - } else { - res.status(400); - } - }, - leave: async () => { - if (playerId) { - await lobbyWrapper.removePlayer(playerId); - return true; - } else { - res.status(400); - } - }, - kickPlayer: async ({pid}) => { - let admin = await lobbyWrapper.admin(); - if (admin.id === playerId) { - await lobbyWrapper.removePlayer(pid); - return new PlayerWrapper(pid); - } else { - res.status(403); - return new GraphQLError('You are not an admin'); - } - }, - startRound: async () => { - let admin = await lobbyWrapper.admin(); - if (admin.id === playerId) { - await lobbyWrapper.startNewRound(); - return lobbyWrapper.currentRound(); + }, + // mutations + setUsername: async ({username}) => { + username = replaceTagSigns(username.substring(0, 30)).replace(/[\n\tšŸ‘‘šŸŒŸ]|^\s+|\s+$/gu, ''); // only allow 30 characters + if (username.length > 0) { + let playerWrapper = new PlayerWrapper(playerId); + + if (!playerId || !(await playerWrapper.exists())) { + req.session.bingoPlayerId = (await bdm.addPlayer(username)).id; + playerId = req.session.bingoPlayerId; } else { - res.status(403); - return new GraphQLError('You are not an admin'); + let oldName = await playerWrapper.username(); + await bdm.updatePlayerUsername(playerId, username); + + if (req.query.g) { + let lobbyWrapper = new LobbyWrapper(req.query.g); + if (await lobbyWrapper.exists()) { + lobbyWrapper.emit('usernameChange', + await resolvePlayer(new PlayerWrapper(playerId), req.query.g)); + await lobbyWrapper.addInfoMessage(`${oldName} changed username to ${username}`); + } + } } - }, - setRoundStatus: async ({status}) => { - let admin = await lobbyWrapper.admin(); - if (admin.id === playerId) { - return await lobbyWrapper.setRoundStatus(status); + return new PlayerWrapper(playerId); + } else { + res.status(400); + return new GraphQLError('Username too short!'); + } + }, + createLobby: async({gridSize}) => { + if (playerId) + if (gridSize > 0 && gridSize < 10) { + let result = await bdm.createLobby(playerId, gridSize); + createSocketIfNotExist(io, result.id); + return new LobbyWrapper(result.id, result); } else { - res.status(403); - return new GraphQLError('You are not an admin'); + res.status(413); } - }, - setGridSize: async ({gridSize}) => { - if (gridSize > 0 && gridSize < 6) { + res.status(400); + }, + mutateLobby: async ({id}) => { + let lobbyId = id; + createSocketIfNotExist(io, lobbyId); + await bdm.updateLobbyExpiration(lobbyId); + let lobbyWrapper = new LobbyWrapper(lobbyId); + return { + join: async () => { + if (playerId) { + await lobbyWrapper.addPlayer(playerId); + return lobbyWrapper; + } else { + res.status(400); + } + }, + leave: async () => { + if (playerId) { + await lobbyWrapper.removePlayer(playerId); + return true; + } else { + res.status(400); + } + }, + kickPlayer: async ({pid}) => { let admin = await lobbyWrapper.admin(); if (admin.id === playerId) { - await lobbyWrapper.setGridSize(gridSize); - return lobbyWrapper; + await lobbyWrapper.removePlayer(pid); + return new PlayerWrapper(pid); } else { res.status(403); return new GraphQLError('You are not an admin'); } - } else { - res.status(400); - return new GraphQLError('Grid size too big!'); - } - }, - setWords: async({words}) => { - let admin = await lobbyWrapper.admin(); - if (admin.id === playerId) - if (words.length < 10000) { - await lobbyWrapper.setWords(words); - return lobbyWrapper; + }, + startRound: async () => { + let admin = await lobbyWrapper.admin(); + if (admin.id === playerId) { + await lobbyWrapper.startNewRound(); + return lobbyWrapper.currentRound(); } else { - res.status(413); // request entity too large - return new GraphQLError('Too many words'); + res.status(403); + return new GraphQLError('You are not an admin'); } - else - res.status(403); // forbidden - - }, - sendMessage: async ({message}) => { - if (await lobbyWrapper.hasPlayer(playerId)) { - let result = await bdm.addUserMessage(lobbyId, playerId, message); - return new MessageWrapper(result); - } else { - res.status(401); // unautorized - return new GraphQLError('You are not in the lobby'); - } - }, - submitBingo: async () => { - let isBingo = await (await (new PlayerWrapper(playerId)).grid({lobbyId: lobbyId})).bingo(); - let currentRound = await lobbyWrapper.currentRound(); - if (isBingo && await lobbyWrapper.hasPlayer(playerId)) { - let result = await currentRound.setWinner(playerId); - let username = await new PlayerWrapper(playerId).username(); - if (result) { - await bdm.addInfoMessage(lobbyId, `**${username}** won!`); - await bdm.clearGrids(lobbyId); - return currentRound; + }, + setRoundStatus: async ({status}) => { + let admin = await lobbyWrapper.admin(); + if (admin.id === playerId) { + return await lobbyWrapper.setRoundStatus(status); } else { - res.status(500); + res.status(403); + return new GraphQLError('You are not an admin'); } - } else { - res.status(400); - return new GraphQLError('Bingo check failed. This is not a bingo!'); + }, + setGridSize: async ({gridSize}) => { + if (gridSize > 0 && gridSize < 6) { + let admin = await lobbyWrapper.admin(); + if (admin.id === playerId) { + await lobbyWrapper.setGridSize(gridSize); + return lobbyWrapper; + } else { + res.status(403); + return new GraphQLError('You are not an admin'); + } + } else { + res.status(400); + return new GraphQLError('Grid size too big!'); + } + }, + setWords: async({words}) => { + let admin = await lobbyWrapper.admin(); + if (admin.id === playerId) + if (words.length < 10000) { + await lobbyWrapper.setWords(words); + return lobbyWrapper; + } else { + res.status(413); // request entity too large + return new GraphQLError('Too many words'); + } + else + res.status(403); // forbidden + + }, + sendMessage: async ({message}) => { + if (await lobbyWrapper.hasPlayer(playerId)) { + let result = await bdm.addUserMessage(lobbyId, playerId, message); + return new MessageWrapper(result); + } else { + res.status(401); // unautorized + return new GraphQLError('You are not in the lobby'); + } + }, + submitBingo: async () => { + let isBingo = await (await (new PlayerWrapper(playerId)).grid({lobbyId: lobbyId})).bingo(); + let currentRound = await lobbyWrapper.currentRound(); + if (isBingo && await lobbyWrapper.hasPlayer(playerId)) { + let result = await currentRound.setWinner(playerId); + let username = await new PlayerWrapper(playerId).username(); + if (result) { + await bdm.addInfoMessage(lobbyId, `**${username}** won!`); + await bdm.clearGrids(lobbyId); + return currentRound; + } else { + res.status(500); + } + } else { + res.status(400); + return new GraphQLError('Bingo check failed. This is not a bingo!'); + } + }, + toggleGridField: async ({location}) => { + let {row, column} = location; + return await (await (new PlayerWrapper(playerId)).grid({lobbyId: lobbyId})) + .toggleField(row, column); } - }, - toggleGridField: async ({location}) => { - let {row, column} = location; - return await (await (new PlayerWrapper(playerId)).grid({lobbyId: lobbyId})) - .toggleField(row, column); - } - }; - } + }; + } + }; }; }; diff --git a/routes/changelog.js b/routes/changelog.js index d4648a5..fc0dc93 100644 --- a/routes/changelog.js +++ b/routes/changelog.js @@ -1,17 +1,14 @@ const express = require('express'), router = express.Router(), globals = require('../lib/globals'), - fsx = require('fs-extra'), mdEmoji = require('markdown-it-emoji'), md = require('markdown-it')() .use(mdEmoji); -let changelog = fsx.readFileSync('CHANGELOG.md', 'utf-8'); - /* GET home page. */ router.get('/', (req, res) => { let info = req.session.acceptedCookies? null: globals.cookieInfo; - res.render('changelog/changes', { changelog: md.render(changelog), info: info}); + res.render('changelog/changes', { changelog: md.render(globals.changelog), info: info}); }); module.exports = router; diff --git a/sql/bingo/createBingoTables.sql b/sql/bingo/createBingoTables.sql index c512cb6..0025b96 100644 --- a/sql/bingo/createBingoTables.sql +++ b/sql/bingo/createBingoTables.sql @@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS bingo.lobbys ( admin_id serial references bingo.players(id) ON DELETE SET NULL, grid_size integer DEFAULT 3 NOT NULL, current_round integer, - expire timestamp DEFAULT (NOW() + interval '1 hour' ) + expire timestamp DEFAULT (NOW() + interval '4 hour' ) ); -- lobbys-players table diff --git a/sql/bingo/queries.yaml b/sql/bingo/queries.yaml index 90bb6fd..ee2b0b0 100644 --- a/sql/bingo/queries.yaml +++ b/sql/bingo/queries.yaml @@ -48,7 +48,7 @@ addLobby: # params: # - {Number} - the id of the lobby updateLobbyExpire: - sql: UPDATE bingo.lobbys SET expire = (NOW() + interval '1 hours') WHERE id = $1; + sql: UPDATE bingo.lobbys SET expire = (NOW() + interval '4 hours') WHERE id = $1 RETURNING *; # inserts a player into a lobby # params: @@ -77,6 +77,9 @@ getPlayerInLobby: getLobbyPlayers: sql: SELECT * FROM bingo.lobby_players WHERE lobby_players.lobby_id = $1; +getLobbyIds: + sql: SELECT lobbys.id FROM bingo.lobbys; + # returns all direct information about the lobby # params: # - {Number} - the id of the lobby @@ -259,9 +262,28 @@ getWordsForGridId: addUserMessage: sql: INSERT INTO bingo.messages (player_id, lobby_id, content) VALUES ($1, $2, $3) RETURNING *; +# edits a message +# params: +# - {Number} - the id of the message +# - {Number} - the new content of the message +editMessage: + sql: UPDATE bingo.messages SET content = $2 WHERE id = $1 RETURNING *; + # inserts a info message # params: # - {Number} - the id of the lobby # - {String} - the content of the message addInfoMessage: sql: INSERT INTO bingo.messages (type, lobby_id, content) VALUES ('INFO', $1, $2) RETURNING *; + +# returns the data of a message +# params: +# - {Number} - the id of the message +getMessageData: + sql: SELECT * from bingo.messages WHERE id = $1; + +# deletes a message +# params: +# - {Number} - the id of the message +deleteMessage: + sql: DELETE FROM bingo.messages WHERE id = $1; diff --git a/views/bingo/bingo-create.pug b/views/bingo/bingo-create.pug index dd603e1..0da1566 100644 --- a/views/bingo/bingo-create.pug +++ b/views/bingo/bingo-create.pug @@ -3,17 +3,25 @@ extends includes/bingo-layout block content div(id='container-bingo-create') div(id='username-form') + h2 Please enter a username input(id='input-username' type='text' placeholder='Enter your name' value=username maxlength=30 onkeydown='submitOnEnter(event, () => indicateStatus(submitUsername, "#username-status"))') - button( - id='submit-username' - onclick='indicateStatus(submitUsername, "#username-status")') Set Username - div(id='username-status' class='statusIndicator') + if primaryJoin + button( + id='submit-username' + onclick='joinLobby()') Join Lobby + else + button( + id='submit-username' + onclick='indicateStatus(submitUsername, "#submit-username")') Set Username div(id='lobby-form') - button(id='join-lobby' onclick='joinLobby()') Join Lobby - button(id='create-lobby' onclick='createLobby()') Create Lobby + if primaryJoin + button(id='create-lobby' class='inactive' onclick='createLobby()') Create Lobby + else + button(id='create-lobby' onclick='createLobby()') Create Lobby + div(id='changelog')!= changelog include includes/bingo-statusbar diff --git a/views/bingo/bingo-lobby.pug b/views/bingo/bingo-lobby.pug index 4e559bf..f55c263 100644 --- a/views/bingo/bingo-lobby.pug +++ b/views/bingo/bingo-lobby.pug @@ -19,4 +19,4 @@ block content include includes/bingo-chat include includes/bingo-statusbar - script(type='text/javascript') refreshLobby(); + script(type='text/javascript') initRefresh(); diff --git a/views/bingo/bingo-round.pug b/views/bingo/bingo-round.pug index 2f42f61..e811dbc 100644 --- a/views/bingo/bingo-round.pug +++ b/views/bingo/bingo-round.pug @@ -23,4 +23,4 @@ block content b-column=field.column b-sub=`${field.submitted}`) span= field.word - script(type='text/javascript') refreshRound(); + script(type='text/javascript') initRefresh(); diff --git a/views/bingo/includes/bingo-chat.pug b/views/bingo/includes/bingo-chat.pug index 3e362f1..a827953 100644 --- a/views/bingo/includes/bingo-chat.pug +++ b/views/bingo/includes/bingo-chat.pug @@ -2,7 +2,12 @@ div(id='container-chat') style(id='js-style') div(id='chat-content') for message in messages - span.chatMessage(msg-type=message.type msg-id=message.id) + span.chatMessage( + msg-type=message.type + msg-id=message.id + msg-pid=message.playerId + msg-raw=message.content) + if message.type === 'USER' span.chatUsername= `${message.username}: ` span(class=`chatMessageContent ${message.type}`)!= message.htmlContent @@ -10,6 +15,6 @@ div(id='container-chat') id='chat-input' type='text' placeholder='send message' - onkeypress='submitOnEnter(event, () => statusWrap(sendChatMessage))' + onkeypress='onInputKeypress(event)' maxlength="250" autocomplete='off') diff --git a/views/bingo/includes/bingo-statusbar.pug b/views/bingo/includes/bingo-statusbar.pug index d9e6637..ecf5e41 100644 --- a/views/bingo/includes/bingo-statusbar.pug +++ b/views/bingo/includes/bingo-statusbar.pug @@ -1,5 +1,5 @@ div(id='statusbar') - div(id='status-indicator' class='statusIndicator' status='idle') + div(id='status-indicator' class='statusIndicator socketStatusIndicator' status='idle') span(id='error-message') span(id='container-info') a(href='https://github.com/Trivernis/whooshy/issues') Bug/Feature diff --git a/views/includes/head.pug b/views/includes/head.pug index 2c11aef..21f70fd 100644 --- a/views/includes/head.pug +++ b/views/includes/head.pug @@ -1,2 +1,3 @@ -link(rel='stylesheet', href='/sass/style.sass') -script(type='text/javascript', src='/javascripts/common.js') \ No newline at end of file +link(rel='stylesheet' href='/sass/style.sass') +script(type='text/javascript' src='/javascripts/common.js') +script(type='text/javascript' src='/socket.io/socket.io.js')