Show who is online

This commit is contained in:
James Allen 2014-07-17 15:25:22 +01:00
parent 3995de3cfc
commit 37a12e88c1
9 changed files with 168 additions and 71 deletions

View file

@ -13,62 +13,64 @@ FOUR_DAYS_IN_S = ONE_DAY_IN_S * 4
USER_TIMEOUT_IN_S = ONE_HOUR_IN_S
buildProjectSetKey = (project_id)-> return "users_in_project:#{project_id}"
buildUserKey = (project_id, user_id)-> return "connected_user:#{project_id}:#{user_id}"
buildProjectSetKey = (project_id)-> return "clients_in_project:#{project_id}"
buildUserKey = (project_id, client_id)-> return "connected_user:#{project_id}:#{client_id}"
module.exports =
markUserAsConnected: (project_id, user_id, callback = (err)->)->
logger.log project_id:project_id, user_id:user_id, "marking user as connected"
markUserAsConnected: (project_id, client_id, user, callback = (err)->)->
logger.log project_id:project_id, client_id:client_id, "marking user as connected"
multi = rclient.multi()
multi.sadd buildProjectSetKey(project_id), user_id
multi.sadd buildProjectSetKey(project_id), client_id
multi.expire buildProjectSetKey(project_id), FOUR_DAYS_IN_S
multi.hset buildUserKey(project_id, user_id), "connected_at", new Date()
multi.expire buildUserKey(project_id, user_id), USER_TIMEOUT_IN_S
multi.hset buildUserKey(project_id, client_id), "connected_at", Date.now()
multi.hset buildUserKey(project_id, client_id), "user_id", user._id
multi.hset buildUserKey(project_id, client_id), "first_name", user.first_name
multi.hset buildUserKey(project_id, client_id), "last_name", user.last_name
multi.hset buildUserKey(project_id, client_id), "email", user.email
multi.expire buildUserKey(project_id, client_id), USER_TIMEOUT_IN_S
multi.exec (err)->
if err?
logger.err err:err, project_id:project_id, user_id:user_id, "problem marking user as connected"
logger.err err:err, project_id:project_id, client_id:client_id, "problem marking user as connected"
callback(err)
markUserAsDisconnected: (project_id, user_id, callback)->
logger.log project_id:project_id, user_id:user_id, "marking user as disconnected"
markUserAsDisconnected: (project_id, client_id, callback)->
logger.log project_id:project_id, client_id:client_id, "marking user as disconnected"
multi = rclient.multi()
multi.srem buildProjectSetKey(project_id), user_id
multi.srem buildProjectSetKey(project_id), client_id
multi.expire buildProjectSetKey(project_id), FOUR_DAYS_IN_S
multi.del buildUserKey(project_id, user_id)
multi.del buildUserKey(project_id, client_id)
multi.exec callback
_getConnectedUser: (project_id, user_id, callback)->
rclient.hgetall buildUserKey(project_id, user_id), (err, result)->
_getConnectedUser: (project_id, client_id, callback)->
rclient.hgetall buildUserKey(project_id, client_id), (err, result)->
if !result?
result =
connected : false
user_id:user_id
client_id:client_id
else
result.connected = true
result.user_id = user_id
result.client_id = client_id
if result.cursorData?
result.cursorData = JSON.parse(result.cursorData)
result.email = result.cursorData.email
result.name = result.cursorData.name
callback err, result
setUserCursorPosition: (project_id, user_id, cursorData, callback)->
setUserCursorPosition: (project_id, client_id, cursorData, callback)->
multi = rclient.multi()
multi.hset buildUserKey(project_id, user_id), "cursorData", JSON.stringify(cursorData)
multi.expire buildUserKey(project_id, user_id), USER_TIMEOUT_IN_S
multi.hset buildUserKey(project_id, client_id), "cursorData", JSON.stringify(cursorData)
multi.expire buildUserKey(project_id, client_id), USER_TIMEOUT_IN_S
multi.exec callback
getConnectedUsers: (project_id, callback)->
self = @
rclient.smembers buildProjectSetKey(project_id), (err, results)->
jobs = results.map (user_id)->
jobs = results.map (client_id)->
(cb)->
self._getConnectedUser(project_id, user_id, cb)
self._getConnectedUser(project_id, client_id, cb)
async.series jobs, (err, users)->
users = _.filter users, (user)->
user.connected

View file

@ -59,16 +59,14 @@ module.exports = EditorController =
callback null, ProjectEditorHandler.buildProjectModelView(project), privilegeLevel, EditorController.protocolVersion
# can be done affter the connection has happened
EditorRealTimeController.emitToRoom(project_id, "ConnectedUsers.userConnected", user)
ConnectedUsersManager.markUserAsConnected project_id, user._id, ->
ConnectedUsersManager.markUserAsConnected project_id, client.id, user, ->
leaveProject: (client, user) ->
self = @
client.get "project_id", (error, project_id) ->
return if error? or !project_id?
EditorRealTimeController.emitToRoom(project_id, "clientTracking.clientDisconnected", client.id)
EditorRealTimeController.emitToRoom(project_id, "ConnectedUsers.userDissconected", user)
ConnectedUsersManager.markUserAsDisconnected project_id, user._id, ->
ConnectedUsersManager.markUserAsDisconnected project_id, client.id, ->
logger.log user_id:user._id, project_id:project_id, "user leaving project"
self.flushProjectIfEmpty(project_id)
@ -126,7 +124,11 @@ module.exports = EditorController =
cursorData.email = email if email?
if first_name? and last_name?
cursorData.name = first_name + " " + last_name
ConnectedUsersManager.setUserCursorPosition(project_id, user_id, cursorData, ->)
ConnectedUsersManager.setUserCursorPosition(project_id, client.id, {
row: cursorData.row,
column: cursorData.column,
doc_id: cursorData.doc_id
}, ->)
else
cursorData.name = "Anonymous"
EditorRealTimeController.emitToRoom(project_id, "clientTracking.clientUpdated", cursorData)

View file

@ -39,6 +39,20 @@ header.toolbar.toolbar-header(ng-cloak, ng-hide="state.loading")
i.fa.fa-pencil
.toolbar-right
span.online-users(
ng-show="onlineUsersArray.length > 0"
ng-controller="OnlineUsersController"
)
span.online-user(
ng-repeat="user in onlineUsersArray",
ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 100%, 50%)' }",
popover="{{ user.name }}"
popover-placement="bottom"
popover-append-to-body="true"
popover-trigger="mouseenter"
ng-click="gotoUser(user)"
) {{ user.name.slice(0,1) }}
a.btn.btn-full-height(
href,
ng-if="permissions.admin",

View file

@ -1,30 +1,63 @@
define [
"libs/md5"
"ide/online-users/controllers/OnlineUsersController"
], () ->
class OnlineUsersManager
constructor: (@ide, @$scope) ->
@$scope.onlineUsers = {}
@$scope.onlineUserCursorHighlights = {}
@$scope.onlineUsersArray = []
@$scope.$on "cursor:editor:update", (event, position) =>
@sendCursorPositionUpdate(position)
@$scope.$on "project:joined", () =>
ide.$http
.get "/project/#{@ide.$scope.project._id}/connected_users"
.success (connectedUsers) =>
@$scope.onlineUsers = {}
for user in connectedUsers or []
if user.client_id == @ide.socket.socket.sessionid
# Don't store myself
continue
# Store data in the same format returned by clientTracking.clientUpdated
@$scope.onlineUsers[user.client_id] = {
id: user.client_id
user_id: user.user_id
email: user.email
name: "#{user.first_name} #{user.last_name}"
doc_id: user.cursorData?.doc_id
row: user.cursorData?.row
column: user.cursorData?.column
}
@refreshOnlineUsers()
@ide.socket.on "clientTracking.clientUpdated", (client) =>
if client.id != @ide.socket.socket.sessionid # Check it's not me!
@$scope.$apply () =>
@$scope.onlineUsers[client.id] = client
@updateCursorHighlights()
@refreshOnlineUsers()
@ide.socket.on "clientTracking.clientDisconnected", (client_id) =>
@$scope.$apply () =>
delete @$scope.onlineUsers[client_id]
@updateCursorHighlights()
@refreshOnlineUsers()
@$scope.getHueForUserId = (user_id) =>
@getHueForUserId(user_id)
updateCursorHighlights: () ->
refreshOnlineUsers: () ->
@$scope.onlineUsersArray = []
for client_id, user of @$scope.onlineUsers
if user.doc_id?
user.doc = @ide.fileTreeManager.findEntityById(user.doc_id)
@$scope.onlineUsersArray.push user
@$scope.onlineUserCursorHighlights = {}
for client_id, client of @$scope.onlineUsers
doc_id = client.doc_id
continue if !doc_id?
continue if !doc_id? or !client.row? or !client.column?
@$scope.onlineUserCursorHighlights[doc_id] ||= []
@$scope.onlineUserCursorHighlights[doc_id].push {
label: client.name

View file

@ -0,0 +1,7 @@
define [
"base"
], (App) ->
App.controller "OnlineUsersController", ($scope, ide) ->
$scope.gotoUser = (user) ->
if user.doc? and user.row?
ide.editorManager.openDoc(user.doc, gotoLine: user.row + 1)

View file

@ -8,6 +8,7 @@
@import "./editor/binary-file.less";
@import "./editor/search.less";
@import "./editor/publish-template.less";
@import "./editor/online-users.less";
.full-size {
position: absolute;

View file

@ -0,0 +1,14 @@
.online-users {
.online-user {
background-color: rgb(0, 170, 255);
width: 24px;
display: inline-block;
height: 24px;
margin-right: 8px;
text-align: center;
color: white;
text-transform: uppercase;
border-radius: 3px;
cursor: pointer;
}
}

View file

@ -36,8 +36,14 @@ describe "ConnectedUsersManager", ->
"logger-sharelatex": log:->
"redis": createClient:=>
return @rClient
@user_id = "32132132"
@client_id = "32132132"
@project_id = "dskjh2u21321"
@user = {
_id: "user-id-123"
first_name: "Joe"
last_name: "Bloggs"
email: "joe@example.com"
}
afterEach ->
tk.reset()
@ -47,23 +53,43 @@ describe "ConnectedUsersManager", ->
@rClient.exec.callsArgWith(0)
it "should set a key with the date and give it a ttl", (done)->
@ConnectedUsersManager.markUserAsConnected @project_id, @user_id, (err)=>
@rClient.hset.calledWith("connected_user:#{@project_id}:#{@user_id}", "connected_at", new Date()).should.equal true
@ConnectedUsersManager.markUserAsConnected @project_id, @client_id, @user, (err)=>
@rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "connected_at", Date.now()).should.equal true
done()
it "should push the user_id on to the project list", (done)->
@ConnectedUsersManager.markUserAsConnected @project_id, @user_id, (err)=>
@rClient.sadd.calledWith("users_in_project:#{@project_id}", @user_id).should.equal true
it "should set a key with the user_id", (done)->
@ConnectedUsersManager.markUserAsConnected @project_id, @client_id, @user, (err)=>
@rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "user_id", @user._id).should.equal true
done()
it "should set a key with the first_name", (done)->
@ConnectedUsersManager.markUserAsConnected @project_id, @client_id, @user, (err)=>
@rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "first_name", @user.first_name).should.equal true
done()
it "should set a key with the last_name", (done)->
@ConnectedUsersManager.markUserAsConnected @project_id, @client_id, @user, (err)=>
@rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "last_name", @user.last_name).should.equal true
done()
it "should set a key with the email", (done)->
@ConnectedUsersManager.markUserAsConnected @project_id, @client_id, @user, (err)=>
@rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "email", @user.email).should.equal true
done()
it "should push the client_id on to the project list", (done)->
@ConnectedUsersManager.markUserAsConnected @project_id, @client_id, @user, (err)=>
@rClient.sadd.calledWith("clients_in_project:#{@project_id}", @client_id).should.equal true
done()
it "should add a ttl to the connected user set so it stays clean", (done)->
@ConnectedUsersManager.markUserAsConnected @project_id, @user_id, (err)=>
@rClient.expire.calledWith("users_in_project:#{@project_id}", 24 * 4 * 60 * 60).should.equal true
@ConnectedUsersManager.markUserAsConnected @project_id, @client_id, @user, (err)=>
@rClient.expire.calledWith("clients_in_project:#{@project_id}", 24 * 4 * 60 * 60).should.equal true
done()
it "should add a ttl to the connected user so it stays clean", (done)->
@ConnectedUsersManager.markUserAsConnected @project_id, @user_id, (err)=>
@rClient.expire.calledWith("connected_user:#{@project_id}:#{@user_id}", 60 * 60).should.equal true
@ConnectedUsersManager.markUserAsConnected @project_id, @client_id, @user, (err)=>
@rClient.expire.calledWith("connected_user:#{@project_id}:#{@client_id}", 60 * 60).should.equal true
done()
describe "markUserAsDisconnected", ->
@ -71,18 +97,18 @@ describe "ConnectedUsersManager", ->
@rClient.exec.callsArgWith(0)
it "should remove the user from the set", (done)->
@ConnectedUsersManager.markUserAsDisconnected @project_id, @user_id, (err)=>
@rClient.srem.calledWith("users_in_project:#{@project_id}", @user_id).should.equal true
@ConnectedUsersManager.markUserAsDisconnected @project_id, @client_id, (err)=>
@rClient.srem.calledWith("clients_in_project:#{@project_id}", @client_id).should.equal true
done()
it "should delete the connected_user string", (done)->
@ConnectedUsersManager.markUserAsDisconnected @project_id, @user_id, (err)=>
@rClient.del.calledWith("connected_user:#{@project_id}:#{@user_id}").should.equal true
@ConnectedUsersManager.markUserAsDisconnected @project_id, @client_id, (err)=>
@rClient.del.calledWith("connected_user:#{@project_id}:#{@client_id}").should.equal true
done()
it "should add a ttl to the connected user set so it stays clean", (done)->
@ConnectedUsersManager.markUserAsDisconnected @project_id, @user_id, (err)=>
@rClient.expire.calledWith("users_in_project:#{@project_id}", 24 * 4 * 60 * 60).should.equal true
@ConnectedUsersManager.markUserAsDisconnected @project_id, @client_id, (err)=>
@rClient.expire.calledWith("clients_in_project:#{@project_id}", 24 * 4 * 60 * 60).should.equal true
done()
describe "_getConnectedUser", ->
@ -90,16 +116,16 @@ describe "ConnectedUsersManager", ->
it "should get the user returning connected if there is a value", (done)->
cursorData = JSON.stringify(cursorData:{row:1})
@rClient.hgetall.callsArgWith(1, null, {connected_at:new Date(), cursorData})
@ConnectedUsersManager._getConnectedUser @project_id, @user_id, (err, result)=>
@ConnectedUsersManager._getConnectedUser @project_id, @client_id, (err, result)=>
result.connected.should.equal true
result.user_id.should.equal @user_id
result.client_id.should.equal @client_id
done()
it "should get the user returning connected if there is a value", (done)->
@rClient.hgetall.callsArgWith(1)
@ConnectedUsersManager._getConnectedUser @project_id, @user_id, (err, result)=>
@ConnectedUsersManager._getConnectedUser @project_id, @client_id, (err, result)=>
result.connected.should.equal false
result.user_id.should.equal @user_id
result.client_id.should.equal @client_id
done()
describe "getConnectedUsers", ->
@ -108,16 +134,16 @@ describe "ConnectedUsersManager", ->
@users = ["1234", "5678", "9123"]
@rClient.smembers.callsArgWith(1, null, @users)
@ConnectedUsersManager._getConnectedUser = sinon.stub()
@ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[0]).callsArgWith(2, null, {connected:true, user_id:@users[0]})
@ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[1]).callsArgWith(2, null, {connected:false, user_id:@users[1]})
@ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[2]).callsArgWith(2, null, {connected:true, user_id:@users[2]})
@ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[0]).callsArgWith(2, null, {connected:true, client_id:@users[0]})
@ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[1]).callsArgWith(2, null, {connected:false, client_id:@users[1]})
@ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[2]).callsArgWith(2, null, {connected:true, client_id:@users[2]})
it "should only return the users in the list which are still in redis", (done)->
@ConnectedUsersManager.getConnectedUsers @project_id, (err, users)=>
users.length.should.equal 2
users[0].should.deep.equal {user_id:@users[0], connected:true}
users[1].should.deep.equal {user_id:@users[2], connected:true}
users[0].should.deep.equal {client_id:@users[0], connected:true}
users[1].should.deep.equal {client_id:@users[2], connected:true}
done()
describe "setUserCursorPosition", ->
@ -127,13 +153,13 @@ describe "ConnectedUsersManager", ->
@rClient.exec.callsArgWith(0)
it "should add the cursor data to the users hash", (done)->
@ConnectedUsersManager.setUserCursorPosition @project_id, @user_id, @cursorData, (err)=>
@rClient.hset.calledWith("connected_user:#{@project_id}:#{@user_id}", "cursorData", JSON.stringify(@cursorData)).should.equal true
@ConnectedUsersManager.setUserCursorPosition @project_id, @client_id, @cursorData, (err)=>
@rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "cursorData", JSON.stringify(@cursorData)).should.equal true
done()
it "should add the ttl on", (done)->
@ConnectedUsersManager.setUserCursorPosition @project_id, @user_id, @cursorData, (err)=>
@rClient.expire.calledWith("connected_user:#{@project_id}:#{@user_id}", 60 * 60).should.equal true
@ConnectedUsersManager.setUserCursorPosition @project_id, @client_id, @cursorData, (err)=>
@rClient.expire.calledWith("connected_user:#{@project_id}:#{@client_id}", 60 * 60).should.equal true
done()

View file

@ -91,7 +91,7 @@ describe "EditorController", ->
@ProjectGetter.populateProjectWithUsers = sinon.stub().callsArgWith(1, null, @project)
@AuthorizationManager.setPrivilegeLevelOnClient = sinon.stub()
@EditorRealTimeController.emitToRoom = sinon.stub()
@ConnectedUsersManager.markUserAsConnected.callsArgWith(2)
@ConnectedUsersManager.markUserAsConnected.callsArgWith(3)
describe "when authorized", ->
beforeEach ->
@ -123,11 +123,8 @@ describe "EditorController", ->
it "should return the project model view, privilege level and protocol version", ->
@callback.calledWith(null, @projectModelView, "owner", @EditorController.protocolVersion).should.equal true
it "should emit the to the room that the user has connected", ->
@EditorRealTimeController.emitToRoom.calledWith(@project_id, "ConnectedUsers.userConnected", @user).should.equal true
it "should mark the user as connected with the ConnectedUsersManager", ->
@ConnectedUsersManager.markUserAsConnected.calledWith(@project_id, @user_id).should.equal true
@ConnectedUsersManager.markUserAsConnected.calledWith(@project_id, @client.id, @user).should.equal true
describe "when not authorized", ->
@ -169,11 +166,8 @@ describe "EditorController", ->
.calledWith(@project_id, "clientTracking.clientDisconnected", @client.id)
.should.equal true
it "should emit the to the room that the user has connected", ->
@EditorRealTimeController.emitToRoom.calledWith(@project_id, "ConnectedUsers.userDissconected", @user).should.equal true
it "should mark the user as connected with the ConnectedUsersManager", ->
@ConnectedUsersManager.markUserAsDisconnected.calledWith(@project_id, @user_id).should.equal true
@ConnectedUsersManager.markUserAsDisconnected.calledWith(@project_id, @client.id).should.equal true
describe "joinDoc", ->
@ -296,7 +290,11 @@ describe "EditorController", ->
@EditorRealTimeController.emitToRoom.calledWith(@project_id, "clientTracking.clientUpdated", @populatedCursorData).should.equal true
it "should send the cursor data to the connected user manager", (done)->
@ConnectedUsersManager.setUserCursorPosition.calledWith(@project_id, @user_id, @populatedCursorData).should.equal true
@ConnectedUsersManager.setUserCursorPosition.calledWith(@project_id, @client.id, {
row: @row
column: @column
doc_id: @doc_id
}).should.equal true
done()
describe "with an anonymous user", ->