Merge pull request #4 from sharelatex/ja-track-changes

Ja track changes
This commit is contained in:
James Allen 2017-01-17 11:45:44 +01:00 committed by GitHub
commit 4805136d59
41 changed files with 391 additions and 40279 deletions

View file

@ -12,14 +12,6 @@ module.exports = (grunt) ->
src: "app.js"
coffee:
client:
expand: true,
flatten: false,
cwd: 'public/coffee',
src: ['**/*.coffee'],
dest: 'public/build/',
ext: '.js'
server:
expand: true,
flatten: false,
@ -56,47 +48,7 @@ module.exports = (grunt) ->
files: ['app/**/*.coffee', 'test/unit/**/*.coffee']
tasks: ['compile:server', 'compile:unit_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"]
clean: ["app/js", "test/unit/js"]
nodemon:
dev:
@ -127,11 +79,6 @@ module.exports = (grunt) ->
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'
@ -142,10 +89,9 @@ module.exports = (grunt) ->
grunt.loadNpmTasks 'grunt-forever'
grunt.registerTask 'compile', ['clean', 'copy', 'coffee', 'less', 'jade', 'requirejs']
grunt.registerTask 'compile', ['clean', 'coffee']
grunt.registerTask 'install', ['compile']
grunt.registerTask 'default', ['compile', 'bunyan', 'execute']
grunt.registerTask 'compileAndCompress', ['compile', 'uglify']
grunt.registerTask 'test:unit', ['compile', 'mochaTest:unit']
grunt.registerTask 'test:acceptance', ['compile:acceptance_tests', 'mochaTest:acceptance']

View file

@ -1,5 +1,3 @@
UserFormatter = require "../Users/UserFormatter"
module.exports = MessageFormatter =
formatMessageForClientSide: (message) ->
if message._id?
@ -9,8 +7,38 @@ module.exports = MessageFormatter =
id: message.id
content: message.content
timestamp: message.timestamp
user: UserFormatter.formatUserForClientSide(message.user)
user_id: message.user_id
return formattedMessage
formatMessagesForClientSide: (messages) ->
(@formatMessageForClientSide(message) for message in messages)
groupMessagesByThreads: (rooms, messages) ->
rooms_by_id = {}
for room in rooms
rooms_by_id[room._id.toString()] = room
threads = {}
getThread = (room) ->
thread_id = room.thread_id.toString()
if threads[thread_id]?
return threads[thread_id]
else
thread = { messages: [] }
if room.resolved?
thread.resolved = true
thread.resolved_at = room.resolved.ts
thread.resolved_by_user_id = room.resolved.user_id
threads[thread_id] = thread
return thread
for message in messages
room = rooms_by_id[message.room_id.toString()]
if room?
thread = getThread(room)
thread.messages.push MessageFormatter.formatMessageForClientSide(message)
for thread_id, thread of threads
thread.messages.sort (a,b) -> a.timestamp - b.timestamp
return threads

View file

@ -2,61 +2,78 @@ logger = require "logger-sharelatex"
metrics = require "metrics-sharelatex"
MessageManager = require "./MessageManager"
MessageFormatter = require "./MessageFormatter"
RoomManager = require "../Rooms/RoomManager"
ThreadManager = require "../Threads/ThreadManager"
{ObjectId} = require "../../mongojs"
module.exports = MessageHttpController =
DEFAULT_MESSAGE_LIMIT: 50
getGlobalMessages: (req, res, next) ->
MessageHttpController._getMessages(ThreadManager.GLOBAL_THREAD, req, res, next)
sendMessage: (req, res, next) ->
sendGlobalMessage: (req, res, next) ->
MessageHttpController._sendMessage(ThreadManager.GLOBAL_THREAD, req, res, next)
sendThreadMessage: (req, res, next) ->
MessageHttpController._sendMessage(req.params.thread_id, req, res, next)
getAllThreads: (req, res, next) ->
{project_id} = req.params
logger.log {project_id}, "getting all threads"
ThreadManager.findAllThreadRooms project_id, (error, rooms) ->
return next(error) if error?
room_ids = rooms.map (r) -> r._id
MessageManager.findAllMessagesInRooms room_ids, (error, messages) ->
return next(error) if error?
threads = MessageFormatter.groupMessagesByThreads rooms, messages
res.json threads
resolveThread: (req, res, next) ->
{project_id, thread_id} = req.params
{user_id} = req.body
logger.log {user_id, project_id, thread_id}, "marking thread as resolved"
ThreadManager.resolveThread project_id, thread_id, user_id, (error) ->
return next(error) if error?
res.send 204 # No content
reopenThread: (req, res, next) ->
{project_id, thread_id} = req.params
logger.log {project_id, thread_id}, "reopening thread"
ThreadManager.reopenThread project_id, thread_id, (error) ->
return next(error) if error?
res.send 204 # No content
_sendMessage: (client_thread_id, 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) ->
if !ObjectId.isValid(user_id)
return res.send(400, "Invalid user_id")
logger.log {client_thread_id, project_id, user_id, content}, "new message received"
ThreadManager.findOrCreateThread project_id, client_thread_id, (error, thread) ->
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)
MessageManager.createMessage thread._id, user_id, content, Date.now(), (error, message) ->
return next(error) if error?
message = MessageFormatter.formatMessageForClientSide(message)
message.room_id = project_id
res.send(201, message)
getMessages: (req, res, next) ->
_getMessages: (client_thread_id, req, res, next) ->
{project_id} = req.params
query = {}
if req.query?.before?
query.timestamp = $lt: parseInt(req.query.before, 10)
before = parseInt(req.query.before, 10)
else
before = null
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) ->
logger.log {limit, before, project_id, client_thread_id}, "get message request received"
ThreadManager.findOrCreateThread project_id, client_thread_id, (error, thread) ->
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
thread_object_id = thread._id
logger.log {limit, before, project_id, client_thread_id, thread_object_id}, "found or created thread"
MessageManager.getMessages thread_object_id, limit, before, (error, messages) ->
return next(error) if error?
messages = MessageFormatter.formatMessagesForClientSide messages
logger.log {project_id, messages}, "got messages"
res.send 200, messages

View file

@ -1,50 +1,32 @@
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
createMessage: (room_id, user_id, content, timestamp, callback = (error, message) ->) ->
newMessageOpts =
content: content
room_id: room_id
user_id: user_id
timestamp: timestamp
newMessageOpts = @_ensureIdsAreObjectIds(newMessageOpts)
db.messages.save newMessageOpts, callback
getMessages: (query, options, callback = (error, messages) ->) ->
getMessages: (room_id, limit, before, callback = (error, messages) ->) ->
query =
room_id: room_id
if before?
query.timestamp = { $lt: before }
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 = db.messages.find(query).sort({ timestamp: -1 }).limit(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) ->
if !message?
return
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
findAllMessagesInRooms: (room_ids, callback = (error, messages) ->) ->
db.messages.find {
room_id: { $in: room_ids }
}, callback
_ensureIdsAreObjectIds: (query) ->
if query.user_id? and query.user_id not instanceof ObjectId
query.user_id = ObjectId(query.user_id)

View file

@ -1,18 +0,0 @@
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

View file

@ -0,0 +1,55 @@
mongojs = require("../../mongojs")
db = mongojs.db
ObjectId = mongojs.ObjectId
module.exports = ThreadManager =
GLOBAL_THREAD: "GLOBAL"
findOrCreateThread: (project_id, thread_id, callback = (error, thread) ->) ->
query =
project_id: ObjectId(project_id.toString())
if thread_id? and thread_id != ThreadManager.GLOBAL_THREAD
query.thread_id = ObjectId(thread_id.toString())
# Threads used to be called rooms, and still are in the DB
db.rooms.findOne query, (error, thread) ->
return callback(error) if error?
if thread?
callback null, thread
else
db.rooms.save query, (error, thread) ->
return callback(error) if error?
callback null, thread
findAllThreadRooms: (project_id, callback = (error, rooms) ->) ->
db.rooms.find {
project_id: ObjectId(project_id.toString())
thread_id: { $exists: true }
}, {
thread_id: 1,
resolved: 1
}, callback
resolveThread: (project_id, thread_id, user_id, callback = (error) ->) ->
db.rooms.update {
project_id: ObjectId(project_id.toString())
thread_id: ObjectId(thread_id.toString())
}, {
$set: {
resolved: {
user_id: user_id
ts: new Date()
}
}
}, callback
reopenThread: (project_id, thread_id, callback = (error) ->) ->
db.rooms.update {
project_id: ObjectId(project_id.toString())
thread_id: ObjectId(thread_id.toString())
}, {
$unset: {
resolved: true
}
}, callback

View file

@ -1,19 +0,0 @@
crypto = require "crypto"
module.exports = UserFormatter =
formatUserForClientSide: (user) ->
return null if !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}"

View file

@ -1,43 +0,0 @@
request = require('request').defaults(jar: false)
Settings = require("settings-sharelatex")
# DEPRECATED! This method of getting user details via chat is deprecated
# in the way we lay out our services.
# Instead, web should be responsible for collecting the raw data (user_ids) and
# filling it out with calls to other services. All API calls should create a
# tree-like structure as much as possible, with web as the root.
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?
if 200 <= response.statusCode < 300
try
result = JSON.parse(body)
catch e
return callback(e)
return callback null, result
else
error = new Error("web api returned non-success code: #{response.statusCode}")
error.statusCode = response.statusCode
return callback error
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
}, (error, data) ->
if error?
if error.statusCode == 404
return callback null, null
else
return callback error
else
return callback null, data

View file

@ -1,6 +1,6 @@
Settings = require("settings-sharelatex")
mongojs = require "mongojs"
db = mongojs.connect(Settings.mongo.url, ["rooms", "messages"])
db = mongojs(Settings.mongo.url, ["rooms", "messages"])
module.exports =
db: db
ObjectId: mongojs.ObjectId

View file

@ -1,10 +1,37 @@
MessageHttpController = require('./Features/Messages/MessageHttpController')
{ObjectId} = require "./mongojs"
module.exports = Router =
route: (app) ->
app.get "/room/:project_id/messages", MessageHttpController.getMessages
app.post "/room/:project_id/messages", MessageHttpController.sendMessage
app.param 'project_id', (req, res, next, project_id) ->
if ObjectId.isValid(project_id)
next()
else
res.send 400, "Invalid project_id"
app.param 'thread_id', (req, res, next, thread_id) ->
if ObjectId.isValid(thread_id)
next()
else
res.send 400, "Invalid thread_id"
# These are for backwards compatibility
app.get "/room/:project_id/messages", MessageHttpController.getGlobalMessages
app.post "/room/:project_id/messages", MessageHttpController.sendGlobalMessage
app.get "/project/:project_id/messages", MessageHttpController.getGlobalMessages
app.post "/project/:project_id/messages", MessageHttpController.sendGlobalMessage
app.post "/project/:project_id/thread/:thread_id/messages", MessageHttpController.sendThreadMessage
app.get "/project/:project_id/threads", MessageHttpController.getAllThreads
# app.post "/project/:project_id/thread/:thread_id/messages/:message_id/edit", MessageHttpController.editMessage
# app.del "/project/:project_id/thread/:thread_id/messages/:message_id", MessageHttpController.deleteMessage
app.post "/project/:project_id/thread/:thread_id/resolve", MessageHttpController.resolveThread
app.post "/project/:project_id/thread/:thread_id/reopen", MessageHttpController.reopenThread
# app.del "/project/:project_id/thread/:thread_id", MessageHttpController.deleteThread
app.get "/status", (req, res, next) ->
res.send("chat is alive")

View file

@ -11,8 +11,8 @@
"coffee-script": "~1.7.1",
"express": "3.3.1",
"logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
"metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.0.0",
"mongojs": "0.18.2",
"metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.6.0",
"mongojs": "^2.4.0",
"redis": "~0.10.1",
"request": "^2.79.0",
"settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0"
@ -25,11 +25,6 @@
"grunt-concurrent": "~0.4.2",
"grunt-contrib-clean": "~0.5.0",
"grunt-contrib-coffee": "~0.7.0",
"grunt-contrib-copy": "~0.4.1",
"grunt-contrib-jade": "~0.8.0",
"grunt-contrib-less": "~0.8.2",
"grunt-contrib-requirejs": "~0.4.1",
"grunt-contrib-uglify": "~0.2.7",
"grunt-contrib-watch": "~0.5.3",
"grunt-execute": "^0.2.2",
"grunt-forever": "^0.4.7",
@ -37,7 +32,6 @@
"grunt-nodemon": "~0.1.2",
"grunt-notify": "~0.2.16",
"grunt-plato": "~0.2.1",
"grunt-requirejs": "~0.4.0",
"sandboxed-module": "",
"sinon": "",
"timekeeper": ""

View file

@ -1,25 +0,0 @@
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
})

View file

@ -1,109 +0,0 @@
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

View file

@ -1,9 +0,0 @@
define [
"libs/backbone"
"models/user"
], (Backbone, User) ->
ConnectedUsers = Backbone.Collection.extend
model: User
initialize: (models, options) ->
{@chat, @room} = options

View file

@ -1,45 +0,0 @@
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

View file

@ -1,5 +0,0 @@
define [
"libs/backbone"
], (Backbone) ->
Message = Backbone.Model.extend {}

View file

@ -1,85 +0,0 @@
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

View file

@ -1,13 +0,0 @@
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

View file

@ -1,10 +0,0 @@
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)

View file

@ -1,219 +0,0 @@
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()

View file

@ -1,40 +0,0 @@
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)

View file

@ -1,64 +0,0 @@
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)])

View file

@ -1,24 +0,0 @@
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

File diff suppressed because it is too large Load diff

View file

@ -1,85 +0,0 @@
/*!
* 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

View file

@ -1,373 +0,0 @@
/**
* @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;
});

View file

@ -1,198 +0,0 @@
@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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);
}
}
}
}

View file

@ -3,7 +3,6 @@ expect = require("chai").expect
async = require "async"
crypto = require "crypto"
MockWebApi = require "./helpers/MockWebApi"
ChatClient = require "./helpers/ChatClient"
describe "Getting messages", ->
@ -12,62 +11,52 @@ describe "Getting messages", ->
@user_id2 = ObjectId().toString()
@content1 = "foo bar"
@content2 = "hello world"
MockWebApi.addUser @user_id1, @user1 = {
id: @user_id1
first_name: "Jane"
last_name: "Smith"
email: "jane@example.com"
}
MockWebApi.addUser @user_id2, @user2 = {
id: @user_id2
first_name: "John"
last_name: "Doe"
email: "john@example.com"
}
describe "normally", ->
describe "globally", ->
before (done) ->
@project_id = ObjectId().toString()
async.series [
(cb) => ChatClient.sendMessage @project_id, @user_id1, @content1, cb
(cb) => ChatClient.sendMessage @project_id, @user_id2, @content2, cb
(cb) => ChatClient.sendGlobalMessage @project_id, @user_id1, @content1, cb
(cb) => ChatClient.sendGlobalMessage @project_id, @user_id2, @content2, cb
], done
it "should contain the messages and populated users when getting the messages", (done) ->
ChatClient.getMessages @project_id, (error, response, messages) =>
ChatClient.getGlobalMessages @project_id, (error, response, messages) =>
expect(messages.length).to.equal 2
messages.reverse()
expect(messages[0].content).to.equal @content1
expect(messages[0].user).to.deep.equal {
id: @user_id1
first_name: "Jane"
last_name: "Smith"
email: "jane@example.com"
gravatar_url: "//www.gravatar.com/avatar/#{crypto.createHash("md5").update("jane@example.com").digest("hex")}"
}
expect(messages[0].user_id).to.equal @user_id1
expect(messages[1].content).to.equal @content2
expect(messages[1].user).to.deep.equal {
id: @user_id2
first_name: "John"
last_name: "Doe"
email: "john@example.com"
gravatar_url: "//www.gravatar.com/avatar/#{crypto.createHash("md5").update("john@example.com").digest("hex")}"
}
expect(messages[1].user_id).to.equal @user_id2
done()
describe "when a user doesn't exit", ->
describe "from all the threads", ->
before (done) ->
@project_id = ObjectId().toString()
@user_id3 = ObjectId().toString()
@thread_id1 = ObjectId().toString()
@thread_id2 = ObjectId().toString()
async.series [
(cb) => ChatClient.sendMessage @project_id, @user_id3, @content1, cb
(cb) => ChatClient.sendMessage @project_id, @user_id2, @content2, cb
(cb) => ChatClient.sendMessage @project_id, @thread_id1, @user_id1, "one", cb
(cb) => ChatClient.sendMessage @project_id, @thread_id2, @user_id2, "two", cb
(cb) => ChatClient.sendMessage @project_id, @thread_id1, @user_id1, "three", cb
(cb) => ChatClient.sendMessage @project_id, @thread_id2, @user_id2, "four", cb
], done
it "should just return null for the user", (done) ->
ChatClient.getMessages @project_id, (error, response, messages) =>
expect(messages.length).to.equal 2
messages.reverse()
expect(messages[0].content).to.equal @content1
expect(messages[0].user).to.equal null
done()
it "should contain a dictionary of threads with messages with populated users", (done) ->
ChatClient.getThreads @project_id, (error, response, threads) =>
expect(Object.keys(threads).length).to.equal 2
thread1 = threads[@thread_id1]
expect(thread1.messages.length).to.equal 2
thread2 = threads[@thread_id2]
expect(thread2.messages.length).to.equal 2
expect(thread1.messages[0].content).to.equal "one"
expect(thread1.messages[0].user_id).to.equal @user_id1
expect(thread1.messages[1].content).to.equal "three"
expect(thread1.messages[1].user_id).to.equal @user_id1
expect(thread2.messages[0].content).to.equal "two"
expect(thread2.messages[0].user_id).to.equal @user_id2
expect(thread2.messages[1].content).to.equal "four"
expect(thread2.messages[1].user_id).to.equal @user_id2
done()

View file

@ -0,0 +1,69 @@
{ObjectId} = require "../../../app/js/mongojs"
expect = require("chai").expect
crypto = require "crypto"
ChatClient = require "./helpers/ChatClient"
describe "Resolving a thread", ->
before ->
@project_id = ObjectId().toString()
@user_id = ObjectId().toString()
describe "with a resolved thread", ->
before (done) ->
@thread_id = ObjectId().toString()
@content = "resolved message"
ChatClient.sendMessage @project_id, @thread_id, @user_id, @content, (error, response, body) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 201
ChatClient.resolveThread @project_id, @thread_id, @user_id, (error, response, body) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 204
done()
it "should then list the thread as resolved", (done) ->
ChatClient.getThreads @project_id, (error, response, threads) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 200
expect(threads[@thread_id].resolved).to.equal true
expect(threads[@thread_id].resolved_by_user_id).to.equal @user_id
resolved_at = new Date(threads[@thread_id].resolved_at)
expect(new Date() - resolved_at).to.be.below 1000
done()
describe "when a thread is not resolved", ->
before (done) ->
@thread_id = ObjectId().toString()
@content = "open message"
ChatClient.sendMessage @project_id, @thread_id, @user_id, @content, (error, response, body) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 201
done()
it "should not list the thread as resolved", (done) ->
ChatClient.getThreads @project_id, (error, response, threads) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 200
expect(threads[@thread_id].resolved).to.be.undefined
done()
describe "when a thread is resolved then reopened", ->
before (done) ->
@thread_id = ObjectId().toString()
@content = "resolved message"
ChatClient.sendMessage @project_id, @thread_id, @user_id, @content, (error, response, body) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 201
ChatClient.resolveThread @project_id, @thread_id, @user_id, (error, response, body) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 204
ChatClient.reopenThread @project_id, @thread_id, (error, response, body) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 204
done()
it "should not list the thread as resolved", (done) ->
ChatClient.getThreads @project_id, (error, response, threads) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 200
expect(threads[@thread_id].resolved).to.be.undefined
done()

View file

@ -1,23 +1,69 @@
{ObjectId} = require "../../../app/js/mongojs"
expect = require("chai").expect
MockWebApi = require "./helpers/MockWebApi"
ChatClient = require "./helpers/ChatClient"
describe "Sending a message", ->
before (done) ->
before ->
@project_id = ObjectId().toString()
@user_id = ObjectId().toString()
@content = "foo bar"
ChatClient.sendMessage @project_id, @user_id, @content, (error, response, body) ->
expect(error).to.be.null
expect(response.statusCode).to.equal 201
done()
@thread_id = ObjectId().toString()
describe "globally", ->
before (done) ->
@content = "global message"
ChatClient.sendGlobalMessage @project_id, @user_id, @content, (error, response, body) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 201
expect(body.content).to.equal @content
expect(body.user_id).to.equal @user_id
expect(body.room_id).to.equal @project_id
done()
it "should then list the message in the project messages", (done) ->
ChatClient.getGlobalMessages @project_id, (error, response, messages) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 200
expect(messages.length).to.equal 1
expect(messages[0].content).to.equal @content
done()
describe "to a thread", ->
before (done) ->
@content = "thread message"
ChatClient.sendMessage @project_id, @thread_id, @user_id, @content, (error, response, body) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 201
expect(body.content).to.equal @content
expect(body.user_id).to.equal @user_id
expect(body.room_id).to.equal @project_id
done()
it "should then list the message in the threads", (done) ->
ChatClient.getThreads @project_id, (error, response, threads) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 200
expect(threads[@thread_id].messages.length).to.equal 1
expect(threads[@thread_id].messages[0].content).to.equal @content
done()
it "should then list the message in project messages", (done) ->
ChatClient.getMessages @project_id, (error, response, messages) =>
expect(error).to.be.null
expect(response.statusCode).to.equal 200
expect(messages.length).to.equal 1
expect(messages[0].content).to.equal @content
done()
describe "with a malformed user_id", ->
it "should return a graceful error", (done) ->
ChatClient.sendMessage @project_id, @thread_id, "malformed-user", "content", (error, response, body) =>
expect(response.statusCode).to.equal 400
expect(body).to.equal "Invalid user_id"
done()
describe "with a malformed project_id", ->
it "should return a graceful error", (done) ->
ChatClient.sendMessage "malformed-project", @thread_id, @user_id, "content", (error, response, body) =>
expect(response.statusCode).to.equal 400
expect(body).to.equal "Invalid project_id"
done()
describe "with a malformed thread_id", ->
it "should return a graceful error", (done) ->
ChatClient.sendMessage @project_id, "malformed-thread-id", @user_id, "content", (error, response, body) =>
expect(response.statusCode).to.equal 400
expect(body).to.equal "Invalid thread_id"
done()

View file

@ -1,16 +1,43 @@
request = require("request").defaults({baseUrl: "http://localhost:3010"})
module.exports =
sendMessage: (project_id, user_id, content, callback) ->
sendGlobalMessage: (project_id, user_id, content, callback) ->
request.post {
url: "/room/#{project_id}/messages"
url: "/project/#{project_id}/messages"
json:
user_id: user_id
content: content
}, callback
getMessages: (project_id, callback) ->
getGlobalMessages: (project_id, callback) ->
request.get {
url: "/room/#{project_id}/messages",
url: "/project/#{project_id}/messages",
json: true
}, callback
sendMessage: (project_id, thread_id, user_id, content, callback) ->
request.post {
url: "/project/#{project_id}/thread/#{thread_id}/messages"
json:
user_id: user_id
content: content
}, callback
getThreads: (project_id, callback) ->
request.get {
url: "/project/#{project_id}/threads",
json: true
}, callback
resolveThread: (project_id, thread_id, user_id, callback) ->
request.post {
url: "/project/#{project_id}/thread/#{thread_id}/resolve",
json: {
user_id: user_id
}
}, callback
reopenThread: (project_id, thread_id, callback) ->
request.post {
url: "/project/#{project_id}/thread/#{thread_id}/reopen",
}, callback

View file

@ -1,27 +0,0 @@
express = require("express")
app = express()
module.exports = MockWebApi =
users: {}
addUser: (user_id, user) ->
@users[user_id] = user
getUser: (user_id, callback = (error, user) ->) ->
return callback null, @users[user_id]
run: () ->
app.get "/user/:user_id/personal_info", (req, res, next) =>
@getUser req.params.user_id, (error, user) ->
if error?
res.send 500
else if user?
res.send JSON.stringify user
else
res.send 404
app.listen 3000, (error) ->
throw error if error?
MockWebApi.run()

View file

@ -1,13 +0,0 @@
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: {}

View file

@ -1,173 +0,0 @@
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)

View file

@ -1,61 +0,0 @@
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 = {}
"../../mongojs": {}
@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

View file

@ -1,58 +0,0 @@
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

View file

@ -1,12 +0,0 @@
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: {}