diff --git a/db/manifest b/db/manifest index 9b732d4fe..3eeea65c4 100755 --- a/db/manifest +++ b/db/manifest @@ -137,4 +137,4 @@ cascading_delete_constraints_for_release.sql events_social_description.sql fix_broken_cities.sql notifications_with_text.sql - +notification_seen_at.sql diff --git a/db/up/notification_seen_at.sql b/db/up/notification_seen_at.sql new file mode 100644 index 000000000..67b29ddd8 --- /dev/null +++ b/db/up/notification_seen_at.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN notification_seen_at TIMESTAMP; \ No newline at end of file diff --git a/pb/src/client_container.proto b/pb/src/client_container.proto index 4ae83c193..04e172870 100644 --- a/pb/src/client_container.proto +++ b/pb/src/client_container.proto @@ -472,6 +472,7 @@ message TestClientMessage { // sent from client to server periodically to let server track if the client is truly alive and avoid TCP timeout scenarios // the server will send a HeartbeatAck in response to this message Heartbeat { + optional string notification_seen_at = 1; } diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 93f73d829..440613536 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -291,7 +291,12 @@ module JamRuby recordings.concat(msh) recordings.sort! {|a,b| b.created_at <=> a.created_at}.first(5) end - + + # returns the # of new notifications + def new_notifications + Notification.select('id').where(target_user_id: id).where('created_at > ?', notification_seen_at).count + end + def confirm_email! self.email_confirmed = true end diff --git a/web/app/assets/javascripts/AAB_message_factory.js b/web/app/assets/javascripts/AAB_message_factory.js index 7f11e603d..0b86c7900 100644 --- a/web/app/assets/javascripts/AAB_message_factory.js +++ b/web/app/assets/javascripts/AAB_message_factory.js @@ -99,8 +99,9 @@ }; // Heartbeat message - factory.heartbeat = function() { + factory.heartbeat = function(notification_last_seen_at) { var data = {}; + data.notification_last_seen_at = notification_last_seen_at; return client_container(msg.HEARTBEAT, route_to.SERVER, data); }; diff --git a/web/app/assets/javascripts/application.js b/web/app/assets/javascripts/application.js index 68db544ad..1210d1d7a 100644 --- a/web/app/assets/javascripts/application.js +++ b/web/app/assets/javascripts/application.js @@ -28,6 +28,7 @@ //= require jquery.infinitescroll //= require jquery.hoverIntent //= require jquery.dotdotdot +//= require jquery.pulse //= require AAA_Log //= require globals //= require AAB_message_factory diff --git a/web/app/assets/javascripts/jam_rest.js b/web/app/assets/javascripts/jam_rest.js index 2b19b5d44..fbdf74a2a 100644 --- a/web/app/assets/javascripts/jam_rest.js +++ b/web/app/assets/javascripts/jam_rest.js @@ -934,6 +934,7 @@ } function getNotifications(options) { + if(!options) options = {}; var id = getId(options); return $.ajax({ type: "GET", diff --git a/web/app/assets/javascripts/jamkazam.js b/web/app/assets/javascripts/jamkazam.js index ea0a39710..d78922ea7 100644 --- a/web/app/assets/javascripts/jamkazam.js +++ b/web/app/assets/javascripts/jamkazam.js @@ -28,6 +28,7 @@ var lastHeartbeatFound = false; var heartbeatAckCheckInterval = null; var userDeferred = null; + var notificationLastSeenAt = undefined; var opts = { inClient: true, // specify false if you want the app object but none of the client-oriented features @@ -92,7 +93,8 @@ function _heartbeat() { if (app.heartbeatActive) { - var message = context.JK.MessageFactory.heartbeat(); + var message = context.JK.MessageFactory.heartbeat(notificationLastSeenAt); + notificationLastSeenAt = undefined; context.JK.JamServer.send(message); lastHeartbeatFound = false; } @@ -384,6 +386,26 @@ return userDeferred; } + this.updateNotificationSeen = function(notificationCreatedAt) { + var time = new Date(notificationCreatedAt); + + if(!notificationCreatedAt) { + throw 'invalid value passed to updateNotificationSeen' + } + + if(!notificationLastSeenAt) { + notificationLastSeenAt = notificationCreatedAt; + logger.debug("updated notificationLastSeenAt with: " + notificationCreatedAt); + } + else if(time.getTime() > new Date(notificationLastSeenAt).getTime()) { + notificationLastSeenAt = notificationCreatedAt; + logger.debug("updated notificationLastSeenAt with: " + notificationCreatedAt); + } + else { + logger.debug("ignored notificationLastSeenAt for: " + notificationCreatedAt); + } + } + this.unloadFunction = function () { logger.debug("window.unload function called."); diff --git a/web/app/assets/javascripts/layout.js b/web/app/assets/javascripts/layout.js index d948945dc..dc130dc78 100644 --- a/web/app/assets/javascripts/layout.js +++ b/web/app/assets/javascripts/layout.js @@ -425,6 +425,7 @@ unstackDialogs($overlay); $dialog.hide(); dialogEvent(dialog, 'afterHide'); + $(me).triggerHandler('dialog_closed', {dialogCount: openDialogs.length}) } function screenEvent(screen, evtName, data) { @@ -526,6 +527,10 @@ } } + function isDialogShowing() { + return openDialogs.length > 0; + } + /** * Responsible for keeping N dialogs in correct stacked order, * also moves the .dialog-overlay such that it hides/obscures all dialogs except the highest one @@ -849,6 +854,10 @@ showDialog(dialog, options); }; + this.isDialogShowing = function() { + return isDialogShowing(); + } + this.close = function (evt) { close(evt); }; diff --git a/web/app/assets/javascripts/notificationPanel.js b/web/app/assets/javascripts/notificationPanel.js new file mode 100644 index 000000000..a5649a634 --- /dev/null +++ b/web/app/assets/javascripts/notificationPanel.js @@ -0,0 +1,107 @@ +(function(context,$) { + + "use strict"; + + context.JK = context.JK || {}; + context.JK.NotificationPanel = function(app) { + var logger = context.JK.logger; + var friends = []; + var rest = context.JK.Rest(); + var missedNotificationsWhileAway = false; + var $panel = null; + var $expanded = null; + var $count = null; + var darkenedColor = '#0D7B89'; + var highlightedColor = 'white' + + + // one important limitation; if the user is focused on an iframe, this will be false + // however, if they are doing something with Facebook or the photo picker, this may actually still be desirable + function userCanSeeNotifications() { + return document.hasFocus() || app.layout.isDialogShowing(); + } + + function isNotificationsPanelVisible() { + return $expanded.is(':visible') + } + + function incrementNotificationCount() { + var count = parseInt($count.text()); + $count.text(count + 1); + } + + // set the element to white, and pulse it down to the un-highlighted value 2x, then set + function pulseToDark() { + lowlightCount(); + $count.pulse({'background-color' : highlightedColor}, {pulses: 2}, function() { + $count.text('0'); + }) + } + + + function lowlightCount() { + $count.removeClass('highlighted'); + } + + function highlightCount() { + $count.addClass('highlighted'); + } + + function onNotificationOccurred(payload) { + if(userCanSeeNotifications()) { + app.updateNotificationSeen(payload.created_at); + } + else { + highlightCount(); + incrementNotificationCount(); + missedNotificationsWhileAway = true; + } + } + + function userCameBack() { + if(isNotificationsPanelVisible()) { + if(missedNotificationsWhileAway) { + // catch user's eye, then put count to 0 + pulseToDark(); + } + } + + missedNotificationsWhileAway = false; + } + + function windowBlurred() { + + } + + function events() { + $(app.layout).on('dialog_closed', function(e, data) {if(data.dialogCount == 0) userCameBack(); }); + $(window).focus(userCameBack); + $(window).blur(windowBlurred); + } + + function populate() { + // retrieve pending notifications for this user + rest.getNotifications() + .done(function(response) { + updateNotificationList(response); + }) + .fail(app.ajaxError) + } + + function initialize(sidebar) { + $panel = $('[layout-id="panelNotifications"]'); + $expanded = $panel.find('.panel.expanded'); + $count = $panel.find('#sidebar-notification-count'); + if($panel.length == 0) throw "notifications panel not found" + if($expanded.length == 0) throw "notifications expanded content not found" + if($count.length == 0) throw "notifications count element not found"; + + events(); + + populate(); + }; + + this.initiliaze = initialize; + this.onNotificationOccurred = onNotificationOccurred; + }; +})(window, jQuery); diff --git a/web/app/assets/javascripts/sidebar.js b/web/app/assets/javascripts/sidebar.js index 6e4f49831..2e90b7c5e 100644 --- a/web/app/assets/javascripts/sidebar.js +++ b/web/app/assets/javascripts/sidebar.js @@ -9,6 +9,7 @@ var rest = context.JK.Rest(); var invitationDialog = null; var textMessageDialog = null; + var notificationPanel = null; function initializeSearchPanel() { $('#search_text_type').change(function() { @@ -116,23 +117,8 @@ } function initializeNotificationsPanel() { - // retrieve pending notifications for this user - var url = "/api/users/" + context.JK.currentUserId + "/notifications" - $.ajax({ - type: "GET", - dataType: "json", - contentType: 'application/json', - url: url, - processData: false, - success: function(response) { - - updateNotificationList(response); - - // set notification count - $('#sidebar-notification-count').html(response.length); - }, - error: app.ajaxError - }); + notificationPanel = new context.JK.NotificationPanel(app); + notificationPanel.initialize(); } function updateNotificationList(response) { @@ -362,12 +348,8 @@ $('#sidebar-search-results').height('0px'); } - function incrementNotificationCount() { - var count = parseInt($('#sidebar-notification-count').html()); - $('#sidebar-notification-count').html(count + 1); - } - function decrementNotificationCount() { + /** var count = parseInt($('#sidebar-notification-count').html()); if (count === 0) { $('#sidebar-notification-count').html(0); @@ -375,6 +357,13 @@ else { $('#sidebar-notification-count').html(count - 1); } + */ + } + + // one important limitation; if the user is focused on an iframe, this will be false + // however, if they are doing something with Facebook or the photo picker, this may actually still be desirable + function userCanSeeNotifications() { + return document.hasFocus() || app.layout.isDialogShowing(); } // default handler for incoming notification @@ -402,6 +391,13 @@ $('#sidebar-notification-list').prepend(notificationHtml); + if(userCanSeeNotifications()) { + app.updateNotificationSeen(payload.created_at); + } + else { + + } + initializeActions(payload, type); return true; diff --git a/web/app/assets/javascripts/utils.js b/web/app/assets/javascripts/utils.js index 583881cbe..61ea676c7 100644 --- a/web/app/assets/javascripts/utils.js +++ b/web/app/assets/javascripts/utils.js @@ -153,9 +153,9 @@ $element.bt(text, options); } - context.JK.bindHoverEvents = function ($parent) { + context.JK.bindHoverEvents = function ($parent) { - if(!$parent) { + if (!$parent) { $parent = $('body'); } @@ -328,9 +328,9 @@ } // creates an array with entries like [{ id: "drums", description: "Drums"}, ] - context.JK.listInstruments = function() { + context.JK.listInstruments = function () { var instrumentArray = []; - $.each(context.JK.server_to_client_instrument_map, function(key, val) { + $.each(context.JK.server_to_client_instrument_map, function (key, val) { instrumentArray.push({"id": context.JK.server_to_client_instrument_map[key].client_id, "description": key}); }); return instrumentArray; @@ -652,22 +652,22 @@ return hasFlash; } - context.JK.hasOneConfiguredDevice = function() { + context.JK.hasOneConfiguredDevice = function () { var result = context.jamClient.FTUEGetGoodConfigurationList(); logger.debug("hasOneConfiguredDevice: ", result); return result.length > 0; }; - context.JK.getGoodAudioConfigs = function() { + context.JK.getGoodAudioConfigs = function () { var result = context.jamClient.FTUEGetGoodAudioConfigurations(); logger.debug("goodAudioConfigs=%o", result); return result; }; - context.JK.getGoodConfigMap = function() { + context.JK.getGoodConfigMap = function () { var goodConfigMap = []; var goodConfigs = context.JK.getGoodAudioConfigs(); - $.each(goodConfigs, function(index, profileKey) { + $.each(goodConfigs, function (index, profileKey) { var friendlyName = context.jamClient.FTUEGetConfigurationDevice(profileKey); goodConfigMap.push({key: profileKey, name: friendlyName}); }); @@ -675,12 +675,12 @@ return goodConfigMap; } - context.JK.getBadAudioConfigs = function() { + context.JK.getBadAudioConfigs = function () { var badAudioConfigs = []; var allAudioConfigs = context.jamClient.FTUEGetAllAudioConfigurations(); var goodAudioConfigs = context.JK.getGoodAudioConfigs(); - for (var i=0; i < allAudioConfigs.length; i++) { + for (var i = 0; i < allAudioConfigs.length; i++) { if ($.inArray(allAudioConfigs[i], goodAudioConfigs) === -1) { badAudioConfigs.push(allAudioConfigs[i]); } @@ -689,10 +689,10 @@ return badAudioConfigs; }; - context.JK.getBadConfigMap = function() { + context.JK.getBadConfigMap = function () { var badConfigMap = []; var badConfigs = context.JK.getBadAudioConfigs(); - $.each(badConfigs, function(index, profileKey) { + $.each(badConfigs, function (index, profileKey) { var friendlyName = context.jamClient.FTUEGetConfigurationDevice(profileKey); badConfigMap.push({key: profileKey, name: friendlyName}); }); @@ -700,7 +700,7 @@ return badConfigMap; } - context.JK.getFirstGoodDevice = function(preferredDeviceId) { + context.JK.getFirstGoodDevice = function (preferredDeviceId) { var badConfigs = context.JK.getBadAudioConfigs(); function getGoodDevice() { @@ -713,7 +713,7 @@ } return deviceId; } - + var deviceId = null; if (preferredDeviceId) { @@ -724,7 +724,7 @@ } else { deviceId = getGoodDevice(); - } + } } else { deviceId = getGoodDevice(); @@ -733,14 +733,14 @@ } // returns /client#/home for http://www.jamkazam.com/client#/home - context.JK.locationPath = function() { + context.JK.locationPath = function () { var bits = context.location.href.split('/'); return '/' + bits.slice(3).join('/'); } - context.JK.nowUTC = function() { + context.JK.nowUTC = function () { var d = new Date(); - return new Date( d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ); + return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()); } /* * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message diff --git a/web/app/assets/javascripts/web/downloads.js b/web/app/assets/javascripts/web/downloads.js index 1c3976f90..82ef0a1da 100644 --- a/web/app/assets/javascripts/web/downloads.js +++ b/web/app/assets/javascripts/web/downloads.js @@ -78,8 +78,8 @@ var clicked = $(this); var href = clicked.attr('href'); if(href != "#") { + context.JK.GA.trackDownload(clicked.attr('data-platform')); rest.userDownloadedClient().always(function() { - context.JK.GA.trackDownload(clicked.attr('data-platform')); $('body').append('