Commit 0690b1ac authored by Robert Knight's avatar Robert Knight

Implement push notifications for when a user joins or leaves a group

This adds a new class of push notification, 'session-change'
which is broadcast to logged-in clients when changes to the user's
state, including the list of groups that they are a member of,
changes.

When the user creates or joins a group, a notification is
added to the event queue with an associated user ID
which is then broadcast via the web socket.

On the client side, the streamer service listens for the new
class of notification and triggers an update of the session
state in response.

When the user leaves a group, this may trigger an implicit change
of focus if the user was a member of the group.

T-105
parent c36acd9d
angular = require('angular')
events = require('./events');
module.exports = class AppController
this.$inject = [
'$controller', '$document', '$location', '$route', '$scope', '$window',
......@@ -38,9 +40,14 @@ module.exports = class AppController
# Default sort
$scope.sort = name: 'Location'
$scope.$on('groupFocused', (event) ->
# Reload the view when the focused group changes or the
# list of groups that the user is a member of changes
groupChangeEvents = [events.SESSION_CHANGED, events.GROUP_FOCUSED];
groupChangeEvents.forEach((eventName) ->
$scope.$on(eventName, (event) ->
$route.reload()
)
);
identity.watch({
onlogin: (identity) -> $scope.auth.user = auth.userid(identity)
......
/**
* This module defines the set of global events that are dispatched
* on $rootScope
*/
module.exports = {
GROUP_FOCUSED: 'groupFocused',
SESSION_CHANGED: 'sessionChanged',
};
......@@ -2,8 +2,12 @@
* @ngdoc service
* @name groups
*
* @description
* Get and set the UI's currently focused group.
* @description Provides access to the list of groups that the user is currently
* a member of and the currently selected group in the UI.
*
* The list of groups is initialized from the session state
* and can then later be updated using the add() and remove()
* methods.
*/
'use strict';
......@@ -11,20 +15,22 @@ var baseURI = require('document-base-uri');
var STORAGE_KEY = 'hypothesis.groups.focus';
var events = require('./events');
// @ngInject
function groups(localStorage, session, $rootScope, features, $http) {
// The currently focused group. This is the group that's shown as selected in
// the groups dropdown, the annotations displayed are filtered to only ones
// that belong to this group, and any new annotations that the user creates
// will be created in this group.
var focused;
var focusedGroup;
var all = function all() {
function all() {
return session.state.groups || [];
};
// Return the full object for the group with the given id.
var get = function get(id) {
function get(id) {
var gs = all();
for (var i = 0, max = gs.length; i < max; i++) {
if (gs[i].id === id) {
......@@ -45,41 +51,63 @@ function groups(localStorage, session, $rootScope, features, $http) {
// TODO - Optimistically call remove() to
// remove the group locally when
// https://github.com/hypothesis/h/pull/2587 has been merged
return response;
};
return {
all: all,
get: get,
leave: leave,
// Return the currently focused group. If no group is explicitly focused we
// will check localStorage to see if we have persisted a focused group from
// a previous session. Lastly, we fall back to the first group available.
focused: function() {
if (focused) {
return focused;
/** Return the currently focused group. If no group is explicitly focused we
* will check localStorage to see if we have persisted a focused group from
* a previous session. Lastly, we fall back to the first group available.
*/
function focused() {
if (focusedGroup) {
return focusedGroup;
} else if (features.flagEnabled('groups')) {
var fromStorage = get(localStorage.getItem(STORAGE_KEY));
if (typeof fromStorage !== 'undefined') {
focused = fromStorage;
return focused;
if (fromStorage) {
var matches = all().filter(function (group) {
return group.id === fromStorage.id;
});
if (matches.length > 0) {
focusedGroup = matches[0];
}
return focusedGroup;
}
}
return all()[0];
},
}
// Set the group with the passed id as the currently focused group.
focus: function(id) {
/** Set the group with the passed id as the currently focused group. */
function focus(id) {
var g = get(id);
if (typeof g !== 'undefined') {
focused = g;
focusedGroup = g;
localStorage.setItem(STORAGE_KEY, g.id);
$rootScope.$broadcast('groupFocused', g.id);
$rootScope.$broadcast(events.GROUP_FOCUSED, g.id);
}
}
// reset the focused group if the user leaves it
$rootScope.$on(events.SESSION_CHANGED, function () {
if (focusedGroup) {
var match = session.state.groups.filter(function (group) {
return group.id === focusedGroup.id;
});
if (match.length === 0) {
focusedGroup = null;
$rootScope.$broadcast(events.GROUP_FOCUSED, focused());
}
}
});
return {
all: all,
get: get,
leave: leave,
focused: focused,
focus: focus,
};
}
......
......@@ -3,6 +3,8 @@
var Promise = require('core-js/library/es6/promise');
var angular = require('angular');
var events = require('./events');
var CACHE_TTL = 5 * 60 * 1000; // 5 minutes
var ACCOUNT_ACTIONS = [
......@@ -59,7 +61,7 @@ function sessionActions(options) {
*
* @ngInject
*/
function session($document, $http, $resource, flash) {
function session($document, $http, $resource, flash, $rootScope) {
// TODO: Move accounts data management (e.g. profile, edit_profile,
// disable_user, etc) into another module with another route.
var actions = sessionActions({
......@@ -103,6 +105,29 @@ function session($document, $http, $resource, flash) {
return lastLoad;
};
/**
* @name session.update()
*
* @description Update the session state using the provided data.
* This is a counterpart to load(). Whereas load() makes
* a call to the server and then updates itself from
* the response, update() can be used to update the client
* when new state has been pushed to it by the server.
*/
resource.update = function (model) {
// Copy the model data (including the CSRF token) into `resource.state`.
angular.copy(model, resource.state);
// Replace lastLoad with the latest data, and update lastLoadTime.
lastLoad = {$promise: Promise.resolve(model), $resolved: true};
lastLoadTime = Date.now();
$rootScope.$broadcast(events.SESSION_CHANGED);
// Return the model
return model;
};
function prepare(data, headersGetter) {
var csrfTok = resource.state.csrf;
if (typeof csrfTok !== 'undefined') {
......@@ -134,15 +159,7 @@ function session($document, $http, $resource, flash) {
}
}
// Copy the model data (including the CSRF token) into `resource.state`.
angular.copy(model, resource.state);
// Replace lastLoad with the latest data, and update lastLoadTime.
lastLoad = {$promise: Promise.resolve(model), $resolved: true};
lastLoadTime = Date.now();
// Return the model
return model;
return resource.update(model);
}
return resource;
......
......@@ -20,7 +20,7 @@ var socket;
* @return An angular-websocket wrapper around the socket.
*/
// @ngInject
function connect($websocket, annotationMapper, groups) {
function connect($websocket, annotationMapper, groups, session) {
// Get the socket URL
var url = new URL('/ws', baseURI);
url.protocol = url.protocol.replace('http', 'ws');
......@@ -39,12 +39,7 @@ function connect($websocket, annotationMapper, groups) {
value: clientId
})
// Listen for updates
socket.onMessage(function (event) {
message = JSON.parse(event.data)
if (!message || message.type !== 'annotation-notification') {
return;
}
function handleAnnotationNotification(message) {
action = message.options.action
annotations = message.payload
......@@ -69,6 +64,26 @@ function connect($websocket, annotationMapper, groups) {
annotationMapper.unloadAnnotations(annotations);
break;
}
}
function handleSessionChangeNotification(message) {
session.update(message.model);
}
// Listen for updates
socket.onMessage(function (event) {
message = JSON.parse(event.data)
if (!message) {
return;
}
if (message.type === 'annotation-notification') {
handleAnnotationNotification(message)
} else if (message.type === 'session-change') {
handleSessionChangeNotification(message)
} else {
console.warn('received unsupported notification', message.type)
}
});
return socket
......
{module, inject} = angular.mock
events = require('../events')
describe 'AppController', ->
$controller = null
......@@ -21,7 +22,7 @@ describe 'AppController', ->
$controller('AppController', locals)
before ->
angular.module('h')
angular.module('h', [])
.controller('AppController', require('../app-controller'))
.controller('AnnotationUIController', angular.noop)
......@@ -137,7 +138,11 @@ describe 'AppController', ->
createController()
assert.isFalse($scope.shareDialog.visible)
it 'calls $route.reload() when the focused group changes', ->
it 'calls $route.reload() when the session state changes', ->
createController()
$scope.$broadcast('groupFocused')
groupEvents = [events.SESSION_CHANGED, events.GROUP_FOCUSED];
groupEvents.forEach((event) ->
fakeRoute.reload = sinon.spy()
$scope.$broadcast(event)
assert.calledOnce(fakeRoute.reload)
)
......@@ -2,6 +2,7 @@
var baseURI = require('document-base-uri');
var events = require('../events');
var groups = require('../groups');
// Return a mock session service containing three groups.
......@@ -17,7 +18,6 @@ var sessionWithThreeGroups = function() {
};
};
describe('groups', function() {
var fakeSession;
var fakeLocalStorage;
......@@ -35,7 +35,15 @@ describe('groups', function() {
setItem: sandbox.stub()
};
fakeRootScope = {
$broadcast: sandbox.stub()
eventCallbacks: [],
$broadcast: sandbox.stub(),
$on: function(event, callback) {
if (event === events.SESSION_CHANGED) {
this.eventCallbacks.push(callback);
}
}
};
fakeFeatures = {
flagEnabled: function() {return true;}
......@@ -107,6 +115,26 @@ describe('groups', function() {
assert.equal(s.focused().id, 'id3');
});
it('should update if the user leaves the focused group', function () {
var s = service();
s.focus('id2');
var leaveGroup = function(id) {
fakeSession.state.groups =
fakeSession.state.groups.slice().filter(function (group) {
return group.id !== id;
});
fakeRootScope.eventCallbacks.forEach(function (callback) {
callback();
});
};
leaveGroup('id3');
assert.equal(s.focused().id, 'id2');
leaveGroup('id2');
assert.notEqual(s.focused().id, 'id2');
});
});
describe('.focus() method', function() {
......
......@@ -2,8 +2,12 @@
var mock = angular.mock;
var events = require('../events');
describe('h:session', function () {
var $httpBackend;
var $rootScope;
var fakeFlash;
var fakeXsrf;
var sandbox;
......@@ -30,9 +34,10 @@ describe('h:session', function () {
}));
beforeEach(mock.inject(function (_$httpBackend_, _session_) {
beforeEach(mock.inject(function (_$httpBackend_, _session_, _$rootScope_) {
$httpBackend = _$httpBackend_;
session = _session_;
$rootScope = _$rootScope_;
}));
afterEach(function () {
......@@ -151,4 +156,17 @@ describe('h:session', function () {
$httpBackend.flush();
});
});
describe('#update()', function () {
it('broadcasts an event when the session is updated', function () {
var sessionChangeCallback = sinon.stub();
$rootScope.$on(events.SESSION_CHANGED, sessionChangeCallback);
session.update({
groups: [{
id: 'groupid'
}]
});
assert.calledOnce(sessionChangeCallback);
});
});
});
......@@ -33,6 +33,7 @@ function fakeSocketConstructor(url) {
describe('streamer', function () {
var fakeAnnotationMapper;
var fakeGroups;
var fakeSession;
var socket;
beforeEach(function () {
......@@ -47,10 +48,15 @@ describe('streamer', function () {
},
};
fakeSession = {
update: sinon.stub(),
};
socket = streamer.connect(
fakeSocketConstructor,
fakeAnnotationMapper,
fakeGroups
fakeGroups,
fakeSession
);
});
......@@ -97,4 +103,19 @@ describe('streamer', function () {
assert.ok(fakeAnnotationMapper.unloadAnnotations.calledOnce);
});
});
describe('session change notifications', function () {
it('updates the session when a notification is received', function () {
var model = {
groups: [{
id: 'new-group'
}]
};
socket.notify({
type: 'session-change',
model: model,
});
assert.ok(fakeSession.update.calledWith(model));
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment