From 3a60958052399bcfcdda221de2c3cb8abe7f19ea Mon Sep 17 00:00:00 2001
From: Benjamin Auder <benjamin.auder@somewhere>
Date: Tue, 18 Dec 2018 23:50:35 +0100
Subject: [PATCH] Finished basic chat implementation

---
 package-lock.json                     |  14 +-
 package.json                          |   2 +-
 public/javascripts/components/game.js | 197 +++++++++++++++++++++++---
 public/stylesheets/variant.sass       |  18 +++
 routes/all.js                         |   3 +-
 sockets.js                            |  15 ++
 6 files changed, 214 insertions(+), 35 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index cf4aad00..555c0d82 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1968,8 +1968,7 @@
         "code-point-at": {
           "version": "1.1.0",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "concat-map": {
           "version": "0.0.1",
@@ -1979,8 +1978,7 @@
         "console-control-strings": {
           "version": "1.1.0",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "core-util-is": {
           "version": "1.0.2",
@@ -2097,8 +2095,7 @@
         "inherits": {
           "version": "2.0.3",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "ini": {
           "version": "1.3.5",
@@ -2110,7 +2107,6 @@
           "version": "1.0.0",
           "bundled": true,
           "dev": true,
-          "optional": true,
           "requires": {
             "number-is-nan": "^1.0.0"
           }
@@ -2236,8 +2232,7 @@
         "number-is-nan": {
           "version": "1.0.1",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "object-assign": {
           "version": "4.1.1",
@@ -2370,7 +2365,6 @@
           "version": "1.0.2",
           "bundled": true,
           "dev": true,
-          "optional": true,
           "requires": {
             "code-point-at": "^1.0.0",
             "is-fullwidth-code-point": "^1.0.0",
diff --git a/package.json b/package.json
index 72a326fc..622ca8ff 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
     "morgan": "~1.9.1",
     "node-sass-middleware": "~0.11.0",
     "pug": "~2.0.3",
-    "sanitize-html": "^1.19.3",
+    "sanitize-html": "^1.20.0",
     "serve-favicon": "~2.5.0",
     "sqlite3": "^4.0.4",
     "ws": "^6.1.2"
diff --git a/public/javascripts/components/game.js b/public/javascripts/components/game.js
index b242ee27..b78fe539 100644
--- a/public/javascripts/components/game.js
+++ b/public/javascripts/components/game.js
@@ -11,8 +11,12 @@ Vue.component('my-game', {
 			selectedPiece: null, //moving piece (or clicked piece)
 			conn: null, //socket connection
 			score: "*", //'*' means 'unfinished'
-			mode: "idle", //human, friend, computer or idle (when not playing)
+			mode: "idle", //human, chat, friend, problem, computer or idle (if not playing)
+			myid: "", //our ID, always set
 			oppid: "", //opponent ID in case of HH game
+			myname: getCookie("username","anonymous"),
+			oppName: "anonymous", //opponent name, revealed after a game (if provided)
+			chats: [], //chat messages after human game
 			oppConnected: false,
 			seek: false,
 			fenStart: "",
@@ -28,7 +32,7 @@ Vue.component('my-game', {
 		};
 	},
 	watch: {
-		problem: function(p, pp) {
+		problem: function(p) {
 			// 'problem' prop changed: update board state
 			this.newGame("problem", p.fen, V.ParseFen(p.fen).turn);
 		},
@@ -59,7 +63,7 @@ Vue.component('my-game', {
 			},
 			[h('i', { 'class': { "material-icons": true } }, "accessibility")])
 		);
-		if (["idle","computer"].includes(this.mode))
+		if (["idle","chat","computer"].includes(this.mode))
 		{
 			actionArray.push(
 				h('button',
@@ -76,7 +80,7 @@ Vue.component('my-game', {
 				[h('i', { 'class': { "material-icons": true } }, "computer")])
 			);
 		}
-		if (["idle","friend"].includes(this.mode))
+		if (["idle","chat","friend"].includes(this.mode))
 		{
 			actionArray.push(
 				h('button',
@@ -122,6 +126,29 @@ Vue.component('my-game', {
 				);
 				elementArray.push(connectedIndic);
 			}
+			else if (this.mode == "chat")
+			{
+				// Also show connection indication, but also nickname
+				const nicknameOpponent = h(
+					'div',
+					{
+						"class": {
+							"clickable": true,
+							"topindicator": true,
+							"indic-left": true,
+							"name-connected": this.oppConnected,
+							"name-disconnected": !this.oppConnected,
+						},
+						domProps: {
+							innerHTML: this.oppName,
+						},
+						on: {
+							"click": () => { document.getElementById("modal-chat").checked = true; },
+						},
+					}
+				);
+				elementArray.push(nicknameOpponent);
+			}
 			const turnIndic = h(
 				'div',
 				{
@@ -149,7 +176,7 @@ Vue.component('my-game', {
 					'class': {
 						"tooltip": true,
 						"topindicator": true,
-						"indic-left": true,
+						"indic-right": true,
 						"settings-btn": !smallScreen,
 						"settings-btn-small": smallScreen,
 					},
@@ -164,7 +191,10 @@ Vue.component('my-game', {
 					h('div',
 						{
 							attrs: { id: "instructions-div" },
-							"class": { "section-content": true },
+							"class": {
+								"clearer": true,
+								"section-content": true,
+							},
 						},
 						[
 							h('p',
@@ -214,7 +244,7 @@ Vue.component('my-game', {
 			// Create board element (+ reserves if needed by variant or mode)
 			const lm = this.vr.lastMove;
 			const showLight = this.hints &&
-				(this.mode!="idle" || this.cursor==this.vr.moves.length);
+				(!["idle","chat"].includes(this.mode) || this.cursor==this.vr.moves.length);
 			const gameDiv = h('div',
 				{
 					'class': { 'game': true },
@@ -289,7 +319,7 @@ Vue.component('my-game', {
 					);
 				}), choices]
 			);
-			if (this.mode != "idle")
+			if (!["idle","chat"].includes(this.mode))
 			{
 				actionArray.push(
 					h('button',
@@ -608,6 +638,27 @@ Vue.component('my-game', {
 							  { },
 								[
 									//h('legend', { domProps: { innerHTML: "Legend title" } }),
+									h('label',
+										{
+											attrs: { for: "nameSetter" },
+											domProps: { innerHTML: "My name is..." },
+										},
+									),
+									h('input',
+										{
+											attrs: {
+												"id": "nameSetter",
+												type: "text",
+												value: this.myname,
+											},
+											on: { "change": this.setMyname },
+										}
+									),
+								]
+							),
+							h('fieldset',
+							  { },
+								[
 									h('label',
 										{
 											attrs: { for: "setHints" },
@@ -724,6 +775,75 @@ Vue.component('my-game', {
 			)
 		];
 		elementArray = elementArray.concat(modalSettings);
+		let chatEltsArray =
+		[
+			h('label',
+				{
+					attrs: { "id": "close-chat", "for": "modal-chat" },
+					"class": { "modal-close": true },
+				}
+			),
+			h('h3',
+				{
+					attrs: { "id": "titleChat" },
+					"class": { "section": true },
+					domProps: { innerHTML: "Chat with " + this.oppName },
+				}
+			)
+		];
+		for (let chat of this.chats)
+		{
+			chatEltsArray.push(
+				h('p',
+					{
+						"class": {
+							"my-chatmsg": chat.author==this.myid,
+							"opp-chatmsg": chat.author==this.oppid,
+						},
+						domProps: { innerHTML: chat.msg }
+					}
+				)
+			);
+		}
+		chatEltsArray = chatEltsArray.concat([
+			h('input',
+				{
+					attrs: {
+						"id": "input-chat",
+						type: "text",
+						placeholder: "Type here",
+					},
+					on: { keyup: this.trySendChat }, //if key is 'enter'
+				}
+			),
+			h('button',
+				{
+					on: { click: this.sendChat },
+					domProps: { innerHTML: "Send" },
+				}
+			)
+		]);
+		const modalChat = [
+			h('input',
+				{
+					attrs: { "id": "modal-chat", type: "checkbox" },
+					"class": { "modal": true },
+				}),
+			h('div',
+				{
+					attrs: { "role": "dialog", "aria-labelledby": "titleChat" },
+				},
+				[
+					h('div',
+						{
+							"class": { "card": true, "smallpad": true },
+						},
+						chatEltsArray
+					)
+				]
+			)
+		];
+		elementArray = elementArray.concat(modalChat);
 		const actions = h('div',
 			{
 				attrs: { "id": "actions" },
@@ -882,6 +1002,14 @@ Vue.component('my-game', {
 			const data = JSON.parse(msg.data);
 			switch (data.code)
 			{
+				case "oppname":
+					// Receive opponent's name
+					this.oppName = data.name;
+					break;
+				case "newchat":
+					// Receive new chat
+					this.chats.push({msg:data.msg, author:this.oppid});
+					break;
 				case "duplicate":
 					// We opened another tab on the same game
 					this.mode = "idle";
@@ -942,8 +1070,14 @@ Vue.component('my-game', {
 				// TODO: also use (dis)connect info to count online players?
 				case "connect":
 				case "disconnect":
-					if (this.mode == "human" && this.oppid == data.id)
+					if (["human","chat"].includes(this.mode) && this.oppid == data.id)
 						this.oppConnected = (data.code == "connect");
+					if (this.oppConnected)
+					{
+						// Send our name to the opponent, in case of he hasn't it
+						this.conn.send(JSON.stringify({
+							code:"myname", name:this.myname, oppid: this.oppid}));
+					}
 					break;
 			}
 		};
@@ -958,8 +1092,8 @@ Vue.component('my-game', {
 		this.conn.onclose = socketCloseListener;
 		// Listen to keyboard left/right to navigate in game
 		document.onkeydown = event => {
-			if (this.mode == "idle" && !!this.vr && this.vr.moves.length > 0
-				&& [37,39].includes(event.keyCode))
+			if (["idle","chat"].includes(this.mode) &&
+				!!this.vr && this.vr.moves.length > 0 && [37,39].includes(event.keyCode))
 			{
 				event.preventDefault();
 				if (event.keyCode == 37) //Back
@@ -983,6 +1117,22 @@ Vue.component('my-game', {
 		}
 	},
 	methods: {
+		setMyname: function(e) {
+			this.myname = e.target.value;
+			setCookie("username",this.myname);
+		},
+		trySendChat: function(e) {
+			if (e.keyCode == 13) //'enter' key
+				this.sendChat();
+		},
+		sendChat: function() {
+			let chatInput = document.getElementById("input-chat");
+			const chatTxt = chatInput.value;
+			chatInput.value = "";
+			this.chats.push({msg:chatTxt, author:this.myid});
+			this.conn.send(JSON.stringify({
+				code:"newchat", oppid: this.oppid, msg: chatTxt}));
+		},
 		toggleShowSolution: function() {
 			let problemSolution = document.getElementById("problem-solution");
 			problemSolution.style.display =
@@ -1012,9 +1162,16 @@ Vue.component('my-game', {
 			this.pgnTxt = this.vr.getPGN(this.mycolor, this.score, this.fenStart, this.mode);
 			if (["human","computer"].includes(this.mode))
 				this.clearStorage();
-			this.mode = "idle";
+			if (this.mode == "human" && this.oppConnected)
+			{
+				// Send our nickname to opponent
+				this.conn.send(JSON.stringify({
+					code:"myname", name:this.myname, oppid:this.oppid}));
+			}
+			this.mode = (this.mode=="human" ? "chat" : "idle");
 			this.cursor = this.vr.moves.length; //to navigate in finished game
-			this.oppid = "";
+			if (this.mode == "idle") //keep oppid in case of chat after human game
+				this.oppid = "";
 		},
 		setStorage: function() {
 			if (this.mode=="human")
@@ -1083,8 +1240,6 @@ Vue.component('my-game', {
 		},
 		clickComputerGame: function(e) {
 			this.getRidOfTooltip(e.currentTarget);
-			if (this.mode == "human")
-				return; //no newgame while playing
 			this.newGame("computer");
 		},
 		clickFriendGame: function(e) {
@@ -1135,11 +1290,6 @@ Vue.component('my-game', {
 					}
 				}
 			}
-			if (this.mode == "computer" && mode == "human")
-			{
-				// Save current computer game to resume it later
-				this.setStorage();
-			}
 			this.vr = new VariantRules(fen, moves || []);
 			this.score = "*";
 			this.pgnTxt = ""; //redundant with this.score = "*", but cleaner
@@ -1177,6 +1327,7 @@ Vue.component('my-game', {
 			}
 			else if (mode == "computer")
 			{
+				this.setStorage(); //store game state
 				this.compWorker.postMessage(["init",this.vr.getFen()]);
 				this.mycolor = Math.random() < 0.5 ? 'w' : 'b';
 				if (this.mycolor == 'b')
@@ -1230,7 +1381,7 @@ Vue.component('my-game', {
 				this.selectedPiece.style.zIndex = 3000;
 				const startSquare = this.getSquareFromId(e.target.parentNode.id);
 				this.possibleMoves = [];
-				if (this.mode != "idle")
+				if (!["idle","chat"].includes(this.mode))
 				{
 					const color = ["friend","problem"].includes(this.mode)
 						? this.vr.turn
@@ -1344,7 +1495,7 @@ Vue.component('my-game', {
 				this.conn.send(JSON.stringify({code:"newmove", move:move, oppid:this.oppid}));
 			if (this.sound == 2)
 				new Audio("/sounds/chessmove1.mp3").play().catch(err => {});
-			if (this.mode != "idle")
+			if (!["idle","chat"].includes(this.mode))
 			{
 				this.incheck = this.vr.getCheckSquares(move); //is opponent in check?
 				this.vr.play(move, "ingame");
@@ -1361,7 +1512,7 @@ Vue.component('my-game', {
 			}
 			if (["human","computer"].includes(this.mode))
 				this.updateStorage(); //after our moves and opponent moves
-			if (this.mode != "idle")
+			if (!["idle","chat"].includes(this.mode))
 			{
 				const eog = this.vr.checkGameOver();
 				if (eog != "*")
diff --git a/public/stylesheets/variant.sass b/public/stylesheets/variant.sass
index a0d4f2a2..c7af125c 100644
--- a/public/stylesheets/variant.sass
+++ b/public/stylesheets/variant.sass
@@ -283,3 +283,21 @@ ul:not(.browser-default) > li
 
 .newproblem-form, .newproblem-preview
   max-width: 90%
+
+.clickable
+  cursor: pointer
+
+.name-connected
+  color: green
+
+.name-disconnected
+  color: red
+
+.clearer
+  clear: both
+
+.my-chatmsg
+  color: black
+
+.opp-chatmsg
+  color: blue
diff --git a/routes/all.js b/routes/all.js
index b9665327..d5b4e569 100644
--- a/routes/all.js
+++ b/routes/all.js
@@ -74,7 +74,8 @@ router.post("/problems/:variant([a-zA-Z0-9]+)", (req,res) => {
 	const instructions = sanitizeHtml(req.body["instructions"]);
 	const solution = sanitizeHtml(req.body["solution"]);
 	db.serialize(function() {
-		let stmt = db.prepare("INSERT INTO Problems VALUES (?,?,?,?,?)");
+		let stmt = db.prepare("INSERT INTO Problems " +
+			"(added,variant,fen,instructions,solution) VALUES (?,?,?,?,?)");
 		stmt.run(timestamp, vname, fen, instructions, solution);
 		stmt.finalize();
 	});
diff --git a/sockets.js b/sockets.js
index f1354f36..962e1d8e 100644
--- a/sockets.js
+++ b/sockets.js
@@ -46,6 +46,13 @@ module.exports = function(wss) {
 						let obj = JSON.parse(objtxt);
 						switch (obj.code)
 						{
+							case "newchat":
+								if (!!clients[page][obj.oppid])
+								{
+									clients[page][obj.oppid].send(
+										JSON.stringify({code:"newchat",msg:obj.msg}), noop);
+								}
+								break;
 							case "newmove":
 								if (!!clients[page][obj.oppid])
 								{
@@ -57,6 +64,14 @@ module.exports = function(wss) {
 								if (!!clients[page][obj.oppid])
 									socket.send(JSON.stringify({code:"pong"}));
 								break;
+							case "myname":
+								// Reveal my username to opponent
+								if (!!clients[page][obj.oppid])
+								{
+									clients[page][obj.oppid].send(JSON.stringify({
+										code:"oppname", name:obj.name}));
+								}
+								break;
 							case "lastate":
 								if (!!clients[page][obj.oppid])
 								{
-- 
2.44.0