diff --git a/app/assets/javascripts/discourse/controllers/notification.js.es6 b/app/assets/javascripts/discourse/controllers/notification.js.es6 index ceb4816b149..c0245002bc4 100644 --- a/app/assets/javascripts/discourse/controllers/notification.js.es6 +++ b/app/assets/javascripts/discourse/controllers/notification.js.es6 @@ -1,42 +1,22 @@ import ObjectController from 'discourse/controllers/object'; - -var INVITED_TYPE= 8; +import { notificationUrl } from 'discourse/lib/desktop-notifications'; export default ObjectController.extend({ - scope: function () { + scope: function() { return "notifications." + this.site.get("notificationLookup")[this.get("notification_type")]; }.property("notification_type"), username: Em.computed.alias("data.display_username"), - safe: function (prop) { - var val = this.get(prop); - if (val) { val = Handlebars.Utils.escapeExpression(val); } - return val; - }, - - url: function () { - var badgeId = this.safe("data.badge_id"); - if (badgeId) { - var badgeName = this.safe("data.badge_name"); - return Discourse.getURL('/badges/' + badgeId + '/' + badgeName.replace(/[^A-Za-z0-9_]+/g, '-').toLowerCase()); - } - - var topicId = this.safe('topic_id'); - if (topicId) { - return Discourse.Utilities.postUrl(this.safe("slug"), topicId, this.safe("post_number")); - } - - if (this.get('notification_type') === INVITED_TYPE) { - return Discourse.getURL('/my/invited'); - } + url: function() { + return notificationUrl(this); }.property("data.{badge_id,badge_name}", "slug", "topic_id", "post_number"), - description: function () { - var badgeName = this.safe("data.badge_name"); - if (badgeName) { return badgeName; } - return this.blank("data.topic_title") ? "" : this.safe("data.topic_title"); + description: function() { + const badgeName = this.get("data.badge_name"); + if (badgeName) { return Handlebars.Utils.escapeExpression(badgeName); } + return this.blank("data.topic_title") ? "" : Handlebars.Utils.escapeExpression(this.get("data.topic_title")); }.property("data.{badge_name,topic_title}") }); diff --git a/app/assets/javascripts/discourse/controllers/notifications.js.es6 b/app/assets/javascripts/discourse/controllers/notifications.js.es6 index 78a80ed0eec..f0ae8ad42e7 100644 --- a/app/assets/javascripts/discourse/controllers/notifications.js.es6 +++ b/app/assets/javascripts/discourse/controllers/notifications.js.es6 @@ -1,6 +1,5 @@ export default Ember.ArrayController.extend({ needs: ['header'], loadingNotifications: Em.computed.alias('controllers.header.loadingNotifications'), - myNotificationsUrl: Discourse.computed.url('/my/notifications') }); diff --git a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 index bb808e5c287..7ee8088b5e5 100644 --- a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 @@ -1,4 +1,6 @@ // Subscribes to user events on the message bus +import { init as initDesktopNotifications, onNotification } from 'discourse/lib/desktop-notifications'; + export default { name: 'subscribe-user-notifications', after: 'message-bus', @@ -14,7 +16,7 @@ export default { if (bus.baseUrl !== '/') { // zepto compatible, 1 param only - bus.ajax = function(opts){ + bus.ajax = function(opts) { opts.headers = opts.headers || {}; opts.headers['X-Shared-Session-Key'] = $('meta[name=shared_session_key]').attr('content'); return $.ajax(opts); @@ -35,23 +37,26 @@ export default { user.set('post_queue_new_count', data.post_queue_new_count); }); } - bus.subscribe("/notification/" + user.get('id'), (function(data) { + bus.subscribe("/notification/" + user.get('id'), function(data) { const oldUnread = user.get('unread_notifications'); const oldPM = user.get('unread_private_messages'); user.set('unread_notifications', data.unread_notifications); user.set('unread_private_messages', data.unread_private_messages); - if(oldUnread !== data.unread_notifications || oldPM !== data.unread_private_messages) { + if (oldUnread !== data.unread_notifications || oldPM !== data.unread_private_messages) { user.set('lastNotificationChange', new Date()); + onNotification(user); } - }), user.notification_channel_position); + }, user.notification_channel_position); - bus.subscribe("/categories", function(data){ - _.each(data.categories,function(c){ + bus.subscribe("/categories", function(data) { + _.each(data.categories,function(c) { site.updateCategory(c); }); }); + + initDesktopNotifications(bus); } } }; diff --git a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 new file mode 100644 index 00000000000..45c720e3df3 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 @@ -0,0 +1,237 @@ + +let primaryTab = false; +let liveEnabled = false; +let mbClientId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"; +let lastAction = -1; + +const focusTrackerKey = "focus-tracker"; +const seenDataKey = "seen-notifications"; +const recentUpdateThreshold = 1000 * 60 * 2; // 2 minutes +const idleThresholdTime = 1000 * 10; // 10 seconds +const INVITED_TYPE = 8; +let notificationTagName; // "discourse-notification-popup-" + Discourse.SiteSettings.title; + +// Called from an initializer +function init(messageBus) { + liveEnabled = false; + mbClientId = messageBus.clientId; + requestPermission().then(function() { + try { + localStorage.getItem(focusTrackerKey); + } catch (e) { + Em.Logger.info('Discourse desktop notifications are disabled - localStorage denied.'); + return; + } + liveEnabled = true; + Em.Logger.info('Discourse desktop notifications are enabled.'); + try { + // Permission is granted, continue with setup + setupNotifications(); + } catch (e) { + Em.Logger.error(e); + } + }).catch(function() { + liveEnabled = false; + //Em.Logger.debug('Discourse desktop notifications are disabled - permission denied.'); + }); +} + +// This function is only called if permission was granted +function setupNotifications() { + // Load up the current state of the notifications + const seenData = JSON.parse(localStorage.getItem(seenDataKey)); + let markAllSeen = true; + if (seenData) { + const lastUpdatedAt = new Date(seenData.updated_at); + if (lastUpdatedAt.getTime() + recentUpdateThreshold > new Date().getTime()) { + // The following conditions are met: + // - This is a new Discourse tab + // - The seen notification data was updated in the last 2 minutes + // Therefore, there is no need to reset the data. + markAllSeen = false; + } + } + if (markAllSeen) { + Discourse.ajax("/notifications.json?silent=true").then(function(result) { + updateSeenNotificationDatesFrom(result); + }); + } + + notificationTagName = "discourse-notification-popup-" + Discourse.SiteSettings.title; + + + window.addEventListener("storage", function(e) { + // note: This event only fires when other tabs setItem() + const key = e.key; + if (key !== focusTrackerKey) { + return true; + } + primaryTab = false; + }); + + window.addEventListener("focus", function() { + if (!primaryTab) { + primaryTab = true; + localStorage.setItem(focusTrackerKey, mbClientId); + } + }); + + if (document && (typeof document.hidden !== "undefined") && document["hidden"]) { + primaryTab = false; + } else { + primaryTab = true; + localStorage.setItem(focusTrackerKey, mbClientId); + } + + if (document) { + document.addEventListener("scroll", resetIdle); + } + window.addEventListener("mouseover", resetIdle); + Discourse.PageTracker.on("change", resetIdle); +} + +function resetIdle() { + lastAction = Date.now(); +} +function isIdle() { + return lastAction + idleThresholdTime < Date.now(); +} + +// Call-in point from message bus +function onNotification(currentUser) { + if (!liveEnabled) { return; } + if (!primaryTab) { return; } + + const blueNotifications = currentUser.get('unread_notifications'); + const greenNotifications = currentUser.get('unread_private_messages'); + + if (blueNotifications > 0 || greenNotifications > 0) { + Discourse.ajax("/notifications.json?silent=true").then(function(result) { + + const unread = result.filter(n => !n.read); + const unseen = updateSeenNotificationDatesFrom(result); + const unreadCount = unread.length; + const unseenCount = unseen.length; + + + // If all notifications are seen, don't display + if (unreadCount === 0 || unseenCount === 0) { + return; + } + // If active in last 10 seconds, don't display + if (!isIdle()) { + return; + } + + let bodyParts = []; + + unread.forEach(function(n) { + const i18nOpts = { + username: n.data['display_username'], + topic: n.data['topic_title'], + badge: n.data['badge_name'] + }; + + bodyParts.push(I18n.t(i18nKey(n), i18nOpts)); + }); + + const notificationTitle = I18n.t('notifications.popup_title', { count: unreadCount, site_title: Discourse.SiteSettings.title }); + const notificationBody = bodyParts.join("\n"); + const notificationIcon = Discourse.SiteSettings.logo_small_url || Discourse.SiteSettings.logo_url; + + // This shows the notification! + const notification = new Notification(notificationTitle, { + body: notificationBody, + icon: notificationIcon, + tag: notificationTagName + }); + + const firstUnseen = unseen[0]; + + function clickEventHandler() { + Discourse.URL.routeTo(_notificationUrl(firstUnseen)); + // Cannot delay this until the page renders :( + // due to trigger-based permissions + window.focus(); + } + + notification.addEventListener('click', clickEventHandler); + setTimeout(function() { + notification.close(); + notification.removeEventListener('click', clickEventHandler); + }, 10 * 1000); + }); + } +} + +const DATA_VERSION = 2; +function updateSeenNotificationDatesFrom(notifications) { + const oldSeenData = JSON.parse(localStorage.getItem(seenDataKey)); + const oldSeenNotificationDates = (oldSeenData && oldSeenData.v === DATA_VERSION) ? oldSeenData.data : []; + let newSeenNotificationDates = []; + let previouslyUnseenNotifications = []; + + notifications.forEach(function(notification) { + const dateString = new Date(notification.created_at).toUTCString(); + + if (oldSeenNotificationDates.indexOf(dateString) === -1) { + previouslyUnseenNotifications.push(notification); + } + newSeenNotificationDates.push(dateString); + }); + + localStorage.setItem(seenDataKey, JSON.stringify({ + data: newSeenNotificationDates, + updated_at: new Date(), + v: DATA_VERSION + })); + return previouslyUnseenNotifications; +} + +// Utility function +// Wraps Notification.requestPermission in a Promise +function requestPermission() { + return new Ember.RSVP.Promise(function(resolve, reject) { + Notification.requestPermission(function(status) { + if (status === "granted") { + resolve(); + } else { + reject(); + } + }); + }); +} + +function i18nKey(notification) { + let key = "notifications.popup." + Discourse.Site.current().get("notificationLookup")[notification.notification_type]; + if (notification.data.display_username && notification.data.original_username && + notification.data.display_username !== notification.data.original_username) { + key += "_mul"; + } + return key; +} + +// Exported for controllers/notification.js.es6 +function notificationUrl(it) { + var badgeId = it.get("data.badge_id"); + if (badgeId) { + var badgeName = it.get("data.badge_name"); + return Discourse.getURL('/badges/' + badgeId + '/' + badgeName.replace(/[^A-Za-z0-9_]+/g, '-').toLowerCase()); + } + + var topicId = it.get('topic_id'); + if (topicId) { + return Discourse.Utilities.postUrl(it.get("slug"), topicId, it.get("post_number")); + } + + if (it.get('notification_type') === INVITED_TYPE) { + return Discourse.getURL('/my/invited'); + } +} + +function _notificationUrl(notificationJson) { + const it = Em.Object.create(notificationJson); + return notificationUrl(it); +} + +export { init, notificationUrl, onNotification }; diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 9efe2cfe6cf..6d856ec381d 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -66,6 +66,7 @@ //= require ./discourse/dialects/dialect //= require ./discourse/lib/emoji/emoji //= require ./discourse/lib/sharing +//= require discourse/lib/desktop-notifications //= require_tree ./discourse/dialects //= require_tree ./discourse/controllers diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 378142c7270..7e182d3dc0f 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -831,6 +831,25 @@ en: linked: "
{{username}} {{description}}
" granted_badge: "Earned '{{description}}'
" + popup_title: + one: "New notification on {{site_title}}" + other: "{{count}} new notifications on {{site_title}}" + popup: + mentioned: '{{username}} mentioned you in "{{topic}}"' + quoted: '{{username}} quoted you in "{{topic}}"' + replied: '{{username}} replied to you in "{{topic}}"' + replied_mul: '{{username}} in "{{topic}}"' + posted: '{{username}} posted in "{{topic}}"' + posted_mul: '{{username}} posted in "{{topic}}"' + edited: '{{username}} edited your post in "{{topic}}"' + liked: '{{username}} liked your post in "{{topic}}"' + private_message: '{{username}} sent you a private message in "{{topic}}"' + invited_to_private_message: '{{username}} invited you to a private message: "{{topic}}"' + invitee_accepted: '{{username}} joined the forum!' + moved_post: '{{username}} moved your post in "{{topic}}"' + linked: '{{username}} linked to your post from "{{topic}}"' + granted_badge: 'You earned the "{{badge}}" badge!' + upload_selector: title: "Add an image" title_with_attachments: "Add an image or a file"