diff --git a/app/assets/javascripts/discourse/controllers/background-notifications.js.es6 b/app/assets/javascripts/discourse/controllers/background-notifications.js.es6 new file mode 100644 index 0000000000..46ab87d1a7 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/background-notifications.js.es6 @@ -0,0 +1,153 @@ + +// TODO deduplicate controllers/notification.js +function notificationUrl(n) { + const it = Em.Object.create(n); + + var badgeId = it.get("data.badge_id"); + if (badgeId) { + var badgeName = it.get("data.badge_name"); + return '/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 '/my/invited'; + } +} + +export default Discourse.Controller.extend({ + + initSeenNotifications: function() { + const self = this; + + // TODO make protocol to elect a tab responsible for desktop notifications + // and choose a new one when a tab is closed + // apparently needs to use localStorage !? + // https://github.com/diy/intercom.js + + // Just causes a bit of a visual glitch as multiple are created and + // instantly replaced as is + self.set('primaryTab', true); + + self.set('liveEnabled', false); + this.requestPermission().then(function() { + self.set('liveEnabled', true); + }).catch(function() { + self.set('liveEnabled', false); + }); + + self.set('seenNotificationDates', {}); + Discourse.ajax("/notifications.json?silent=true").then(function(result) { + self.updateSeenNotificationDatesFrom(result); + }); + }.on('init'), + + // Call-in point from message bus + notificationsChanged(currentUser) { + if (!this.get('liveEnabled')) { return; } + if (!this.get('primaryTab')) { return; } + + const blueNotifications = currentUser.get('unread_notifications'); + const greenNotifications = currentUser.get('unread_private_messages'); + const self = this; + + if (blueNotifications > 0 || greenNotifications > 0) { + Discourse.ajax("/notifications.json?silent=true").then(function(result) { + + const unread = result.filter(n => !n.read); + const unseen = self.updateSeenNotificationDatesFrom(result); + const unreadCount = unread.length; + const unseenCount = unseen.length; + + if (unseenCount === 0) { + return; + } + if (typeof document.hidden !== "undefined" && !document.hidden) { + 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(self.i18nKey(n), i18nOpts)); + }); + + const notificationTitle = I18n.t('notifications.popup_title', { count: unseenCount, site_title: Discourse.SiteSettings.title }); + const notificationBody = bodyParts.join("\n"); + const notificationIcon = Discourse.SiteSettings.logo_small_url || Discourse.SiteSettings.logo_url; + const notificationTag = self.get('notificationTagName'); + + // This shows the notification! + const notification = new Notification(notificationTitle, { + body: notificationBody, + icon: notificationIcon, + tag: notificationTag + }); + + const firstUnseen = unseen[0]; + notification.addEventListener('click', function() { + window.location.href = notificationUrl(firstUnseen); + window.focus(); + }); + }); + } + }, + + // Utility function + // Wraps Notification.requestPermission in a Promise + requestPermission() { + return new Ember.RSVP.Promise(function(resolve, reject) { + console.log('requesting'); + Notification.requestPermission(function(status) { + console.log('requested, status:', status); + if (status === "granted") { + resolve(); + } else { + reject(); + } + }); + }); + }, + + i18nKey(notification) { + let key = "notifications.popup." + this.site.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; + }, + + notificationTagName: function() { + return "discourse-notification-popup-" + Discourse.SiteSettings.title; + }.property(), + + // Utility function + updateSeenNotificationDatesFrom(notifications) { + const oldSeenNotificationDates = this.get('seenNotificationDates'); + let newSeenNotificationDates = {}; + let previouslyUnseenNotifications = []; + + notifications.forEach(function(notification) { + const dateString = new Date(notification.created_at).toUTCString(); + + if (!oldSeenNotificationDates[dateString]) { + previouslyUnseenNotifications.push(notification); + } + newSeenNotificationDates[dateString] = true; + }); + + this.set('seenNotificationDates', newSeenNotificationDates); + return previouslyUnseenNotifications; + } +}) diff --git a/app/assets/javascripts/discourse/controllers/notification.js.es6 b/app/assets/javascripts/discourse/controllers/notification.js.es6 index ceb4816b14..c582cd651c 100644 --- a/app/assets/javascripts/discourse/controllers/notification.js.es6 +++ b/app/assets/javascripts/discourse/controllers/notification.js.es6 @@ -2,28 +2,31 @@ import ObjectController from 'discourse/controllers/object'; var INVITED_TYPE= 8; -export default ObjectController.extend({ +const NotificationController = 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); + safe(prop) { + let val = this.get(prop); if (val) { val = Handlebars.Utils.escapeExpression(val); } return val; }, - url: function () { - var badgeId = this.safe("data.badge_id"); + // This is model logic + // It belongs in a model + // TODO deduplicate controllers/background-notifications.js + url: function() { + const badgeId = this.safe("data.badge_id"); if (badgeId) { - var badgeName = this.safe("data.badge_name"); + const 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'); + const topicId = this.safe('topic_id'); if (topicId) { return Discourse.Utilities.postUrl(this.safe("slug"), topicId, this.safe("post_number")); } @@ -33,10 +36,12 @@ export default ObjectController.extend({ } }.property("data.{badge_id,badge_name}", "slug", "topic_id", "post_number"), - description: function () { - var badgeName = this.safe("data.badge_name"); + description: function() { + const badgeName = this.safe("data.badge_name"); if (badgeName) { return badgeName; } return this.blank("data.topic_title") ? "" : this.safe("data.topic_title"); }.property("data.{badge_name,topic_title}") }); + +export default NotificationController; diff --git a/app/assets/javascripts/discourse/controllers/notifications.js.es6 b/app/assets/javascripts/discourse/controllers/notifications.js.es6 index 78a80ed0ee..280db96cc9 100644 --- a/app/assets/javascripts/discourse/controllers/notifications.js.es6 +++ b/app/assets/javascripts/discourse/controllers/notifications.js.es6 @@ -1,6 +1,7 @@ -export default Ember.ArrayController.extend({ +const NotificationsController = Ember.ArrayController.extend({ needs: ['header'], loadingNotifications: Em.computed.alias('controllers.header.loadingNotifications'), - myNotificationsUrl: Discourse.computed.url('/my/notifications') }); + +export default NotificationsController; 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 bb808e5c28..5ec2635eff 100644 --- a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 @@ -6,7 +6,8 @@ export default { const user = container.lookup('current-user:main'), site = container.lookup('site:main'), siteSettings = container.lookup('site-settings:main'), - bus = container.lookup('message-bus:main'); + bus = container.lookup('message-bus:main'), + bgController = container.lookup('controller:background-notifications'); bus.callbackInterval = siteSettings.anon_polling_interval; bus.backgroundCallbackInterval = siteSettings.background_polling_interval; @@ -44,6 +45,7 @@ export default { if(oldUnread !== data.unread_notifications || oldPM !== data.unread_private_messages) { user.set('lastNotificationChange', new Date()); + bgController.notificationsChanged(user); } }), user.notification_channel_position); diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 79eddd24ce..67e5f3dd3a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -821,6 +821,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"