Merge pull request #3304 from riking/desktop-notifications

Desktop notifications!
This commit is contained in:
Robin Ward 2015-04-28 16:24:02 -04:00
commit 15ea0c4789
6 changed files with 276 additions and 35 deletions

View File

@ -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}")
});

View File

@ -1,6 +1,5 @@
export default Ember.ArrayController.extend({
needs: ['header'],
loadingNotifications: Em.computed.alias('controllers.header.loadingNotifications'),
myNotificationsUrl: Discourse.computed.url('/my/notifications')
});

View File

@ -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);
}
}
};

View File

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

View File

@ -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

View File

@ -831,6 +831,25 @@ en:
linked: "<i title='linked post' class='fa fa-arrow-left'></i><p><span>{{username}}</span> {{description}}</p>"
granted_badge: "<i title='badge granted' class='fa fa-certificate'></i><p>Earned '{{description}}'</p>"
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"