Skip to content

Commit

Permalink
Real-time Chat (#7664)
Browse files Browse the repository at this point in the history
* feat(realtime-chat): add Pusher library to the server

* feat(realtime-chat): only for private groups

* feat(realtime-chat): add authentication endpoint for Pusher

* feat(realtime-chat): client proof of concept

* fix typo in apidoc

* feat(realtime-chat): redo authentication and write integration tests

* remove firebase code

* fix client side tests

* fix line ending in bower.json

* feat(realtime chat): use presence channels for parties, send events & disconnect clients if user leaves or is removed from party, automatically update UI

* pusher: enable all events in the background

* fix pusher integration tests
  • Loading branch information
paglias authored Jul 2, 2016
1 parent 889c41f commit 0880850
Show file tree
Hide file tree
Showing 26 changed files with 393 additions and 285 deletions.
4 changes: 3 additions & 1 deletion bower.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
"sticky": "1.0.3",
"swagger-ui": "wordnik/swagger-ui#v2.0.24",
"smart-app-banner": "78ef9c0679723b25be1a0ae04f7b4aef7cbced4f",
"habitica-markdown": "1.2.2"
"habitica-markdown": "1.2.2",
"pusher-js-auth": "^2.0.0",
"pusher-websocket-iso": "pusher#^3.1.0"
},
"devDependencies": {
"angular-mocks": "1.3.9"
Expand Down
9 changes: 5 additions & 4 deletions config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,10 @@
"GCM_SERVER_API_KEY": "",
"APN_ENABLED": "true"
},
"FIREBASE": {
"APP": "app-name",
"SECRET": "secret",
"ENABLED": "false"
"PUSHER": {
"ENABLED": "false",
"APP_ID": "appId",
"KEY": "key",
"SECRET": "secret"
}
}
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@
"express": "~4.13.3",
"express-csv": "~0.6.0",
"express-validator": "^2.18.0",
"firebase": "^2.2.9",
"firebase-token-generator": "^2.0.0",
"glob": "^4.3.5",
"got": "^6.1.1",
"grunt": "~0.4.1",
Expand Down Expand Up @@ -80,6 +78,7 @@
"pretty-data": "^0.40.0",
"ps-tree": "^1.0.0",
"push-notify": "habitrpg/push-notify#v1.2.0",
"pusher": "^1.3.0",
"request": "~2.72.0",
"rimraf": "^2.4.3",
"run-sequence": "^1.1.4",
Expand Down
18 changes: 0 additions & 18 deletions test/api/v3/integration/user/auth/POST-firebase.test.js

This file was deleted.

102 changes: 102 additions & 0 deletions test/api/v3/integration/user/auth/POST-user_auth_pusher.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/* eslint-disable camelcase */

import {
generateUser,
requester,
translate as t,
} from '../../../../../helpers/api-integration/v3';
import { v4 as generateUUID } from 'uuid';

describe('POST /user/auth/pusher', () => {
let user;
let endpoint = '/user/auth/pusher';

beforeEach(async () => {
user = await generateUser();
});

it('requires authentication', async () => {
let api = requester();

await expect(api.post(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('missingAuthHeaders'),
});
});

it('returns an error if req.body.socket_id is missing', async () => {
await expect(user.post(endpoint, {
channel_name: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});

it('returns an error if req.body.channel_name is missing', async () => {
await expect(user.post(endpoint, {
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});

it('returns an error if req.body.channel_name is badly formatted', async () => {
await expect(user.post(endpoint, {
channel_name: '123',
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid Pusher channel type.',
});
});

it('returns an error if an invalid channel type is passed', async () => {
await expect(user.post(endpoint, {
channel_name: 'invalid-group-123',
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid Pusher channel type.',
});
});

it('returns an error if an invalid resource type is passed', async () => {
await expect(user.post(endpoint, {
channel_name: 'presence-user-123',
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid Pusher resource type.',
});
});

it('returns an error if an invalid resource id is passed', async () => {
await expect(user.post(endpoint, {
channel_name: 'presence-group-123',
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid Pusher resource id, must be a UUID.',
});
});

it('returns an error if the passed resource id doesn\'t match the user\'s party', async () => {
await expect(user.post(endpoint, {
channel_name: `presence-group-${generateUUID()}`,
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: 'Resource id must be the user\'s party.',
});
});
});
5 changes: 3 additions & 2 deletions website/client/js/controllers/chatCtrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A
$scope.postChat = function(group, message){
if (_.isEmpty(message) || $scope._sending) return;
$scope._sending = true;
var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false;
Chat.postChat(group._id, message, previousMsg)
// var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false;
Chat.postChat(group._id, message) //, previousMsg) not sending the previousMsg as we have real time updates
.then(function(response) {
var message = response.data.data.message;

if (message) {
group.chat.unshift(message);
group.chat.splice(200);
} else {
group.chat = response.data.data.chat;
}
Expand Down
4 changes: 2 additions & 2 deletions website/client/js/controllers/guildsCtrl.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict';

habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', 'Analytics',
function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile, Analytics) {
habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', 'Analytics', 'Pusher',
function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile, Analytics, Pusher) {
$scope.groups = {
guilds: [],
public: [],
Expand Down
4 changes: 2 additions & 2 deletions website/client/js/controllers/partyCtrl.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict';

habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social',
function($rootScope, $scope, Groups, Chat, User, Challenges, $state, $compile, Analytics, Quests, Social) {
habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social', 'Pusher',
function($rootScope, $scope, Groups, Chat, User, Challenges, $state, $compile, Analytics, Quests, Social, Pusher) {

var user = User.user;

Expand Down
19 changes: 12 additions & 7 deletions website/client/js/controllers/rootCtrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$
function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics, TAVERN_ID) {
var user = User.user;

var initSticky = _.once(function(){
$timeout(function () {
if (window.env.IS_MOBILE || User.user.preferences.stickyHeader === false) return;
$('.header-wrap').sticky({topSpacing:0});
});
});
// Setup page once user is synced
var clearAppLoadedListener = $rootScope.$watch('appLoaded', function (after) {
if (after === true) {
// Initialize sticky header
$timeout(function () {
if (window.env.IS_MOBILE || User.user.preferences.stickyHeader === false) return;
$('.header-wrap').sticky({topSpacing:0});
});

$rootScope.$on('userUpdated',initSticky);
// Remove listener
clearAppLoadedListener();
}
});

$rootScope.$on('$stateChangeSuccess',
function(event, toState, toParams, fromState, fromParams){
Expand Down
5 changes: 3 additions & 2 deletions website/client/js/services/chatServices.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use strict';

angular.module('habitrpg')
.factory('Chat', ['$http', 'ApiUrl', 'User',
function($http, ApiUrl, User) {
.factory('Chat', ['$http', 'ApiUrl', 'User', 'Pusher',
function($http, ApiUrl, User, Pusher) {
var apiV3Prefix = '/api/v3';

function getChat (groupId) {
Expand All @@ -24,6 +24,7 @@ angular.module('habitrpg')
url: url,
data: {
message: message,
pusherSocketId: Pusher.socketId, // to make sure the send doesn't get notified of it's own message
}
});
}
Expand Down
94 changes: 94 additions & 0 deletions website/client/js/services/pusherService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use strict';

angular.module('habitrpg')
.factory('Pusher', ['$rootScope', 'STORAGE_SETTINGS_ID', 'Groups',
function($rootScope, STORAGE_SETTINGS_ID, Groups) {
var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID));
var IS_PUSHER_ENABLED = window.env['PUSHER:ENABLED'] === 'true';

var api = {
pusher: undefined,
socketId: undefined, // when defined the user is connected
};

// Setup chat channels once app is ready, only for parties for now
var clearAppLoadedListener = $rootScope.$watch('appLoaded', function (after) {
if (!after) return;
clearAppLoadedListener(); // clean the event listerner

if (!IS_PUSHER_ENABLED) return;

var user = $rootScope.user;

// Connect the user to Pusher and to the party's chat channel
var partyId = user && $rootScope.user.party && $rootScope.user.party._id;
if (!partyId) return;

api.pusher = new Pusher(window.env['PUSHER:KEY'], {
encrypted: true,
authEndpoint: '/api/v3/user/auth/pusher',
auth: {
headers: {
'x-api-user': settings && settings.auth && settings.auth.apiId,
'x-api-key': settings && settings.auth && settings.auth.apiToken,
},
},
});

api.pusher.connection.bind('error', function(err) {
console.error(err);
// TODO if( err.data.code === 4004 ) detected connection limit
});

api.pusher.connection.bind('connected', function () {
api.socketId = api.pusher.connection.socket_id;
});

var partyChannelName = 'presence-group-' + partyId;
var partyChannel = api.pusher.subscribe(partyChannelName);

// When an error occurs while joining the channel
partyChannel.bind('pusher:subscription_error', function(status) {
console.error('Impossible to join the Pusher channel for your party, status: ', status);
});

// When the user correctly enters the party channel
partyChannel.bind('pusher:subscription_succeeded', function(members) {
// TODO members = [{id, info}]
});

// When a member enters the party channel
partyChannel.bind('pusher:member_added', function(member) {
// TODO member = {id, info}
});

// When a member leaves the party channel
partyChannel.bind('pusher:member_removed', function(member) {
// TODO member = {id, info}
});

// When the user is booted from the party, they get disconnected from Pusher
partyChannel.bind('user-removed', function (data) {
if (data.userId === user._id) {
api.pusher.unsubscribe(partyChannelName);
}
});

// Same when the user leaves the party
partyChannel.bind('user-left', function (data) {
if (data.userId === user._id) {
api.pusher.unsubscribe(partyChannelName);
}
});

// When a new chat message is posted
partyChannel.bind('new-chat', function (data) {
Groups.party().then(function () {
// Groups.data.party.chat.unshift(data);
// Groups.data.party.chat.splice(200);
});
});
});

return api;
}]);
2 changes: 2 additions & 0 deletions website/client/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"app": {
"js": [
"bower_components/pusher-websocket-iso/dist/web/pusher.js",
"bower_components/jquery/dist/jquery.min.js",
"bower_components/jquery.cookie/jquery.cookie.js",
"bower_components/pnotify/jquery.pnotify.min.js",
Expand Down Expand Up @@ -58,6 +59,7 @@
"js/services/userNotificationsService.js",
"js/services/userServices.js",
"js/services/hallServices.js",
"js/services/pusherService.js",

"js/filters/money.js",
"js/filters/roundLargeNumbers.js",
Expand Down
23 changes: 0 additions & 23 deletions website/server/controllers/api-v2/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ var async = require('async');
var utils = require('../../libs/api-v2/utils');
var nconf = require('nconf');
var request = require('request');
var FirebaseTokenGenerator = require('firebase-token-generator');
import {
model as User,
} from '../../models/user';
Expand Down Expand Up @@ -351,28 +350,6 @@ api.changePassword = function(req, res, next) {
})
};

// DISABLED FOR API v2
/*var firebaseTokenGeneratorInstance = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET'));
api.getFirebaseToken = function(req, res, next) {
var user = res.locals.user;
// Expires 24 hours after now (60*60*24*1000) (in milliseconds)
var expires = new Date();
expires.setTime(expires.getTime() + 86400000);
var token = firebaseTokenGeneratorInstance
.createToken({
uid: user._id,
isHabiticaUser: true
}, {
expires: expires
});
res.status(200).json({
token: token,
expires: expires
});
};*/

// DISABLED FOR API v2
/*api.setupPassport = function(router) {
Expand Down
Loading

0 comments on commit 0880850

Please sign in to comment.