From: Benjamin Auder <benjamin.auder@somewhere>
Date: Thu, 26 Mar 2020 17:56:02 +0000 (+0100)
Subject: Add basic Desktop notifications for game start + new move + game over
X-Git-Url: https://git.auder.net/variants/current/css/img/pieces/%7B%7B%20pkg.url%20%7D%7D?a=commitdiff_plain;h=f5768809ae96cf4565c0bc3d2747ffc206837e20;p=vchess.git

Add basic Desktop notifications for game start + new move + game over
---

diff --git a/TODO b/TODO
index df4231ac..67912987 100644
--- a/TODO
+++ b/TODO
@@ -1,7 +1,3 @@
-# Enhancements:
-Desktop notifications
-Do not remove other challenges when one is accepted?
-
 # New variants
 Finish first https://www.chessvariants.com/mvopponent.dir/dynamo.html
 https://echekk.fr/spip.php?page=article&id_article=599
diff --git a/client/src/utils/notifications.js b/client/src/utils/notifications.js
new file mode 100644
index 00000000..f849be7e
--- /dev/null
+++ b/client/src/utils/notifications.js
@@ -0,0 +1,13 @@
+// https://developer.mozilla.org/en-US/docs/Web/API/notification
+export function notify(title, options) {
+  if (Notification.permission === "granted")
+    new Notification(title, options);
+  else if (Notification.permission !== 'denied') {
+    Notification.requestPermission(function (permission) {
+      if(!('permission' in Notification))
+        Notification.permission = permission;
+      if (permission === "granted")
+        var notification = new Notification(title, options);
+    });
+  }
+}
diff --git a/client/src/views/Game.vue b/client/src/views/Game.vue
index 6ef161f3..6d37d1ed 100644
--- a/client/src/views/Game.vue
+++ b/client/src/views/Game.vue
@@ -131,6 +131,7 @@ import Chat from "@/components/Chat.vue";
 import { store } from "@/store";
 import { GameStorage } from "@/utils/gameStorage";
 import { ppt } from "@/utils/datetime";
+import { notify } from "@/utils/notifications";
 import { ajax } from "@/utils/ajax";
 import { extractTime } from "@/utils/timeControl";
 import { getRandString } from "@/utils/alea";
@@ -154,6 +155,7 @@ export default {
       gameRef: "",
       nextIds: [],
       game: {}, //passed to BaseGame
+      focus: false,
       // virtualClocks will be initialized from true game.clocks
       virtualClocks: [],
       vr: null, //"variant rules" object initialized from FEN
@@ -229,6 +231,8 @@ export default {
     cleanBeforeDestroy: function() {
       clearInterval(this.socketCloseListener);
       document.removeEventListener('visibilitychange', this.visibilityChange);
+      window.removeEventListener('focus', this.onFocus);
+      window.removeEventListener('blur', this.onBlur);
       if (!!this.askLastate) clearInterval(this.askLastate);
       if (!!this.retrySendmove) clearInterval(this.retrySendmove);
       if (!!this.clockUpdate) clearInterval(this.clockUpdate);
@@ -238,11 +242,25 @@ export default {
     },
     visibilityChange: function() {
       // TODO: Use document.hidden? https://webplatform.news/issues/2019-03-27
-      this.send(
-        document.visibilityState == "visible"
-          ? "getfocus"
-          : "losefocus"
-      );
+      this.focus = (document.visibilityState == "visible");
+      if (!this.focus && !!this.rematchOffer) {
+        this.rematchOffer = "";
+        this.send("rematchoffer", { data: false });
+        // Do not remove rematch offer from (local) storage
+      }
+      this.send(this.focus ? "getfocus" : "losefocus");
+    },
+    onFocus: function() {
+      this.focus = true;
+      this.send("getfocus");
+    },
+    onBlur: function() {
+      this.focus = false;
+      if (!!this.rematchOffer) {
+        this.rematchOffer = "";
+        this.send("rematchoffer", { data: false });
+      }
+      this.send("losefocus");
     },
     participateInChat: function(p) {
       return Object.keys(p.tmpIds).some(x => p.tmpIds[x].focus) && !!p.name;
@@ -256,6 +274,8 @@ export default {
     },
     atCreation: function() {
       document.addEventListener('visibilitychange', this.visibilityChange);
+      window.addEventListener('focus', this.onFocus);
+      window.addEventListener('blur', this.onBlur);
       // 0] (Re)Set variables
       this.gameRef = this.$route.params["id"];
       // next = next corr games IDs to navigate faster (if applicable)
@@ -680,9 +700,22 @@ export default {
             } else {
               this.gotMoveIdx = movePlus.index;
               const receiveMyMove = (movePlus.color == this.game.mycolor);
-              if (!receiveMyMove && !!this.game.mycolor)
+              const moveColIdx = ["w", "b"].indexOf(movePlus.color);
+              if (!receiveMyMove && !!this.game.mycolor) {
                 // Notify opponent that I got the move:
                 this.send("gotmove", {data: movePlus.index, target: data.from});
+                // And myself if I'm elsewhere:
+                if (!this.focus) {
+                  notify(
+                    "New move",
+                    {
+                      body:
+                        (this.game.players[moveColIdx].name || "@nonymous") +
+                        " just played."
+                    }
+                  );
+                }
+              }
               if (movePlus.cancelDrawOffer) {
                 // Opponent refuses draw
                 this.drawOffer = "";
@@ -696,7 +729,6 @@ export default {
                 }
               }
               this.$refs["basegame"].play(movePlus.move, "received", null, true);
-              const moveColIdx = ["w", "b"].indexOf(movePlus.color);
               this.game.clocks[moveColIdx] = movePlus.clock;
               this.processMove(
                 movePlus.move,
@@ -1295,7 +1327,7 @@ export default {
               });
             };
             // The active tab can update storage immediately
-            if (!document.hidden) updateStorage();
+            if (this.focus) updateStorage();
             // Small random delay otherwise
             else setTimeout(updateStorage, 500 + 1000 * Math.random());
           }
@@ -1426,6 +1458,13 @@ export default {
         };
         if (this.game.type == "live") {
           GameStorage.update(this.gameRef, scoreObj);
+          // Notify myself locally if I'm elsewhere:
+          if (!this.focus) {
+            notify(
+              "Game over",
+              { body: score + " : " + scoreMsg }
+            );
+          }
           if (!!callback) callback();
         }
         else this.updateCorrGame(scoreObj, callback);
diff --git a/client/src/views/Hall.vue b/client/src/views/Hall.vue
index 8cbbf200..48fd68e6 100644
--- a/client/src/views/Hall.vue
+++ b/client/src/views/Hall.vue
@@ -205,6 +205,7 @@ main
 <script>
 import { store } from "@/store";
 import { checkChallenge } from "@/data/challengeCheck";
+import { notify } from "@/utils/notifications";
 import { ArrayFun } from "@/utils/array";
 import { ajax } from "@/utils/ajax";
 import params from "@/parameters";
@@ -248,6 +249,7 @@ export default {
         diag: "", //visualizing FEN
         memorize: false //put settings in localStorage
       },
+      focus: true,
       tchallDiag: "",
       curChallToAccept: {from: {}},
       presetChalls: JSON.parse(localStorage.getItem("presetChalls") || "[]"),
@@ -275,6 +277,8 @@ export default {
   },
   created: function() {
     document.addEventListener('visibilitychange', this.visibilityChange);
+    window.addEventListener('focus', this.onFocus);
+    window.addEventListener('blur', this.onBlur);
     window.addEventListener("beforeunload", this.cleanBeforeDestroy);
     if (this.st.variants.length > 0 && this.newchallenge.vid > 0)
       this.loadNewchallVariant();
@@ -408,6 +412,8 @@ export default {
     cleanBeforeDestroy: function() {
       clearInterval(this.socketCloseListener);
       document.removeEventListener('visibilitychange', this.visibilityChange);
+      window.removeEventListener('focus', this.onFocus);
+      window.removeEventListener('blur', this.onBlur);
       window.removeEventListener("beforeunload", this.cleanBeforeDestroy);
       this.conn.removeEventListener("message", this.socketMessageListener);
       this.send("disconnect");
@@ -432,11 +438,16 @@ export default {
     },
     visibilityChange: function() {
       // TODO: Use document.hidden? https://webplatform.news/issues/2019-03-27
-      this.send(
-        document.visibilityState == "visible"
-          ? "getfocus"
-          : "losefocus"
-      );
+      this.focus = (document.visibilityState == "visible");
+      this.send(this.focus ? "getfocus" : "losefocus");
+    },
+    onFocus: function() {
+      this.focus = true;
+      this.send("getfocus");
+    },
+    onBlur: function() {
+      this.focus = false;
+      this.send("losefocus");
     },
     partialResetNewchallenge: function() {
       // Reset potential target and custom FEN:
@@ -1236,6 +1247,7 @@ export default {
       );
       setTimeout(
         () => {
+          const myIdx = (game.players[0].sid == this.st.user.sid ? 0 : 1);
           GameStorage.add(game, (err) => {
             // If an error occurred, game is not added: a tab already
             // added the game. Maybe a focused one, maybe not.
@@ -1243,10 +1255,16 @@ export default {
             // ==> Do not play it again.
             if (!err && this.st.settings.sound)
               new Audio("/sounds/newgame.flac").play().catch(() => {});
+            if (!this.focus) {
+              notify(
+                "New live game",
+                { body: "vs " + game.players[1-myIdx].name || "@nonymous" }
+              );
+            }
             this.$router.push("/game/" + gameInfo.id);
           });
         },
-        document.hidden ? 500 + 1000 * Math.random() : 0
+        this.focus ? 500 + 1000 * Math.random() : 0
       );
     }
   }