From fd20e821234e7ccc640c5d4f500f2740b2a5b8d3 Mon Sep 17 00:00:00 2001
From: David Baker <dave@matrix.org>
Date: Fri, 3 Jul 2015 11:12:54 +0100
Subject: [PATCH] Add desktop notifications, overridable in the same way as
 other components (although it's not a react component). Also extend the flux
 dispatcher a little to be less dumb about dispatching while something else is
 already dispatching.

---
 .../views/atoms/EnableNotificationsButton.js  |  38 +++++++
 skins/base/views/molecules/MatrixToolbar.js   |   2 +
 skins/base/views/organisms/Notifier.js        | 102 ++++++++++++++++++
 src/ComponentBroker.js                        |   2 +
 .../atoms/EnableNotificationsButton.js        |  64 +++++++++++
 src/controllers/organisms/Notifier.js         |  46 ++++++++
 src/controllers/pages/MatrixChat.js           |  10 +-
 src/dispatcher.js                             |  19 +++-
 src/extend.js                                 |   8 ++
 9 files changed, 288 insertions(+), 3 deletions(-)
 create mode 100644 skins/base/views/atoms/EnableNotificationsButton.js
 create mode 100644 skins/base/views/organisms/Notifier.js
 create mode 100644 src/controllers/atoms/EnableNotificationsButton.js
 create mode 100644 src/controllers/organisms/Notifier.js
 create mode 100644 src/extend.js

diff --git a/skins/base/views/atoms/EnableNotificationsButton.js b/skins/base/views/atoms/EnableNotificationsButton.js
new file mode 100644
index 00000000..7caebb76
--- /dev/null
+++ b/skins/base/views/atoms/EnableNotificationsButton.js
@@ -0,0 +1,38 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var EnableNotificationsButtonController = require("../../../../src/controllers/atoms/EnableNotificationsButton");
+
+module.exports = React.createClass({
+    displayName: 'EnableNotificationsButton',
+    mixins: [EnableNotificationsButtonController],
+
+    render: function() {
+        if (this.enabled()) {
+            return (
+                <button className="mx_EnableNotificationsButton" onClick={this.onClick}>Disable Notifications</button>
+            );
+        } else {
+            return (
+                <button className="mx_EnableNotificationsButton" onClick={this.onClick}>Enable Notifications</button>
+            );
+        }
+    }
+});
diff --git a/skins/base/views/molecules/MatrixToolbar.js b/skins/base/views/molecules/MatrixToolbar.js
index fe2c7614..e4444ee9 100644
--- a/skins/base/views/molecules/MatrixToolbar.js
+++ b/skins/base/views/molecules/MatrixToolbar.js
@@ -21,6 +21,7 @@ var React = require('react');
 var ComponentBroker = require('../../../../src/ComponentBroker');
 
 var LogoutButton = ComponentBroker.get("atoms/LogoutButton");
+var EnableNotificationsButton = ComponentBroker.get("atoms/EnableNotificationsButton");
 
 var MatrixToolbarController = require("../../../../src/controllers/molecules/MatrixToolbar");
 
@@ -32,6 +33,7 @@ module.exports = React.createClass({
         return (
             <div className="mx_MatrixToolbar">
                 <LogoutButton />
+                <EnableNotificationsButton />
             </div>
         );
     }
diff --git a/skins/base/views/organisms/Notifier.js b/skins/base/views/organisms/Notifier.js
new file mode 100644
index 00000000..110c21e2
--- /dev/null
+++ b/skins/base/views/organisms/Notifier.js
@@ -0,0 +1,102 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var NotifierController = require("../../../../src/controllers/organisms/Notifier");
+
+var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
+var extend = require("../../../../src/extend");
+var dis = require("../../../../src/dispatcher");
+
+
+var NotifierView = {
+    notificationMessageForEvent: function(ev) {
+        var senderDisplayName = ev.sender.name;
+        var message = null;
+
+        if (ev.event.type === "m.room.message") {
+            message = ev.getContent().body;
+            if (ev.getContent().msgtype === "m.emote") {
+                message = "* " + senderDisplayName + " " + message;
+            } else if (ev.getContent().msgtype === "m.image") {
+                message = senderDisplayName + " sent an image.";
+            }
+        } else if (ev.event.type == "m.room.member") {
+            if (ev.event.state_key !== MatrixClientPeg.get().credentials.userId  && "join" === ev.getContent().membership) {
+                // Notify when another user joins
+                message = ev.target.name + " joined";
+            } else if (ev.event.state_key === MatrixClientPeg.get().credentials.userId  && "invite" === ev.getContent().membership) {
+                // notify when you are invited
+                message = senderDisplayName + " invited you to a room";
+            }
+        }
+        return message;
+    },
+
+    displayNotification: function(ev, room) {
+        if (!global.Notification || global.Notification.permission != 'granted') {
+            return;
+        }
+        if (global.document.hasFocus()) {
+            return;
+        }
+
+        var msg = this.notificationMessageForEvent(ev);
+        if (!msg) return;
+
+        var title;
+        if (room.name == ev.sender.name) {
+            title = room.name;
+        } else {
+            title = ev.sender.name + " (" + room.name + ")";
+        }
+
+        var notification = new global.Notification(
+            title,
+            {
+                "body": msg,
+                "icon": MatrixClientPeg.get().getAvatarUrlForMember(ev.sender)
+            }
+        );
+
+        notification.onclick = function() {
+            dis.dispatch({
+                action: 'view_room',
+                room_id: room.roomId
+            });
+            global.focus();
+        };
+        
+        /*var audioClip;
+        
+        if (audioNotification) {
+            audioClip = playAudio(audioNotification);
+        }*/
+
+        global.setTimeout(function() {
+            notification.close();
+        }, 5 * 1000);
+        
+    }
+};
+
+var NotifierClass = function() {};
+extend(NotifierClass.prototype, NotifierController);
+extend(NotifierClass.prototype, NotifierView);
+
+module.exports = new NotifierClass();
+
diff --git a/src/ComponentBroker.js b/src/ComponentBroker.js
index 7d711e9b..07261061 100644
--- a/src/ComponentBroker.js
+++ b/src/ComponentBroker.js
@@ -42,6 +42,7 @@ module.exports = {
 // Must be in this file (because the require is file-specific) and
 // must be at the end because the components include this file.
 require('../skins/base/views/atoms/LogoutButton');
+require('../skins/base/views/atoms/EnableNotificationsButton');
 require('../skins/base/views/atoms/MessageTimestamp');
 require('../skins/base/views/molecules/MatrixToolbar');
 require('../skins/base/views/molecules/RoomTile');
@@ -60,3 +61,4 @@ require('../skins/base/views/molecules/MemberTile');
 require('../skins/base/views/organisms/RoomList');
 require('../skins/base/views/organisms/RoomView');
 require('../skins/base/views/templates/Login');
+require('../skins/base/views/organisms/Notifier');
diff --git a/src/controllers/atoms/EnableNotificationsButton.js b/src/controllers/atoms/EnableNotificationsButton.js
new file mode 100644
index 00000000..02d99f65
--- /dev/null
+++ b/src/controllers/atoms/EnableNotificationsButton.js
@@ -0,0 +1,64 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+module.exports = {
+    notificationsAvailable: function() {
+        return !!global.Notification;
+    },
+
+    havePermission: function() {
+        return global.Notification.permission == 'granted';
+    },
+
+    enabled: function() {
+        if (!this.havePermission()) return false;
+
+        if (!global.localStorage) return true;
+
+        var enabled = global.localStorage.getItem('notifications_enabled');
+        if (enabled === null) return true;
+        return enabled === 'true';
+    },
+
+    disable: function() {
+        if (!global.localStorage) return;
+        global.localStorage.setItem('notifications_enabled', 'false');
+        this.forceUpdate();
+    },
+
+    enable: function() {
+        if (!this.havePermission()) {
+            global.Notification.requestPermission();
+        }
+
+        if (!global.localStorage) return;
+        global.localStorage.setItem('notifications_enabled', 'true');
+        this.forceUpdate();
+    },
+
+    onClick: function() {
+        if (!this.notificationsAvailable()) {
+            return;
+        }
+        if (!this.enabled()) {
+            this.enable();
+        } else {
+            this.disable();
+        }
+    },
+};
diff --git a/src/controllers/organisms/Notifier.js b/src/controllers/organisms/Notifier.js
new file mode 100644
index 00000000..9c16d7ea
--- /dev/null
+++ b/src/controllers/organisms/Notifier.js
@@ -0,0 +1,46 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var MatrixClientPeg = require("../../MatrixClientPeg");
+
+module.exports = {
+    start: function() {
+        this.boundOnRoomTimeline = this.onRoomTimeline.bind(this);
+        MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline);
+    },
+
+    stop: function() {
+        if (MatrixClientPeg.get()) {
+            MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline);
+        }
+    },
+
+    onRoomTimeline: function(ev, room, toStartOfTimeline) {
+        if (toStartOfTimeline) return;
+        if (ev.sender.userId == MatrixClientPeg.get().credentials.userId) return;
+
+        var enabled = global.localStorage.getItem('notifications_enabled');
+        if (enabled === 'false') return;
+
+        var actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
+        if (actions && actions.notify) {
+            this.displayNotification(ev, room);
+        }
+    }
+};
+
diff --git a/src/controllers/pages/MatrixChat.js b/src/controllers/pages/MatrixChat.js
index 6f2d4ba3..04735c0b 100644
--- a/src/controllers/pages/MatrixChat.js
+++ b/src/controllers/pages/MatrixChat.js
@@ -23,10 +23,14 @@ var MatrixClientPeg = require("../../MatrixClientPeg");
 
 var dis = require("../../dispatcher");
 
+var ComponentBroker = require('../../ComponentBroker');
+
+var Notifier = ComponentBroker.get('organisms/Notifier');
+
 module.exports = {
     getInitialState: function() {
         return {
-            logged_in: !!(MatrixClientPeg.get() && mxCliPeg.get().credentials),
+            logged_in: !!(MatrixClientPeg.get() && MatrixClientPeg.get().credentials),
             ready: false
         };
     },
@@ -61,14 +65,15 @@ module.exports = {
                     logged_in: false,
                     ready: false
                 });
+                Notifier.stop();
                 MatrixClientPeg.get().removeAllListeners();
                 MatrixClientPeg.replace(null);
                 break;
             case 'view_room':
+                this.focusComposer = true;
                 this.setState({
                     currentRoom: payload.room_id
                 });
-                this.focusComposer = true;
                 break;
             case 'view_prev_room':
                 roomIndexDelta = -1;
@@ -105,6 +110,7 @@ module.exports = {
             that.setState({ready: true, currentRoom: firstRoom});
             dis.dispatch({action: 'focus_composer'});
         });
+        Notifier.start();
         cli.startClient();
     },
 
diff --git a/src/dispatcher.js b/src/dispatcher.js
index ede63912..3edb9c69 100644
--- a/src/dispatcher.js
+++ b/src/dispatcher.js
@@ -17,4 +17,21 @@ limitations under the License.
 'use strict';
 
 var flux = require("flux");
-module.exports = new flux.Dispatcher();
+var extend = require("./extend");
+
+var MatrixDispatcher = function() {
+    flux.Dispatcher.call(this);
+};
+
+extend(MatrixDispatcher.prototype, flux.Dispatcher.prototype);
+MatrixDispatcher.prototype.dispatch = function(payload) {
+    if (this.dispatching) {
+        setTimeout(flux.Dispatcher.prototype.dispatch.bind(this, payload), 0);
+    } else {
+        this.dispatching = true;
+        flux.Dispatcher.prototype.dispatch.call(this, payload);
+        this.dispatching = false;
+    }
+}
+
+module.exports = new MatrixDispatcher();
diff --git a/src/extend.js b/src/extend.js
new file mode 100644
index 00000000..f5658b58
--- /dev/null
+++ b/src/extend.js
@@ -0,0 +1,8 @@
+module.exports = function(dest, src) {
+    for (var i in src) {
+        if (src.hasOwnProperty(i)) {
+            dest[i] = src[i];
+        }
+    }
+    return dest;
+}