Browse Source

Move converse-notifications plugin into a folder and split up

JC Brand 4 years ago
parent
commit
50dda3244e

+ 30 - 52
spec/notification.js

@@ -1,4 +1,4 @@
-/*global mock, converse, _ */
+/*global mock, converse */
 
 const { Strophe } = converse.env;
 const $msg = converse.env.$msg;
@@ -15,9 +15,8 @@ describe("Notifications", function () {
                         mock.initConverse(['rosterGroupsFetched'], {}, async (done, _converse) => {
 
                     await mock.waitForRoster(_converse, 'current');
-                    spyOn(_converse, 'showMessageNotification').and.callThrough();
-                    spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-                    spyOn(_converse, 'isMessageToHiddenChat').and.returnValue(true);
+                    const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+                    spyOn(window, 'Notification').and.returnValue(stub);
 
                     const message = 'This message will show a desktop notification';
                     const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
@@ -30,8 +29,7 @@ describe("Notifications", function () {
                         .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
                     await _converse.handleMessageStanza(msg); // This will emit 'message'
                     await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
-                    expect(_converse.areDesktopNotificationsEnabled).toHaveBeenCalled();
-                    expect(_converse.showMessageNotification).toHaveBeenCalled();
+                    expect(window.Notification).toHaveBeenCalled();
                     done();
                 }));
 
@@ -44,17 +42,9 @@ describe("Notifications", function () {
                     if (!view.el.querySelectorAll('.chat-area').length) {
                         view.renderChatArea();
                     }
-                    let no_notification = false;
-                    if (typeof window.Notification === 'undefined') {
-                        no_notification = true;
-                        window.Notification = function () {
-                            return {
-                                'close': function () {}
-                            };
-                        };
-                    }
-                    spyOn(_converse, 'showMessageNotification').and.callThrough();
-                    spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
+
+                    const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+                    spyOn(window, 'Notification').and.returnValue(stub);
 
                     // Test mention with setting false
                     const nick = mock.chatroom_names[0];
@@ -66,8 +56,7 @@ describe("Notifications", function () {
                     }).c('body').t(text).tree();
                     _converse.connection._dataRecv(mock.createRequest(makeMsg('romeo: this will NOT show a notification')));
                     await new Promise(resolve => view.model.messages.once('rendered', resolve));
-                    await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 0);
-                    expect(_converse.showMessageNotification).not.toHaveBeenCalled();
+                    expect(window.Notification).not.toHaveBeenCalled();
 
                     // Test reference
                     const message_with_ref = $msg({
@@ -79,27 +68,21 @@ describe("Notifications", function () {
                     .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'0', 'end':'5', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).tree();
                     _converse.connection._dataRecv(mock.createRequest(message_with_ref));
                     await new Promise(resolve => view.model.messages.once('rendered', resolve));
-                    await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 1);
-                    expect(_converse.showMessageNotification).toHaveBeenCalled();
+                    expect(window.Notification.calls.count()).toBe(1);
 
                     // Test mention with setting true
                     _converse.api.settings.set('notify_all_room_messages', true);
                     _converse.connection._dataRecv(mock.createRequest(makeMsg('romeo: this will show a notification')));
                     await new Promise(resolve => view.model.messages.once('rendered', resolve));
-                    await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 2);
-                    expect(_converse.showMessageNotification).toHaveBeenCalled();
-                    if (no_notification) {
-                        delete window.Notification;
-                    }
+                    expect(window.Notification.calls.count()).toBe(2);
                     done();
                 }));
 
                 it("is shown for headline messages",
                         mock.initConverse(['rosterGroupsFetched'], {}, async (done, _converse) => {
 
-                    spyOn(_converse, 'showMessageNotification').and.callThrough();
-                    spyOn(_converse, 'isMessageToHiddenChat').and.returnValue(true);
-                    spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
+                    const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+                    spyOn(window, 'Notification').and.returnValue(stub);
                     const stanza = $msg({
                             'type': 'headline',
                             'from': 'notify.example.com',
@@ -116,14 +99,13 @@ describe("Notifications", function () {
                     const view = _converse.chatboxviews.get('notify.example.com');
                     await new Promise(resolve => view.model.messages.once('rendered', resolve));
                     expect(_converse.chatboxviews.keys().includes('notify.example.com')).toBeTruthy();
-                    expect(_converse.showMessageNotification).toHaveBeenCalled();
+                    expect(window.Notification).toHaveBeenCalled();
                     done();
                 }));
 
                 it("is not shown for full JID headline messages if allow_non_roster_messaging is false", mock.initConverse((done, _converse) => {
                     _converse.allow_non_roster_messaging = false;
-                    spyOn(_converse, 'showMessageNotification').and.callThrough();
-                    spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
+                    spyOn(window, 'Notification');
                     const stanza = $msg({
                             'type': 'headline',
                             'from': 'someone@notify.example.com',
@@ -135,11 +117,8 @@ describe("Notifications", function () {
                         .c('x', {'xmlns': 'jabber:x:oob'})
                         .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
                     _converse.connection._dataRecv(mock.createRequest(stanza));
-                    expect(
-                        _.includes(_converse.chatboxviews.keys(),
-                            'someone@notify.example.com')
-                        ).toBeFalsy();
-                    expect(_converse.showMessageNotification).not.toHaveBeenCalled();
+                    expect(_converse.chatboxviews.keys().includes('someone@notify.example.com')).toBeFalsy();
+                    expect(window.Notification).not.toHaveBeenCalled();
                     done();
                 }));
 
@@ -148,12 +127,10 @@ describe("Notifications", function () {
                         async (done, _converse) => {
 
                     await mock.waitForRoster(_converse, 'current', 3);
-                    spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-                    spyOn(_converse, 'showChatStateNotification');
+                    spyOn(window, 'Notification');
                     const jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    _converse.roster.get(jid).presence.set('show', 'busy'); // This will emit 'contactStatusChanged'
-                    await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 1);
-                    expect(_converse.showChatStateNotification).toHaveBeenCalled();
+                    _converse.roster.get(jid).presence.set('show', 'dnd');
+                    expect(window.Notification).toHaveBeenCalled();
                     done()
                 }));
             });
@@ -161,11 +138,9 @@ describe("Notifications", function () {
 
         describe("When a new contact request is received", function () {
             it("an HTML5 Notification is received", mock.initConverse((done, _converse) => {
-                spyOn(_converse, 'areDesktopNotificationsEnabled').and.returnValue(true);
-                spyOn(_converse, 'showContactRequestNotification');
-                _converse.api.trigger('contactRequest', {'fullname': 'Peter Parker', 'jid': 'peter@parker.com'});
-                expect(_converse.areDesktopNotificationsEnabled).toHaveBeenCalled();
-                expect(_converse.showContactRequestNotification).toHaveBeenCalled();
+                spyOn(window, 'Notification');
+                _converse.api.trigger('contactRequest', {'getDisplayName': () => 'Peter Parker'});
+                expect(window.Notification).toHaveBeenCalled();
                 done();
             }));
         });
@@ -180,7 +155,10 @@ describe("Notifications", function () {
                 mock.createContacts(_converse, 'current');
                 await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
                 _converse.play_sounds = true;
-                spyOn(_converse, 'playSoundNotification');
+
+                const stub = jasmine.createSpyObj('MyAudio', ['play', 'canPlayType']);
+                spyOn(window, 'Audio').and.returnValue(stub);
+
                 const view = _converse.chatboxviews.get('lounge@montague.lit');
                 if (!view.el.querySelectorAll('.chat-area').length) {
                     view.renderChatArea();
@@ -194,8 +172,8 @@ describe("Notifications", function () {
                 }).c('body').t(text);
                 _converse.api.settings.set('notify_all_room_messages', true);
                 await view.model.handleMessageStanza(message.nodeTree);
-                await u.waitUntil(() => _converse.playSoundNotification.calls.count());
-                expect(_converse.playSoundNotification).toHaveBeenCalled();
+                await u.waitUntil(() => window.Audio.calls.count());
+                expect(window.Audio).toHaveBeenCalled();
 
                 text = "This message won't play a sound";
                 message = $msg({
@@ -205,7 +183,7 @@ describe("Notifications", function () {
                     type: 'groupchat'
                 }).c('body').t(text);
                 await view.model.handleMessageStanza(message.nodeTree);
-                expect(_converse.playSoundNotification, 1);
+                expect(window.Audio, 1);
                 _converse.play_sounds = false;
 
                 text = "This message won't play a sound because it is sent by romeo";
@@ -216,7 +194,7 @@ describe("Notifications", function () {
                     type: 'groupchat'
                 }).c('body').t(text);
                 await view.model.handleMessageStanza(message.nodeTree);
-                expect(_converse.playSoundNotification, 1);
+                expect(window.Audio, 1);
                 _converse.play_sounds = false;
                 done();
             }));

+ 1 - 1
src/converse.js

@@ -21,7 +21,7 @@ import "./plugins/mam-views.js";
 import "./plugins/minimize/index.js";             // Allows chat boxes to be minimized
 import "./plugins/muc-views/index.js";      // Views related to MUC
 import "./plugins/headlines-view/index.js";
-import "./plugins/notifications.js";
+import "./plugins/notifications/index.js";
 import "./plugins/omemo.js";
 import "./plugins/profile/index.js";
 import "./plugins/push.js";                 // XEP-0357 Push Notifications

+ 0 - 337
src/plugins/notifications.js

@@ -1,337 +0,0 @@
-/**
- * @module converse-notification
- * @copyright 2020, the Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- */
-import Favico from 'favico.js-slevomat';
-import log from "@converse/headless/log";
-import { __ } from '../i18n';
-import { _converse, api, converse } from "@converse/headless/core";
-
-const { Strophe } = converse.env;
-const u = converse.env.utils;
-
-const supports_html5_notification = "Notification" in window;
-
-converse.env.Favico = Favico;
-let favicon;
-
-function updateUnreadFavicon () {
-    if (api.settings.get('show_tab_notifications')) {
-        favicon = favicon ?? new converse.env.Favico({type: 'circle', animation: 'pop'});
-        const chats = _converse.chatboxes.models;
-        const num_unread = chats.reduce((acc, chat) => (acc + (chat.get('num_unread') || 0)), 0);
-        favicon.badge(num_unread);
-    }
-}
-
-
-converse.plugins.add('converse-notification', {
-
-    dependencies: ["converse-chatboxes"],
-
-    initialize () {
-        /* The initialize function gets called as soon as the plugin is
-         * loaded by converse.js's plugin machinery.
-         */
-
-        api.settings.extend({
-            // ^ a list of JIDs to ignore concerning chat state notifications
-            chatstate_notification_blacklist: [],
-            notification_delay: 5000,
-            notification_icon: 'logo/conversejs-filled.svg',
-            notify_all_room_messages: false,
-            notify_nicknames_without_references: false,
-            play_sounds: true,
-            show_chat_state_notifications: false,
-            show_desktop_notifications: true,
-            show_tab_notifications: true,
-            sounds_path: api.settings.get("assets_path")+'/sounds/',
-        });
-
-        /**
-         * Is this a group message for which we should notify the user?
-         * @private
-         * @method _converse#shouldNotifyOfGroupMessage
-         * @param { MUCMessageAttributes } attrs
-         */
-        _converse.shouldNotifyOfGroupMessage = function (attrs) {
-            if (!attrs?.body) {
-                return false;
-            }
-            const jid = attrs.from;
-            const muc_jid = attrs.from_muc;
-            const notify_all = api.settings.get('notify_all_room_messages');
-            const room = _converse.chatboxes.get(muc_jid);
-            const resource = Strophe.getResourceFromJid(jid);
-            const sender = resource && Strophe.unescapeNode(resource) || '';
-            let is_mentioned = false;
-            const nick = room.get('nick');
-
-            if (api.settings.get('notify_nicknames_without_references')) {
-                is_mentioned = (new RegExp(`\\b${nick}\\b`)).test(attrs.body);
-            }
-
-            const references_me = (r) => {
-                const jid =  r.uri.replace(/^xmpp:/, '');
-                return jid == _converse.bare_jid || jid === `${muc_jid}/${nick}`;
-            }
-            const is_referenced = attrs.references.reduce((acc, r) => acc || references_me(r), false);
-            const is_not_mine = sender !== nick;
-            const should_notify_user = notify_all === true
-                || (Array.isArray(notify_all) && notify_all.includes(muc_jid))
-                || is_referenced
-                || is_mentioned;
-            return is_not_mine && !!should_notify_user;
-        };
-
-        /**
-         * Given parsed attributes for a message stanza, get the related
-         * chatbox and check whether it's hidden.
-         * @private
-         * @method _converse#isMessageToHiddenChat
-         * @param { MUCMessageAttributes } attrs
-         */
-        _converse.isMessageToHiddenChat = function (attrs) {
-            return _converse.chatboxes.get(attrs.from)?.isHidden() ?? false;
-        };
-
-        /**
-         * @private
-         * @method _converse#shouldNotifyOfMessage
-         * @param { MessageData|MUCMessageData } data
-         */
-        _converse.shouldNotifyOfMessage = function (data) {
-            const { attrs, stanza } = data;
-            if (!attrs || stanza.querySelector('forwarded') !== null) {
-                return false;
-            }
-            if (attrs['type'] === 'groupchat') {
-                return _converse.shouldNotifyOfGroupMessage(attrs);
-            } else if (attrs.is_headline) {
-                // We want to show notifications for headline messages.
-                return _converse.isMessageToHiddenChat(attrs);
-            }
-            const is_me = Strophe.getBareJidFromJid(attrs.from) === _converse.bare_jid;
-            return !u.isOnlyChatStateNotification(stanza) &&
-                !u.isOnlyMessageDeliveryReceipt(stanza) &&
-                !is_me &&
-                (api.settings.get('show_desktop_notifications') === 'all' || _converse.isMessageToHiddenChat(attrs));
-        };
-
-
-        /**
-         * Plays a notification sound
-         * @private
-         * @method _converse#playSoundNotification
-         */
-        _converse.playSoundNotification = function () {
-            if (api.settings.get('play_sounds') && window.Audio !== undefined) {
-                const audioOgg = new Audio(api.settings.get('sounds_path')+"msg_received.ogg");
-                const canPlayOgg = audioOgg.canPlayType('audio/ogg');
-                if (canPlayOgg === 'probably') {
-                    return audioOgg.play();
-                }
-                const audioMp3 = new Audio(api.settings.get('sounds_path')+"msg_received.mp3");
-                const canPlayMp3 = audioMp3.canPlayType('audio/mp3');
-                if (canPlayMp3 === 'probably') {
-                    audioMp3.play();
-                } else if (canPlayOgg === 'maybe') {
-                    audioOgg.play();
-                } else if (canPlayMp3 === 'maybe') {
-                    audioMp3.play();
-                }
-            }
-        };
-
-        _converse.areDesktopNotificationsEnabled = function () {
-            return supports_html5_notification &&
-                api.settings.get('show_desktop_notifications') &&
-                Notification.permission === "granted";
-        };
-
-        /**
-         * Shows an HTML5 Notification with the passed in message
-         * @private
-         * @method _converse#showMessageNotification
-         * @param { MessageData|MUCMessageData } data
-         */
-        _converse.showMessageNotification = function (data) {
-            const { attrs } = data;
-            if (attrs.is_error) {
-                return;
-            }
-
-            if (!_converse.areDesktopNotificationsEnabled()) {
-                return;
-            }
-            let title, roster_item;
-            const full_from_jid = attrs.from,
-                  from_jid = Strophe.getBareJidFromJid(full_from_jid);
-            if (attrs.type === 'headline') {
-                if (!from_jid.includes('@') || api.settings.get("allow_non_roster_messaging")) {
-                    title = __("Notification from %1$s", from_jid);
-                } else {
-                    return;
-                }
-            } else if (!from_jid.includes('@')) {
-                // workaround for Prosody which doesn't give type "headline"
-                title = __("Notification from %1$s", from_jid);
-            } else if (attrs.type === 'groupchat') {
-                title = __("%1$s says", Strophe.getResourceFromJid(full_from_jid));
-            } else {
-                if (_converse.roster === undefined) {
-                    log.error("Could not send notification, because roster is undefined");
-                    return;
-                }
-                roster_item = _converse.roster.get(from_jid);
-                if (roster_item !== undefined) {
-                    title = __("%1$s says", roster_item.getDisplayName());
-                } else {
-                    if (api.settings.get("allow_non_roster_messaging")) {
-                        title = __("%1$s says", from_jid);
-                    } else {
-                        return;
-                    }
-                }
-            }
-
-            const body = attrs.is_encrypted ? __('Encrypted message received') : attrs.body;
-            if (!body) {
-                return;
-            }
-            const n = new Notification(title, {
-                'body': body,
-                'lang': _converse.locale,
-                'icon': api.settings.get('notification_icon'),
-                'requireInteraction': !_converse.notification_delay
-            });
-            if (api.settings.get('notification_delay')) {
-                setTimeout(n.close.bind(n), api.settings.get('notification_delay'));
-            }
-            n.onclick = function (event) {
-                event.preventDefault();
-                window.focus();
-                const chat = _converse.chatboxes.get(from_jid);
-                chat.maybeShow(true);
-            }
-            n.onclick.bind(_converse);
-        };
-
-        _converse.showChatStateNotification = function (contact) {
-            /* Creates an HTML5 Notification to inform of a change in a
-             * contact's chat state.
-             */
-            if (_converse.chatstate_notification_blacklist.includes(contact.jid)) {
-                // Don't notify if the user is being ignored.
-                return;
-            }
-            const chat_state = contact.chat_status;
-            let message = null;
-            if (chat_state === 'offline') {
-                message = __('has gone offline');
-            } else if (chat_state === 'away') {
-                message = __('has gone away');
-            } else if ((chat_state === 'dnd')) {
-                message = __('is busy');
-            } else if (chat_state === 'online') {
-                message = __('has come online');
-            }
-            if (message === null) {
-                return;
-            }
-            const n = new Notification(contact.getDisplayName(), {
-                    body: message,
-                    lang: _converse.locale,
-                    icon: _converse.notification_icon
-                });
-            setTimeout(n.close.bind(n), 5000);
-        };
-
-        _converse.showContactRequestNotification = function (contact) {
-            const n = new Notification(contact.getDisplayName(), {
-                    body: __('wants to be your contact'),
-                    lang: _converse.locale,
-                    icon: _converse.notification_icon
-                });
-            setTimeout(n.close.bind(n), 5000);
-        };
-
-        _converse.showFeedbackNotification = function (data) {
-            if (data.klass === 'error' || data.klass === 'warn') {
-                const n = new Notification(data.subject, {
-                        body: data.message,
-                        lang: _converse.locale,
-                        icon: _converse.notification_icon
-                    });
-                setTimeout(n.close.bind(n), 5000);
-            }
-        };
-
-        _converse.handleChatStateNotification = function (contact) {
-            /* Event handler for on('contactPresenceChanged').
-             * Will show an HTML5 notification to indicate that the chat
-             * status has changed.
-             */
-            if (_converse.areDesktopNotificationsEnabled() && api.settings.get('show_chat_state_notifications')) {
-                _converse.showChatStateNotification(contact);
-            }
-        };
-
-        _converse.handleMessageNotification = function (data) {
-            /* Event handler for the on('message') event. Will call methods
-             * to play sounds and show HTML5 notifications.
-             */
-            if (!_converse.shouldNotifyOfMessage(data)) {
-                return false;
-            }
-            /**
-             * Triggered when a notification (sound or HTML5 notification) for a new
-             * message has will be made.
-             * @event _converse#messageNotification
-             * @type { MessageData|MUCMessageData}
-             * @example _converse.api.listen.on('messageNotification', stanza => { ... });
-             */
-            api.trigger('messageNotification', data);
-            _converse.playSoundNotification();
-            _converse.showMessageNotification(data);
-        };
-
-        _converse.handleContactRequestNotification = function (contact) {
-            if (_converse.areDesktopNotificationsEnabled(true)) {
-                _converse.showContactRequestNotification(contact);
-            }
-        };
-
-        _converse.handleFeedback = function (data) {
-            if (_converse.areDesktopNotificationsEnabled(true)) {
-                _converse.showFeedbackNotification(data);
-            }
-        };
-
-        _converse.requestPermission = function () {
-            if (supports_html5_notification && !['denied', 'granted'].includes(Notification.permission)) {
-                // Ask user to enable HTML5 notifications
-                Notification.requestPermission();
-            }
-        };
-
-        /************************ BEGIN Event Handlers ************************/
-
-        api.listen.on('clearSession', () => (favicon = null)); // Needed for tests
-
-        api.waitUntil('chatBoxesInitialized').then(
-            () => _converse.chatboxes.on('change:num_unread', updateUnreadFavicon));
-
-        api.listen.on('pluginsInitialized', function () {
-            // We only register event handlers after all plugins are
-            // registered, because other plugins might override some of our
-            // handlers.
-            api.listen.on('contactRequest',  _converse.handleContactRequestNotification);
-            api.listen.on('contactPresenceChanged',  _converse.handleChatStateNotification);
-            api.listen.on('message',  _converse.handleMessageNotification);
-            api.listen.on('feedback', _converse.handleFeedback);
-            api.listen.on('connected', _converse.requestPermission);
-        });
-    }
-});

+ 53 - 0
src/plugins/notifications/index.js

@@ -0,0 +1,53 @@
+/**
+ * @module converse-notification
+ * @copyright 2020, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import { _converse, api, converse } from '@converse/headless/core';
+import {
+    clearFavicon,
+    handleChatStateNotification,
+    handleContactRequestNotification,
+    handleFeedback,
+    handleMessageNotification,
+    requestPermission,
+    updateUnreadFavicon
+} from './utils.js';
+
+converse.plugins.add('converse-notification', {
+    dependencies: ['converse-chatboxes'],
+
+    initialize () {
+        api.settings.extend({
+            // ^ a list of JIDs to ignore concerning chat state notifications
+            chatstate_notification_blacklist: [],
+            notification_delay: 5000,
+            notification_icon: 'logo/conversejs-filled.svg',
+            notify_all_room_messages: false,
+            notify_nicknames_without_references: false,
+            play_sounds: true,
+            show_chat_state_notifications: false,
+            show_desktop_notifications: true,
+            show_tab_notifications: true,
+            sounds_path: api.settings.get('assets_path') + '/sounds/'
+        });
+
+        /************************ Event Handlers ************************/
+        api.listen.on('clearSession', clearFavicon); // Needed for tests
+
+        api.waitUntil('chatBoxesInitialized').then(() =>
+            _converse.chatboxes.on('change:num_unread', updateUnreadFavicon)
+        );
+
+        api.listen.on('pluginsInitialized', function () {
+            // We only register event handlers after all plugins are
+            // registered, because other plugins might override some of our
+            // handlers.
+            api.listen.on('contactRequest', handleContactRequestNotification);
+            api.listen.on('contactPresenceChanged', handleChatStateNotification);
+            api.listen.on('message', handleMessageNotification);
+            api.listen.on('feedback', handleFeedback);
+            api.listen.on('connected', requestPermission);
+        });
+    }
+});

+ 284 - 0
src/plugins/notifications/utils.js

@@ -0,0 +1,284 @@
+import Favico from 'favico.js-slevomat';
+import log from '@converse/headless/log';
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { Strophe, u } = converse.env;
+const supports_html5_notification = 'Notification' in window;
+
+converse.env.Favico = Favico;
+
+let favicon;
+
+
+export function isMessageToHiddenChat (attrs) {
+    return _converse.isTestEnv() || (_converse.chatboxes.get(attrs.from)?.isHidden() ?? false);
+}
+
+export function areDesktopNotificationsEnabled () {
+    return _converse.isTestEnv() || (
+        supports_html5_notification &&
+        api.settings.get('show_desktop_notifications') &&
+        Notification.permission === 'granted'
+    );
+}
+
+export function clearFavicon () {
+    favicon = null;
+}
+
+export function updateUnreadFavicon () {
+    if (api.settings.get('show_tab_notifications')) {
+        favicon = favicon ?? new converse.env.Favico({ type: 'circle', animation: 'pop' });
+        const chats = _converse.chatboxes.models;
+        const num_unread = chats.reduce((acc, chat) => acc + (chat.get('num_unread') || 0), 0);
+        favicon.badge(num_unread);
+    }
+}
+
+/**
+ * Is this a group message for which we should notify the user?
+ * @private
+ * @param { MUCMessageAttributes } attrs
+ */
+export function shouldNotifyOfGroupMessage (attrs) {
+    if (!attrs?.body) {
+        return false;
+    }
+    const jid = attrs.from;
+    const muc_jid = attrs.from_muc;
+    const notify_all = api.settings.get('notify_all_room_messages');
+    const room = _converse.chatboxes.get(muc_jid);
+    const resource = Strophe.getResourceFromJid(jid);
+    const sender = (resource && Strophe.unescapeNode(resource)) || '';
+    let is_mentioned = false;
+    const nick = room.get('nick');
+
+    if (api.settings.get('notify_nicknames_without_references')) {
+        is_mentioned = new RegExp(`\\b${nick}\\b`).test(attrs.body);
+    }
+
+    const references_me = r => {
+        const jid = r.uri.replace(/^xmpp:/, '');
+        return jid == _converse.bare_jid || jid === `${muc_jid}/${nick}`;
+    };
+    const is_referenced = attrs.references.reduce((acc, r) => acc || references_me(r), false);
+    const is_not_mine = sender !== nick;
+    const should_notify_user =
+        notify_all === true ||
+        (Array.isArray(notify_all) && notify_all.includes(muc_jid)) ||
+        is_referenced ||
+        is_mentioned;
+    return is_not_mine && !!should_notify_user;
+}
+
+/**
+ * @private
+ * @method shouldNotifyOfMessage
+ * @param { MessageData|MUCMessageData } data
+ */
+function shouldNotifyOfMessage (data) {
+    const { attrs, stanza } = data;
+    if (!attrs || stanza.querySelector('forwarded') !== null) {
+        return false;
+    }
+    if (attrs['type'] === 'groupchat') {
+        return shouldNotifyOfGroupMessage(attrs);
+    } else if (attrs.is_headline) {
+        // We want to show notifications for headline messages.
+        return isMessageToHiddenChat(attrs);
+    }
+    const is_me = Strophe.getBareJidFromJid(attrs.from) === _converse.bare_jid;
+    return (
+        !u.isOnlyChatStateNotification(stanza) &&
+        !u.isOnlyMessageDeliveryReceipt(stanza) &&
+        !is_me &&
+        (api.settings.get('show_desktop_notifications') === 'all' || isMessageToHiddenChat(attrs))
+    );
+}
+
+export function showFeedbackNotification (data) {
+    if (data.klass === 'error' || data.klass === 'warn') {
+        const n = new Notification(data.subject, {
+            body: data.message,
+            lang: _converse.locale,
+            icon: _converse.notification_icon
+        });
+        setTimeout(n.close.bind(n), 5000);
+    }
+}
+
+/**
+ * Creates an HTML5 Notification to inform of a change in a
+ * contact's chat state.
+ */
+function showChatStateNotification (contact) {
+    if (_converse.chatstate_notification_blacklist.includes(contact.jid)) {
+        // Don't notify if the user is being ignored.
+        return;
+    }
+    const chat_state = contact.presence.get('show');
+    let message = null;
+    if (chat_state === 'offline') {
+        message = __('has gone offline');
+    } else if (chat_state === 'away') {
+        message = __('has gone away');
+    } else if (chat_state === 'dnd') {
+        message = __('is busy');
+    } else if (chat_state === 'online') {
+        message = __('has come online');
+    }
+    if (message === null) {
+        return;
+    }
+    const n = new Notification(contact.getDisplayName(), {
+        body: message,
+        lang: _converse.locale,
+        icon: _converse.notification_icon
+    });
+    setTimeout(() => n.close(), 5000);
+}
+
+
+/**
+ * Shows an HTML5 Notification with the passed in message
+ * @private
+ * @param { MessageData|MUCMessageData } data
+ */
+function showMessageNotification (data) {
+    const { attrs } = data;
+    if (attrs.is_error) {
+        return;
+    }
+
+    if (!areDesktopNotificationsEnabled()) {
+        return;
+    }
+    let title, roster_item;
+    const full_from_jid = attrs.from,
+        from_jid = Strophe.getBareJidFromJid(full_from_jid);
+    if (attrs.type === 'headline') {
+        if (!from_jid.includes('@') || api.settings.get('allow_non_roster_messaging')) {
+            title = __('Notification from %1$s', from_jid);
+        } else {
+            return;
+        }
+    } else if (!from_jid.includes('@')) {
+        // workaround for Prosody which doesn't give type "headline"
+        title = __('Notification from %1$s', from_jid);
+    } else if (attrs.type === 'groupchat') {
+        title = __('%1$s says', Strophe.getResourceFromJid(full_from_jid));
+    } else {
+        if (_converse.roster === undefined) {
+            log.error('Could not send notification, because roster is undefined');
+            return;
+        }
+        roster_item = _converse.roster.get(from_jid);
+        if (roster_item !== undefined) {
+            title = __('%1$s says', roster_item.getDisplayName());
+        } else {
+            if (api.settings.get('allow_non_roster_messaging')) {
+                title = __('%1$s says', from_jid);
+            } else {
+                return;
+            }
+        }
+    }
+
+    const body = attrs.is_encrypted ? __('Encrypted message received') : attrs.body;
+    if (!body) {
+        return;
+    }
+    const n = new Notification(title, {
+        'body': body,
+        'lang': _converse.locale,
+        'icon': api.settings.get('notification_icon'),
+        'requireInteraction': !_converse.notification_delay
+    });
+    if (api.settings.get('notification_delay')) {
+        setTimeout(() => n.close(), api.settings.get('notification_delay'));
+    }
+    n.onclick = function (event) {
+        event.preventDefault();
+        window.focus();
+        const chat = _converse.chatboxes.get(from_jid);
+        chat.maybeShow(true);
+    }
+}
+
+function playSoundNotification () {
+    if (api.settings.get('play_sounds') && window.Audio !== undefined) {
+        const audioOgg = new Audio(api.settings.get('sounds_path') + 'msg_received.ogg');
+        const canPlayOgg = audioOgg.canPlayType('audio/ogg');
+        if (canPlayOgg === 'probably') {
+            return audioOgg.play();
+        }
+        const audioMp3 = new Audio(api.settings.get('sounds_path') + 'msg_received.mp3');
+        const canPlayMp3 = audioMp3.canPlayType('audio/mp3');
+        if (canPlayMp3 === 'probably') {
+            audioMp3.play();
+        } else if (canPlayOgg === 'maybe') {
+            audioOgg.play();
+        } else if (canPlayMp3 === 'maybe') {
+            audioMp3.play();
+        }
+    }
+}
+
+/**
+ * Event handler for the on('message') event. Will call methods
+ * to play sounds and show HTML5 notifications.
+ */
+export function handleMessageNotification (data) {
+    if (!shouldNotifyOfMessage(data)) {
+        return false;
+    }
+    /**
+     * Triggered when a notification (sound or HTML5 notification) for a new
+     * message has will be made.
+     * @event _converse#messageNotification
+     * @type { MessageData|MUCMessageData}
+     * @example _converse.api.listen.on('messageNotification', stanza => { ... });
+     */
+    api.trigger('messageNotification', data);
+    playSoundNotification();
+    showMessageNotification(data);
+}
+
+export function handleFeedback (data) {
+    if (areDesktopNotificationsEnabled(true)) {
+        showFeedbackNotification(data);
+    }
+}
+
+/**
+ * Event handler for on('contactPresenceChanged').
+ * Will show an HTML5 notification to indicate that the chat status has changed.
+ */
+export function handleChatStateNotification (contact) {
+    if (areDesktopNotificationsEnabled() && api.settings.get('show_chat_state_notifications')) {
+        showChatStateNotification(contact);
+    }
+}
+
+function showContactRequestNotification (contact) {
+    const n = new Notification(contact.getDisplayName(), {
+        body: __('wants to be your contact'),
+        lang: _converse.locale,
+        icon: _converse.notification_icon
+    });
+    setTimeout(() => n.close(), 5000);
+}
+
+export function handleContactRequestNotification (contact) {
+    if (areDesktopNotificationsEnabled(true)) {
+        showContactRequestNotification(contact);
+    }
+}
+
+export function requestPermission () {
+    if (supports_html5_notification && !['denied', 'granted'].includes(Notification.permission)) {
+        // Ask user to enable HTML5 notifications
+        Notification.requestPermission();
+    }
+}