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; +}