Initial Open Source commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
**.swp
|
||||
|
||||
app.js
|
||||
app/js/
|
||||
test/unit/js/
|
||||
public/build/
|
||||
|
||||
node_modules/
|
||||
|
||||
/public/js/chat.js
|
||||
plato/
|
||||
@@ -0,0 +1,124 @@
|
||||
module.exports = (grunt) ->
|
||||
|
||||
# Project configuration.
|
||||
grunt.initConfig
|
||||
coffee:
|
||||
client:
|
||||
expand: true,
|
||||
flatten: false,
|
||||
cwd: 'public/coffee',
|
||||
src: ['**/*.coffee'],
|
||||
dest: 'public/build/',
|
||||
ext: '.js'
|
||||
|
||||
server:
|
||||
expand: true,
|
||||
flatten: false,
|
||||
cwd: 'app/coffee',
|
||||
src: ['**/*.coffee'],
|
||||
dest: 'app/js/',
|
||||
ext: '.js'
|
||||
|
||||
app_server:
|
||||
expand: true,
|
||||
flatten: false,
|
||||
src: ['app.coffee'],
|
||||
dest: './',
|
||||
ext: '.js'
|
||||
|
||||
server_tests:
|
||||
expand: true,
|
||||
flatten: false,
|
||||
cwd: 'test/unit/coffee',
|
||||
src: ['**/*.coffee'],
|
||||
dest: 'test/unit/js/',
|
||||
ext: '.js'
|
||||
|
||||
watch:
|
||||
server_coffee:
|
||||
files: ['app/**/*.coffee', 'test/unit/**/*.coffee']
|
||||
tasks: ['compile:server', 'compile:server_tests', 'mochaTest']
|
||||
|
||||
client_coffee:
|
||||
files: ['public/**/*.coffee']
|
||||
tasks: ['compile']
|
||||
|
||||
less:
|
||||
files: ['public/less/*.less']
|
||||
tasks: ['compile']
|
||||
|
||||
jade:
|
||||
files: ['public/jade/*.jade']
|
||||
tasks: ['compile']
|
||||
|
||||
|
||||
less:
|
||||
production:
|
||||
files:
|
||||
"public/build/css/chat.css": "public/less/chat.less"
|
||||
|
||||
jade:
|
||||
compile:
|
||||
files:
|
||||
"public/build/html/templates.html": ["public/jade/templates.jade"]
|
||||
|
||||
requirejs:
|
||||
compile:
|
||||
options:
|
||||
mainConfigFile: 'public/app.build.js',
|
||||
|
||||
uglify:
|
||||
my_target:
|
||||
files:
|
||||
'public/build/chat.js': ['public/build/chat.js']
|
||||
|
||||
copy:
|
||||
main:
|
||||
expand: true
|
||||
cwd: 'public/js'
|
||||
src: '**'
|
||||
dest: 'public/build/'
|
||||
|
||||
clean: ["public/build", "app/js", "test/unit/js"]
|
||||
|
||||
nodemon:
|
||||
dev:
|
||||
options:
|
||||
file: 'app.js'
|
||||
|
||||
concurrent:
|
||||
dev:
|
||||
tasks: ['nodemon', 'watch']
|
||||
options:
|
||||
logConcurrentOutput: true
|
||||
|
||||
mochaTest:
|
||||
unit:
|
||||
options:
|
||||
reporter: process.env.MOCHA_RUNNER || "spec"
|
||||
grep: grunt.option("grep")
|
||||
src: ['test/**/*.js']
|
||||
|
||||
plato:
|
||||
your_task:
|
||||
files: 'plato': ['app/js/**/*.js'],
|
||||
|
||||
grunt.loadNpmTasks 'grunt-contrib-coffee'
|
||||
grunt.loadNpmTasks 'grunt-contrib-watch'
|
||||
grunt.loadNpmTasks 'grunt-contrib-copy'
|
||||
grunt.loadNpmTasks 'grunt-contrib-less'
|
||||
grunt.loadNpmTasks 'grunt-contrib-jade'
|
||||
grunt.loadNpmTasks 'grunt-contrib-requirejs'
|
||||
grunt.loadNpmTasks 'grunt-contrib-uglify'
|
||||
grunt.loadNpmTasks 'grunt-nodemon'
|
||||
grunt.loadNpmTasks 'grunt-contrib-clean'
|
||||
grunt.loadNpmTasks 'grunt-concurrent'
|
||||
grunt.loadNpmTasks 'grunt-mocha-test'
|
||||
grunt.loadNpmTasks 'grunt-plato'
|
||||
|
||||
grunt.registerTask 'compile', ['clean', 'copy', 'coffee', 'less', 'jade', 'requirejs']
|
||||
grunt.registerTask 'install', ['compile']
|
||||
grunt.registerTask 'compileAndCompress', ['compile', 'uglify']
|
||||
grunt.registerTask 'default', ['compile', 'concurrent']
|
||||
grunt.registerTask 'test:unit', ['compile', 'mochaTest:unit']
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
logger = require 'logger-sharelatex'
|
||||
|
||||
Server = require "./app/js/server"
|
||||
Server.server.listen(3010, "localhost")
|
||||
logger.log "chat sharelatex listening on port 3010"
|
||||
@@ -0,0 +1,23 @@
|
||||
async = require "async"
|
||||
logger = require "logger-sharelatex"
|
||||
WebApiManager = require("../WebApi/WebApiManager")
|
||||
UserFormatter = require("../Users/UserFormatter")
|
||||
|
||||
module.exports = AuthenticationController =
|
||||
authClient: (client, data, callback = (error) ->) ->
|
||||
logger.log auth_token: data.auth_token, "authenticating user"
|
||||
WebApiManager.getUserDetailsFromAuthToken data.auth_token, (error, user) =>
|
||||
if error?
|
||||
logger.error data: data, client_id: client.id, err: error, "error authenticating user"
|
||||
return callback("something went wrong")
|
||||
logger.log user: user, auth_token: data.auth_token, "authenticated user"
|
||||
user = UserFormatter.formatUserForClientSide user
|
||||
jobs = []
|
||||
for key, value of user
|
||||
do (key, value) ->
|
||||
jobs.push (callback) -> client.set key, value, callback
|
||||
jobs.push (callback) -> client.set "auth_token", data.auth_token, callback
|
||||
async.series jobs, (error, results) =>
|
||||
callback(error, user)
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
WebApiManager = require "../WebApi/WebApiManager"
|
||||
SocketManager = require "../Sockets/SocketManager"
|
||||
|
||||
module.exports = AuthorizationManager =
|
||||
canClientJoinProjectRoom: (client, project_id, callback = (error, authorized) ->) ->
|
||||
client.get "auth_token", (error, auth_token) ->
|
||||
return callback(error) if error?
|
||||
WebApiManager.getProjectCollaborators project_id, auth_token, (error, collaborators) ->
|
||||
return callback(error) if error?
|
||||
client.get "id", (error, user_id) ->
|
||||
return callback(error) if error?
|
||||
authorized = false
|
||||
for collaborator in collaborators
|
||||
if collaborator.id == user_id
|
||||
authorized = true
|
||||
break
|
||||
callback null, authorized
|
||||
|
||||
canClientSendMessageToRoom: (client, room_id, callback = (error, authorized) ->) ->
|
||||
SocketManager.isClientInRoom(client, room_id, callback)
|
||||
|
||||
canClientReadMessagesInRoom: (client, room_id, callback = (error, authorized) ->) ->
|
||||
SocketManager.isClientInRoom(client, room_id, callback)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
logger = require "logger-sharelatex"
|
||||
metrics = require "../../metrics"
|
||||
MessageManager = require "./MessageManager"
|
||||
MessageFormatter = require "./MessageFormatter"
|
||||
SocketManager = require "../Sockets/SocketManager"
|
||||
AuthorizationManager = require "../Authorization/AuthorizationManager"
|
||||
|
||||
|
||||
module.exports = MessageController =
|
||||
DEFAULT_MESSAGE_LIMIT: 50
|
||||
|
||||
sendMessage: (client, data, callback = (error) ->) ->
|
||||
content = data?.message?.content
|
||||
room_id = data?.room?.id
|
||||
return callback("malformed message") if not (content? and room_id?)
|
||||
|
||||
client.get "id", (error, user_id) ->
|
||||
logger.log user_id: user_id, room_id: room_id, "sending message"
|
||||
AuthorizationManager.canClientSendMessageToRoom client, room_id, (error, authorized) ->
|
||||
if error?
|
||||
logger.err err:error, user_id:user_id, "something went wrong checking if canClientSendMessageToRoom"
|
||||
return callback("something went wrong")
|
||||
if authorized
|
||||
SocketManager.getClientAttributes client, ["id"], (error, values) ->
|
||||
if error?
|
||||
logger.err err:error, user_id:user_id, "something went wrong getClientAttributes"
|
||||
return callback("something went wrong")
|
||||
newMessageOpts =
|
||||
content: content
|
||||
room_id: room_id
|
||||
user_id: values[0]
|
||||
timestamp: Date.now()
|
||||
MessageManager.createMessage newMessageOpts, (error, message) ->
|
||||
if error?
|
||||
logger.err err:error, user_id:user_id, "something went wrong createMessage"
|
||||
return callback("something went wrong")
|
||||
MessageManager.populateMessagesWithUsers [message], (error, messages) ->
|
||||
if error?
|
||||
logger.err err:error, user_id:user_id, "something went wrong populateMessagesWithUsers"
|
||||
return callback("something went wrong")
|
||||
message = MessageFormatter.formatMessageForClientSide(messages[0])
|
||||
message.room =
|
||||
id: room_id
|
||||
SocketManager.emitToRoom data.room.id, "messageReceived", message:message
|
||||
metrics.inc "editor.instant-message"
|
||||
logger.log user_id: user_id, room_id: room_id, "sent message"
|
||||
callback()
|
||||
else
|
||||
logger.log user_id: user_id, room_id: room_id, "unauthorized attempt to send message"
|
||||
callback("unknown room")
|
||||
|
||||
getMessages: (client, data, callback = (error, messages) ->) ->
|
||||
room_id = data?.room?.id
|
||||
return callback("malformed message") if not room_id?
|
||||
|
||||
client.get "id", (error, user_id) ->
|
||||
logger.log user_id: user_id, room_id: room_id, "getting messages"
|
||||
AuthorizationManager.canClientReadMessagesInRoom client, room_id, (error, authorized) ->
|
||||
if error?
|
||||
logger.err err:error, user_id:user_id, "something went canClientReadMessagesInRoom"
|
||||
return callback("something went wrong")
|
||||
if authorized
|
||||
query = room_id: room_id
|
||||
if data.before?
|
||||
query.timestamp = $lt: data.before
|
||||
options =
|
||||
order_by: "timestamp"
|
||||
sort_order: -1
|
||||
limit: data.limit || MessageController.DEFAULT_MESSAGE_LIMIT
|
||||
MessageManager.getMessages query, options, (error, messages) ->
|
||||
if error?
|
||||
logger.err err:error, user_id:user_id, "something went getMessages"
|
||||
return callback("something went wrong")
|
||||
MessageManager.populateMessagesWithUsers messages, (error, messages) ->
|
||||
if error?
|
||||
logger.err err:error, user_id:user_id, "something went populateMessagesWithUsers"
|
||||
return callback("something went wrong")
|
||||
messages = MessageFormatter.formatMessagesForClientSide messages
|
||||
logger.log user_id: user_id, room_id: room_id, "got messages"
|
||||
callback null, messages
|
||||
else
|
||||
logger.log user_id: user_id, room_id: room_id, "unauthorized attempt to get messages"
|
||||
callback("unknown room")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
UserFormatter = require "../Users/UserFormatter"
|
||||
|
||||
module.exports = MessageFormatter =
|
||||
formatMessageForClientSide: (message) ->
|
||||
if message._id?
|
||||
message.id = message._id.toString()
|
||||
delete message._id
|
||||
formattedMessage =
|
||||
id: message.id
|
||||
content: message.content
|
||||
timestamp: message.timestamp
|
||||
user: UserFormatter.formatUserForClientSide(message.user)
|
||||
return formattedMessage
|
||||
|
||||
formatMessagesForClientSide: (messages) ->
|
||||
(@formatMessageForClientSide(message) for message in messages)
|
||||
@@ -0,0 +1,62 @@
|
||||
logger = require "logger-sharelatex"
|
||||
metrics = require "../../metrics"
|
||||
MessageManager = require "./MessageManager"
|
||||
MessageFormatter = require "./MessageFormatter"
|
||||
RoomManager = require "../Rooms/RoomManager"
|
||||
|
||||
module.exports = MessageHttpController =
|
||||
DEFAULT_MESSAGE_LIMIT: 50
|
||||
|
||||
sendMessage: (req, res, next) ->
|
||||
{user_id, content} = req?.body
|
||||
{project_id} = req.params
|
||||
|
||||
logger.log user_id: user_id, content: content, "new message recived"
|
||||
RoomManager.findOrCreateRoom project_id: project_id, (error, room) ->
|
||||
return next(error) if error?
|
||||
newMessageOpts =
|
||||
content: content
|
||||
room_id: room._id
|
||||
user_id: user_id
|
||||
timestamp: Date.now()
|
||||
MessageManager.createMessage newMessageOpts, (error, message) ->
|
||||
if err?
|
||||
logger.err err:error, user_id:user_id, "something went wrong with create message"
|
||||
return next(err)
|
||||
MessageManager.populateMessagesWithUsers [message], (error, messages) ->
|
||||
if error?
|
||||
logger.err err:error, user_id:user_id, "something went wrong populateMessagesWithUsers"
|
||||
return next("something went wrong")
|
||||
message = MessageFormatter.formatMessageForClientSide(messages[0])
|
||||
message.room =
|
||||
id: project_id
|
||||
res.send(201, message)
|
||||
|
||||
getMessages: (req, res, next) ->
|
||||
{project_id} = req.params
|
||||
query = {}
|
||||
if req.query?.before?
|
||||
query.timestamp = $lt: parseInt(req.query.before, 10)
|
||||
if req.query?.limit?
|
||||
limit = parseInt(req.query.limit, 10)
|
||||
else
|
||||
limit = MessageHttpController.DEFAULT_MESSAGE_LIMIT
|
||||
options =
|
||||
order_by: "timestamp"
|
||||
sort_order: -1
|
||||
limit: limit
|
||||
logger.log options:options, "get message request recived"
|
||||
RoomManager.findOrCreateRoom project_id: project_id, (error, room) ->
|
||||
return next(error) if error?
|
||||
query.room_id = room._id
|
||||
MessageManager.getMessages query, options, (error, messages) ->
|
||||
if error?
|
||||
logger.err err:error, "something went getMessages"
|
||||
return next("something went wrong")
|
||||
MessageManager.populateMessagesWithUsers messages, (error, messages) ->
|
||||
if error?
|
||||
logger.err err:error, "something went populateMessagesWithUsers"
|
||||
return next("something went wrong")
|
||||
messages = MessageFormatter.formatMessagesForClientSide messages
|
||||
logger.log project_id: project_id, "got messages"
|
||||
res.send 200, messages
|
||||
@@ -0,0 +1,52 @@
|
||||
mongojs = require "../../mongojs"
|
||||
db = mongojs.db
|
||||
ObjectId = mongojs.ObjectId
|
||||
WebApiManager = require "../WebApi/WebApiManager"
|
||||
async = require "async"
|
||||
|
||||
module.exports = MessageManager =
|
||||
createMessage: (message, callback = (error, message) ->) ->
|
||||
message = @_ensureIdsAreObjectIds(message)
|
||||
db.messages.save message, callback
|
||||
|
||||
getMessages: (query, options, callback = (error, messages) ->) ->
|
||||
query = @_ensureIdsAreObjectIds(query)
|
||||
cursor = db.messages.find(query)
|
||||
if options.order_by?
|
||||
options.sort_order ||= 1
|
||||
sortQuery = {}
|
||||
sortQuery[options.order_by] = options.sort_order
|
||||
cursor = cursor.sort(sortQuery)
|
||||
if options.limit?
|
||||
cursor = cursor.limit(options.limit)
|
||||
cursor.toArray callback
|
||||
|
||||
populateMessagesWithUsers: (messages, callback = (error, messages) ->) ->
|
||||
jobs = new Array()
|
||||
|
||||
userCache = {}
|
||||
getUserDetails = (user_id, callback = (error, user) ->) ->
|
||||
return callback(null, userCache[user_id]) if userCache[user_id]?
|
||||
WebApiManager.getUserDetails user_id, (error, user) ->
|
||||
return callback(error) if error?
|
||||
userCache[user_id] = user
|
||||
callback null, user
|
||||
|
||||
for message in messages
|
||||
do (message) ->
|
||||
jobs.push (callback) ->
|
||||
getUserDetails message.user_id.toString(), (error, user) ->
|
||||
return callback(error) if error?
|
||||
delete message.user_id
|
||||
message.user = user
|
||||
callback(null, message)
|
||||
|
||||
async.series jobs, callback
|
||||
|
||||
_ensureIdsAreObjectIds: (query) ->
|
||||
if query.user_id? and query.user_id not instanceof ObjectId
|
||||
query.user_id = ObjectId(query.user_id)
|
||||
if query.room_id? and query.room_id not instanceof ObjectId
|
||||
query.room_id = ObjectId(query.room_id)
|
||||
return query
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
async = require "async"
|
||||
logger = require "logger-sharelatex"
|
||||
AuthorizationManager = require "../Authorization/AuthorizationManager"
|
||||
RoomManager = require "../Rooms/RoomManager"
|
||||
SocketManager = require "../Sockets/SocketManager"
|
||||
|
||||
module.exports = RoomController =
|
||||
joinRoom: (client, data, callback = (error) ->) ->
|
||||
if !data.room?.project_id?
|
||||
return callback("unknown room")
|
||||
project_id = data.room.project_id
|
||||
|
||||
client.get "id", (error, id) ->
|
||||
logger.log user_id: id, project_id: project_id, "joining room"
|
||||
AuthorizationManager.canClientJoinProjectRoom client, project_id, (error, authorized) ->
|
||||
return callback("something went wrong") if error?
|
||||
if authorized
|
||||
RoomManager.findOrCreateRoom project_id: project_id, (error, room) ->
|
||||
return callback("something went wrong") if error?
|
||||
room_id = room._id.toString()
|
||||
RoomController._addClientToRoom client, room_id, (error) ->
|
||||
return callback("something went wrong") if error?
|
||||
RoomController._getClientsInRoom room_id, (error, clients) ->
|
||||
return callback("something went wrong") if error?
|
||||
logger.log user_id: id, project_id: project_id, room_id: room_id, "joined room"
|
||||
roomDetails =
|
||||
room:
|
||||
id: room_id
|
||||
connectedUsers: clients
|
||||
callback null, roomDetails
|
||||
else
|
||||
logger.log user_id: id, project_id: project_id, "unauthorized attempt to join room"
|
||||
callback("unknown room")
|
||||
|
||||
leaveAllRooms: (client, callback = (error) ->) ->
|
||||
client.get "id", (error, id) ->
|
||||
logger.log user_id: id, "leaving all rooms"
|
||||
SocketManager.getRoomIdsClientHasJoined client, (error, room_ids) ->
|
||||
return callback("something went wrong") if error?
|
||||
jobs = []
|
||||
for room_id in room_ids
|
||||
do (room_id) ->
|
||||
jobs.push (callback) ->
|
||||
RoomController.leaveRoom client, room_id, callback
|
||||
async.series jobs, (error)-> callback(error)
|
||||
|
||||
leaveRoom: (client, room_id, callback = (error) ->) ->
|
||||
client.get "id", (error, id) ->
|
||||
logger.log user_id: id, room_id: room_id, "leaving room"
|
||||
RoomController._getClientAttributes client, (error, attributes) ->
|
||||
return callback("something went wrong") if error?
|
||||
SocketManager.removeClientFromRoom client, room_id, (error) ->
|
||||
return callback("something went wrong") if error?
|
||||
leftRoomUpdate =
|
||||
room:
|
||||
id: room_id
|
||||
user: attributes
|
||||
SocketManager.emitToRoom room_id, "userLeft", leftRoomUpdate
|
||||
logger.log user_id: id, room_id: room_id, "left room"
|
||||
callback()
|
||||
|
||||
_addClientToRoom: (client, room_id, callback = (error) ->) ->
|
||||
RoomController._getClientAttributes client, (error, attributes) ->
|
||||
return callback(error) if error?
|
||||
update =
|
||||
room:
|
||||
id: room_id
|
||||
user: attributes
|
||||
SocketManager.emitToRoom room_id, "userJoined", update
|
||||
SocketManager.addClientToRoom client, room_id, callback
|
||||
|
||||
_getClientsInRoom: (room_id, callback = (error, clients) ->) ->
|
||||
SocketManager.getClientsInRoom room_id, (error, clients) ->
|
||||
return callback(error) if error?
|
||||
formattedClients = []
|
||||
jobs = []
|
||||
|
||||
for client in clients
|
||||
do (client) ->
|
||||
jobs.push (callback) ->
|
||||
RoomController._getClientAttributes client, (error, attributes) ->
|
||||
return callback(error) if error?
|
||||
formattedClients.push attributes
|
||||
callback()
|
||||
|
||||
async.series jobs, (error) ->
|
||||
return callback(error) if error?
|
||||
callback null, formattedClients
|
||||
|
||||
_getClientAttributes: (client, callback = (error, attributes) ->) ->
|
||||
SocketManager.getClientAttributes client, ["id", "first_name", "last_name", "email", "gravatar_url"], (error, attributes) ->
|
||||
return callback(error) if error?
|
||||
[id, first_name, last_name, email, gravatar_url] = attributes
|
||||
clientAttributes =
|
||||
id : id
|
||||
first_name : first_name
|
||||
last_name : last_name
|
||||
email : email
|
||||
gravatar_url : gravatar_url
|
||||
callback null, clientAttributes
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
mongojs = require("../../mongojs")
|
||||
db = mongojs.db
|
||||
ObjectId = mongojs.ObjectId
|
||||
|
||||
module.exports = RoomManager =
|
||||
findOrCreateRoom: (query, callback = (error, room) ->) ->
|
||||
if query.project_id? and query.project_id not instanceof ObjectId
|
||||
query.project_id = ObjectId(query.project_id)
|
||||
|
||||
db.rooms.findOne query, (error, room) ->
|
||||
return callback(error) if error?
|
||||
if room?
|
||||
callback null, room
|
||||
else
|
||||
db.rooms.save query, (error, room) ->
|
||||
return callback(error) if error?
|
||||
callback null, room
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
settings = require 'settings-sharelatex'
|
||||
rclientPub = require("redis").createClient(settings.redis.web.port, settings.redis.web.host)
|
||||
rclientPub.auth(settings.redis.web.password)
|
||||
rclientSub = require("redis").createClient(settings.redis.web.port, settings.redis.web.host)
|
||||
rclientSub.auth(settings.redis.web.password)
|
||||
|
||||
module.exports = RealTimeEventManager =
|
||||
|
||||
rclientPub:rclientPub
|
||||
rclientSub:rclientSub
|
||||
|
||||
emitToRoom: (room_id, message, payload...) ->
|
||||
RealTimeEventManager.rclientPub.publish "chat-events", JSON.stringify
|
||||
room_id: room_id
|
||||
message: message
|
||||
payload: payload
|
||||
|
||||
listenForChatEvents: () ->
|
||||
@rclientSub.subscribe "chat-events"
|
||||
@rclientSub.on "message", @_processEditorEvent.bind(@)
|
||||
|
||||
_processEditorEvent: (channel, message) ->
|
||||
io = require('../../server').io
|
||||
message = JSON.parse(message)
|
||||
io.sockets.in(message.room_id).emit(message.message, message.payload...)
|
||||
@@ -0,0 +1,40 @@
|
||||
async = require "async"
|
||||
RealTimeEventManager = require("./RealTimeEventManager")
|
||||
|
||||
module.exports = SocketManager =
|
||||
addClientToRoom: (client, room_id, callback = (error) ->) ->
|
||||
client.join(room_id)
|
||||
callback()
|
||||
|
||||
removeClientFromRoom: (client, room_id, callback = (error) ->) ->
|
||||
client.leave(room_id)
|
||||
callback()
|
||||
|
||||
getClientAttributes: (client, attributes, callback = (error, values) ->) ->
|
||||
jobs = []
|
||||
for attribute in attributes
|
||||
do (attribute) ->
|
||||
jobs.push (cb) -> client.get attribute, cb
|
||||
async.series jobs, callback
|
||||
|
||||
emitToRoom: RealTimeEventManager.emitToRoom
|
||||
|
||||
isClientInRoom: (targetClient, room_id, callback = (error, inRoom) ->) ->
|
||||
io = require("../../server").io
|
||||
for client in io.sockets.clients(room_id)
|
||||
if client.id == targetClient.id
|
||||
return callback null, true
|
||||
callback null, false
|
||||
|
||||
getClientsInRoom: (room_id, callback = (error, clients) ->) ->
|
||||
io = require("../../server").io
|
||||
callback null, io.sockets.clients(room_id)
|
||||
|
||||
getRoomIdsClientHasJoined: (client, callback = (error, room_ids) ->) ->
|
||||
io = require("../../server").io
|
||||
room_ids = []
|
||||
for room_id, value of io.sockets.manager.roomClients[client.id]
|
||||
if room_id[0] == "/"
|
||||
room_ids.push room_id.slice(1)
|
||||
callback null, room_ids
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
crypto = require "crypto"
|
||||
|
||||
module.exports = UserFormatter =
|
||||
formatUserForClientSide: (user) ->
|
||||
if user._id?
|
||||
user.id = user._id.toString()
|
||||
delete user._id
|
||||
return {
|
||||
id: user.id
|
||||
first_name: user.first_name
|
||||
last_name: user.last_name
|
||||
email: user.email
|
||||
gravatar_url: @_getGravatarUrlForEmail(user.email)
|
||||
}
|
||||
|
||||
_getGravatarUrlForEmail: (email) ->
|
||||
hash = crypto.createHash("md5").update(email.toLowerCase()).digest("hex")
|
||||
return "//www.gravatar.com/avatar/#{hash}"
|
||||
@@ -0,0 +1,32 @@
|
||||
request = require('request').defaults(jar: false)
|
||||
Settings = require("settings-sharelatex")
|
||||
|
||||
module.exports = WebApiManager =
|
||||
apiRequest: (url, method, options = {}, callback = (error, result) ->) ->
|
||||
if typeof options == "function"
|
||||
callback = options
|
||||
options = {}
|
||||
url = "#{Settings.apis.web.url}#{url}"
|
||||
options.url = url
|
||||
options.method = method
|
||||
request options, (error, response, body) ->
|
||||
return callback(error) if error?
|
||||
try
|
||||
result = JSON.parse(body)
|
||||
catch e
|
||||
return callback(e)
|
||||
return callback null, result
|
||||
|
||||
getUserDetailsFromAuthToken: (auth_token, callback = (error, details) ->) ->
|
||||
@apiRequest "/user/personal_info?auth_token=#{auth_token}", "get", callback
|
||||
|
||||
getUserDetails: (user_id, callback = (error, details) ->) ->
|
||||
@apiRequest "/user/#{user_id}/personal_info", "get", {
|
||||
auth:
|
||||
user: Settings.apis.web.user
|
||||
pass: Settings.apis.web.pass
|
||||
sendImmediately: true
|
||||
}, callback
|
||||
|
||||
getProjectCollaborators: (project_id, auth_token, callback = (error, collaborators) ->) ->
|
||||
@apiRequest "/project/#{project_id}/collaborators?auth_token=#{auth_token}", "get", callback
|
||||
@@ -0,0 +1,22 @@
|
||||
StatsD = require('lynx')
|
||||
statsd = new StatsD('localhost', 8125, {on_error:->})
|
||||
|
||||
buildKey = (key)-> "chat.#{process.env.NODE_ENV}.#{key}"
|
||||
|
||||
module.exports =
|
||||
set : (key, value, sampleRate = 1)->
|
||||
statsd.set buildKey(key), value, sampleRate
|
||||
|
||||
inc : (key, sampleRate = 1)->
|
||||
statsd.increment buildKey(key), sampleRate
|
||||
|
||||
Timer : class
|
||||
constructor :(key, sampleRate = 1)->
|
||||
this.start = new Date()
|
||||
this.key = buildKey(key)
|
||||
done:->
|
||||
timeSpan = new Date - this.start
|
||||
statsd.timing(this.key, timeSpan, this.sampleRate)
|
||||
|
||||
gauge : (key, value, sampleRate = 1)->
|
||||
statsd.gauge key, value, sampleRate
|
||||
@@ -0,0 +1,6 @@
|
||||
Settings = require("settings-sharelatex")
|
||||
mongojs = require "mongojs"
|
||||
db = mongojs.connect(Settings.mongo.url, ["rooms", "messages"])
|
||||
module.exports =
|
||||
db: db
|
||||
ObjectId: mongojs.ObjectId
|
||||
@@ -0,0 +1,31 @@
|
||||
AuthenticationController = require("./Features/Authentication/AuthenticationController")
|
||||
MessageController = require("./Features/Messages/MessageController")
|
||||
RoomController = require("./Features/Rooms/RoomController")
|
||||
MessageHttpController = require('./Features/Messages/MessageHttpController')
|
||||
|
||||
module.exports = Router =
|
||||
route: (app, io) ->
|
||||
|
||||
app.get "/room/:project_id/messages", MessageHttpController.getMessages
|
||||
app.post "/room/:project_id/messages", MessageHttpController.sendMessage
|
||||
|
||||
app.get "/status", (req, res, next) ->
|
||||
res.send("chat is alive")
|
||||
|
||||
io.sockets.on "connection", (client) ->
|
||||
client.on "disconnect", () ->
|
||||
RoomController.leaveAllRooms(client)
|
||||
|
||||
client.on "auth", (data, callback = (error) ->) ->
|
||||
AuthenticationController.authClient(client, data, callback)
|
||||
|
||||
client.on "joinRoom", (data, callback = (error) ->) ->
|
||||
RoomController.joinRoom(client, data, callback)
|
||||
|
||||
client.on "sendMessage", (data, callback = (error) ->) ->
|
||||
MessageController.sendMessage(client, data, callback)
|
||||
|
||||
client.on "getMessages", (data, callback = (error) ->) ->
|
||||
MessageController.getMessages(client, data, callback)
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
logger = require 'logger-sharelatex'
|
||||
logger.initialize("chat-sharelatex")
|
||||
metrics = require("metrics-sharelatex")
|
||||
metrics.initialize("chat")
|
||||
Path = require("path")
|
||||
express = require("express")
|
||||
app = express()
|
||||
server = require("http").createServer(app)
|
||||
io = require("socket.io").listen(server)
|
||||
io.set("resource", "/chat/socket.io")
|
||||
io.set("log level", 1)
|
||||
Router = require "./router"
|
||||
|
||||
metrics.mongodb.monitor(Path.resolve(__dirname + "/../../node_modules/mongojs/node_modules/mongodb"), logger)
|
||||
|
||||
app.configure ()->
|
||||
app.use express.bodyParser()
|
||||
app.use metrics.http.monitor(logger)
|
||||
Router.route(app, io)
|
||||
|
||||
app.configure 'development', ->
|
||||
console.log "Development Enviroment"
|
||||
app.use express.errorHandler({ dumpExceptions: true, showStack: true })
|
||||
|
||||
app.configure 'production', ->
|
||||
console.log "Production Enviroment"
|
||||
app.use express.logger()
|
||||
app.use express.errorHandler()
|
||||
|
||||
|
||||
|
||||
mountPoint = "/chat"
|
||||
app.use (req, res, next) ->
|
||||
|
||||
if req.url.slice(0, mountPoint.length) == mountPoint
|
||||
req.url = req.url.slice(mountPoint.length)
|
||||
next()
|
||||
else
|
||||
res.send(404)
|
||||
|
||||
app.use(express.static(__dirname + "/../../public/build"))
|
||||
|
||||
|
||||
|
||||
module.exports = {
|
||||
server: server
|
||||
io: io
|
||||
app: app
|
||||
}
|
||||
|
||||
require("./Features/Sockets/RealTimeEventManager").listenForChatEvents()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports =
|
||||
apis:
|
||||
web:
|
||||
url: "http://localhost:3000"
|
||||
user: "sharelatex"
|
||||
pass: "password"
|
||||
mongo:
|
||||
url : 'mongodb://127.0.0.1/sharelatex'
|
||||
|
||||
redis:
|
||||
web:
|
||||
host: "localhost"
|
||||
port: "6379"
|
||||
password: ""
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "chat-sharelatex",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"async": "0.2.9",
|
||||
"express": "3.3.1",
|
||||
"lynx": "0.0.11",
|
||||
"request": "2.21.0",
|
||||
"socket.io": "0.9.14",
|
||||
"settings": "git+ssh://git@bitbucket.org:sharelatex/settings-sharelatex.git#master",
|
||||
"metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#master",
|
||||
"logger": "git+ssh://git@bitbucket.org:sharelatex/logger-sharelatex.git#bunyan",
|
||||
"grunt-requirejs": "~0.4.0",
|
||||
"grunt-mocha-test": "~0.8.0",
|
||||
"mongojs": "0.9.11",
|
||||
"redis": "~0.10.1",
|
||||
"coffee-script": "~1.7.1",
|
||||
"timekeeper": "0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "",
|
||||
"sandboxed-module": "",
|
||||
"sinon": "",
|
||||
"timekeeper": "",
|
||||
"grunt": "~0.4.1",
|
||||
"grunt-contrib-requirejs": "~0.4.1",
|
||||
"grunt-contrib-coffee": "~0.7.0",
|
||||
"grunt-contrib-watch": "~0.5.3",
|
||||
"grunt-contrib-copy": "~0.4.1",
|
||||
"grunt-contrib-less": "~0.8.2",
|
||||
"grunt-contrib-jade": "~0.8.0",
|
||||
"grunt-contrib-uglify": "~0.2.7",
|
||||
"grunt-nodemon": "~0.1.2",
|
||||
"grunt-contrib-clean": "~0.5.0",
|
||||
"grunt-concurrent": "~0.4.2",
|
||||
"grunt-plato": "~0.2.1",
|
||||
"grunt-notify": "~0.2.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
requirejs.config({
|
||||
baseUrl: "./build",
|
||||
out: "./build/chat.js",
|
||||
inlineText:true,
|
||||
preserveLicenseComments:false,
|
||||
shim: {
|
||||
"libs/underscore": {
|
||||
init: function() {
|
||||
return _.noConflict();
|
||||
}
|
||||
},
|
||||
"libs/backbone": {
|
||||
deps: ["libs/underscore"],
|
||||
init: function() {
|
||||
return Backbone.noConflict();
|
||||
}
|
||||
}
|
||||
},
|
||||
paths: {
|
||||
"moment": "libs/moment",
|
||||
},
|
||||
name:"chat",
|
||||
optimize: 'none',
|
||||
skipDirOptimize: true
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
define [
|
||||
"utils/staticLoader"
|
||||
"libs/underscore"
|
||||
"libs/backbone"
|
||||
"libs/jquery.storage"
|
||||
"models/room"
|
||||
"models/user"
|
||||
"views/chatWindowView"
|
||||
|
||||
], (staticLoader, _, Backbone, jqueryStorage, Room, User, ChatWindowView) ->
|
||||
|
||||
staticLoader.appendAssets()
|
||||
_.templateSettings = escape : /\{\{(.+?)\}\}/g
|
||||
|
||||
class GlobalNotificationManager
|
||||
constructor: (@chat) ->
|
||||
@focussed = true
|
||||
$(window).on "focus", () =>
|
||||
@clearNewMessageNotification()
|
||||
@focussed = true
|
||||
$(window).on "blur", () => @focussed = false
|
||||
|
||||
@chat.on "joinedRoom", (room) =>
|
||||
notifyIfAppropriate = (message) =>
|
||||
if message.get("user") != @chat.user and !message.get("preloaded")
|
||||
@notifyAboutNewMessage()
|
||||
|
||||
room.get("messages").on "add", notifyIfAppropriate
|
||||
room.on "disconnect", () ->
|
||||
room.get("messages").off "add", notifyIfAppropriate
|
||||
|
||||
notifyAboutNewMessage: () ->
|
||||
if !@focussed and !@newMessageNotificationTimeout?
|
||||
@originalTitle ||= window.document.title
|
||||
do changeTitle = () =>
|
||||
if window.document.title == @originalTitle
|
||||
window.document.title = "New Message"
|
||||
else
|
||||
window.document.title = @originalTitle
|
||||
@newMessageNotificationTimeout = setTimeout changeTitle, 800
|
||||
|
||||
clearNewMessageNotification: () ->
|
||||
clearTimeout @newMessageNotificationTimeout
|
||||
delete @newMessageNotificationTimeout
|
||||
if @originalTitle?
|
||||
window.document.title = @originalTitle
|
||||
|
||||
class Chat
|
||||
constructor: (options) ->
|
||||
_.extend(@, Backbone.Events)
|
||||
window.chat = @
|
||||
@rooms = {}
|
||||
project_id = window.location.pathname.split( '/' )[2]
|
||||
@socket = socket = io.connect options.url, {
|
||||
resource: "chat/socket.io",
|
||||
"force new connection": true
|
||||
query:"project_id=#{project_id}"
|
||||
}
|
||||
|
||||
@socket.on "connect", () =>
|
||||
@connected = true
|
||||
@getAuthToken (error, auth_token) =>
|
||||
return @handleError(error) if error?
|
||||
@socket.emit "auth", {auth_token: auth_token}, (error, user_info) =>
|
||||
return @handleError(error) if error?
|
||||
@user = User.findOrCreate(user_info)
|
||||
@joinProjectRoom(options.room.project_id)
|
||||
@trigger "authed"
|
||||
|
||||
@socket.on "disconnect", () =>
|
||||
@connected = false
|
||||
@trigger "disconnected"
|
||||
|
||||
@socket.on "messageReceived", (data) =>
|
||||
@getRoom(data.message.room.id)?.onMessageReceived(data)
|
||||
|
||||
@socket.on "userJoined", (data) =>
|
||||
@getRoom(data.room.id).addConnectedUser(data.user)
|
||||
|
||||
@socket.on "userLeft", (data) =>
|
||||
@getRoom(data.room.id)?.removeConnectedUser(data.user)
|
||||
|
||||
@globalNotificationManager = new GlobalNotificationManager(@)
|
||||
|
||||
getRoom: (room_id) ->
|
||||
@rooms[room_id]
|
||||
|
||||
joinProjectRoom: (project_id) ->
|
||||
if !@room?
|
||||
@room = new Room(
|
||||
project_id: project_id
|
||||
chat: @
|
||||
)
|
||||
@window = new ChatWindowView({
|
||||
room: @room
|
||||
chat: @
|
||||
})
|
||||
@room.on "joined", => @trigger("joinedRoom", @room)
|
||||
|
||||
getAuthToken: (callback = (error, auth_token) ->) ->
|
||||
$.ajax "/user/auth_token", {
|
||||
success: (data, status, xhr) ->
|
||||
callback null, data
|
||||
error: (xhr, status, error) ->
|
||||
callback error
|
||||
}
|
||||
|
||||
handleError: (error) ->
|
||||
console.error error
|
||||
@@ -0,0 +1,9 @@
|
||||
define [
|
||||
"libs/backbone"
|
||||
"models/user"
|
||||
], (Backbone, User) ->
|
||||
ConnectedUsers = Backbone.Collection.extend
|
||||
model: User
|
||||
|
||||
initialize: (models, options) ->
|
||||
{@chat, @room} = options
|
||||
@@ -0,0 +1,45 @@
|
||||
define [
|
||||
"libs/backbone"
|
||||
"models/message"
|
||||
"models/user"
|
||||
|
||||
], (Backbone, Message, User) ->
|
||||
|
||||
Messages = Backbone.Collection.extend
|
||||
model: Message
|
||||
|
||||
initialize: (models, options) ->
|
||||
{@chat, @room} = options
|
||||
|
||||
fetchMoreMessages: (options = { preloading: false }, callback = (error) ->) ->
|
||||
limit = Messages.DEFAULT_MESSAGE_LIMIT
|
||||
|
||||
@room.fetchMessages @_buildMessagesQuery(limit), (error, messages) =>
|
||||
if error?
|
||||
callback(error)
|
||||
return @chat.handleError(error)
|
||||
if messages.length < limit
|
||||
@trigger "noMoreMessages"
|
||||
@_parseAndAddMessages(messages, options)
|
||||
callback()
|
||||
|
||||
_parseAndAddMessages: (messages, options) ->
|
||||
for message in messages
|
||||
user = User.findOrCreate message.user
|
||||
@add new Message(
|
||||
content : message.content
|
||||
timestamp : message.timestamp
|
||||
user : user
|
||||
preloaded : !!options.preloading
|
||||
), at: 0
|
||||
|
||||
_buildMessagesQuery: (limit) ->
|
||||
query =
|
||||
limit: limit
|
||||
firstMessage = @at(0)
|
||||
if firstMessage?
|
||||
query.before = firstMessage.get("timestamp")
|
||||
return query
|
||||
Messages.DEFAULT_MESSAGE_LIMIT = 50
|
||||
|
||||
return Messages
|
||||
@@ -0,0 +1,5 @@
|
||||
define [
|
||||
"libs/backbone"
|
||||
], (Backbone) ->
|
||||
|
||||
Message = Backbone.Model.extend {}
|
||||
@@ -0,0 +1,85 @@
|
||||
define [
|
||||
"libs/underscore"
|
||||
"libs/backbone"
|
||||
"collections/messages"
|
||||
"collections/connectedUsers"
|
||||
"models/user"
|
||||
"models/message"
|
||||
], (_, Backbone, Messages, ConnectedUsers, User, Message) ->
|
||||
|
||||
|
||||
Room = Backbone.Model.extend
|
||||
initialize: () ->
|
||||
@chat = @get("chat")
|
||||
@set "messages", new Messages([], chat: @chat, room: @)
|
||||
@set "connectedUsers", new ConnectedUsers([], chat: @chat, room: @)
|
||||
|
||||
@get("connectedUsers").on "change", () ->
|
||||
@get("connectedUsers").on "add", () ->
|
||||
@get("connectedUsers").on "remove", () ->
|
||||
|
||||
@connected = false
|
||||
|
||||
@chat.on "authed", () => @join()
|
||||
@chat.on "disconnected", () => @_onDisconnect()
|
||||
|
||||
join: () ->
|
||||
@chat.socket.emit "joinRoom", room: project_id: @get("project_id"), (error, data) =>
|
||||
return @chat.handleError(error) if error?
|
||||
room = data.room
|
||||
@set("id", room.id)
|
||||
@chat.rooms[room.id] = @
|
||||
@addConnectedUsers(room.connectedUsers)
|
||||
@_onJoin()
|
||||
|
||||
_onJoin: () ->
|
||||
@trigger "joined"
|
||||
@connected = true
|
||||
|
||||
if @get("messages").models.length == 0
|
||||
@get("messages").fetchMoreMessages preloading: true, () =>
|
||||
@trigger("afterMessagesPreloaded")
|
||||
|
||||
_onDisconnect: () ->
|
||||
@trigger "disconnected"
|
||||
@connected = false
|
||||
|
||||
addConnectedUsers: (users) ->
|
||||
for user in users
|
||||
@addConnectedUser(user)
|
||||
|
||||
addConnectedUser: (user) ->
|
||||
if user not instanceof User
|
||||
user = User.findOrCreate(user)
|
||||
@get("connectedUsers").add user
|
||||
|
||||
removeConnectedUser: (user) ->
|
||||
if user not instanceof User
|
||||
user = User.findOrCreate(user)
|
||||
@get("connectedUsers").remove user
|
||||
|
||||
sendMessage: (content, callback = (error) ->) ->
|
||||
if !@connected
|
||||
return callback(new Error("Not connected"))
|
||||
@chat.socket.emit "sendMessage", {
|
||||
message:
|
||||
content: content
|
||||
room:
|
||||
id: @get("id")
|
||||
}
|
||||
|
||||
fetchMessages: (query, callback = (error, messages) ->) ->
|
||||
if !@connected
|
||||
return callback(new Error("Not connected"))
|
||||
query.room = id: @get("id")
|
||||
@chat.socket.emit "getMessages", query, callback
|
||||
|
||||
onMessageReceived: (data) ->
|
||||
message = data.message
|
||||
user = User.findOrCreate message.user
|
||||
message = new Message(
|
||||
content : data.message.content
|
||||
timestamp : data.message.timestamp
|
||||
user : user
|
||||
)
|
||||
@get("messages").add message
|
||||
@@ -0,0 +1,13 @@
|
||||
define [
|
||||
"libs/backbone"
|
||||
], (Backbone, room) ->
|
||||
|
||||
User = Backbone.Model.extend {},
|
||||
findOrCreate: (attributes) ->
|
||||
User.cache ||= {}
|
||||
if User.cache[attributes.id]?
|
||||
return User.cache[attributes.id]
|
||||
else
|
||||
user = new User(attributes)
|
||||
User.cache[attributes.id] = user
|
||||
return user
|
||||
@@ -0,0 +1,10 @@
|
||||
define [
|
||||
"text!html/templates.html"
|
||||
"text!css/chat.css"
|
||||
], (templates, css)->
|
||||
|
||||
appendAssets : ->
|
||||
$(document.body).append($(templates))
|
||||
style = $("<style/>")
|
||||
style.html(css)
|
||||
$(document.body).append(style)
|
||||
@@ -0,0 +1,219 @@
|
||||
define [
|
||||
"libs/underscore"
|
||||
"libs/backbone"
|
||||
"views/userMessageBlockView"
|
||||
"views/timeMessageBlockView"
|
||||
], (_, Backbone, UserMessageBlockView, TimeMessageBlockView) ->
|
||||
FIVE_MINS = 5 * 60 * 1000
|
||||
ONE_HOUR = 60 * 60 * 1000
|
||||
TWELVE_HOURS = ONE_HOUR * 12
|
||||
ONE_DAY = ONE_HOUR * 24
|
||||
|
||||
ChatWindowView = Backbone.View.extend
|
||||
events:
|
||||
"keydown textarea" : "_onTextAreaKeyDown"
|
||||
"click .js-load-older-messages" : "_loadOlderMessages"
|
||||
"click .js-minimize-toggle" : "_toggleMinimizeState"
|
||||
"click h3" : "_toggleMinimizeState"
|
||||
"click .js-new-message-alert" : "_toggleMinimizeState"
|
||||
"click" : "_removeNotification"
|
||||
|
||||
initialize: () ->
|
||||
@template = $("#chatWindowTemplate").html()
|
||||
@chat = @options.chat
|
||||
@room = @options.room
|
||||
@listenTo @room.get("messages"), "add", (model, collection) -> @_onNewMessage(model, collection)
|
||||
@listenTo @room.get("messages"), "noMoreMessages", () -> @$(".js-loading").hide()
|
||||
@listenTo @room.get("connectedUsers"), "add", (user, collection) -> @_renderConnectedUsers()
|
||||
@listenTo @room.get("connectedUsers"), "remove", (user, collection) -> @_renderConnectedUsers()
|
||||
@listenTo @room.get("connectedUsers"), "change", (user, collection) -> @_renderConnectedUsers()
|
||||
@listenTo @room, "joined", -> @_onJoin()
|
||||
@listenTo @room, "disconnected", -> @_onDisconnect()
|
||||
@listenTo @room, "afterMessagesPreloaded", -> @_scrollToBottomOfMessages()
|
||||
|
||||
render: () ->
|
||||
@setElement($(@template))
|
||||
$(document.body).append(@$el)
|
||||
@_renderConnectedUsers()
|
||||
@_initializeMinimizedState()
|
||||
|
||||
_onJoin: () ->
|
||||
if !@rendered?
|
||||
@render()
|
||||
@rendered = true
|
||||
@$el.removeClass("disconnected")
|
||||
@$("textarea").removeAttr("disabled")
|
||||
|
||||
_onDisconnect: () ->
|
||||
@$el.addClass("disconnected")
|
||||
@$("textarea").attr("disabled", "disabled")
|
||||
|
||||
_onNewMessage: (message, collection) ->
|
||||
@_renderMessage(message, collection)
|
||||
@_notifyAboutNewMessage(message)
|
||||
|
||||
_renderMessage: (message, collection) ->
|
||||
|
||||
@messageBlocks ||= []
|
||||
scrollEl = @$(".sent-message-area")
|
||||
|
||||
isOldestMessage = (message, collection)->
|
||||
collection.indexOf(message) == 0
|
||||
|
||||
ismessageFromNewUser = (messageView, message)->
|
||||
!messageView? or messageView.user != message.get("user")
|
||||
|
||||
isTimeForNewBlockBackwards = (message, previousUserMessageBlockView)->
|
||||
if !message? or !previousUserMessageBlockView?
|
||||
return true
|
||||
|
||||
timeSinceMessageWasSent = new Date().getTime() - message.get("timestamp")
|
||||
|
||||
if timeSinceMessageWasSent < ONE_HOUR
|
||||
timeBlockSize = FIVE_MINS
|
||||
else if timeSinceMessageWasSent > ONE_HOUR and timeSinceMessageWasSent < (ONE_DAY + TWELVE_HOURS)
|
||||
timeBlockSize = ONE_HOUR
|
||||
else
|
||||
timeBlockSize = ONE_DAY
|
||||
|
||||
timeSinceLastPrinted = previousUserMessageBlockView.getTime() - message.get("timestamp")
|
||||
|
||||
if timeSinceLastPrinted > timeBlockSize
|
||||
return true
|
||||
else
|
||||
return false
|
||||
|
||||
|
||||
isTimeForNewBlock = (message, previousUserMessageBlockView)->
|
||||
(message.get("timestamp") - previousUserMessageBlockView.getTime()) > FIVE_MINS
|
||||
|
||||
|
||||
if isOldestMessage(message, collection)
|
||||
oldScrollTopFromBottom = scrollEl[0].scrollHeight - scrollEl.scrollTop()
|
||||
|
||||
userMessageBlockView = @messageBlocks[0]
|
||||
if ismessageFromNewUser(userMessageBlockView, message) or isTimeForNewBlockBackwards(message, userMessageBlockView)
|
||||
userMessageBlockView = new UserMessageBlockView(user: message.get("user"))
|
||||
@$(".sent-messages").prepend(userMessageBlockView.$el)
|
||||
@messageBlocks.unshift userMessageBlockView
|
||||
|
||||
userMessageBlockView.prependMessage(message)
|
||||
|
||||
scrollEl.scrollTop(scrollEl[0].scrollHeight - oldScrollTopFromBottom)
|
||||
else
|
||||
oldScrollBottom = @_getScrollBottom()
|
||||
userMessageBlockView = @messageBlocks[@messageBlocks.length - 1]
|
||||
|
||||
if ismessageFromNewUser(userMessageBlockView, message) or isTimeForNewBlock(message, userMessageBlockView)
|
||||
userMessageBlockView = new UserMessageBlockView(user: message.get("user"))
|
||||
@$(".sent-messages").append(userMessageBlockView.$el)
|
||||
@messageBlocks.push userMessageBlockView
|
||||
|
||||
userMessageBlockView.appendMessage(message)
|
||||
|
||||
if oldScrollBottom <= 0
|
||||
@_scrollToBottomOfMessages()
|
||||
|
||||
|
||||
_renderConnectedUsers: () ->
|
||||
users = @room.get("connectedUsers")
|
||||
names = users
|
||||
.reject((user) => user == @chat.user)
|
||||
.map((user) -> "#{user.get("first_name")} #{user.get("last_name")}")
|
||||
if names.length == 0
|
||||
text = "No one else is online :("
|
||||
else if names.length == 1
|
||||
text = "#{names[0]} is also online"
|
||||
else
|
||||
text = "#{names.slice(0, -1).join(", ")} and #{names[names.length - 1]} are also online"
|
||||
@$(".js-connected-users").text(text)
|
||||
@_resizeSentMessageArea()
|
||||
|
||||
_resizeSentMessageArea: () ->
|
||||
marginTop = @$(".js-header").outerHeight() + @$(".js-connected-users").outerHeight()
|
||||
@$(".js-sent-message-area").css({
|
||||
top: marginTop + "px"
|
||||
})
|
||||
|
||||
_getScrollBottom: () ->
|
||||
scrollEl = @$(".sent-message-area")
|
||||
return scrollEl[0].scrollHeight - scrollEl.scrollTop() - scrollEl.innerHeight()
|
||||
|
||||
_scrollToBottomOfMessages: () ->
|
||||
scrollEl = @$(".sent-message-area")
|
||||
doScroll = ->
|
||||
return scrollEl.scrollTop(scrollEl[0].scrollHeight - scrollEl.innerHeight())
|
||||
MathJax.Hub.Queue(["Typeset", doScroll])
|
||||
|
||||
_notifyAboutNewMessage: (message) ->
|
||||
isMessageNewToUser = message.get("user") != @chat.user and !message.get("preloaded")
|
||||
isTextAreaFocused = @$("textarea").is(":focus")
|
||||
if !isTextAreaFocused and isMessageNewToUser
|
||||
@unseenMessages ||= 0
|
||||
@unseenMessages += 1
|
||||
@$el.addClass("new-messages")
|
||||
@$(".new-message-alert").text(@unseenMessages)
|
||||
|
||||
_removeNotification: () ->
|
||||
@unseenMessages = 0
|
||||
@$el.removeClass("new-messages")
|
||||
@$(".new-message-alert").text("")
|
||||
|
||||
_onTextAreaKeyDown: (e) ->
|
||||
if e.keyCode == 13 # Enter
|
||||
e.preventDefault()
|
||||
message = @$("textarea").val()
|
||||
@$("textarea").val("")
|
||||
@_sendMessage(message)
|
||||
|
||||
_loadOlderMessages: (e) ->
|
||||
e.preventDefault()
|
||||
@room.get("messages").fetchMoreMessages()
|
||||
|
||||
_sendMessage: (content) ->
|
||||
@room.sendMessage(content)
|
||||
|
||||
isMinimized: () ->
|
||||
minimized = $.localStorage "chat.rooms.project-chat.minimized"
|
||||
if !minimized?
|
||||
minimized = false
|
||||
return minimized
|
||||
|
||||
_setMinimizedState: (state) ->
|
||||
$.localStorage "chat.rooms.project-chat.minimized", state
|
||||
|
||||
_initializeMinimizedState: () ->
|
||||
minimized = @isMinimized()
|
||||
if minimized
|
||||
@_minimize(false)
|
||||
|
||||
_toggleMinimizeState: (e) ->
|
||||
e.preventDefault()
|
||||
minimized = @isMinimized()
|
||||
if !minimized
|
||||
@_setMinimizedState(true)
|
||||
@_minimize()
|
||||
else
|
||||
@_setMinimizedState(false)
|
||||
@_unminimize()
|
||||
|
||||
_minimize: (animate = true) ->
|
||||
@$(".new-message-area").hide()
|
||||
@$(".js-connected-users").hide()
|
||||
@$el.addClass("minimized")
|
||||
if animate
|
||||
@$el.animate height: 20, width: 80
|
||||
else
|
||||
@$el.css height: 20, width: 80
|
||||
|
||||
_unminimize: () ->
|
||||
@$(".new-message-area").show()
|
||||
@$(".js-connected-users").show()
|
||||
@$el.removeClass("minimized")
|
||||
@$el.animate height: 260, width: 220, () =>
|
||||
@_resizeSentMessageArea()
|
||||
@_scrollToBottomOfMessages()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
define [
|
||||
"libs/underscore"
|
||||
"libs/backbone"
|
||||
"moment"
|
||||
], (_, Backbone, moment) ->
|
||||
ONE_WEEK = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
TimeMessageBlockView = Backbone.View.extend
|
||||
|
||||
className : "timeSinceMessage"
|
||||
|
||||
initialize: () ->
|
||||
@autoRefresh()
|
||||
|
||||
setTimeOnce: (timestamp)->
|
||||
if !@timestamp?
|
||||
@timestamp = timestamp
|
||||
@render()
|
||||
return @
|
||||
|
||||
setTime: (@timestamp)->
|
||||
@render()
|
||||
return @
|
||||
|
||||
autoRefresh: ->
|
||||
if @timestamp?
|
||||
@render()
|
||||
self = @
|
||||
doIt = =>
|
||||
self.autoRefresh()
|
||||
setTimeout doIt, 60 * 1000
|
||||
|
||||
render: () ->
|
||||
milisecondsSince = new Date().getTime() - @timestamp
|
||||
if milisecondsSince > ONE_WEEK
|
||||
time = moment(@timestamp).format("D/MMM/YY, h:mm:ss a")
|
||||
else
|
||||
time = moment(@timestamp).fromNow()
|
||||
this.$el.html(time)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
define [
|
||||
"libs/underscore"
|
||||
"libs/backbone"
|
||||
"views/timeMessageBlockView"
|
||||
"moment"
|
||||
"https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML"
|
||||
], (_, Backbone, TimeMessageBlockView, moment) ->
|
||||
|
||||
mathjaxConfig =
|
||||
"HTML-CSS": { availableFonts: ["TeX"] },
|
||||
TeX:
|
||||
equationNumbers: { autoNumber: "AMS" },
|
||||
useLabelIDs: false
|
||||
tex2jax:
|
||||
inlineMath: [ ['$','$'], ["\\(","\\)"] ],
|
||||
displayMath: [ ['$$','$$'], ["\\[","\\]"] ],
|
||||
processEscapes: true
|
||||
|
||||
|
||||
MathJax.Hub.Config(mathjaxConfig);
|
||||
|
||||
|
||||
|
||||
UserMessageBlockView = Backbone.View.extend
|
||||
|
||||
initialize: () ->
|
||||
@template = _.template($("#messageBlockTemplate").html())
|
||||
@user = @options.user
|
||||
@timeMessageBlock = new TimeMessageBlockView()
|
||||
@render()
|
||||
|
||||
render: () ->
|
||||
@setElement $(@template(
|
||||
first_name: @user.get("first_name")
|
||||
last_name: @user.get("last_name")
|
||||
gravatar_url: @user.get("gravatar_url")
|
||||
))
|
||||
@$(".timeArea").html(@timeMessageBlock.$el)
|
||||
|
||||
getTime: ->
|
||||
return @timeMessageBlock.timestamp
|
||||
|
||||
appendMessage: (message) ->
|
||||
el = @buildHtml(message)
|
||||
@$(".messages").append(el)
|
||||
@_renderMathJax(el)
|
||||
@timeMessageBlock.setTimeOnce message.get("timestamp")
|
||||
|
||||
prependMessage: (message) ->
|
||||
el = @buildHtml(message)
|
||||
@$(".messages").prepend(el)
|
||||
@_renderMathJax(el)
|
||||
@timeMessageBlock.setTime message.get("timestamp")
|
||||
|
||||
buildHtml : (message)->
|
||||
time = moment(message.get("timestamp")).format("dddd, MMMM Do YYYY, h:mm:ss a")
|
||||
el = $("<div class='message' title='#{time}'>")
|
||||
el.text(message.get("content"))
|
||||
return el
|
||||
|
||||
|
||||
_renderMathJax: (element)->
|
||||
if element?
|
||||
MathJax.Hub.Queue(["Typeset", MathJax.Hub, element.get(0)])
|
||||
@@ -0,0 +1,24 @@
|
||||
script(type="text/templates")#chatWindowTemplate
|
||||
.chat-window
|
||||
.header.js-header
|
||||
h3 Chat
|
||||
.new-message-alert.js-new-message-alert
|
||||
.window-controls
|
||||
.js-minimize-toggle.minimize-toggle
|
||||
.connected-users.js-connected-users
|
||||
.sent-message-area.js-sent-message-area
|
||||
.loading.js-loading
|
||||
a(href="#").load-older-messages.js-load-older-messages Load older messages
|
||||
.sent-messages
|
||||
.new-message-area
|
||||
textarea(placeholder="Your message")
|
||||
|
||||
script(type="text/templates")#messageBlockTemplate
|
||||
.chat-block
|
||||
.message-block
|
||||
.timeArea
|
||||
.avatar
|
||||
img(src="{{ gravatar_url }}?d=mm&s=40", alt="{{first_name}} {{last_name}}")
|
||||
span.name {{first_name}} {{last_name}}
|
||||
.messages
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+9478
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
/*!
|
||||
* jquery.storage.js 0.0.3 - https://github.com/yckart/jquery.storage.js
|
||||
* The client-side storage for every browser, on any device.
|
||||
*
|
||||
* Copyright (c) 2012 Yannick Albert (http://yckart.com)
|
||||
* Licensed under the MIT license (http://www.opensource.org/licenses/mit-license.php).
|
||||
* 2013/02/10
|
||||
**/
|
||||
define([], function() {
|
||||
;(function($, window, document) {
|
||||
'use strict';
|
||||
|
||||
$.map(['localStorage', 'sessionStorage'], function( method ) {
|
||||
var defaults = {
|
||||
cookiePrefix : 'fallback:' + method + ':',
|
||||
cookieOptions : {
|
||||
path : '/',
|
||||
domain : document.domain,
|
||||
expires : ('localStorage' === method) ? { expires: 365 } : undefined
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$.support[method] = method in window && window[method] !== null;
|
||||
} catch (e) {
|
||||
$.support[method] = false;
|
||||
}
|
||||
|
||||
$[method] = function(key, value) {
|
||||
var options = $.extend({}, defaults, $[method].options);
|
||||
|
||||
this.getItem = function( key ) {
|
||||
var returns = function(key){
|
||||
return JSON.parse($.support[method] ? window[method].getItem(key) : $.cookie(options.cookiePrefix + key));
|
||||
};
|
||||
if(typeof key === 'string') return returns(key);
|
||||
|
||||
var arr = [],
|
||||
i = key.length;
|
||||
while(i--) arr[i] = returns(key[i]);
|
||||
return arr;
|
||||
};
|
||||
|
||||
this.setItem = function( key, value ) {
|
||||
value = JSON.stringify(value);
|
||||
return $.support[method] ? window[method].setItem(key, value) : $.cookie(options.cookiePrefix + key, value, options.cookieOptions);
|
||||
};
|
||||
|
||||
this.removeItem = function( key ) {
|
||||
return $.support[method] ? window[method].removeItem(key) : $.cookie(options.cookiePrefix + key, null, $.extend(options.cookieOptions, {
|
||||
expires: -1
|
||||
}));
|
||||
};
|
||||
|
||||
this.clear = function() {
|
||||
if($.support[method]) {
|
||||
return window[method].clear();
|
||||
} else {
|
||||
var reg = new RegExp('^' + options.cookiePrefix, ''),
|
||||
opts = $.extend(options.cookieOptions, {
|
||||
expires: -1
|
||||
});
|
||||
|
||||
if(document.cookie && document.cookie !== ''){
|
||||
$.map(document.cookie.split(';'), function( cookie ){
|
||||
if(reg.test(cookie = $.trim(cookie))) {
|
||||
$.cookie( cookie.substr(0,cookie.indexOf('=')), null, opts);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof key !== "undefined") {
|
||||
return typeof value !== "undefined" ? ( value === null ? this.removeItem(key) : this.setItem(key, value) ) : this.getItem(key);
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
$[method].options = defaults;
|
||||
});
|
||||
}(jQuery, window, document));
|
||||
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* @license RequireJS text 2.0.7 Copyright (c) 2010-2012, The Dojo Foundation All Rights Reserved.
|
||||
* Available via the MIT or new BSD license.
|
||||
* see: http://github.com/requirejs/text for details
|
||||
*/
|
||||
/*jslint regexp: true */
|
||||
/*global require, XMLHttpRequest, ActiveXObject,
|
||||
define, window, process, Packages,
|
||||
java, location, Components, FileUtils */
|
||||
|
||||
define(['module'], function (module) {
|
||||
'use strict';
|
||||
|
||||
var text, fs, Cc, Ci,
|
||||
progIds = ['Msxml2.XMLHTTP', 'Microsoft.XMLHTTP', 'Msxml2.XMLHTTP.4.0'],
|
||||
xmlRegExp = /^\s*<\?xml(\s)+version=[\'\"](\d)*.(\d)*[\'\"](\s)*\?>/im,
|
||||
bodyRegExp = /<body[^>]*>\s*([\s\S]+)\s*<\/body>/im,
|
||||
hasLocation = typeof location !== 'undefined' && location.href,
|
||||
defaultProtocol = hasLocation && location.protocol && location.protocol.replace(/\:/, ''),
|
||||
defaultHostName = hasLocation && location.hostname,
|
||||
defaultPort = hasLocation && (location.port || undefined),
|
||||
buildMap = {},
|
||||
masterConfig = (module.config && module.config()) || {};
|
||||
|
||||
text = {
|
||||
version: '2.0.7',
|
||||
|
||||
strip: function (content) {
|
||||
//Strips <?xml ...?> declarations so that external SVG and XML
|
||||
//documents can be added to a document without worry. Also, if the string
|
||||
//is an HTML document, only the part inside the body tag is returned.
|
||||
if (content) {
|
||||
content = content.replace(xmlRegExp, "");
|
||||
var matches = content.match(bodyRegExp);
|
||||
if (matches) {
|
||||
content = matches[1];
|
||||
}
|
||||
} else {
|
||||
content = "";
|
||||
}
|
||||
return content;
|
||||
},
|
||||
|
||||
jsEscape: function (content) {
|
||||
return content.replace(/(['\\])/g, '\\$1')
|
||||
.replace(/[\f]/g, "\\f")
|
||||
.replace(/[\b]/g, "\\b")
|
||||
.replace(/[\n]/g, "\\n")
|
||||
.replace(/[\t]/g, "\\t")
|
||||
.replace(/[\r]/g, "\\r")
|
||||
.replace(/[\u2028]/g, "\\u2028")
|
||||
.replace(/[\u2029]/g, "\\u2029");
|
||||
},
|
||||
|
||||
createXhr: masterConfig.createXhr || function () {
|
||||
//Would love to dump the ActiveX crap in here. Need IE 6 to die first.
|
||||
var xhr, i, progId;
|
||||
if (typeof XMLHttpRequest !== "undefined") {
|
||||
return new XMLHttpRequest();
|
||||
} else if (typeof ActiveXObject !== "undefined") {
|
||||
for (i = 0; i < 3; i += 1) {
|
||||
progId = progIds[i];
|
||||
try {
|
||||
xhr = new ActiveXObject(progId);
|
||||
} catch (e) {}
|
||||
|
||||
if (xhr) {
|
||||
progIds = [progId]; // so faster next time
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return xhr;
|
||||
},
|
||||
|
||||
/**
|
||||
* Parses a resource name into its component parts. Resource names
|
||||
* look like: module/name.ext!strip, where the !strip part is
|
||||
* optional.
|
||||
* @param {String} name the resource name
|
||||
* @returns {Object} with properties "moduleName", "ext" and "strip"
|
||||
* where strip is a boolean.
|
||||
*/
|
||||
parseName: function (name) {
|
||||
var modName, ext, temp,
|
||||
strip = false,
|
||||
index = name.indexOf("."),
|
||||
isRelative = name.indexOf('./') === 0 ||
|
||||
name.indexOf('../') === 0;
|
||||
|
||||
if (index !== -1 && (!isRelative || index > 1)) {
|
||||
modName = name.substring(0, index);
|
||||
ext = name.substring(index + 1, name.length);
|
||||
} else {
|
||||
modName = name;
|
||||
}
|
||||
|
||||
temp = ext || modName;
|
||||
index = temp.indexOf("!");
|
||||
if (index !== -1) {
|
||||
//Pull off the strip arg.
|
||||
strip = temp.substring(index + 1) === "strip";
|
||||
temp = temp.substring(0, index);
|
||||
if (ext) {
|
||||
ext = temp;
|
||||
} else {
|
||||
modName = temp;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
moduleName: modName,
|
||||
ext: ext,
|
||||
strip: strip
|
||||
};
|
||||
},
|
||||
|
||||
xdRegExp: /^((\w+)\:)?\/\/([^\/\\]+)/,
|
||||
|
||||
/**
|
||||
* Is an URL on another domain. Only works for browser use, returns
|
||||
* false in non-browser environments. Only used to know if an
|
||||
* optimized .js version of a text resource should be loaded
|
||||
* instead.
|
||||
* @param {String} url
|
||||
* @returns Boolean
|
||||
*/
|
||||
useXhr: function (url, protocol, hostname, port) {
|
||||
var uProtocol, uHostName, uPort,
|
||||
match = text.xdRegExp.exec(url);
|
||||
if (!match) {
|
||||
return true;
|
||||
}
|
||||
uProtocol = match[2];
|
||||
uHostName = match[3];
|
||||
|
||||
uHostName = uHostName.split(':');
|
||||
uPort = uHostName[1];
|
||||
uHostName = uHostName[0];
|
||||
|
||||
return (!uProtocol || uProtocol === protocol) &&
|
||||
(!uHostName || uHostName.toLowerCase() === hostname.toLowerCase()) &&
|
||||
((!uPort && !uHostName) || uPort === port);
|
||||
},
|
||||
|
||||
finishLoad: function (name, strip, content, onLoad) {
|
||||
content = strip ? text.strip(content) : content;
|
||||
if (masterConfig.isBuild) {
|
||||
buildMap[name] = content;
|
||||
}
|
||||
onLoad(content);
|
||||
},
|
||||
|
||||
load: function (name, req, onLoad, config) {
|
||||
//Name has format: some.module.filext!strip
|
||||
//The strip part is optional.
|
||||
//if strip is present, then that means only get the string contents
|
||||
//inside a body tag in an HTML string. For XML/SVG content it means
|
||||
//removing the <?xml ...?> declarations so the content can be inserted
|
||||
//into the current doc without problems.
|
||||
|
||||
// Do not bother with the work if a build and text will
|
||||
// not be inlined.
|
||||
if (config.isBuild && !config.inlineText) {
|
||||
onLoad();
|
||||
return;
|
||||
}
|
||||
|
||||
masterConfig.isBuild = config.isBuild;
|
||||
|
||||
var parsed = text.parseName(name),
|
||||
nonStripName = parsed.moduleName +
|
||||
(parsed.ext ? '.' + parsed.ext : ''),
|
||||
url = req.toUrl(nonStripName),
|
||||
useXhr = (masterConfig.useXhr) ||
|
||||
text.useXhr;
|
||||
|
||||
//Load the text. Use XHR if possible and in a browser.
|
||||
if (!hasLocation || useXhr(url, defaultProtocol, defaultHostName, defaultPort)) {
|
||||
text.get(url, function (content) {
|
||||
text.finishLoad(name, parsed.strip, content, onLoad);
|
||||
}, function (err) {
|
||||
if (onLoad.error) {
|
||||
onLoad.error(err);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
//Need to fetch the resource across domains. Assume
|
||||
//the resource has been optimized into a JS module. Fetch
|
||||
//by the module name + extension, but do not include the
|
||||
//!strip part to avoid file system issues.
|
||||
req([nonStripName], function (content) {
|
||||
text.finishLoad(parsed.moduleName + '.' + parsed.ext,
|
||||
parsed.strip, content, onLoad);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
write: function (pluginName, moduleName, write, config) {
|
||||
if (buildMap.hasOwnProperty(moduleName)) {
|
||||
var content = text.jsEscape(buildMap[moduleName]);
|
||||
write.asModule(pluginName + "!" + moduleName,
|
||||
"define(function () { return '" +
|
||||
content +
|
||||
"';});\n");
|
||||
}
|
||||
},
|
||||
|
||||
writeFile: function (pluginName, moduleName, req, write, config) {
|
||||
var parsed = text.parseName(moduleName),
|
||||
extPart = parsed.ext ? '.' + parsed.ext : '',
|
||||
nonStripName = parsed.moduleName + extPart,
|
||||
//Use a '.js' file name so that it indicates it is a
|
||||
//script that can be loaded across domains.
|
||||
fileName = req.toUrl(parsed.moduleName + extPart) + '.js';
|
||||
|
||||
//Leverage own load() method to load plugin value, but only
|
||||
//write out values that do not have the strip argument,
|
||||
//to avoid any potential issues with ! in file names.
|
||||
text.load(nonStripName, req, function (value) {
|
||||
//Use own write() method to construct full module value.
|
||||
//But need to create shell that translates writeFile's
|
||||
//write() to the right interface.
|
||||
var textWrite = function (contents) {
|
||||
return write(fileName, contents);
|
||||
};
|
||||
textWrite.asModule = function (moduleName, contents) {
|
||||
return write.asModule(moduleName, fileName, contents);
|
||||
};
|
||||
|
||||
text.write(pluginName, nonStripName, textWrite, config);
|
||||
}, config);
|
||||
}
|
||||
};
|
||||
|
||||
if (masterConfig.env === 'node' || (!masterConfig.env &&
|
||||
typeof process !== "undefined" &&
|
||||
process.versions &&
|
||||
!!process.versions.node)) {
|
||||
//Using special require.nodeRequire, something added by r.js.
|
||||
fs = require.nodeRequire('fs');
|
||||
|
||||
text.get = function (url, callback, errback) {
|
||||
try {
|
||||
var file = fs.readFileSync(url, 'utf8');
|
||||
//Remove BOM (Byte Mark Order) from utf8 files if it is there.
|
||||
if (file.indexOf('\uFEFF') === 0) {
|
||||
file = file.substring(1);
|
||||
}
|
||||
callback(file);
|
||||
} catch (e) {
|
||||
errback(e);
|
||||
}
|
||||
};
|
||||
} else if (masterConfig.env === 'xhr' || (!masterConfig.env &&
|
||||
text.createXhr())) {
|
||||
text.get = function (url, callback, errback, headers) {
|
||||
var xhr = text.createXhr(), header;
|
||||
xhr.open('GET', url, true);
|
||||
|
||||
//Allow plugins direct access to xhr headers
|
||||
if (headers) {
|
||||
for (header in headers) {
|
||||
if (headers.hasOwnProperty(header)) {
|
||||
xhr.setRequestHeader(header.toLowerCase(), headers[header]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Allow overrides specified in config
|
||||
if (masterConfig.onXhr) {
|
||||
masterConfig.onXhr(xhr, url);
|
||||
}
|
||||
|
||||
xhr.onreadystatechange = function (evt) {
|
||||
var status, err;
|
||||
//Do not explicitly handle errors, those should be
|
||||
//visible via console output in the browser.
|
||||
if (xhr.readyState === 4) {
|
||||
status = xhr.status;
|
||||
if (status > 399 && status < 600) {
|
||||
//An http 4xx or 5xx error. Signal an error.
|
||||
err = new Error(url + ' HTTP status: ' + status);
|
||||
err.xhr = xhr;
|
||||
errback(err);
|
||||
} else {
|
||||
callback(xhr.responseText);
|
||||
}
|
||||
|
||||
if (masterConfig.onXhrComplete) {
|
||||
masterConfig.onXhrComplete(xhr, url);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send(null);
|
||||
};
|
||||
} else if (masterConfig.env === 'rhino' || (!masterConfig.env &&
|
||||
typeof Packages !== 'undefined' && typeof java !== 'undefined')) {
|
||||
//Why Java, why is this so awkward?
|
||||
text.get = function (url, callback) {
|
||||
var stringBuffer, line,
|
||||
encoding = "utf-8",
|
||||
file = new java.io.File(url),
|
||||
lineSeparator = java.lang.System.getProperty("line.separator"),
|
||||
input = new java.io.BufferedReader(new java.io.InputStreamReader(new java.io.FileInputStream(file), encoding)),
|
||||
content = '';
|
||||
try {
|
||||
stringBuffer = new java.lang.StringBuffer();
|
||||
line = input.readLine();
|
||||
|
||||
// Byte Order Mark (BOM) - The Unicode Standard, version 3.0, page 324
|
||||
// http://www.unicode.org/faq/utf_bom.html
|
||||
|
||||
// Note that when we use utf-8, the BOM should appear as "EF BB BF", but it doesn't due to this bug in the JDK:
|
||||
// http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4508058
|
||||
if (line && line.length() && line.charAt(0) === 0xfeff) {
|
||||
// Eat the BOM, since we've already found the encoding on this file,
|
||||
// and we plan to concatenating this buffer with others; the BOM should
|
||||
// only appear at the top of a file.
|
||||
line = line.substring(1);
|
||||
}
|
||||
|
||||
if (line !== null) {
|
||||
stringBuffer.append(line);
|
||||
}
|
||||
|
||||
while ((line = input.readLine()) !== null) {
|
||||
stringBuffer.append(lineSeparator);
|
||||
stringBuffer.append(line);
|
||||
}
|
||||
//Make sure we return a JavaScript string and not a Java string.
|
||||
content = String(stringBuffer.toString()); //String
|
||||
} finally {
|
||||
input.close();
|
||||
}
|
||||
callback(content);
|
||||
};
|
||||
} else if (masterConfig.env === 'xpconnect' || (!masterConfig.env &&
|
||||
typeof Components !== 'undefined' && Components.classes &&
|
||||
Components.interfaces)) {
|
||||
//Avert your gaze!
|
||||
Cc = Components.classes,
|
||||
Ci = Components.interfaces;
|
||||
Components.utils['import']('resource://gre/modules/FileUtils.jsm');
|
||||
|
||||
text.get = function (url, callback) {
|
||||
var inStream, convertStream,
|
||||
readData = {},
|
||||
fileObj = new FileUtils.File(url);
|
||||
|
||||
//XPCOM, you so crazy
|
||||
try {
|
||||
inStream = Cc['@mozilla.org/network/file-input-stream;1']
|
||||
.createInstance(Ci.nsIFileInputStream);
|
||||
inStream.init(fileObj, 1, 0, false);
|
||||
|
||||
convertStream = Cc['@mozilla.org/intl/converter-input-stream;1']
|
||||
.createInstance(Ci.nsIConverterInputStream);
|
||||
convertStream.init(inStream, "utf-8", inStream.available(),
|
||||
Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER);
|
||||
|
||||
convertStream.readString(inStream.available(), readData);
|
||||
convertStream.close();
|
||||
inStream.close();
|
||||
callback(readData.value);
|
||||
} catch (e) {
|
||||
throw new Error((fileObj && fileObj.path || '') + ': ' + e);
|
||||
}
|
||||
};
|
||||
}
|
||||
return text;
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
@border-color: #999;
|
||||
|
||||
.chat-window {
|
||||
border-bottom: none;
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
right: 10px;
|
||||
height: 260px;
|
||||
width: 220px;
|
||||
background-color: white;
|
||||
z-index: 10;
|
||||
|
||||
.header {
|
||||
h3 {
|
||||
background-color: rgb(40,40,40);
|
||||
border: 1px solid #222;
|
||||
border-bottom: none;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
padding: 4px 6px 5px;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
position: relative;
|
||||
.window-controls {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 4px;
|
||||
bottom: 0px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: black;
|
||||
}
|
||||
.minimize-toggle {
|
||||
width: 12px;
|
||||
height: 100%;
|
||||
padding: 0 4px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 4px center;
|
||||
background-image: url(data:image/png;base64,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);
|
||||
}
|
||||
}
|
||||
|
||||
.new-message-alert {
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
padding: 4px;
|
||||
top: -16px;
|
||||
left: 35px;
|
||||
background-color: red;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
padding: 1px 6px;
|
||||
-moz-border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
&:after {
|
||||
border: 3px solid red;
|
||||
border-right: 3px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
bottom: -6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.connected-users {
|
||||
font-size: 10px;
|
||||
padding: 4px;
|
||||
background-color: #ddd;
|
||||
border: 1px solid @border-color;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.sent-message-area {
|
||||
border: 1px solid @border-color;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
bottom: 28px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
font-size: 12px;
|
||||
font-family: arial, san-serif;
|
||||
overflow-y: scroll;
|
||||
|
||||
|
||||
|
||||
.chat-block {
|
||||
position: relative;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
.message-block {
|
||||
min-height: 40px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.timeSinceMessage {
|
||||
color: @border-color;
|
||||
text-align:center;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
float: left;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.name {
|
||||
font-weight: bold;
|
||||
color: #999;
|
||||
margin-top: 0;
|
||||
}
|
||||
.message {
|
||||
margin: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
a.load-older-messages {
|
||||
padding: 10px;
|
||||
background-color: #eee;
|
||||
text-align:center;
|
||||
display: block;
|
||||
&:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@new-message-area-height: 27px;
|
||||
@new-message-area-padding-top: 3px;
|
||||
.new-message-area {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
height: @new-message-area-height;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
|
||||
border: 1px solid @border-color;
|
||||
border-bottom: none;
|
||||
|
||||
textarea {
|
||||
height: @new-message-area-height - 2 * @new-message-area-padding-top;
|
||||
width: 212px;
|
||||
border: none;
|
||||
padding: @new-message-area-padding-top;
|
||||
margin: 0;
|
||||
resize: none;
|
||||
font-size: 12px;
|
||||
font-family: arial, san-serif;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.chat-window.disconnected {
|
||||
.sent-message-area {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window.new-messages {
|
||||
.header {
|
||||
h3 {
|
||||
background-color: #049cdb;
|
||||
border-color: darken(#049cdb, 10%);
|
||||
}
|
||||
|
||||
.new-message-alert {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
&:hover {
|
||||
background-color: darken(#049cdb, 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window.minimized {
|
||||
.header {
|
||||
.window-controls {
|
||||
.minimize-toggle {
|
||||
background-image: url(data:image/png;base64,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
namespace "run" do
|
||||
end
|
||||
|
||||
namespace "test" do
|
||||
desc "Run the unit tests"
|
||||
task :unit => ["compile:unittests"] do
|
||||
puts "Running unit tests"
|
||||
featurePath = ENV['feature']
|
||||
puts featurePath
|
||||
if featurePath.nil?
|
||||
featurePath = ''
|
||||
elsif featurePath.include? '/'
|
||||
elsif !featurePath.include? '/'
|
||||
featurePath +='/'
|
||||
else
|
||||
featurePath = ''
|
||||
end
|
||||
runner = ENV['MOCHA_RUNNER'] || "spec"
|
||||
sh %{mocha -R #{runner} test/unit/js/#{featurePath}* --ignore-leaks} do |ok, res|
|
||||
if ! ok
|
||||
raise "error running unit tests : #{res}"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
namespace "compile" do
|
||||
desc "Compile server and client coffeescript files"
|
||||
task :app => ["compile:serverApp", "compile:clientApp"]
|
||||
|
||||
desc "Compile the main serverside app folder"
|
||||
task :serverApp do
|
||||
puts "Compiling app"
|
||||
sh %{coffee -o app/js/ -c app/coffee/} do |ok, res|
|
||||
if ! ok
|
||||
raise "error compiling app folder: #{res}"
|
||||
end
|
||||
puts 'Finished server app compile'
|
||||
end
|
||||
sh %{coffee -c app.coffee} do |ok, res|
|
||||
if ! ok
|
||||
raise "error compiling app file: #{res}"
|
||||
end
|
||||
puts 'Finished app.coffee compile'
|
||||
end
|
||||
end
|
||||
|
||||
desc "Compile the main client app folder"
|
||||
task :clientApp do
|
||||
puts "Compiling client app"
|
||||
sh %{coffee -o public/js/ -c public/coffee/} do |ok, res|
|
||||
if ! ok
|
||||
raise "error compiling client app folder: #{res}"
|
||||
end
|
||||
end
|
||||
sh %{mkdir -p public/js/html}
|
||||
sh %{mkdir -p public/js/css}
|
||||
sh %{jade < public/jade/templates.jade > public/js/html/templates.html} do |ok, res|
|
||||
if !ok
|
||||
raise "error compiling jade templates: #{res}"
|
||||
end
|
||||
end
|
||||
sh %{lessc - < public/less/chat.less > public/js/css/chat.css} do |ok, res|
|
||||
if !ok
|
||||
raise "error compiling css: #{res}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "compress the js"
|
||||
task :compressAndCompileJs do
|
||||
sh %{node public/js/r.js -o public/app.build.js} do |ok, res|
|
||||
if ! ok
|
||||
raise "error compiling client app folder: #{res}"
|
||||
end
|
||||
end
|
||||
puts "Finished client app compile"
|
||||
end
|
||||
|
||||
desc "Compile the unit tests folder"
|
||||
task :unittests => ["compile:serverApp"] do
|
||||
puts "Compiling Unit Tests to JS"
|
||||
sh %{coffee -c -o test/unit/js/ test/unit/coffee/} do |ok, res|
|
||||
if ! ok
|
||||
raise "error compiling Unit tests : #{res}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace 'bootstrap' do
|
||||
desc "Creates a new Feature and module, and corresponding test framework file"
|
||||
task :feature, :feature_name, :module_name do |task, args|
|
||||
feature_name = args[:feature_name]
|
||||
module_name = args[:module_name]
|
||||
FileUtils.mkdir_p("app/coffee/Features/#{feature_name}")
|
||||
File.open("app/coffee/Features/#{feature_name}/#{module_name}.coffee", "w") { |f|
|
||||
f.write(<<-EOS
|
||||
module.exports = #{module_name} =
|
||||
EOS
|
||||
)
|
||||
}
|
||||
FileUtils.mkdir_p("test/unit/coffee/#{feature_name}")
|
||||
File.open("test/unit/coffee/#{feature_name}/#{module_name}Tests.coffee", "w") { |f|
|
||||
f.write(<<-EOS
|
||||
sinon = require('sinon')
|
||||
chai = require('chai')
|
||||
should = chai.should()
|
||||
expect = chai.expect
|
||||
modulePath = "../../../../app/js/Features/#{feature_name}/#{module_name}.js"
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
events = require "events"
|
||||
|
||||
|
||||
describe "#{module_name}", ->
|
||||
beforeEach ->
|
||||
@#{module_name} = SandboxedModule.require modulePath, requires:
|
||||
|
||||
EOS
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
sinon = require('sinon')
|
||||
chai = require('chai')
|
||||
should = chai.should()
|
||||
expect = chai.expect
|
||||
modulePath = "../../../../app/js/Features/Authentication/AuthenticationController.js"
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
|
||||
describe "AuthenticationController", ->
|
||||
beforeEach ->
|
||||
@AuthenticationController = SandboxedModule.require modulePath, requires:
|
||||
"../WebApi/WebApiManager": @WebApiManager = {}
|
||||
"../Users/UserFormatter": @UserFormatter = {}
|
||||
"logger-sharelatex": @logger = { log: sinon.stub() }
|
||||
@callback = sinon.stub()
|
||||
|
||||
describe "authClient", ->
|
||||
beforeEach ->
|
||||
@auth_token = "super-secret-auth-token"
|
||||
@client =
|
||||
params: {}
|
||||
set: (key, value, callback) ->
|
||||
@params[key] = value
|
||||
callback()
|
||||
@user =
|
||||
id: "user-id-123"
|
||||
email: "doug@sharelatex.com"
|
||||
first_name: "Douglas"
|
||||
last_name: "Adams"
|
||||
@WebApiManager.getUserDetailsFromAuthToken = sinon.stub().callsArgWith(1, null, @user)
|
||||
@UserFormatter.formatUserForClientSide = sinon.stub().returns({
|
||||
id: @user.id
|
||||
first_name: @user.first_name
|
||||
last_name: @user.last_name
|
||||
email: @user.email
|
||||
gravatar_url: "//gravatar/url"
|
||||
})
|
||||
@AuthenticationController.authClient(@client, auth_token: @auth_token, @callback)
|
||||
|
||||
it "should get the user's data from the web api", ->
|
||||
@WebApiManager.getUserDetailsFromAuthToken
|
||||
.calledWith(@auth_token)
|
||||
.should.equal true
|
||||
|
||||
it "should set the user's data and auth_token on the client object", ->
|
||||
@client.params.should.deep.equal {
|
||||
id: @user.id
|
||||
first_name: @user.first_name
|
||||
last_name: @user.last_name
|
||||
email: @user.email
|
||||
gravatar_url: "//gravatar/url"
|
||||
auth_token: @auth_token
|
||||
}
|
||||
|
||||
it "should call the callback with the user details (including the gravatar URL, but not the auth_token)", ->
|
||||
@callback
|
||||
.calledWith(null, {
|
||||
id: @user.id
|
||||
email: @user.email
|
||||
first_name: @user.first_name
|
||||
last_name: @user.last_name
|
||||
gravatar_url: "//gravatar/url"
|
||||
}).should.equal true
|
||||
|
||||
it "should log the request", ->
|
||||
@logger.log
|
||||
.calledWith(auth_token: @auth_token, "authenticating user")
|
||||
.should.equal true
|
||||
@logger.log
|
||||
.calledWith(user: @user, auth_token: @auth_token, "authenticated user")
|
||||
.should.equal true
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
sinon = require('sinon')
|
||||
chai = require('chai')
|
||||
should = chai.should()
|
||||
expect = chai.expect
|
||||
modulePath = "../../../../app/js/Features/Authorization/AuthorizationManager.js"
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
events = require "events"
|
||||
|
||||
describe "AuthorizationManager", ->
|
||||
beforeEach ->
|
||||
@SocketManager = {}
|
||||
@AuthorizationManager = SandboxedModule.require modulePath, requires:
|
||||
"../WebApi/WebApiManager": @WebApiManager = {}
|
||||
"../Sockets/SocketManager": @SocketManager
|
||||
@callback = sinon.stub()
|
||||
@user_id = "user-id-123"
|
||||
@project_id = "project-id-456"
|
||||
@auth_token = "auth-token-789"
|
||||
@client =
|
||||
params: {}
|
||||
get: (key, callback = (error, value) ->) ->
|
||||
callback null, @params[key]
|
||||
|
||||
describe "canClientJoinProjectRoom", ->
|
||||
beforeEach ->
|
||||
@client.params.auth_token = @auth_token
|
||||
@client.params.id = @user_id
|
||||
|
||||
describe "when the client is a collaborator", ->
|
||||
beforeEach ->
|
||||
@collaborators = [
|
||||
id: @user_id
|
||||
]
|
||||
@WebApiManager.getProjectCollaborators = sinon.stub().callsArgWith(2, null, @collaborators)
|
||||
@AuthorizationManager.canClientJoinProjectRoom(@client, @project_id, @callback)
|
||||
|
||||
it "should get the list of collaborators from the web api", ->
|
||||
@WebApiManager.getProjectCollaborators
|
||||
.calledWith(@project_id, @auth_token)
|
||||
.should.equal true
|
||||
|
||||
it "should return true", ->
|
||||
@callback.calledWith(null, true).should.equal true
|
||||
|
||||
describe "when the client is not a collaborator", ->
|
||||
beforeEach ->
|
||||
@collaborators = [
|
||||
id: "not the user id"
|
||||
]
|
||||
@WebApiManager.getProjectCollaborators = sinon.stub().callsArgWith(2, null, @collaborators)
|
||||
@AuthorizationManager.canClientJoinProjectRoom(@client, @project_id, @callback)
|
||||
|
||||
it "should return false", ->
|
||||
@callback.calledWith(null, false).should.equal true
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
sinon = require('sinon')
|
||||
chai = require('chai')
|
||||
should = chai.should()
|
||||
expect = chai.expect
|
||||
modulePath = "../../../../app/js/Features/Messages/MessageController.js"
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
events = require "events"
|
||||
tk = require "timekeeper"
|
||||
ObjectId = require("mongojs").ObjectId
|
||||
|
||||
describe "MessageController", ->
|
||||
beforeEach ->
|
||||
tk.freeze(new Date())
|
||||
@MessageController = SandboxedModule.require modulePath, requires:
|
||||
"./MessageManager": @MessageManager = {}
|
||||
"./MessageFormatter": @MessageFormatter = {}
|
||||
"../Sockets/SocketManager": @SocketManager = {}
|
||||
"../Authorization/AuthorizationManager": @AuthorizationManager = {}
|
||||
"logger-sharelatex": @logger = { log: sinon.stub() }
|
||||
"../../metrics": @metrics = {inc: sinon.stub()}
|
||||
@callback = sinon.stub()
|
||||
@client =
|
||||
params:
|
||||
id: @user_id = "user-id-123"
|
||||
first_name: @first_name = "Douglas"
|
||||
last_name: @last_name = "Adams"
|
||||
email: @email = "doug@sharelatex.com"
|
||||
gravatar_url: @gravatar_url = "//gravatar/url"
|
||||
get: (key, callback = (error, value) ->) -> callback null, @params[key]
|
||||
|
||||
afterEach ->
|
||||
tk.reset()
|
||||
|
||||
describe "sendMessage", ->
|
||||
beforeEach ->
|
||||
@MessageManager.createMessage = sinon.stub().callsArg(1)
|
||||
@SocketManager.emitToRoom = sinon.stub()
|
||||
@singlePopulatedMessage = {data:"here"}
|
||||
@formattedMessage = {formatted:true}
|
||||
@MessageFormatter.formatMessageForClientSide = sinon.stub().returns(@formattedMessage)
|
||||
@MessageManager.populateMessagesWithUsers = sinon.stub().callsArgWith(1, null, [@singlePopulatedMessage])
|
||||
@SocketManager.getClientAttributes = (client, attributes, callback) ->
|
||||
values = (client.params[key] for key in attributes)
|
||||
callback null, values
|
||||
|
||||
describe "when the client is authorized to send a message to the room", ->
|
||||
beforeEach ->
|
||||
@AuthorizationManager.canClientSendMessageToRoom = sinon.stub().callsArgWith(2, null, true)
|
||||
@MessageController.sendMessage(@client, {
|
||||
message:
|
||||
content: @content = "Hello world"
|
||||
room:
|
||||
id: @room_id = "room-id-123"
|
||||
}, @callback)
|
||||
|
||||
it "should check that the client can send a message to the room", ->
|
||||
@AuthorizationManager.canClientSendMessageToRoom
|
||||
.calledWith(@client, @room_id)
|
||||
.should.equal true
|
||||
|
||||
it "should insert the message into the database", ->
|
||||
@MessageManager.createMessage
|
||||
.calledWith({
|
||||
content: @content
|
||||
user_id: @user_id
|
||||
room_id: @room_id
|
||||
timestamp: Date.now()
|
||||
})
|
||||
.should.equal true
|
||||
|
||||
|
||||
it "should format the message for the client", ->
|
||||
@MessageFormatter.formatMessageForClientSide.calledWith(@singlePopulatedMessage).should.equal true
|
||||
|
||||
it "should send the formatted message out to the other clients in the room", ->
|
||||
@SocketManager.emitToRoom.calledWith(@room_id, "messageReceived", message:@formattedMessage).should.equal true
|
||||
|
||||
it "should record the message as a metric", ->
|
||||
@metrics.inc
|
||||
.calledWith("editor.instant-message")
|
||||
.should.equal true
|
||||
|
||||
it "should call the callback", ->
|
||||
@callback.called.should.equal true
|
||||
|
||||
describe "when the client is not authorized", ->
|
||||
beforeEach ->
|
||||
@AuthorizationManager.canClientSendMessageToRoom = sinon.stub().callsArgWith(2, null, false)
|
||||
@MessageController.sendMessage(@client, {
|
||||
message:
|
||||
content: @content = "Hello world"
|
||||
room:
|
||||
id: @room_id = "room-id-123"
|
||||
}, @callback)
|
||||
|
||||
it "should not insert the message into the database", ->
|
||||
@MessageManager.createMessage.called.should.equal false
|
||||
|
||||
it "should not send the message out to the other clients in the room", ->
|
||||
@SocketManager.emitToRoom.called.should.equal false
|
||||
|
||||
it "should call the callback with an error that doesn't give anything away", ->
|
||||
@callback.calledWith("unknown room").should.equal true
|
||||
|
||||
describe "getMessage", ->
|
||||
beforeEach ->
|
||||
@room_id = "room-id-123"
|
||||
@timestamp = Date.now()
|
||||
@limit = 42
|
||||
|
||||
describe "when the client is authorized", ->
|
||||
beforeEach ->
|
||||
@messages = "messages without users stub"
|
||||
@messagesWithUsers = "messages with users stub"
|
||||
@formattedMessages = "formatted messages stub"
|
||||
@MessageManager.getMessages = sinon.stub().callsArgWith(2, null, @messages)
|
||||
@MessageManager.populateMessagesWithUsers = sinon.stub().callsArgWith(1, null, @messagesWithUsers)
|
||||
@AuthorizationManager.canClientReadMessagesInRoom = sinon.stub().callsArgWith(2, null, true)
|
||||
@MessageFormatter.formatMessagesForClientSide = sinon.stub().returns @formattedMessages
|
||||
|
||||
describe "with a timestamp and limit", ->
|
||||
beforeEach ->
|
||||
@MessageController.getMessages(@client, {
|
||||
room:
|
||||
id: @room_id,
|
||||
before: @timestamp,
|
||||
limit: @limit
|
||||
}, @callback)
|
||||
|
||||
it "should get the requested messages", ->
|
||||
@MessageManager.getMessages
|
||||
.calledWith({
|
||||
timestamp: $lt: @timestamp
|
||||
room_id: @room_id
|
||||
}, {
|
||||
limit: @limit
|
||||
order_by: "timestamp"
|
||||
sort_order: -1
|
||||
})
|
||||
.should.equal true
|
||||
|
||||
it "should populate the messages with the users", ->
|
||||
@MessageManager.populateMessagesWithUsers
|
||||
.calledWith(@messages)
|
||||
.should.equal true
|
||||
|
||||
it "should return the formatted messages", ->
|
||||
@MessageFormatter.formatMessagesForClientSide
|
||||
.calledWith(@messagesWithUsers)
|
||||
.should.equal true
|
||||
|
||||
it "should call the callback with the formatted messages", ->
|
||||
@callback
|
||||
.calledWith(null, @formattedMessages)
|
||||
.should.equal true
|
||||
|
||||
describe "without a timestamp or limit", ->
|
||||
beforeEach ->
|
||||
@MessageController.getMessages(@client, {
|
||||
room:
|
||||
id: @room_id,
|
||||
}, @callback)
|
||||
|
||||
it "should get a default number of messages from the beginning", ->
|
||||
@MessageManager.getMessages
|
||||
.calledWith({
|
||||
room_id: @room_id
|
||||
}, {
|
||||
limit: @MessageController.DEFAULT_MESSAGE_LIMIT
|
||||
order_by: "timestamp"
|
||||
sort_order: -1
|
||||
})
|
||||
.should.equal true
|
||||
|
||||
describe "when the client is not authorized", ->
|
||||
beforeEach ->
|
||||
@AuthorizationManager.canClientReadMessagesInRoom = sinon.stub().callsArgWith(2, null, false)
|
||||
@MessageController.getMessages(@client, {
|
||||
room:
|
||||
id: @room_id,
|
||||
before: @timestamp,
|
||||
limit: @limit
|
||||
}, @callback)
|
||||
|
||||
it "should call the callback with an error", ->
|
||||
@callback.calledWith("unknown room").should.equal true
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
sinon = require('sinon')
|
||||
chai = require('chai')
|
||||
should = chai.should()
|
||||
expect = chai.expect
|
||||
modulePath = "../../../../app/js/Features/Messages/MessageFormatter.js"
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
events = require "events"
|
||||
ObjectId = require("mongojs").ObjectId
|
||||
|
||||
describe "MessageFormatter", ->
|
||||
beforeEach ->
|
||||
@MessageFormatter = SandboxedModule.require modulePath, requires: {}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
should = require('chai').should()
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
assert = require('assert')
|
||||
path = require('path')
|
||||
sinon = require('sinon')
|
||||
modulePath = path.join __dirname, "../../../../app/js/Features/Messages/MessageHttpController"
|
||||
expect = require("chai").expect
|
||||
tk = require("timekeeper")
|
||||
|
||||
describe "MessagesHttpController", ->
|
||||
|
||||
beforeEach ->
|
||||
|
||||
@settings = {}
|
||||
@date = Date.now()
|
||||
tk.freeze(@date)
|
||||
@MessagesHttpController = SandboxedModule.require modulePath, requires:
|
||||
"settings-sharelatex":@settings
|
||||
"logger-sharelatex": log:->
|
||||
"./MessageManager": @MessageManager = {}
|
||||
"./MessageFormatter": @MessageFormatter = {}
|
||||
"../Rooms/RoomManager": @RoomManager = {}
|
||||
|
||||
@req =
|
||||
body:{}
|
||||
@res = {}
|
||||
@project_id = "12321321"
|
||||
@room_id = "Asdfadf adfafd"
|
||||
@user_id = "09832910838239081203981"
|
||||
@content = "my message here"
|
||||
|
||||
|
||||
afterEach ->
|
||||
tk.reset()
|
||||
|
||||
describe "sendMessage", ->
|
||||
|
||||
beforeEach ->
|
||||
@initialMessage = {content:@content}
|
||||
@MessageManager.createMessage = sinon.stub().callsArgWith(1, null, @initialMessage)
|
||||
@req.params =
|
||||
project_id:@project_id
|
||||
@req.body =
|
||||
user_id:@user_id
|
||||
content:@content
|
||||
@singlePopulatedMessage = {data:"here"}
|
||||
@MessageManager.populateMessagesWithUsers = sinon.stub().callsArgWith(1, null, [@singlePopulatedMessage])
|
||||
@RoomManager.findOrCreateRoom = sinon.stub().callsArgWith(1, null, @room = { _id : @room_id })
|
||||
@formattedMessage = {formatted:true}
|
||||
@MessageFormatter.formatMessageForClientSide = sinon.stub().returns(@formattedMessage)
|
||||
|
||||
it "should look up the room for the project", ->
|
||||
@res.send = =>
|
||||
@RoomManager.findOrCreateRoom
|
||||
.calledWith({
|
||||
project_id: @project_id
|
||||
})
|
||||
.should.equal true
|
||||
done()
|
||||
|
||||
it "should create the message with the message manager", (done)->
|
||||
@res.send = =>
|
||||
@MessageManager.createMessage
|
||||
.calledWith({
|
||||
content: @content
|
||||
user_id: @user_id
|
||||
room_id: @room_id
|
||||
timestamp: @date
|
||||
})
|
||||
.should.equal true
|
||||
done()
|
||||
@MessagesHttpController.sendMessage @req, @res
|
||||
|
||||
|
||||
it "should return the formetted message", (done)->
|
||||
|
||||
@res.send = (code, data)=>
|
||||
assert.deepEqual @MessageManager.populateMessagesWithUsers.args[0][0], [@initialMessage]
|
||||
code.should.equal 201
|
||||
data.should.equal @formattedMessage
|
||||
done()
|
||||
|
||||
@MessagesHttpController.sendMessage @req, @res
|
||||
|
||||
|
||||
describe "getMessages", ->
|
||||
|
||||
beforeEach ->
|
||||
@project_id = "room-id-123"
|
||||
@timestamp = Date.now()
|
||||
@limit = 42
|
||||
|
||||
@messages = "messages without users stub"
|
||||
@messagesWithUsers = "messages with users stub"
|
||||
@formattedMessages = "formatted messages stub"
|
||||
@RoomManager.findOrCreateRoom = sinon.stub().callsArgWith(1, null, @room = { _id : @room_id })
|
||||
@MessageManager.getMessages = sinon.stub().callsArgWith(2, null, @messages)
|
||||
@MessageManager.populateMessagesWithUsers = sinon.stub().callsArgWith(1, null, @messagesWithUsers)
|
||||
@MessageFormatter.formatMessagesForClientSide = sinon.stub().returns @formattedMessages
|
||||
|
||||
|
||||
describe "with a timestamp and limit", ->
|
||||
beforeEach ->
|
||||
@req.params =
|
||||
project_id:@project_id
|
||||
@req.query =
|
||||
before: @timestamp,
|
||||
limit: "#{@limit}"
|
||||
|
||||
|
||||
it "should look up the room for the project", ->
|
||||
@res.send = =>
|
||||
@RoomManager.findOrCreateRoom
|
||||
.calledWith({
|
||||
project_id: @project_id
|
||||
})
|
||||
.should.equal true
|
||||
done()
|
||||
|
||||
it "should get the requested messages", ->
|
||||
@res.send = =>
|
||||
@MessageManager.getMessages
|
||||
.calledWith({
|
||||
timestamp: $lt: @timestamp
|
||||
room_id: @room_id
|
||||
}, {
|
||||
limit: @limit
|
||||
order_by: "timestamp"
|
||||
sort_order: -1
|
||||
})
|
||||
.should.equal true
|
||||
|
||||
@MessagesHttpController.getMessages(@req, @res)
|
||||
|
||||
it "should populate the messages with the users", (done)->
|
||||
@res.send = =>
|
||||
@MessageManager.populateMessagesWithUsers.calledWith(@messages).should.equal true
|
||||
done()
|
||||
|
||||
@MessagesHttpController.getMessages(@req, @res)
|
||||
|
||||
it "should return the formatted messages", (done)->
|
||||
@res.send = ()=>
|
||||
@MessageFormatter.formatMessagesForClientSide.calledWith(@messagesWithUsers).should.equal true
|
||||
done()
|
||||
@MessagesHttpController.getMessages(@req, @res)
|
||||
|
||||
it "should send the formated messages back with a 200", (done)->
|
||||
@res.send = (code, data)=>
|
||||
code.should.equal 200
|
||||
data.should.equal @formattedMessages
|
||||
done()
|
||||
@MessagesHttpController.getMessages(@req, @res)
|
||||
|
||||
describe "without a timestamp or limit", ->
|
||||
beforeEach ->
|
||||
@req.params =
|
||||
project_id:@project_id
|
||||
|
||||
|
||||
it "should get a default number of messages from the beginning", ->
|
||||
@res.send = =>
|
||||
@MessageManager.getMessages
|
||||
.calledWith({
|
||||
room_id: @room_id
|
||||
}, {
|
||||
limit: @MessagesHttpController.DEFAULT_MESSAGE_LIMIT
|
||||
order_by: "timestamp"
|
||||
sort_order: -1
|
||||
})
|
||||
.should.equal true
|
||||
|
||||
@MessagesHttpController.getMessages(@req, @res)
|
||||
@@ -0,0 +1,60 @@
|
||||
sinon = require('sinon')
|
||||
chai = require('chai')
|
||||
should = chai.should()
|
||||
expect = chai.expect
|
||||
modulePath = "../../../../app/js/Features/Messages/MessageManager.js"
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
events = require "events"
|
||||
ObjectId = require("mongojs").ObjectId
|
||||
|
||||
describe "MessageManager", ->
|
||||
beforeEach ->
|
||||
@MessageManager = SandboxedModule.require modulePath, requires:
|
||||
"../WebApi/WebApiManager": @WebApiManager = {}
|
||||
@callback = sinon.stub()
|
||||
|
||||
describe "populateMessagesWithUsers", ->
|
||||
beforeEach ->
|
||||
@user0 =
|
||||
id: ObjectId().toString()
|
||||
first_name: "Adam"
|
||||
@user1 =
|
||||
id: ObjectId().toString()
|
||||
first_name: "Eve"
|
||||
@users = {}
|
||||
@users[@user0.id] = @user0
|
||||
@users[@user1.id] = @user1
|
||||
@messages = [{
|
||||
content: "First message content"
|
||||
user_id: ObjectId(@user0.id)
|
||||
}, {
|
||||
content: "Second message content"
|
||||
user_id: ObjectId(@user0.id)
|
||||
}, {
|
||||
content: "Third message content"
|
||||
user_id: ObjectId(@user1.id)
|
||||
}]
|
||||
@WebApiManager.getUserDetails = (user_id, callback = (error, user) ->) =>
|
||||
callback null, @users[user_id]
|
||||
sinon.spy @WebApiManager, "getUserDetails"
|
||||
@MessageManager.populateMessagesWithUsers @messages, @callback
|
||||
|
||||
it "should insert user objects in the place of user_ids", ->
|
||||
messages = @callback.args[0][1]
|
||||
expect(messages).to.deep.equal [{
|
||||
content: "First message content"
|
||||
user: @user0
|
||||
}, {
|
||||
content: "Second message content"
|
||||
user: @user0
|
||||
}, {
|
||||
content: "Third message content"
|
||||
user: @user1
|
||||
}]
|
||||
|
||||
it "should call getUserDetails once and only once for each user", ->
|
||||
@WebApiManager.getUserDetails.calledWith(@user0.id).should.equal true
|
||||
@WebApiManager.getUserDetails.calledWith(@user1.id).should.equal true
|
||||
@WebApiManager.getUserDetails.calledTwice.should.equal true
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
sinon = require('sinon')
|
||||
chai = require('chai')
|
||||
should = chai.should()
|
||||
expect = chai.expect
|
||||
modulePath = "../../../../app/js/Features/Rooms/RoomController.js"
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
events = require "events"
|
||||
ObjectId = require("mongojs").ObjectId
|
||||
|
||||
class MockClient
|
||||
params: {}
|
||||
get: (key, callback = (error, value) ->) ->
|
||||
callback null, @params[key]
|
||||
|
||||
describe "RoomController", ->
|
||||
beforeEach ->
|
||||
@SocketManager =
|
||||
getClientAttributes: sinon.stub()
|
||||
|
||||
|
||||
@RoomController = SandboxedModule.require modulePath, requires:
|
||||
"../Authorization/AuthorizationManager": @AuthorizationManager = {}
|
||||
"../Sockets/SocketManager": @SocketManager
|
||||
"../Rooms/RoomManager": @RoomManager = {}
|
||||
"logger-sharelatex": @logger = { log: sinon.stub() }
|
||||
|
||||
|
||||
@project_id = ObjectId().toString()
|
||||
@room_id = ObjectId().toString()
|
||||
@room =
|
||||
_id: ObjectId(@room_id)
|
||||
project_id: ObjectId(@project_id)
|
||||
@callback = sinon.stub()
|
||||
@client =
|
||||
params: {}
|
||||
get: (key, callback = (error, value) ->) -> callback null, @params[key]
|
||||
|
||||
describe "joinRoom", ->
|
||||
describe "when the client is authorized", ->
|
||||
beforeEach ->
|
||||
@AuthorizationManager.canClientJoinProjectRoom = sinon.stub().callsArgWith(2, null, true)
|
||||
@RoomManager.findOrCreateRoom = sinon.stub().callsArgWith(1, null, @room)
|
||||
@RoomController._addClientToRoom = sinon.stub().callsArg(2)
|
||||
@RoomController._getClientsInRoom = sinon.stub().callsArgWith(1, null, @clients = ["client1", "client2"])
|
||||
@RoomController.joinRoom @client, { room: project_id: @project_id }, @callback
|
||||
|
||||
it "should check that the client can join the room", ->
|
||||
@AuthorizationManager.canClientJoinProjectRoom
|
||||
.calledWith(@client, @project_id)
|
||||
.should.equal true
|
||||
|
||||
it "should ensure that the room exists", ->
|
||||
@RoomManager.findOrCreateRoom
|
||||
.calledWith({ project_id: @project_id })
|
||||
.should.equal true
|
||||
|
||||
it "should put the client into the room", ->
|
||||
@RoomController._addClientToRoom
|
||||
.calledWith(@client, @room_id)
|
||||
.should.equal true
|
||||
|
||||
it "should get the clients already in the room", ->
|
||||
@RoomController._getClientsInRoom
|
||||
.calledWith(@room_id)
|
||||
.should.equal true
|
||||
|
||||
it "should call the callback with the room id", ->
|
||||
@callback.calledWith(null, {
|
||||
room:
|
||||
id: @room_id
|
||||
connectedUsers: @clients
|
||||
}).should.equal true
|
||||
|
||||
describe "when the client is not authorized", ->
|
||||
beforeEach ->
|
||||
@AuthorizationManager.canClientJoinProjectRoom = sinon.stub().callsArgWith(2, null, false)
|
||||
@RoomController._addClientToRoom = sinon.stub().callsArg(2)
|
||||
@RoomController.joinRoom @client, { room: project_id: @project_id }, @callback
|
||||
|
||||
it "should not put the client into the room", ->
|
||||
@RoomController._addClientToRoom.called.should.equal false
|
||||
|
||||
it "should call the callback with an error that gives nothing away", ->
|
||||
@callback.calledWith("unknown room").should.equal true
|
||||
|
||||
describe "leaveAllRooms", ->
|
||||
|
||||
beforeEach ->
|
||||
@client = new MockClient()
|
||||
@client.params =
|
||||
id: "client-1-id"
|
||||
first_name: "Douglas"
|
||||
last_name: "Adams"
|
||||
email: "doug@sharelatex.com"
|
||||
gravatar_url: "//gravatar/url/1"
|
||||
@room_ids = ["room-id-1", "room-id-2"]
|
||||
@SocketManager.getRoomIdsClientHasJoined = sinon.stub().callsArgWith(1, null, @room_ids)
|
||||
@RoomController.leaveRoom = sinon.stub().callsArg(2)
|
||||
@RoomController.leaveAllRooms @client, @callback
|
||||
|
||||
it "should get the rooms the client has joined", ->
|
||||
@SocketManager.getRoomIdsClientHasJoined
|
||||
.calledWith(@client)
|
||||
.should.equal true
|
||||
|
||||
it "should leave each room", ->
|
||||
for room_id in @room_ids
|
||||
@RoomController.leaveRoom
|
||||
.calledWith(@client, room_id)
|
||||
.should.equal true
|
||||
|
||||
it "should call the callback", ->
|
||||
@callback.called.should.equal true
|
||||
|
||||
describe "leaveRoom", ->
|
||||
beforeEach ->
|
||||
@client = new MockClient()
|
||||
@client.params =
|
||||
id: "client-1-id"
|
||||
first_name: "Douglas"
|
||||
last_name: "Adams"
|
||||
email: "doug@sharelatex.com"
|
||||
gravatar_url: "//gravatar/url/1"
|
||||
@RoomController._getClientAttributes = sinon.stub().callsArgWith(1, null, @client.params)
|
||||
|
||||
@SocketManager.removeClientFromRoom = sinon.stub().callsArg(2)
|
||||
@SocketManager.emitToRoom = sinon.stub()
|
||||
@RoomController.leaveRoom @client, @room_id, @callback
|
||||
|
||||
it "should leave the room", ->
|
||||
@SocketManager.removeClientFromRoom
|
||||
.calledWith(@client, @room_id)
|
||||
.should.equal true
|
||||
|
||||
it "should tell the other clients in the room that we have left", ->
|
||||
@SocketManager.emitToRoom
|
||||
.calledWith(@room_id, "userLeft", {
|
||||
room:
|
||||
id: @room_id
|
||||
user:
|
||||
id : @client.params["id"]
|
||||
first_name : @client.params["first_name"]
|
||||
last_name : @client.params["last_name"]
|
||||
email : @client.params["email"]
|
||||
gravatar_url : @client.params["gravatar_url"]
|
||||
})
|
||||
.should.equal true
|
||||
|
||||
it "should call the callback", ->
|
||||
@callback.called.should.equal true
|
||||
|
||||
describe "_getClientsInRoom", ->
|
||||
beforeEach ->
|
||||
@client1 = new MockClient()
|
||||
@client1.params =
|
||||
id: "client-1-id"
|
||||
first_name: "Douglas"
|
||||
last_name: "Adams"
|
||||
email: "doug@sharelatex.com"
|
||||
gravatar_url: "//gravatar/url/1"
|
||||
@client2 = new MockClient()
|
||||
@client2.params =
|
||||
id: "client-2-id"
|
||||
first_name: "James"
|
||||
last_name: "Allen"
|
||||
email: "james@sharelatex.com"
|
||||
gravatar_url: "//gravatar/url/2"
|
||||
@clients = [ @client1, @client2 ]
|
||||
callCount = 0
|
||||
|
||||
@RoomController._getClientAttributes = (ignore, cb)=>
|
||||
if callCount == 0
|
||||
callCount++
|
||||
cb(null, @client1.params)
|
||||
else
|
||||
cb null, @client2.params
|
||||
|
||||
|
||||
@SocketManager.getClientsInRoom = sinon.stub().callsArgWith(1, null, @clients)
|
||||
@RoomController._getClientsInRoom(@room_id, @callback)
|
||||
|
||||
it "should get the socket.io clients in the room", ->
|
||||
@SocketManager.getClientsInRoom
|
||||
.calledWith(@room_id)
|
||||
.should.equal true
|
||||
|
||||
it "should return a formatted array of clients", ->
|
||||
@callback
|
||||
.calledWith(null, [{
|
||||
id : @client1.params["id"]
|
||||
first_name : @client1.params["first_name"]
|
||||
last_name : @client1.params["last_name"]
|
||||
email : @client1.params["email"]
|
||||
gravatar_url : @client1.params["gravatar_url"]
|
||||
}, {
|
||||
id : @client2.params["id"]
|
||||
first_name : @client2.params["first_name"]
|
||||
last_name : @client2.params["last_name"]
|
||||
email : @client2.params["email"]
|
||||
gravatar_url : @client2.params["gravatar_url"]
|
||||
}])
|
||||
.should.equal true
|
||||
|
||||
describe "_addClientToRoom", ->
|
||||
beforeEach ->
|
||||
@client = new MockClient()
|
||||
@client.params =
|
||||
id: "client-1-id"
|
||||
first_name: "Douglas"
|
||||
last_name: "Adams"
|
||||
email: "doug@sharelatex.com"
|
||||
gravatar_url: "//gravatar/url/1"
|
||||
@RoomController._getClientAttributes = sinon.stub().callsArgWith(1, null, @client.params)
|
||||
@SocketManager.addClientToRoom = sinon.stub().callsArg(2)
|
||||
@SocketManager.emitToRoom = sinon.stub()
|
||||
@RoomController._addClientToRoom(@client, @room_id, @callback)
|
||||
|
||||
it "should add the client to the room", ->
|
||||
@SocketManager.addClientToRoom
|
||||
.calledWith(@client, @room_id)
|
||||
.should.equal true
|
||||
|
||||
it "should tell the room that the client has been added", ->
|
||||
@SocketManager.emitToRoom
|
||||
.calledWith(@room_id, "userJoined", {
|
||||
room:
|
||||
id: @room_id
|
||||
user:
|
||||
id : @client.params["id"]
|
||||
first_name : @client.params["first_name"]
|
||||
last_name : @client.params["last_name"]
|
||||
email : @client.params["email"]
|
||||
gravatar_url : @client.params["gravatar_url"]
|
||||
})
|
||||
.should.equal true
|
||||
|
||||
it "should call the callback", ->
|
||||
@callback.called.should.equal true
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
sinon = require('sinon')
|
||||
chai = require('chai')
|
||||
should = chai.should()
|
||||
expect = chai.expect
|
||||
modulePath = "../../../../app/js/Features/Rooms/RoomManager.js"
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
events = require "events"
|
||||
mongojs = require "mongojs"
|
||||
ObjectId = mongojs.ObjectId
|
||||
|
||||
describe "RoomManager", ->
|
||||
beforeEach ->
|
||||
@RoomManager = SandboxedModule.require modulePath, requires:
|
||||
"../../mongojs":
|
||||
db: @db = { rooms: {} }
|
||||
ObjectId: ObjectId
|
||||
@callback = sinon.stub()
|
||||
|
||||
describe "findOrCreateRoom", ->
|
||||
describe "when the room exists", ->
|
||||
beforeEach ->
|
||||
@project_id = ObjectId().toString()
|
||||
@room =
|
||||
_id: ObjectId()
|
||||
project_id: ObjectId(@project_id)
|
||||
@db.rooms.findOne = sinon.stub().callsArgWith(1, null, @room)
|
||||
@RoomManager.findOrCreateRoom(project_id: @project_id, @callback)
|
||||
|
||||
it "should look up the room based on the query", ->
|
||||
@db.rooms.findOne
|
||||
.calledWith(project_id: ObjectId(@project_id))
|
||||
.should.equal true
|
||||
|
||||
it "should return the room in the callback", ->
|
||||
@callback
|
||||
.calledWith(null, @room)
|
||||
.should.equal true
|
||||
|
||||
describe "when the room does not exist", ->
|
||||
beforeEach ->
|
||||
@project_id = ObjectId().toString()
|
||||
@room =
|
||||
_id: ObjectId()
|
||||
project_id: ObjectId(@project_id)
|
||||
@db.rooms.findOne = sinon.stub().callsArgWith(1, null, null)
|
||||
@db.rooms.save = sinon.stub().callsArgWith(1, null, @room)
|
||||
@RoomManager.findOrCreateRoom(project_id: @project_id, @callback)
|
||||
|
||||
it "should create the room", ->
|
||||
@db.rooms.save
|
||||
.calledWith(project_id: ObjectId(@project_id))
|
||||
.should.equal true
|
||||
|
||||
it "should return the room in the callback", ->
|
||||
@callback
|
||||
.calledWith(null, @room)
|
||||
.should.equal true
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
sinon = require('sinon')
|
||||
require('chai').should()
|
||||
modulePath = "../../../../app/js/Features/Sockets/RealTimeEventManager.js"
|
||||
|
||||
describe "RealTimeEventManager", ->
|
||||
|
||||
beforeEach ->
|
||||
@settings =
|
||||
redis:
|
||||
web:
|
||||
host: "host here"
|
||||
port: "port here"
|
||||
password: "password here"
|
||||
@RealTimeEventManager = SandboxedModule.require modulePath, requires:
|
||||
"redis":
|
||||
createClient: () ->
|
||||
auth:->
|
||||
"../../server" : io: @io = {}
|
||||
"settings-sharelatex":@settings
|
||||
@RealTimeEventManager.rclientPub = publish: sinon.stub()
|
||||
@RealTimeEventManager.rclientSub =
|
||||
subscribe: sinon.stub()
|
||||
on: sinon.stub()
|
||||
|
||||
@room_id = "room-id-here"
|
||||
@message = "message-to-chat-here"
|
||||
@payload = ["argument one", 42]
|
||||
|
||||
describe "emitToRoom", ->
|
||||
beforeEach ->
|
||||
@RealTimeEventManager.emitToRoom(@room_id, @message, @payload...)
|
||||
|
||||
it "should publish the message to redis", ->
|
||||
@RealTimeEventManager.rclientPub.publish
|
||||
.calledWith("chat-events", JSON.stringify(
|
||||
room_id: @room_id,
|
||||
message: @message
|
||||
payload: @payload
|
||||
))
|
||||
.should.equal true
|
||||
|
||||
describe "listenForChatEvents", ->
|
||||
beforeEach ->
|
||||
@RealTimeEventManager._processEditorEvent = sinon.stub()
|
||||
@RealTimeEventManager.listenForChatEvents()
|
||||
|
||||
it "should subscribe to the chat-events channel", ->
|
||||
@RealTimeEventManager.rclientSub.subscribe
|
||||
.calledWith("chat-events")
|
||||
.should.equal true
|
||||
|
||||
it "should process the events with _processEditorEvent", ->
|
||||
@RealTimeEventManager.rclientSub.on
|
||||
.calledWith("message", sinon.match.func)
|
||||
.should.equal true
|
||||
|
||||
describe "_processEditorEvent", ->
|
||||
describe "with a designated room", ->
|
||||
beforeEach ->
|
||||
@io.sockets =
|
||||
in: sinon.stub().returns(emit: @emit = sinon.stub())
|
||||
data = JSON.stringify
|
||||
room_id: @room_id
|
||||
message: @message
|
||||
payload: @payload
|
||||
@RealTimeEventManager._processEditorEvent("chat-events", data)
|
||||
|
||||
it "should send the message to all clients in the room", ->
|
||||
@io.sockets.in
|
||||
.calledWith(@room_id)
|
||||
.should.equal true
|
||||
@emit.calledWith(@message, @payload...).should.equal true
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
sinon = require('sinon')
|
||||
chai = require('chai')
|
||||
should = chai.should()
|
||||
expect = chai.expect
|
||||
modulePath = "../../../../app/js/Features/Users/UserFormatter.js"
|
||||
SandboxedModule = require('sandboxed-module')
|
||||
events = require "events"
|
||||
|
||||
describe "UserFormatter", ->
|
||||
beforeEach ->
|
||||
@UserFormatter = SandboxedModule.require modulePath, requires: {}
|
||||
|
||||
Reference in New Issue
Block a user