From 9c5544af393ac8a45f9a527af51ffc51dc15fa88 Mon Sep 17 00:00:00 2001
From: Zach Fox <fox@highfidelity.io>
Date: Fri, 14 Sep 2018 14:37:04 -0700
Subject: [PATCH] Merge in big work from appui_notifications branch

---
 .../icons/tablet-icons/people-a-msg.svg       |  83 +++++++++++
 .../icons/tablet-icons/people-i-msg.svg       |  24 ++++
 interface/resources/qml/hifi/Pal.qml          |  23 ++-
 scripts/modules/appUi.js                      | 132 +++++++++++++++---
 scripts/system/pal.js                         |  62 +++++++-
 5 files changed, 299 insertions(+), 25 deletions(-)
 create mode 100644 interface/resources/icons/tablet-icons/people-a-msg.svg
 create mode 100644 interface/resources/icons/tablet-icons/people-i-msg.svg

diff --git a/interface/resources/icons/tablet-icons/people-a-msg.svg b/interface/resources/icons/tablet-icons/people-a-msg.svg
new file mode 100644
index 0000000000..862ce936ce
--- /dev/null
+++ b/interface/resources/icons/tablet-icons/people-a-msg.svg
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Generator: Adobe Illustrator 19.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   version="1.1"
+   x="0px"
+   y="0px"
+   viewBox="0 0 50 50"
+   style="enable-background:new 0 0 50 50;"
+   xml:space="preserve"
+   id="svg2"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="people-a.svg"><metadata
+     id="metadata24"><rdf:RDF><cc:Work
+         rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
+     id="defs22" /><sodipodi:namedview
+     pagecolor="#ff0000"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="852"
+     inkscape:window-height="480"
+     id="namedview20"
+     showgrid="false"
+     inkscape:zoom="4.72"
+     inkscape:cx="25"
+     inkscape:cy="25"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg2" /><style
+     type="text/css"
+     id="style4">
+	.st0{fill:#FFFFFF;}
+	.st1{fill:#EF3B4E;}
+</style>
+<circle class="st1" cx="44.1" cy="6" r="5.6"/>
+<g
+     id="Layer_2" /><g
+     id="Layer_1"
+     style="fill:#000000;fill-opacity:1"><circle
+       class="st0"
+       cx="25.6"
+       cy="14.8"
+       r="8.1"
+       id="circle8"
+       style="fill:#000000;fill-opacity:1" /><path
+       class="st0"
+       d="M31.4,27h-11c-4.6,0-8.2,3.9-8.2,8.5v4.3c3.8,2.8,8.1,4.5,13.1,4.5c5.6,0,10.6-2.1,14.4-5.6v-3.2   C39.6,30.9,35.9,27,31.4,27z"
+       id="path10"
+       style="fill:#000000;fill-opacity:1" /><circle
+       class="st0"
+       cx="41.6"
+       cy="17.1"
+       r="3.5"
+       id="circle12"
+       style="fill:#000000;fill-opacity:1" /><path
+       class="st0"
+       d="M43.9,23.9h-4.1c-0.9,0-1.7,0.4-2.3,1c1.2,0.6,2.3,1.6,3.1,2.5c1,1.2,1.5,2.7,1.7,4.3c0.3,0.9,0.4,1.8,0.2,2.8   v0.6c1.6-2.2,3.3-6,4-8.1v-0.3C46.5,25.7,45.3,23.9,43.9,23.9z"
+       id="path14"
+       style="fill:#000000;fill-opacity:1" /><circle
+       class="st0"
+       cx="9.4"
+       cy="18"
+       r="3.5"
+       id="circle16"
+       style="fill:#000000;fill-opacity:1" /><path
+       class="st0"
+       d="M8.5,35.7c-0.1-0.7-0.1-1.4,0-2.1l0.1-0.2c0-0.2,0-0.4,0-0.6c0-0.2,0-0.3,0.1-0.5c0-0.2,0-0.3,0-0.5   c0.2-2.1,1.6-4.4,3.2-5.7c0.4-0.4,0.9-0.6,1.4-0.9c-0.5-0.5-1.3-0.7-2-0.7H7c-1.4,0-2.6,1.8-2.4,2.7v0.3   C5.1,29.8,6.8,33.5,8.5,35.7z"
+       id="path18"
+       style="fill:#000000;fill-opacity:1" /></g></svg>
\ No newline at end of file
diff --git a/interface/resources/icons/tablet-icons/people-i-msg.svg b/interface/resources/icons/tablet-icons/people-i-msg.svg
new file mode 100644
index 0000000000..635a01be4b
--- /dev/null
+++ b/interface/resources/icons/tablet-icons/people-i-msg.svg
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#FFFFFF;}
+	.st1{fill:#EF3B4E;}
+</style>
+<circle class="st1" cx="44.1" cy="6" r="5.6"/>
+<g id="Layer_2">
+</g>
+<g id="Layer_1">
+	<circle class="st0" cx="25.6" cy="14.8" r="8.1"/>
+	<path class="st0" d="M31.4,27h-11c-4.6,0-8.2,3.9-8.2,8.5v4.3c3.8,2.8,8.1,4.5,13.1,4.5c5.6,0,10.6-2.1,14.4-5.6v-3.2
+		C39.6,30.9,35.9,27,31.4,27z"/>
+	<circle class="st0" cx="41.6" cy="17.1" r="3.5"/>
+	<path class="st0" d="M43.9,23.9h-4.1c-0.9,0-1.7,0.4-2.3,1c1.2,0.6,2.3,1.6,3.1,2.5c1,1.2,1.5,2.7,1.7,4.3c0.3,0.9,0.4,1.8,0.2,2.8
+		v0.6c1.6-2.2,3.3-6,4-8.1v-0.3C46.5,25.7,45.3,23.9,43.9,23.9z"/>
+	<circle class="st0" cx="9.4" cy="18" r="3.5"/>
+	<path class="st0" d="M8.5,35.7c-0.1-0.7-0.1-1.4,0-2.1l0.1-0.2c0-0.2,0-0.4,0-0.6c0-0.2,0-0.3,0.1-0.5c0-0.2,0-0.3,0-0.5
+		c0.2-2.1,1.6-4.4,3.2-5.7c0.4-0.4,0.9-0.6,1.4-0.9c-0.5-0.5-1.3-0.7-2-0.7H7c-1.4,0-2.6,1.8-2.4,2.7v0.3
+		C5.1,29.8,6.8,33.5,8.5,35.7z"/>
+</g>
+</svg>
diff --git a/interface/resources/qml/hifi/Pal.qml b/interface/resources/qml/hifi/Pal.qml
index 35a0078d32..e8fc41da63 100644
--- a/interface/resources/qml/hifi/Pal.qml
+++ b/interface/resources/qml/hifi/Pal.qml
@@ -271,6 +271,8 @@ Rectangle {
                             connectionsUserModel.getFirstPage();
                         }
                         activeTab = "connectionsTab";
+                        connectionsOnlineDot.visible = false;
+                        pal.sendToScript({method: 'hideNotificationDot'});
                         connectionsHelpText.color = hifi.colors.blueAccent;
                     }
                 }
@@ -298,6 +300,16 @@ Rectangle {
                             }
                         }
                     }
+                    Rectangle {
+                        id: connectionsOnlineDot;
+                        visible: false;
+                        width: 10;
+                        height: width;
+                        radius: width;
+                        color: "#EF3B4E"
+                        anchors.left: parent.left;
+                        anchors.verticalCenter: parent.verticalCenter;
+                    }
                     // "CONNECTIONS" text
                     RalewaySemiBold {
                         id: connectionsTabSelectorText;
@@ -305,7 +317,11 @@ Rectangle {
                         // Text size
                         size: hifi.fontSizes.tabularData;
                         // Anchors
-                        anchors.fill: parent;
+                        anchors.left: connectionsOnlineDot.visible ? connectionsOnlineDot.right : parent.left;
+                        anchors.leftMargin: connectionsOnlineDot.visible ? 4 : 0;
+                        anchors.top: parent.top;
+                        anchors.bottom: parent.bottom;
+                        anchors.right: parent.right;
                         // Style
                         font.capitalization: Font.AllUppercase;
                         color: activeTab === "connectionsTab" ? hifi.colors.blueAccent : hifi.colors.baseGray;
@@ -326,7 +342,7 @@ Rectangle {
                         anchors.left: connectionsTabSelectorTextContainer.left;
                         anchors.top: connectionsTabSelectorTextContainer.top;
                         anchors.topMargin: 1;
-                        anchors.leftMargin: connectionsTabSelectorTextMetrics.width + 42;
+                        anchors.leftMargin: connectionsTabSelectorTextMetrics.width + 42 + connectionsOnlineDot.width + connectionsTabSelectorText.anchors.leftMargin;
                         RalewayRegular {
                             id: connectionsHelpText;
                             text: "[?]";
@@ -1267,6 +1283,9 @@ Rectangle {
         case 'http.response':
             http.handleHttpResponse(message);
             break;
+        case 'changeConnectionsDotStatus':
+            connectionsOnlineDot.visible = message.shouldShowDot;
+            break;
         default:
             console.log('Unrecognized message:', JSON.stringify(message));
         }
diff --git a/scripts/modules/appUi.js b/scripts/modules/appUi.js
index 3a70a69d4d..02beb41674 100644
--- a/scripts/modules/appUi.js
+++ b/scripts/modules/appUi.js
@@ -11,6 +11,7 @@
 //
 
 function AppUi(properties) {
+    var request = Script.require('request').request;
     /* Example development order:
        1. var AppUi = Script.require('appUi');
        2. Put appname-i.svg, appname-a.svg in graphicsDirectory (where non-default graphicsDirectory can be added in #3).
@@ -80,13 +81,6 @@ function AppUi(properties) {
     that.buttonActive = function buttonActive(isActive) { // How to make the button active (white).
         that.button.editProperties({isActive: isActive});
     };
-    that.messagesWaiting = function messagesWaiting(isWaiting) { // How to indicate a message light on button.
-        // Note that waitingButton doesn't have to exist unless someone explicitly calls this with isWaiting true.
-        that.button.editProperties({
-            icon: isWaiting ? that.normalMessagesButton : that.normalButton,
-            activeIcon: isWaiting ? that.activeMessagesButton : that.activeButton
-        });
-    };
     that.isQMLUrl = function isQMLUrl(url) {
         var type = /.qml$/.test(url) ? 'QML' : 'Web';
         return type === 'QML';
@@ -95,6 +89,32 @@ function AppUi(properties) {
         return that.currentVisibleScreenType === 'QML';
     };
 
+    //
+    // START Notification Handling Defaults
+    //
+    that.messagesWaiting = function messagesWaiting(isWaiting) { // How to indicate a message light on button.
+        // Note that waitingButton doesn't have to exist unless someone explicitly calls this with isWaiting true.
+        that.button.editProperties({
+            icon: isWaiting ? that.normalMessagesButton : that.normalButton,
+            activeIcon: isWaiting ? that.activeMessagesButton : that.activeButton
+        });
+    };
+    that.notificationPollTimeout = false;
+    that.notificationPollTimeoutMs = 60000;
+    that.notificationPollEndpoint = false;
+    that.notificationPollStopPaginatingConditionMet = false;
+    that.notificationDataProcessPage = function (data) {
+        return data;
+    };
+    that.notificationPollCallback = that.ignore;
+    that.notificationPollCaresAboutSince = false;
+    that.notificationDisplayBanner = function (message) {
+        Window.displayAnnouncement(message);
+    };
+    //
+    // END Notification Handling Defaults
+    //
+
     // Handlers
     that.onScreenChanged = function onScreenChanged(type, url) {
         // Set isOpen, wireEventBridge, set buttonActive as appropriate,
@@ -126,6 +146,75 @@ function AppUi(properties) {
     // Overwrite with the given properties:
     Object.keys(properties).forEach(function (key) { that[key] = properties[key]; });
 
+    //
+    // START Notification Handling
+    //
+    var METAVERSE_BASE = Account.metaverseServerURL;
+    var currentDataPageToRetrieve = 1;
+    var concatenatedServerResponse = new Array();
+    that.notificationPoll = function () {
+        if (!that.notificationPollEndpoint) {
+            return;
+        }
+
+        // User is "appearing offline"
+        if (GlobalServices.findableBy === "none") {
+            that.notificationPollTimeout = Script.setTimeout(that.notificationPoll, that.notificationPollTimeoutMs);
+            return;
+        }
+
+        var url = METAVERSE_BASE + that.notificationPollEndpoint;
+
+        if (that.notificationPollCaresAboutSince) {
+            url = url + "&since=" + (new Date().getTime());
+        }
+
+        console.debug(that.buttonName, 'polling for notifications at endpoint', url);
+
+        function requestCallback(error, response) {
+            if (error || (response.status !== 'success')) {
+                print("Error: unable to get", url, error || response.status);
+                that.notificationPollTimeout = Script.setTimeout(that.notificationPoll, that.notificationPollTimeoutMs);
+                return;
+            }
+
+            if (!that.notificationPollStopPaginatingConditionMet || that.notificationPollStopPaginatingConditionMet(response)) {
+                that.notificationPollTimeout = Script.setTimeout(that.notificationPoll, that.notificationPollTimeoutMs);
+
+                var notificationData;
+                if (concatenatedServerResponse.length) {
+                    notificationData = concatenatedServerResponse;
+                } else {
+                    notificationData = that.notificationDataProcessPage(response);
+                }
+                console.debug(that.buttonName, 'notification data for processing:', JSON.stringify(notificationData));
+                that.notificationPollCallback(notificationData);
+                currentDataPageToRetrieve = 1;
+                concatenatedServerResponse = new Array();
+            } else {
+                concatenatedServerResponse = concatenatedServerResponse.concat(that.notificationDataProcessPage(response));
+                currentDataPageToRetrieve++;
+                request({ uri: (url + "&page=" + currentDataPageToRetrieve) }, requestCallback);
+            }
+        }
+
+        request({ uri: url }, requestCallback);
+    };
+
+    // This won't do anything if there isn't a notification endpoint set
+    that.notificationPoll();
+
+    function availabilityChanged() {
+        if (that.notificationPollTimeout) {
+            Script.clearTimeout(that.notificationPollTimeout);
+            that.notificationPollTimeout = false;
+        }
+        that.notificationPoll();
+    }
+    //
+    // END Notification Handling
+    //
+
     // Properties:
     that.tablet = Tablet.getTablet(that.tabletName);
     // Must be after we gather properties.
@@ -147,8 +236,9 @@ function AppUi(properties) {
     }
     that.button = that.tablet.addButton(buttonOptions);
     that.ignore = function ignore() { };
-    that.hasQmlEventBridge = false;
-    that.hasHtmlEventBridge = false;
+    that.hasOutboundEventBridge = false;
+    that.hasInboundQmlEventBridge = false;
+    that.hasInboundHtmlEventBridge = false;
     // HTML event bridge uses strings, not objects. Here we abstract over that.
     // (Although injected javascript still has to use JSON.stringify/JSON.parse.)
     that.sendToHtml = function (messageObject) {
@@ -167,8 +257,10 @@ function AppUi(properties) {
         // Outbound (always, regardless of whether there is an inbound handler).
         if (on) {
             that.sendMessage = isCurrentlyOnQMLScreen ? that.tablet.sendToQml : that.sendToHtml;
+            that.hasOutboundEventBridge = true;
         } else {
             that.sendMessage = that.ignore;
+            that.hasOutboundEventBridge = false;
         }
 
         if (!that.onMessage) {
@@ -177,25 +269,25 @@ function AppUi(properties) {
 
         // Inbound
         if (on) {
-            if (isCurrentlyOnQMLScreen && !that.hasQmlEventBridge) {
+            if (isCurrentlyOnQMLScreen && !that.hasInboundQmlEventBridge) {
                 console.debug(that.buttonName, 'connecting', that.tablet.fromQml);
                 that.tablet.fromQml.connect(that.onMessage);
-                that.hasQmlEventBridge = true;
-            } else if (!isCurrentlyOnQMLScreen && !that.hasHtmlEventBridge) {
+                that.hasInboundQmlEventBridge = true;
+            } else if (!isCurrentlyOnQMLScreen && !that.hasInboundHtmlEventBridge) {
                 console.debug(that.buttonName, 'connecting', that.tablet.webEventReceived);
                 that.tablet.webEventReceived.connect(that.fromHtml);
-                that.hasHtmlEventBridge = true;
+                that.hasInboundHtmlEventBridge = true;
             }
         } else {
-            if (that.hasQmlEventBridge) {
+            if (that.hasInboundQmlEventBridge) {
                 console.debug(that.buttonName, 'disconnecting', that.tablet.fromQml);
                 that.tablet.fromQml.disconnect(that.onMessage);
-                that.hasQmlEventBridge = false;
+                that.hasInboundQmlEventBridge = false;
             }
-            if (that.hasHtmlEventBridge) {
+            if (that.hasInboundHtmlEventBridge) {
                 console.debug(that.buttonName, 'disconnecting', that.tablet.webEventReceived);
                 that.tablet.webEventReceived.disconnect(that.fromHtml);
-                that.hasHtmlEventBridge = false;
+                that.hasInboundHtmlEventBridge = false;
             }
         }
     };
@@ -212,6 +304,7 @@ function AppUi(properties) {
         } : that.ignore;
     that.onScriptEnding = function onScriptEnding() {
         // Close if necessary, clean up any remaining handlers, and remove the button.
+        GlobalServices.findableByChanged.disconnect(availabilityChanged);
         if (that.isOpen) {
             that.close();
         }
@@ -222,10 +315,15 @@ function AppUi(properties) {
             }
             that.tablet.removeButton(that.button);
         }
+        if (that.notificationPollTimeout) {
+            Script.clearInterval(that.notificationPollTimeout);
+            that.notificationPollTimeout = false;
+        }
     };
     // Set up the handlers.
     that.tablet.screenChanged.connect(that.onScreenChanged);
     that.button.clicked.connect(that.onClicked);
     Script.scriptEnding.connect(that.onScriptEnding);
+    GlobalServices.findableByChanged.connect(availabilityChanged);
 }
 module.exports = AppUi;
diff --git a/scripts/system/pal.js b/scripts/system/pal.js
index 5e38624b35..1abac53f50 100644
--- a/scripts/system/pal.js
+++ b/scripts/system/pal.js
@@ -321,6 +321,10 @@ function fromQml(message) { // messages are {method, params}, like json-rpc. See
         break;
     case 'http.request':
         break; // Handled by request-service.
+    case 'hideNotificationDot':
+        shouldShowDot = false;
+        ui.messagesWaiting(shouldShowDot);
+        break;
     default:
         print('Unrecognized message from Pal.qml:', JSON.stringify(message));
     }
@@ -364,8 +368,8 @@ function getProfilePicture(username, callback) { // callback(url) if successfull
     });
 }
 var SAFETY_LIMIT = 400;
-function getAvailableConnections(domain, callback) { // callback([{usename, location}...]) if successfull. (Logs otherwise)
-    var url = METAVERSE_BASE + '/api/v1/users?per_page=' + SAFETY_LIMIT + '&';
+function getAvailableConnections(domain, callback, numResultsPerPage) { // callback([{usename, location}...]) if successfull. (Logs otherwise)
+    var url = METAVERSE_BASE + '/api/v1/users?per_page=' + (numResultsPerPage || SAFETY_LIMIT) + '&';
     if (domain) {
         url += 'status=' + domain.slice(1, -1); // without curly braces
     } else {
@@ -728,10 +732,14 @@ function createUpdateInterval() {
 
 var previousContextOverlay = ContextOverlay.enabled;
 var previousRequestsDomainListData = Users.requestsDomainListData;
-function on() {
+function palOpened() {
+    ui.sendMessage({
+        method: 'changeConnectionsDotStatus',
+        shouldShowDot: shouldShowDot
+    });
 
     previousContextOverlay = ContextOverlay.enabled;
-    previousRequestsDomainListData = Users.requestsDomainListData
+    previousRequestsDomainListData = Users.requestsDomainListData;
     ContextOverlay.enabled = false;
     Users.requestsDomainListData = true;
 
@@ -810,14 +818,56 @@ function avatarSessionChanged(avatarID) {
     sendToQml({ method: 'palIsStale', params: [avatarID, 'avatarSessionChanged'] });
 }
 
+function notificationDataProcessPage(data) {
+    return data.data.users;
+}
+
+var shouldShowDot = false;
+var firstBannerNotificationShown = false;
+function notificationPollCallback(onlineUsersArray) {
+    shouldShowDot = onlineUsersArray.length > 0;
+
+    if (!ui.isOpen) {
+        ui.messagesWaiting(shouldShowDot);
+        ui.sendMessage({
+            method: 'changeConnectionsDotStatus',
+            shouldShowDot: shouldShowDot
+        });
+
+        var message;
+        if (!firstBannerNotificationShown) {
+            message = onlineUsersArray.length + " of your connections are online. Open PEOPLE to join them!";
+            ui.notificationDisplayBanner(message);
+            firstBannerNotificationShown = true;
+        } else {
+            for (var i = 0; i < onlineUsersArray.length; i++) {
+                message = onlineUsersArray[i].username + " is available in " +
+                    onlineUsersArray[i].location.root.name + ". Open PEOPLE to join them!";
+                ui.notificationDisplayBanner(message);
+            }
+        }
+    }
+}
+
+function isReturnedDataEmpty(data) {
+    var usersArray = data.data.users;
+    return usersArray.length === 0;
+}
+
 function startup() {
     ui = new AppUi({
         buttonName: "PEOPLE",
         sortOrder: 7,
         home: "hifi/Pal.qml",
-        onOpened: on,
+        onOpened: palOpened,
         onClosed: off,
-        onMessage: fromQml
+        onMessage: fromQml,
+        notificationPollEndpoint: "/api/v1/users?filter=connections&status=online&per_page=10",
+        notificationPollTimeoutMs: 60000,
+        notificationDataProcessPage: notificationDataProcessPage,
+        notificationPollCallback: notificationPollCallback,
+        notificationPollStopPaginatingConditionMet: isReturnedDataEmpty,
+        notificationPollCaresAboutSince: true
     });
     Window.domainChanged.connect(clearLocalQMLDataAndClosePAL);
     Window.domainConnectionRefused.connect(clearLocalQMLDataAndClosePAL);