Преглед изворни кода

Merge branch 'master' into ho-promote-case-study

Henry Oswald пре 9 година
родитељ
комит
2341a8481a
100 измењених фајлова са 7156 додато и 1334 уклоњено
  1. 9 2
      services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee
  2. 22 6
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  3. 1 1
      services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee
  4. 61 0
      services/web/app/coffee/Features/Chat/ChatApiHandler.coffee
  5. 21 20
      services/web/app/coffee/Features/Chat/ChatController.coffee
  6. 0 32
      services/web/app/coffee/Features/Chat/ChatHandler.coffee
  7. 2 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee
  8. 26 5
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  9. 1 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
  10. 83 0
      services/web/app/coffee/Features/Comments/CommentsController.coffee
  11. 20 3
      services/web/app/coffee/Features/Docstore/DocstoreManager.coffee
  12. 17 11
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  13. 5 3
      services/web/app/coffee/Features/Documents/DocumentController.coffee
  14. 0 1
      services/web/app/coffee/Features/Editor/EditorController.coffee
  15. 49 0
      services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
  16. 64 56
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  17. 32 17
      services/web/app/coffee/Features/Email/EmailSender.coffee
  18. 380 0
      services/web/app/coffee/Features/Email/Layouts/BaseWithHeaderEmailLayout.coffee
  19. 20 0
      services/web/app/coffee/Features/History/HistoryController.coffee
  20. 28 0
      services/web/app/coffee/Features/History/HistoryManager.coffee
  21. 0 3
      services/web/app/coffee/Features/InactiveData/InactiveProjectManager.coffee
  22. 3 2
      services/web/app/coffee/Features/Project/ProjectController.coffee
  23. 6 0
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  24. 3 3
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  25. 9 1
      services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
  26. 0 5
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  27. 23 0
      services/web/app/coffee/Features/TrackChanges/RangesManager.coffee
  28. 39 17
      services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee
  29. 3 26
      services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee
  30. 8 2
      services/web/app/coffee/Features/User/UserController.coffee
  31. 10 13
      services/web/app/coffee/Features/User/UserInfoController.coffee
  32. 5 0
      services/web/app/coffee/Features/User/UserInfoManager.coffee
  33. 7 2
      services/web/app/coffee/Features/User/UserPagesController.coffee
  34. 2 0
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  35. 1 0
      services/web/app/coffee/infrastructure/Server.coffee
  36. 1 0
      services/web/app/coffee/models/Project.coffee
  37. 1 1
      services/web/app/coffee/models/User.coffee
  38. 19 6
      services/web/app/coffee/router.coffee
  39. 1 1
      services/web/app/views/general/closed.jade
  40. 35 4
      services/web/app/views/layout/navbar.jade
  41. 1 0
      services/web/app/views/project/editor.jade
  42. 15 4
      services/web/app/views/project/editor/editor.jade
  43. 1 1
      services/web/app/views/project/editor/header.jade
  44. 185 72
      services/web/app/views/project/editor/review-panel.jade
  45. 9 2
      services/web/app/views/project/editor/share.jade
  46. 39 5
      services/web/app/views/project/list.jade
  47. 1 1
      services/web/app/views/sentry.jade
  48. 4 4
      services/web/app/views/subscriptions/plans.jade
  49. 0 1
      services/web/app/views/user/login.jade
  50. 1 1
      services/web/app/views/user/register.jade
  51. 28 19
      services/web/app/views/user/settings.jade
  52. 9 25
      services/web/config/settings.defaults.coffee
  53. 11 0
      services/web/public/coffee/directives/asyncForm.coffee
  54. 2 4
      services/web/public/coffee/ide.coffee
  55. 8 2
      services/web/public/coffee/ide/chat/services/chatMessages.coffee
  56. 5 0
      services/web/public/coffee/ide/directives/layout.coffee
  57. 49 3
      services/web/public/coffee/ide/editor/Document.coffee
  58. 30 0
      services/web/public/coffee/ide/editor/EditorManager.coffee
  59. 12 18
      services/web/public/coffee/ide/editor/ShareJsDoc.coffee
  60. 14 2
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  61. 102 126
      services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee
  62. 5 2
      services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee
  63. 1 1
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee
  64. 60 6
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee
  65. 10 0
      services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
  66. 7 0
      services/web/public/coffee/ide/permissions/PermissionsManager.coffee
  67. 82 57
      services/web/public/coffee/ide/review-panel/RangesTracker.coffee
  68. 3 0
      services/web/public/coffee/ide/review-panel/ReviewPanelManager.coffee
  69. 315 229
      services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee
  70. 5 3
      services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee
  71. 14 2
      services/web/public/coffee/ide/review-panel/directives/changeEntry.coffee
  72. 15 9
      services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee
  73. 21 0
      services/web/public/coffee/ide/review-panel/directives/resolvedCommentEntry.coffee
  74. 58 0
      services/web/public/coffee/ide/review-panel/directives/resolvedCommentsDropdown.coffee
  75. 36 14
      services/web/public/coffee/ide/review-panel/directives/reviewPanelSorted.coffee
  76. 10 2
      services/web/public/coffee/ide/review-panel/directives/reviewPanelToggle.coffee
  77. 5 0
      services/web/public/coffee/ide/review-panel/filters/notEmpty.coffee
  78. 18 10
      services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
  79. 15 1
      services/web/public/coffee/main.coffee
  80. 5 1
      services/web/public/coffee/main/account-settings.coffee
  81. 23 14
      services/web/public/coffee/main/announcements.coffee
  82. BIN
      services/web/public/img/about/chris.jpg
  83. BIN
      services/web/public/img/about/geri.jpg
  84. BIN
      services/web/public/img/spellcheck-underline.png
  85. BIN
      services/web/public/img/spellcheck-underline@2x.png
  86. 10 4
      services/web/public/js/ace-1.2.5/mode-latex.js
  87. 378 0
      services/web/public/js/ace-1.2.5/mode-latex_beta.js
  88. 7 0
      services/web/public/js/ace-1.2.5/snippets/latex_beta.js
  89. 565 112
      services/web/public/js/ace-1.2.5/worker-latex.js
  90. 3207 0
      services/web/public/js/ace-1.2.5/worker-latex_beta.js
  91. 4 0
      services/web/public/stylesheets/app/editor.less
  92. 325 158
      services/web/public/stylesheets/app/editor/review-panel.less
  93. 7 1
      services/web/public/stylesheets/app/editor/share.less
  94. 166 0
      services/web/public/stylesheets/app/project-list.less
  95. 6 3
      services/web/public/stylesheets/components/dropdowns.less
  96. 23 6
      services/web/test/UnitTests/coffee/Announcement/AnnouncementsHandlerTests.coffee
  97. 62 23
      services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
  98. 92 0
      services/web/test/UnitTests/coffee/Chat/ChatApiHandlerTests.coffee
  99. 53 52
      services/web/test/UnitTests/coffee/Chat/ChatControllerTests.coffee
  100. 0 89
      services/web/test/UnitTests/coffee/Chat/ChatHandlerTests.coffee

+ 9 - 2
services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee

@@ -25,8 +25,15 @@ module.exports =
 			announcementIndex = _.findIndex announcements, (announcement)-> 
 			announcementIndex = _.findIndex announcements, (announcement)-> 
 				announcement.id == lastSeenBlogId
 				announcement.id == lastSeenBlogId
 
 
-			if announcementIndex != -1
-				announcements = announcements.slice(0, announcementIndex)
+			announcements = _.map announcements, (announcement, index)->
+				if announcementIndex == -1
+					read = false
+				else if index >= announcementIndex
+					read = true
+				else
+					read = false
+				announcement.read = read
+				return announcement
 
 
 			logger.log announcementsLength:announcements?.length, user_id:user_id, "returning announcements"
 			logger.log announcementsLength:announcements?.length, user_id:user_id, "returning announcements"
 
 

+ 22 - 6
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -62,17 +62,18 @@ module.exports = AuthenticationController =
 			if err?
 			if err?
 				return next(err)
 				return next(err)
 			if user # `user` is either a user object or false
 			if user # `user` is either a user object or false
+				redir = AuthenticationController._getRedirectFromSession(req) || "/project"
 				AuthenticationController.afterLoginSessionSetup req, user, (err) ->
 				AuthenticationController.afterLoginSessionSetup req, user, (err) ->
 					if err?
 					if err?
 						return next(err)
 						return next(err)
-					res.json {redir: req._redir}
+					AuthenticationController._clearRedirectFromSession(req)
+					res.json {redir: redir}
 			else
 			else
 				res.json message: info
 				res.json message: info
 		)(req, res, next)
 		)(req, res, next)
 
 
 	doPassportLogin: (req, username, password, done) ->
 	doPassportLogin: (req, username, password, done) ->
 		email = username.toLowerCase()
 		email = username.toLowerCase()
-		redir = Url.parse(req?.body?.redir or "/project").path
 		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
 		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
 			return done(err) if err?
 			return done(err) if err?
 			if !isAllowed
 			if !isAllowed
@@ -90,7 +91,6 @@ module.exports = AuthenticationController =
 					req.session.justLoggedIn = true
 					req.session.justLoggedIn = true
 					# capture the request ip for use when creating the session
 					# capture the request ip for use when creating the session
 					user._login_req_ip = req.ip
 					user._login_req_ip = req.ip
-					req._redir = redir
 					return done(null, user)
 					return done(null, user)
 				else
 				else
 					AuthenticationController._recordFailedLogin()
 					AuthenticationController._recordFailedLogin()
@@ -148,6 +148,7 @@ module.exports = AuthenticationController =
 			return next()
 			return next()
 		else
 		else
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
+			AuthenticationController._setRedirectInSession(req)
 			return res.redirect "/login"
 			return res.redirect "/login"
 
 
 	httpAuth: basicAuth (user, pass)->
 	httpAuth: basicAuth (user, pass)->
@@ -157,21 +158,23 @@ module.exports = AuthenticationController =
 		return isValid
 		return isValid
 
 
 	_redirectToLoginOrRegisterPage: (req, res)->
 	_redirectToLoginOrRegisterPage: (req, res)->
-		if req.query.zipUrl? or req.query.project_name?
+		if (req.query.zipUrl? or
+			  req.query.project_name? or
+			  req.path == '/user/subscription/new')
 			return AuthenticationController._redirectToRegisterPage(req, res)
 			return AuthenticationController._redirectToRegisterPage(req, res)
 		else
 		else
 			AuthenticationController._redirectToLoginPage(req, res)
 			AuthenticationController._redirectToLoginPage(req, res)
 
 
 	_redirectToLoginPage: (req, res) ->
 	_redirectToLoginPage: (req, res) ->
 		logger.log url: req.url, "user not logged in so redirecting to login page"
 		logger.log url: req.url, "user not logged in so redirecting to login page"
-		req.query.redir = req.path
+		AuthenticationController._setRedirectInSession(req)
 		url = "/login?#{querystring.stringify(req.query)}"
 		url = "/login?#{querystring.stringify(req.query)}"
 		res.redirect url
 		res.redirect url
 		Metrics.inc "security.login-redirect"
 		Metrics.inc "security.login-redirect"
 
 
 	_redirectToRegisterPage: (req, res) ->
 	_redirectToRegisterPage: (req, res) ->
 		logger.log url: req.url, "user not logged in so redirecting to register page"
 		logger.log url: req.url, "user not logged in so redirecting to register page"
-		req.query.redir = req.path
+		AuthenticationController._setRedirectInSession(req)
 		url = "/register?#{querystring.stringify(req.query)}"
 		url = "/register?#{querystring.stringify(req.query)}"
 		res.redirect url
 		res.redirect url
 		Metrics.inc "security.login-redirect"
 		Metrics.inc "security.login-redirect"
@@ -188,3 +191,16 @@ module.exports = AuthenticationController =
 	_recordFailedLogin: (callback = (error) ->) ->
 	_recordFailedLogin: (callback = (error) ->) ->
 		Metrics.inc "user.login.failed"
 		Metrics.inc "user.login.failed"
 		callback()
 		callback()
+
+	_setRedirectInSession: (req, value) ->
+		if !value?
+			value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else "#{req.path}"
+		if req.session? && !value.match(new RegExp('^\/(socket.io|js|stylesheets|img)\/.*$'))
+			req.session.postLoginRedirect = value
+
+	_getRedirectFromSession: (req) ->
+		return req?.session?.postLoginRedirect || null
+
+	_clearRedirectFromSession: (req) ->
+		if req.session?
+			delete req.session.postLoginRedirect

+ 1 - 1
services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee

@@ -108,5 +108,5 @@ module.exports = AuthorizationMiddlewear =
 			logger.log {from: from}, "redirecting to login"
 			logger.log {from: from}, "redirecting to login"
 			redirect_to = "/login"
 			redirect_to = "/login"
 			if from?
 			if from?
-				redirect_to += "?redir=#{encodeURIComponent(from)}"
+				AuthenticationController._setRedirectInSession(req, from)
 			res.redirect redirect_to
 			res.redirect redirect_to

+ 61 - 0
services/web/app/coffee/Features/Chat/ChatApiHandler.coffee

@@ -0,0 +1,61 @@
+request = require("request")
+settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+
+module.exports = ChatApiHandler =
+	_apiRequest: (opts, callback = (error, data) ->) ->
+		request opts, (error, response, data) ->
+			return callback(error) if error?
+			if 200 <= response.statusCode < 300
+				return callback null, data
+			else
+				error = new Error("chat api returned non-success code: #{response.statusCode}")
+				error.statusCode = response.statusCode
+				logger.error {err: error, opts}, "error sending request to chat api"
+				return callback error
+
+	sendGlobalMessage: (project_id, user_id, content, callback)->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/messages"
+			method: "POST"
+			json:   {user_id, content}
+		}, callback
+
+	getGlobalMessages: (project_id, limit, before, callback)->
+		qs = {}
+		qs.limit = limit if limit?
+		qs.before = before if before?
+		
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/messages"
+			method: "GET"
+			qs:     qs
+			json:   true
+		}, callback
+	
+	sendComment: (project_id, thread_id, user_id, content, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages"
+			method: "POST"
+			json:   {user_id, content}
+		}, callback
+	
+	getThreads: (project_id, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/threads"
+			method: "GET"
+			json:   true
+		}, callback
+	
+	resolveThread: (project_id, thread_id, user_id, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/resolve"
+			method: "POST"
+			json:   {user_id}
+		}, callback
+	
+	reopenThread: (project_id, thread_id, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/reopen"
+			method: "POST"
+		}, callback

+ 21 - 20
services/web/app/coffee/Features/Chat/ChatController.coffee

@@ -1,33 +1,34 @@
-ChatHandler = require("./ChatHandler")
+ChatApiHandler = require("./ChatApiHandler")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 AuthenticationController = require('../Authentication/AuthenticationController')
 AuthenticationController = require('../Authentication/AuthenticationController')
+UserInfoManager = require('../User/UserInfoManager')
+UserInfoController = require('../User/UserInfoController')
+CommentsController = require('../Comments/CommentsController')
 
 
 module.exports =
 module.exports =
-
-
 	sendMessage: (req, res, next)->
 	sendMessage: (req, res, next)->
-		project_id = req.params.Project_id
-		messageContent = req.body.content
+		project_id = req.params.project_id
+		content = req.body.content
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		if !user_id?
 		if !user_id?
 			err = new Error('no logged-in user')
 			err = new Error('no logged-in user')
 			return next(err)
 			return next(err)
-		ChatHandler.sendMessage project_id, user_id, messageContent, (err, builtMessge)->
-			if err?
-				logger.err err:err, project_id:project_id, user_id:user_id, messageContent:messageContent, "problem sending message to chat api"
-				return res.sendStatus(500)
-			EditorRealTimeController.emitToRoom project_id,  "new-chat-message", builtMessge, (err)->
-			res.send()
+		ChatApiHandler.sendGlobalMessage project_id, user_id, content, (err, message) ->
+			return next(err) if err?
+			UserInfoManager.getPersonalInfo message.user_id, (err, user) ->
+				return next(err) if err?
+				message.user = UserInfoController.formatPersonalInfo(user)
+				EditorRealTimeController.emitToRoom project_id, "new-chat-message", message, (err)->
+				res.send(204)
 
 
-	getMessages: (req, res)->
-		project_id = req.params.Project_id
+	getMessages: (req, res, next)->
+		project_id = req.params.project_id
 		query = req.query
 		query = req.query
 		logger.log project_id:project_id, query:query, "getting messages"
 		logger.log project_id:project_id, query:query, "getting messages"
-		ChatHandler.getMessages project_id, query, (err, messages)->
-			if err?
-				logger.err err:err, query:query, "problem getting messages from chat api"
-				return res.sendStatus 500
-			logger.log length:messages?.length, "sending messages to client"
-			res.set 'Content-Type', 'application/json'
-			res.send messages
+		ChatApiHandler.getGlobalMessages project_id, query.limit, query.before, (err, messages) ->
+			return next(err) if err?
+			CommentsController._injectUserInfoIntoThreads {global: { messages: messages }}, (err) ->
+				return next(err) if err?
+				logger.log length: messages?.length, "sending messages to client"
+				res.json messages

+ 0 - 32
services/web/app/coffee/Features/Chat/ChatHandler.coffee

@@ -1,32 +0,0 @@
-request = require("request")
-settings = require("settings-sharelatex")
-logger = require("logger-sharelatex")
-
-module.exports = 
-
-	sendMessage: (project_id, user_id, messageContent, callback)->
-		opts =
-			method:"post"
-			json:
-				content:messageContent
-				user_id:user_id
-			uri:"#{settings.apis.chat.internal_url}/room/#{project_id}/messages"
-		request opts, (err, response, body)->
-			if err?
-				logger.err err:err, "problem sending new message to chat"
-			callback(err, body)
-
-
-
-	getMessages: (project_id, query, callback)->
-		qs = {}
-		qs.limit = query.limit if query?.limit?
-		qs.before = query.before if query?.before?
-
-		opts =
-			uri:"#{settings.apis.chat.internal_url}/room/#{project_id}/messages"
-			method:"get"
-			qs: qs
-
-		request opts, (err, response, body)->
-			callback(err, body)

+ 2 - 1
services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee

@@ -11,7 +11,7 @@ module.exports = CollaboratorsEmailHandler =
 			"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
 			"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
 		].join("&")
 		].join("&")
 
 
-	notifyUserOfProjectInvite: (project_id, email, invite, callback)->
+	notifyUserOfProjectInvite: (project_id, email, invite, sendingUser, callback)->
 		Project
 		Project
 			.findOne(_id: project_id )
 			.findOne(_id: project_id )
 			.select("name owner_ref")
 			.select("name owner_ref")
@@ -24,4 +24,5 @@ module.exports = CollaboratorsEmailHandler =
 						name: project.name
 						name: project.name
 					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
 					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
 					owner: project.owner_ref
 					owner: project.owner_ref
+					sendingUser_id: sendingUser._id
 				EmailHandler.sendEmail "projectInvite", emailOptions, callback
 				EmailHandler.sendEmail "projectInvite", emailOptions, callback

+ 26 - 5
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee

@@ -4,6 +4,7 @@ UserGetter = require "../User/UserGetter"
 CollaboratorsHandler = require('./CollaboratorsHandler')
 CollaboratorsHandler = require('./CollaboratorsHandler')
 CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler')
 CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler')
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
+Settings = require('settings-sharelatex')
 EmailHelper = require "../Helpers/EmailHelper"
 EmailHelper = require "../Helpers/EmailHelper"
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
@@ -21,11 +22,24 @@ module.exports = CollaboratorsInviteController =
 				return next(err)
 				return next(err)
 			res.json({invites: invites})
 			res.json({invites: invites})
 
 
+	_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
+		if Settings.restrictInvitesToExistingAccounts == true
+			logger.log {email}, "checking if user exists with this email"
+			UserGetter.getUser {email: email}, {_id: 1}, (err, user) ->
+				return callback(err) if err?
+				userExists = user? and user?._id?
+				callback(null, userExists)
+		else
+			callback(null, true)
+
 	inviteToProject: (req, res, next) ->
 	inviteToProject: (req, res, next) ->
 		projectId = req.params.Project_id
 		projectId = req.params.Project_id
 		email = req.body.email
 		email = req.body.email
 		sendingUser = AuthenticationController.getSessionUser(req)
 		sendingUser = AuthenticationController.getSessionUser(req)
 		sendingUserId = sendingUser._id
 		sendingUserId = sendingUser._id
+		if email == sendingUser.email
+			logger.log {projectId, email, sendingUserId}, "cannot invite yourself to project"
+			return res.json {invite: null, error: 'cannot_invite_self'}
 		logger.log {projectId, email, sendingUserId}, "inviting to project"
 		logger.log {projectId, email, sendingUserId}, "inviting to project"
 		LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) =>
 		LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) =>
 			return next(error) if error?
 			return next(error) if error?
@@ -37,13 +51,20 @@ module.exports = CollaboratorsInviteController =
 			if !email? or email == ""
 			if !email? or email == ""
 				logger.log {projectId, email, sendingUserId}, "invalid email address"
 				logger.log {projectId, email, sendingUserId}, "invalid email address"
 				return res.sendStatus(400)
 				return res.sendStatus(400)
-			CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
+			CollaboratorsInviteController._checkShouldInviteEmail email, (err, shouldAllowInvite)->
 				if err?
 				if err?
-					logger.err {projectId, email, sendingUserId}, "error creating project invite"
+					logger.err {err, email, projectId, sendingUserId}, "error checking if we can invite this email address"
 					return next(err)
 					return next(err)
-				logger.log {projectId, email, sendingUserId}, "invite created"
-				EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true})
-				return res.json {invite: invite}
+				if !shouldAllowInvite
+					logger.log {email, projectId, sendingUserId}, "not allowed to send an invite to this email address"
+					return res.json {invite: null, error: 'cannot_invite_non_user'}
+				CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
+					if err?
+						logger.err {projectId, email, sendingUserId}, "error creating project invite"
+						return next(err)
+					logger.log {projectId, email, sendingUserId}, "invite created"
+					EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true})
+					return res.json {invite: invite}
 
 
 	revokeInvite: (req, res, next) ->
 	revokeInvite: (req, res, next) ->
 		projectId = req.params.Project_id
 		projectId = req.params.Project_id

+ 1 - 1
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee

@@ -53,7 +53,7 @@ module.exports = CollaboratorsInviteHandler =
 
 
 	_sendMessages: (projectId, sendingUser, invite, callback=(err)->) ->
 	_sendMessages: (projectId, sendingUser, invite, callback=(err)->) ->
 		logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite"
 		logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite"
-		CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, (err)->
+		CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, sendingUser, (err)->
 			return callback(err) if err?
 			return callback(err) if err?
 			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
 			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
 				return callback(err) if err?
 				return callback(err) if err?

+ 83 - 0
services/web/app/coffee/Features/Comments/CommentsController.coffee

@@ -0,0 +1,83 @@
+ChatApiHandler = require("../Chat/ChatApiHandler")
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+logger = require("logger-sharelatex")
+AuthenticationController = require('../Authentication/AuthenticationController')
+UserInfoManager = require('../User/UserInfoManager')
+UserInfoController = require('../User/UserInfoController')
+async = require "async"
+
+module.exports = CommentsController =
+	sendComment: (req, res, next) ->
+		{project_id, thread_id} = req.params
+		content = req.body.content
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		if !user_id?
+			err = new Error('no logged-in user')
+			return next(err)
+		logger.log {project_id, thread_id, user_id, content}, "sending comment"
+		ChatApiHandler.sendComment project_id, thread_id, user_id, content, (err, comment) ->
+			return next(err) if err?
+			UserInfoManager.getPersonalInfo comment.user_id, (err, user) ->
+				return next(err) if err?
+				comment.user = UserInfoController.formatPersonalInfo(user)
+				EditorRealTimeController.emitToRoom project_id, "new-comment", thread_id, comment, (err) ->
+				res.send 204
+
+	getThreads: (req, res, next) ->
+		{project_id} = req.params
+		logger.log {project_id}, "getting comment threads for project"
+		ChatApiHandler.getThreads project_id, (err, threads) ->
+			return next(err) if err?
+			CommentsController._injectUserInfoIntoThreads threads, (error, threads) ->
+				return next(err) if err?
+				res.json threads
+
+	resolveThread: (req, res, next) ->
+		{project_id, thread_id} = req.params
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log {project_id, thread_id, user_id}, "resolving comment thread"
+		ChatApiHandler.resolveThread project_id, thread_id, user_id, (err) ->
+			return next(err) if err?
+			UserInfoManager.getPersonalInfo user_id, (err, user) ->
+				return next(err) if err?
+				EditorRealTimeController.emitToRoom project_id, "resolve-thread", thread_id, UserInfoController.formatPersonalInfo(user), (err)->
+				res.send 204
+
+	reopenThread: (req, res, next) ->
+		{project_id, thread_id} = req.params
+		logger.log {project_id, thread_id}, "reopening comment thread"
+		ChatApiHandler.reopenThread project_id, thread_id, (err, threads) ->
+			return next(err) if err?
+			EditorRealTimeController.emitToRoom project_id, "reopen-thread", thread_id, (err)->
+			res.send 204
+
+	_injectUserInfoIntoThreads: (threads, callback = (error, threads) ->) ->
+		userCache = {}
+		getUserDetails = (user_id, callback = (error, user) ->) ->
+			return callback(null, userCache[user_id]) if userCache[user_id]?
+			UserInfoManager.getPersonalInfo user_id, (err, user) ->
+				return callback(error) if error?
+				user = UserInfoController.formatPersonalInfo user
+				userCache[user_id] = user
+				callback null, user
+		
+		jobs = []
+		for thread_id, thread of threads
+			do (thread) ->
+				if thread.resolved
+					jobs.push (cb) ->
+						getUserDetails thread.resolved_by_user_id, (error, user) ->
+							cb(error) if error?
+							thread.resolved_by_user = user
+							cb()
+				for message in thread.messages
+					do (message) ->
+						jobs.push (cb) ->
+							getUserDetails message.user_id, (error, user) ->
+								cb(error) if error?
+								message.user = user
+								cb()
+		
+		async.series jobs, (error) ->
+			return callback(error) if error?
+			return callback null, threads

+ 20 - 3
services/web/app/coffee/Features/Docstore/DocstoreManager.coffee

@@ -29,8 +29,23 @@ module.exports = DocstoreManager =
 				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
 				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
 				logger.error err: error, project_id: project_id, "error getting all docs from docstore"
 				logger.error err: error, project_id: project_id, "error getting all docs from docstore"
 				callback(error)
 				callback(error)
+	
+	getAllRanges: (project_id, callback = (error) ->) ->
+		logger.log { project_id }, "getting all doc ranges for project in docstore api"
+		url = "#{settings.apis.docstore.url}/project/#{project_id}/ranges"
+		request.get {
+			url: url
+			json: true
+		}, (error, res, docs) ->
+			return callback(error) if error?
+			if 200 <= res.statusCode < 300
+				callback(null, docs)
+			else
+				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
+				logger.error err: error, project_id: project_id, "error getting all doc ranges from docstore"
+				callback(error)
 
 
-	getDoc: (project_id, doc_id, options = {}, callback = (error, lines, rev) ->) ->
+	getDoc: (project_id, doc_id, options = {}, callback = (error, lines, rev, version) ->) ->
 		if typeof(options) == "function"
 		if typeof(options) == "function"
 			callback = options
 			callback = options
 			options = {}
 			options = {}
@@ -45,19 +60,21 @@ module.exports = DocstoreManager =
 			return callback(error) if error?
 			return callback(error) if error?
 			if 200 <= res.statusCode < 300
 			if 200 <= res.statusCode < 300
 				logger.log doc_id: doc_id, project_id: project_id, version: doc.version, rev: doc.rev, "got doc from docstore api"
 				logger.log doc_id: doc_id, project_id: project_id, version: doc.version, rev: doc.rev, "got doc from docstore api"
-				callback(null, doc.lines, doc.rev)
+				callback(null, doc.lines, doc.rev, doc.version, doc.ranges)
 			else
 			else
 				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
 				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
 				logger.error err: error, project_id: project_id, doc_id: doc_id, "error getting doc from docstore"
 				logger.error err: error, project_id: project_id, doc_id: doc_id, "error getting doc from docstore"
 				callback(error)
 				callback(error)
 
 
-	updateDoc: (project_id, doc_id, lines, callback = (error, modified, rev) ->) ->
+	updateDoc: (project_id, doc_id, lines, version, ranges, callback = (error, modified, rev) ->) ->
 		logger.log project_id: project_id, doc_id: doc_id, "updating doc in docstore api"
 		logger.log project_id: project_id, doc_id: doc_id, "updating doc in docstore api"
 		url = "#{settings.apis.docstore.url}/project/#{project_id}/doc/#{doc_id}"
 		url = "#{settings.apis.docstore.url}/project/#{project_id}/doc/#{doc_id}"
 		request.post {
 		request.post {
 			url: url
 			url: url
 			json:
 			json:
 				lines: lines
 				lines: lines
+				version: version
+				ranges: ranges
 		}, (error, res, result) ->
 		}, (error, res, result) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			if 200 <= res.statusCode < 300
 			if 200 <= res.statusCode < 300

+ 17 - 11
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

@@ -95,7 +95,7 @@ module.exports = DocumentUpdaterHandler =
 				logger.error err: error, project_id: project_id, doc_id: doc_id, "document updater returned failure status code: #{res.statusCode}"
 				logger.error err: error, project_id: project_id, doc_id: doc_id, "document updater returned failure status code: #{res.statusCode}"
 				return callback(error)
 				return callback(error)
 
 
-	getDocument: (project_id, doc_id, fromVersion, callback = (error, exists, doclines, version) ->) ->
+	getDocument: (project_id, doc_id, fromVersion, callback = (error, doclines, version, ranges, ops) ->) ->
 		timer = new metrics.Timer("get-document")
 		timer = new metrics.Timer("get-document")
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}?fromVersion=#{fromVersion}"
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}?fromVersion=#{fromVersion}"
 		logger.log project_id:project_id, doc_id: doc_id, "getting doc from document updater"
 		logger.log project_id:project_id, doc_id: doc_id, "getting doc from document updater"
@@ -110,7 +110,7 @@ module.exports = DocumentUpdaterHandler =
 					body = JSON.parse(body)
 					body = JSON.parse(body)
 				catch error
 				catch error
 					return callback(error)
 					return callback(error)
-				callback null, body.lines, body.version, body.ops
+				callback null, body.lines, body.version, body.ranges, body.ops
 			else
 			else
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
@@ -137,15 +137,21 @@ module.exports = DocumentUpdaterHandler =
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 
 
-	getNumberOfDocsInMemory : (callback)->
-		request.get "#{settings.apis.documentupdater.url}/total", (err, req, body)->
-			try
-				body = JSON.parse body
-			catch err
-				logger.err err:err, "error parsing response from doc updater about the total number of docs"
-			callback(err, body?.total)
-
-
+	acceptChange: (project_id, doc_id, change_id, callback = (error) ->) ->
+		timer = new metrics.Timer("accept-change")
+		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}/change/#{change_id}/accept"
+		logger.log {project_id, doc_id, change_id}, "accepting change in document updater"
+		request.post url, (error, res, body)->
+			timer.done()
+			if error?
+				logger.error {err:error, project_id, doc_id, change_id}, "error accepting change in doc updater"
+				return callback(error)
+			if res.statusCode >= 200 and res.statusCode < 300
+				logger.log {project_id, doc_id, change_id}, "accepted change in document updater"
+				return callback(null)
+			else
+				logger.error {project_id, doc_id, change_id}, "doc updater returned a non-success status code: #{res.statusCode}"
+				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 
 
 PENDINGUPDATESKEY = "PendingUpdates"
 PENDINGUPDATESKEY = "PendingUpdates"
 DOCLINESKEY = "doclines"
 DOCLINESKEY = "doclines"

+ 5 - 3
services/web/app/coffee/Features/Documents/DocumentController.coffee

@@ -7,7 +7,7 @@ module.exports =
 		doc_id = req.params.doc_id
 		doc_id = req.params.doc_id
 		plain = req?.query?.plain == 'true'
 		plain = req?.query?.plain == 'true'
 		logger.log doc_id:doc_id, project_id:project_id, "receiving get document request from api (docupdater)"
 		logger.log doc_id:doc_id, project_id:project_id, "receiving get document request from api (docupdater)"
-		ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev) ->
+		ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev, version, ranges) ->
 			if error?
 			if error?
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				return next(error)
 				return next(error)
@@ -18,14 +18,16 @@ module.exports =
 				res.type "json"
 				res.type "json"
 				res.send JSON.stringify {
 				res.send JSON.stringify {
 					lines: lines
 					lines: lines
+					version: version
+					ranges: ranges
 				}
 				}
 
 
 	setDocument: (req, res, next = (error) ->) ->
 	setDocument: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		doc_id = req.params.doc_id
 		doc_id = req.params.doc_id
-		lines = req.body.lines
+		{lines, version, ranges} = req.body
 		logger.log doc_id:doc_id, project_id:project_id, "receiving set document request from api (docupdater)"
 		logger.log doc_id:doc_id, project_id:project_id, "receiving set document request from api (docupdater)"
-		ProjectEntityHandler.updateDocLines project_id, doc_id, lines, (error) ->
+		ProjectEntityHandler.updateDocLines project_id, doc_id, lines, version, ranges, (error) ->
 			if error?
 			if error?
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				return next(error)
 				return next(error)

+ 0 - 1
services/web/app/coffee/Features/Editor/EditorController.coffee

@@ -7,7 +7,6 @@ ProjectDetailsHandler = require('../Project/ProjectDetailsHandler')
 ProjectDeleter = require("../Project/ProjectDeleter")
 ProjectDeleter = require("../Project/ProjectDeleter")
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 EditorRealTimeController = require("./EditorRealTimeController")
 EditorRealTimeController = require("./EditorRealTimeController")
-TrackChangesManager = require("../TrackChanges/TrackChangesManager")
 async = require('async')
 async = require('async')
 LockManager = require("../../infrastructure/LockManager")
 LockManager = require("../../infrastructure/LockManager")
 _ = require('underscore')
 _ = require('underscore')

+ 49 - 0
services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee

@@ -0,0 +1,49 @@
+_ = require("underscore")
+settings = require "settings-sharelatex"
+
+module.exports = _.template """
+<table class="row" style="border-collapse: collapse; border-spacing: 0; display: table; padding: 0; position: relative; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;">
+	<th class="small-12 large-12 columns first last" style="Margin: 0 auto; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0 auto; padding: 0; padding-bottom: 16px; padding-left: 16px; padding-right: 16px; text-align: left; width: 564px;"><table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><th style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left;">
+		<h3 class="avoid-auto-linking" style="Margin: 0; Margin-bottom: px; color: inherit; font-family: Baskerville, 'Baskerville Old Face', Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: px; padding: 0; text-align: left; word-wrap: normal;">
+			<%= title %>
+		</h3>
+		<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+		<p style="Margin: 0; Margin-bottom: 10px; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
+			<%= greeting %>
+		</p>
+		<p class="avoid-auto-linking" style="Margin: 0; Margin-bottom: 10px; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
+			<%= message %>
+		</p>
+		<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+		<center data-parsed="" style="min-width: 532px; width: 100%;">
+			<table class="button float-center" style="Margin: 0 0 16px 0; border-collapse: collapse; border-spacing: 0; float: none; margin: 0 0 16px 0; padding: 0; text-align: center; vertical-align: top; width: auto;"><tr style="padding: 0; text-align: left; vertical-align: top;"><td style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;"><table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><td style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; background: #a93529; border: 2px solid #a93529; border-collapse: collapse !important; color: #fefefe; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
+				<a href="<%= ctaURL %>" style="Margin: 0; border: 0 solid #a93529; border-radius: 3px; color: #fefefe; display: inline-block; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: bold; line-height: 1.3; margin: 0; padding: 8px 16px 8px 16px; text-align: left; text-decoration: none;">
+					<%= ctaText %>
+				</a>
+			</td></tr></table></td></tr></table>
+		</center>
+		<% if (secondaryMessage) { %>
+			<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+			<p class="avoid-auto-linking" style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left;">
+				<%= secondaryMessage %>
+			</p>
+		<% } %>
+	</th>
+	<th class="expander" style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0 !important; text-align: left; visibility: hidden; width: 0;"></th></tr></table></th>
+</tr></tbody></table>
+<% if (gmailGoToAction) { %>
+	<script type="application/ld+json">
+		{
+			"@context": "http://schema.org",
+			"@type": "EmailMessage",
+			"potentialAction": {
+				"@type": "ViewAction",
+				"target": "<%= gmailGoToAction.target %>",
+				"url": "<%= gmailGoToAction.target %>",
+				"name": "<%= gmailGoToAction.name %>"
+			},
+			"description": "<%= gmailGoToAction.description %>"
+		}
+	</script>
+<% } %>
+"""

+ 64 - 56
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -1,6 +1,12 @@
 _ = require('underscore')
 _ = require('underscore')
+
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
+BaseWithHeaderEmailLayout = require("./Layouts/BaseWithHeaderEmailLayout") 
+
+SingleCTAEmailBody = require("./Bodies/SingleCTAEmailBody")
+
+
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
 
 
 
 
@@ -61,7 +67,7 @@ ShareLaTeX Co-founder
 
 
 templates.passwordResetRequested =
 templates.passwordResetRequested =
 	subject:  _.template "Password Reset - #{settings.appName}"
 	subject:  _.template "Password Reset - #{settings.appName}"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	type:"notification"
 	plainTextTemplate: _.template """
 	plainTextTemplate: _.template """
 Password Reset
 Password Reset
@@ -78,36 +84,21 @@ Thank you
 
 
 #{settings.appName} - <%= siteUrl %>
 #{settings.appName} - <%= siteUrl %>
 """
 """
-	compiledTemplate: _.template """
-<h2>Password Reset</h2>
-<p>
-We got a request to reset your #{settings.appName} password.
-<p>
-<center>
-	<div style="width:200px;background-color:#a93629;border:1px solid #e24b3b;border-radius:3px;padding:15px; margin:24px;">
-		<div style="padding-right:10px;padding-left:10px">
-			<a href="<%= setNewPasswordUrl %>" style="text-decoration:none" target="_blank">
-				<span style= "font-size:16px;font-family:Arial;font-weight:bold;color:#fff;white-space:nowrap;display:block; text-align:center">
-		  			Reset password
-				</span>
-			</a>
-		</div>
-	</div>
-</center>
-
-If you ignore this message, your password won't be changed.
-<p>
-If you didn't request a password reset, let us know.
-
-</p>
-<p>Thank you</p>
-<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
-"""
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "Password Reset"
+			greeting: "Hi,"
+			message: "We got a request to reset your #{settings.appName} password."
+			secondaryMessage: "If you ignore this message, your password won't be changed.<br>If you didn't request a password reset, let us know."
+			ctaText: "Reset password"
+			ctaURL: opts.setNewPasswordUrl
+			gmailGoToAction: null
+		})
 
 
 
 
 templates.projectInvite =
 templates.projectInvite =
 	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
 	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	type:"notification"
 	plainTextTemplate: _.template """
 	plainTextTemplate: _.template """
 Hi, <%= owner.email %> wants to share '<%= project.name %>' with you.
 Hi, <%= owner.email %> wants to share '<%= project.name %>' with you.
@@ -118,23 +109,23 @@ Thank you
 
 
 #{settings.appName} - <%= siteUrl %>
 #{settings.appName} - <%= siteUrl %>
 """
 """
-	compiledTemplate: _.template """
-<p>Hi, <%= owner.email %> wants to share <a href="<%= inviteUrl %>">'<%= project.name %>'</a> with you</p>
-<center>
-			<a style="text-decoration: none; width: 200px; background-color: #a93629; border: 1px solid #e24b3b; border-radius: 3px; padding: 15px; margin: 24px; display: block;" href="<%= inviteUrl %>" style="text-decoration:none" target="_blank">
-				<span style= "font-size:16px;font-family:Helvetica,Arial;font-weight:400;color:#fff;white-space:nowrap;display:block; text-align:center">
-					View Project
-				</span>
-			</a>
-</center>
-<p> Thank you</p>
-<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
-"""
-
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "#{ opts.project.name } &ndash; shared by #{ opts.owner.email }"
+			greeting: "Hi,"
+			message: "#{ opts.owner.email } wants to share &ldquo;#{ opts.project.name }&rdquo; with you."
+			secondaryMessage: null
+			ctaText: "View project"
+			ctaURL: opts.inviteUrl
+			gmailGoToAction: 
+				target: opts.inviteUrl
+				name: "View project"
+				description: "Join #{ opts.project.name } at ShareLaTeX"
+		})
 
 
 templates.completeJoinGroupAccount =
 templates.completeJoinGroupAccount =
 	subject: _.template "Verify Email to join <%= group_name %> group"
 	subject: _.template "Verify Email to join <%= group_name %> group"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	type:"notification"
 	plainTextTemplate: _.template """
 	plainTextTemplate: _.template """
 Hi, please verify your email to join the <%= group_name %> and get your free premium account
 Hi, please verify your email to join the <%= group_name %> and get your free premium account
@@ -145,22 +136,39 @@ Thank You
 
 
 #{settings.appName} - <%= siteUrl %>
 #{settings.appName} - <%= siteUrl %>
 """
 """
-	compiledTemplate: _.template """
-<p>Hi, please verify your email to join the <%= group_name %> and get your free premium account</p>
-<center>
-	<div style="width:200px;background-color:#a93629;border:1px solid #e24b3b;border-radius:3px;padding:15px; margin:24px;">
-		<div style="padding-right:10px;padding-left:10px">
-			<a href="<%= completeJoinUrl %>" style="text-decoration:none" target="_blank">
-				<span style= "font-size:16px;font-family:Helvetica,Arial;font-weight:400;color:#fff;white-space:nowrap;display:block; text-align:center">
-		  			Verify now
-				</span>
-			</a>
-		</div>
-	</div>
-</center>
-<p> Thank you</p>
-<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "Verify Email to join #{ opts.group_name } group"
+			greeting: "Hi,"
+			message: "please verify your email to join the #{ opts.group_name } group and get your free premium account."
+			secondaryMessage: null
+			ctaText: "Verify now"
+			ctaURL: opts.completeJoinUrl
+			gmailGoToAction: null
+		})
+
+
+templates.testEmail =
+	subject: _.template "A Test Email from ShareLaTeX"
+	layout: BaseWithHeaderEmailLayout
+	type:"notification"
+	plainTextTemplate: _.template """
+Hi,
+
+This is a test email sent from ShareLaTeX.
+
+#{settings.appName} - <%= siteUrl %>
 """
 """
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "A Test Email from ShareLaTeX"
+			greeting: "Hi,"
+			message: "This is a test email sent from ShareLaTeX"
+			secondaryMessage: null
+			ctaText: "Open ShareLaTeX"
+			ctaURL: "/"
+			gmailGoToAction: null
+		})
 
 
 
 
 module.exports =
 module.exports =

+ 32 - 17
services/web/app/coffee/Features/Email/EmailSender.coffee

@@ -4,7 +4,7 @@ Settings = require('settings-sharelatex')
 nodemailer = require("nodemailer")
 nodemailer = require("nodemailer")
 sesTransport = require('nodemailer-ses-transport')
 sesTransport = require('nodemailer-ses-transport')
 sgTransport = require('nodemailer-sendgrid-transport')
 sgTransport = require('nodemailer-sendgrid-transport')
-
+rateLimiter = require('../../infrastructure/RateLimiter')
 _ = require("underscore")
 _ = require("underscore")
 
 
 if Settings.email? and Settings.email.fromAddress?
 if Settings.email? and Settings.email.fromAddress?
@@ -39,24 +39,39 @@ if nm_client?
 else
 else
 	logger.warn "Failed to create email transport. Please check your settings. No email will be sent."
 	logger.warn "Failed to create email transport. Please check your settings. No email will be sent."
 
 
+checkCanSendEmail = (options, callback)->
+	if !options.sendingUser_id? #email not sent from user, not rate limited
+		return callback(null, true)
+	opts = 
+		endpointName: "send_email"
+		timeInterval: 60 * 60 * 3
+		subjectName: options.sendingUser_id
+		throttle: 100
+	rateLimiter.addCount opts, callback
 
 
 module.exports =
 module.exports =
 	sendEmail : (options, callback = (error) ->)->
 	sendEmail : (options, callback = (error) ->)->
 		logger.log receiver:options.to, subject:options.subject, "sending email"
 		logger.log receiver:options.to, subject:options.subject, "sending email"
-		metrics.inc "email"
-		options =
-			to: options.to
-			from: defaultFromAddress
-			subject: options.subject
-			html: options.html
-			text: options.text
-			replyTo: options.replyTo || Settings.email.replyToAddress
-			socketTimeout: 30 * 1000
-		if Settings.email.textEncoding?
-			opts.textEncoding = textEncoding
-		client.sendMail options, (err, res)->
+		checkCanSendEmail options, (err, canContinue)->
 			if err?
 			if err?
-				logger.err err:err, "error sending message"
-			else
-				logger.log "Message sent to #{options.to}"
-			callback(err)
+				return callback(err)
+			if !canContinue
+				logger.log sendingUser_id:options.sendingUser_id, to:options.to, subject:options.subject, canContinue:canContinue, "rate limit hit for sending email, not sending"
+				return callback("rate limit hit sending email")
+			metrics.inc "email"
+			options =
+				to: options.to
+				from: defaultFromAddress
+				subject: options.subject
+				html: options.html
+				text: options.text
+				replyTo: options.replyTo || Settings.email.replyToAddress
+				socketTimeout: 30 * 1000
+			if Settings.email.textEncoding?
+				opts.textEncoding = textEncoding
+			client.sendMail options, (err, res)->
+				if err?
+					logger.err err:err, "error sending message"
+				else
+					logger.log "Message sent to #{options.to}"
+				callback(err)

+ 380 - 0
services/web/app/coffee/Features/Email/Layouts/BaseWithHeaderEmailLayout.coffee

@@ -0,0 +1,380 @@
+_ = require("underscore")
+settings = require "settings-sharelatex"
+
+module.exports = _.template """
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en" style="Margin: 0; background: #f6f6f6 !important; margin: 0; min-height: 100%; padding: 0;">
+	<head>
+		
+		
+		<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+		<meta name="viewport" content="width=device-width">
+		<title>Project invite</title>
+		<style>.avoid-auto-linking a,
+.avoid-auto-linking a[href] {
+	color: #a93529 !important;
+	text-decoration: none !important;
+	-moz-hyphens: none;
+	-ms-hyphens: none;
+	-webkit-hyphens: none;
+	hyphens: none; }
+	.avoid-auto-linking a:visited,
+	.avoid-auto-linking a[href]:visited {
+		color: #a93529; }
+	.avoid-auto-linking a:hover,
+	.avoid-auto-linking a[href]:hover {
+		color: #80281f; }
+	.avoid-auto-linking a:active,
+	.avoid-auto-linking a[href]:active {
+		color: #80281f; }
+@media only screen {
+	html {
+		min-height: 100%;
+		background: #f6f6f6;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	.small-float-center {
+		margin: 0 auto !important;
+		float: none !important;
+		text-align: center !important;
+	}
+
+	.small-text-center {
+		text-align: center !important;
+	}
+
+	.small-text-left {
+		text-align: left !important;
+	}
+
+	.small-text-right {
+		text-align: right !important;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	.hide-for-large {
+		display: block !important;
+		width: auto !important;
+		overflow: visible !important;
+		max-height: none !important;
+		font-size: inherit !important;
+		line-height: inherit !important;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	table.body table.container .hide-for-large,
+	table.body table.container .row.hide-for-large {
+		display: table !important;
+		width: 100% !important;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	table.body table.container .callout-inner.hide-for-large {
+		display: table-cell !important;
+		width: 100% !important;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	table.body table.container .show-for-large {
+		display: none !important;
+		width: 0;
+		mso-hide: all;
+		overflow: hidden;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	table.body img {
+		width: auto;
+		height: auto;
+	}
+
+	table.body center {
+		min-width: 0 !important;
+	}
+
+	table.body .container {
+		width: 95% !important;
+	}
+
+	table.body .columns,
+	table.body .column {
+		height: auto !important;
+		-moz-box-sizing: border-box;
+		-webkit-box-sizing: border-box;
+		box-sizing: border-box;
+		padding-left: 16px !important;
+		padding-right: 16px !important;
+	}
+
+	table.body .columns .column,
+	table.body .columns .columns,
+	table.body .column .column,
+	table.body .column .columns {
+		padding-left: 0 !important;
+		padding-right: 0 !important;
+	}
+
+	table.body .collapse .columns,
+	table.body .collapse .column {
+		padding-left: 0 !important;
+		padding-right: 0 !important;
+	}
+
+	td.small-1,
+	th.small-1 {
+		display: inline-block !important;
+		width: 8.33333% !important;
+	}
+
+	td.small-2,
+	th.small-2 {
+		display: inline-block !important;
+		width: 16.66667% !important;
+	}
+
+	td.small-3,
+	th.small-3 {
+		display: inline-block !important;
+		width: 25% !important;
+	}
+
+	td.small-4,
+	th.small-4 {
+		display: inline-block !important;
+		width: 33.33333% !important;
+	}
+
+	td.small-5,
+	th.small-5 {
+		display: inline-block !important;
+		width: 41.66667% !important;
+	}
+
+	td.small-6,
+	th.small-6 {
+		display: inline-block !important;
+		width: 50% !important;
+	}
+
+	td.small-7,
+	th.small-7 {
+		display: inline-block !important;
+		width: 58.33333% !important;
+	}
+
+	td.small-8,
+	th.small-8 {
+		display: inline-block !important;
+		width: 66.66667% !important;
+	}
+
+	td.small-9,
+	th.small-9 {
+		display: inline-block !important;
+		width: 75% !important;
+	}
+
+	td.small-10,
+	th.small-10 {
+		display: inline-block !important;
+		width: 83.33333% !important;
+	}
+
+	td.small-11,
+	th.small-11 {
+		display: inline-block !important;
+		width: 91.66667% !important;
+	}
+
+	td.small-12,
+	th.small-12 {
+		display: inline-block !important;
+		width: 100% !important;
+	}
+
+	.columns td.small-12,
+	.column td.small-12,
+	.columns th.small-12,
+	.column th.small-12 {
+		display: block !important;
+		width: 100% !important;
+	}
+
+	table.body td.small-offset-1,
+	table.body th.small-offset-1 {
+		margin-left: 8.33333% !important;
+		Margin-left: 8.33333% !important;
+	}
+
+	table.body td.small-offset-2,
+	table.body th.small-offset-2 {
+		margin-left: 16.66667% !important;
+		Margin-left: 16.66667% !important;
+	}
+
+	table.body td.small-offset-3,
+	table.body th.small-offset-3 {
+		margin-left: 25% !important;
+		Margin-left: 25% !important;
+	}
+
+	table.body td.small-offset-4,
+	table.body th.small-offset-4 {
+		margin-left: 33.33333% !important;
+		Margin-left: 33.33333% !important;
+	}
+
+	table.body td.small-offset-5,
+	table.body th.small-offset-5 {
+		margin-left: 41.66667% !important;
+		Margin-left: 41.66667% !important;
+	}
+
+	table.body td.small-offset-6,
+	table.body th.small-offset-6 {
+		margin-left: 50% !important;
+		Margin-left: 50% !important;
+	}
+
+	table.body td.small-offset-7,
+	table.body th.small-offset-7 {
+		margin-left: 58.33333% !important;
+		Margin-left: 58.33333% !important;
+	}
+
+	table.body td.small-offset-8,
+	table.body th.small-offset-8 {
+		margin-left: 66.66667% !important;
+		Margin-left: 66.66667% !important;
+	}
+
+	table.body td.small-offset-9,
+	table.body th.small-offset-9 {
+		margin-left: 75% !important;
+		Margin-left: 75% !important;
+	}
+
+	table.body td.small-offset-10,
+	table.body th.small-offset-10 {
+		margin-left: 83.33333% !important;
+		Margin-left: 83.33333% !important;
+	}
+
+	table.body td.small-offset-11,
+	table.body th.small-offset-11 {
+		margin-left: 91.66667% !important;
+		Margin-left: 91.66667% !important;
+	}
+
+	table.body table.columns td.expander,
+	table.body table.columns th.expander {
+		display: none !important;
+	}
+
+	table.body .right-text-pad,
+	table.body .text-pad-right {
+		padding-left: 10px !important;
+	}
+
+	table.body .left-text-pad,
+	table.body .text-pad-left {
+		padding-right: 10px !important;
+	}
+
+	table.menu {
+		width: 100% !important;
+	}
+
+	table.menu td,
+	table.menu th {
+		width: auto !important;
+		display: inline-block !important;
+	}
+
+	table.menu.vertical td,
+	table.menu.vertical th,
+	table.menu.small-vertical td,
+	table.menu.small-vertical th {
+		display: block !important;
+	}
+
+	table.menu[align="center"] {
+		width: auto !important;
+	}
+
+	table.button.small-expand,
+	table.button.small-expanded {
+		width: 100% !important;
+	}
+
+	table.button.small-expand table,
+	table.button.small-expanded table {
+		width: 100%;
+	}
+
+	table.button.small-expand table a,
+	table.button.small-expanded table a {
+		text-align: center !important;
+		width: 100% !important;
+		padding-left: 0 !important;
+		padding-right: 0 !important;
+	}
+
+	table.button.small-expand center,
+	table.button.small-expanded center {
+		min-width: 0;
+	}
+}</style>
+	</head>
+	<body leftmargin="0" topmargin="0" marginwidth="0" marginheight="0" bgcolor="#F6F6F6" style="-moz-box-sizing: border-box; -ms-text-size-adjust: 100%; -webkit-box-sizing: border-box; -webkit-text-size-adjust: 100%; Margin: 0; background: #f6f6f6 !important; box-sizing: border-box; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; min-height: 100%; min-width: 100%; padding: 0; text-align: left; width: 100% !important;">
+		<!-- <span class="preheader"></span> -->
+		<table class="body" border="0" cellspacing="0" cellpadding="0" width="100%" height="100%" style="Margin: 0; background: #f6f6f6 !important; border-collapse: collapse; border-spacing: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; height: 100%; line-height: 1.3; margin: 0; min-height: 100%; padding: 0; text-align: left; vertical-align: top; width: 100%;">
+			<tr style="padding: 0; text-align: left; vertical-align: top;">
+				<td class="body-cell" align="center" valign="top" bgcolor="#F6F6F6" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; background: #f6f6f6 !important; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; padding-bottom: 20px; text-align: left; vertical-align: top; word-wrap: break-word;">
+					<center data-parsed="" style="min-width: 580px; width: 100%;">
+						
+						<table align="center" class="wrapper header float-center" style="Margin: 0 auto; background: #fefefe; border-bottom: solid 1px #cfcfcf; border-collapse: collapse; border-spacing: 0; float: none; margin: 0 auto; padding: 0; text-align: center; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><td class="wrapper-inner" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 20px; text-align: left; vertical-align: top; word-wrap: break-word;">
+							<table align="center" class="container" style="Margin: 0 auto; background: transparent; border-collapse: collapse; border-spacing: 0; margin: 0 auto; padding: 0; text-align: inherit; vertical-align: top; width: 580px;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
+							<table class="row collapse" style="border-collapse: collapse; border-spacing: 0; display: table; padding: 0; position: relative; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;">
+								<th class="small-12 large-12 columns first last" style="Margin: 0 auto; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0 auto; padding: 0; padding-bottom: 0; padding-left: 0; padding-right: 0; text-align: left; width: 588px;"><table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><th style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left;">
+								<h1 class="sl-logotype" style="Margin: 0; Margin-bottom: 0; color: #333333; font-family: Baskerville, 'Baskerville Old Face', Georgia, serif; font-size: 26px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 0; padding: 0; text-align: left; word-wrap: normal;">
+									<span>S</span><span class="sl-logotype-small" style="font-size: 80%;">HARE</span><span>L</span><span class="sl-logotype-small" style="font-size: 80%;">A</span><span>T</span><span class="sl-logotype-small" style="font-size: 80%;">E</span><span>X</span>
+								</h1>
+								</th>
+								<th class="expander" style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0 !important; text-align: left; visibility: hidden; width: 0;"></th></tr></table></th>
+							</tr></tbody></table>
+							</td></tr></tbody></table>
+						</td></tr></table>
+						<table class="spacer float-center" style="Margin: 0 auto; border-collapse: collapse; border-spacing: 0; float: none; margin: 0 auto; padding: 0; text-align: center; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+						<table align="center" class="container main float-center" style="Margin: 0 auto; Margin-top: 10px; background: #fefefe; border-collapse: collapse; border-spacing: 0; float: none; margin: 0 auto; margin-top: 10px; padding: 0; text-align: center; vertical-align: top; width: 580px;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
+							<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+							
+							<%= body %>
+							
+							<table class="wrapper secondary" align="center" style="background: #f6f6f6; border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><td class="wrapper-inner" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
+								<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="10px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 10px; font-weight: normal; hyphens: auto; line-height: 10px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+								<p style="Margin: 0; Margin-bottom: 10px; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;"><small style="color: #7a7a7a; font-size: 80%;">
+									#{ settings.appName} &bull; <a href="#{ settings.siteUrl }" style="Margin: 0; color: #a93529; font-family: Helvetica, Arial, sans-serif; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left; text-decoration: none;">#{ settings.siteUrl }</a>
+								</small></p>
+							</td></tr></table>
+						</td></tr></tbody></table>
+						
+					</center>
+				</td>
+			</tr>
+		</table>
+		<!-- prevent Gmail on iOS font size manipulation -->
+	 <div style="display:none; white-space:nowrap; font:15px courier; line-height:0;"> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
+	</body>
+</html>
+
+"""

+ 20 - 0
services/web/app/coffee/Features/History/HistoryController.coffee

@@ -0,0 +1,20 @@
+logger = require "logger-sharelatex"
+request = require "request"
+settings = require "settings-sharelatex"
+AuthenticationController = require "../Authentication/AuthenticationController"
+
+module.exports = HistoryController =
+	proxyToHistoryApi: (req, res, next = (error) ->) ->
+		user_id = AuthenticationController.getLoggedInUserId req
+		url = settings.apis.trackchanges.url + req.url
+		logger.log url: url, "proxying to track-changes api"
+		getReq = request(
+			url: url
+			method: req.method
+			headers:
+				"X-User-Id": user_id
+		)
+		getReq.pipe(res)
+		getReq.on "error", (error) ->
+			logger.error err: error, "track-changes API error"
+			next(error)

+ 28 - 0
services/web/app/coffee/Features/History/HistoryManager.coffee

@@ -0,0 +1,28 @@
+settings = require "settings-sharelatex"
+request = require "request"
+logger = require "logger-sharelatex"
+
+module.exports = HistoryManager =
+	flushProject: (project_id, callback = (error) ->) ->
+		logger.log project_id: project_id, "flushing project in track-changes api"
+		url = "#{settings.apis.trackchanges.url}/project/#{project_id}/flush"
+		request.post url, (error, res, body) ->
+			return callback(error) if error?
+			if 200 <= res.statusCode < 300
+				callback(null)
+			else
+				error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}")
+				logger.error err: error, project_id: project_id, "error flushing project in track-changes api"
+				callback(error)
+
+	archiveProject: (project_id, callback = ()->)->
+		logger.log project_id: project_id, "archving project in track-changes api"
+		url = "#{settings.apis.trackchanges.url}/project/#{project_id}/archive"
+		request.post url, (error, res, body) ->
+			return callback(error) if error?
+			if 200 <= res.statusCode < 300
+				callback(null)
+			else
+				error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}")
+				logger.error err: error, project_id: project_id, "error archving project in track-changes api"
+				callback(error)

+ 0 - 3
services/web/app/coffee/Features/InactiveData/InactiveProjectManager.coffee

@@ -5,8 +5,6 @@ DocstoreManager = require("../Docstore/DocstoreManager")
 ProjectGetter = require("../Project/ProjectGetter")
 ProjectGetter = require("../Project/ProjectGetter")
 ProjectUpdateHandler = require("../Project/ProjectUpdateHandler")
 ProjectUpdateHandler = require("../Project/ProjectUpdateHandler")
 Project = require("../../models/Project").Project
 Project = require("../../models/Project").Project
-TrackChangesManager = require("../TrackChanges/TrackChangesManager")
-
 
 
 MILISECONDS_IN_DAY = 86400000
 MILISECONDS_IN_DAY = 86400000
 module.exports = InactiveProjectManager =
 module.exports = InactiveProjectManager =
@@ -52,7 +50,6 @@ module.exports = InactiveProjectManager =
 		logger.log project_id:project_id, "deactivating inactive project"
 		logger.log project_id:project_id, "deactivating inactive project"
 		jobs = [
 		jobs = [
 			(cb)-> DocstoreManager.archiveProject project_id, cb
 			(cb)-> DocstoreManager.archiveProject project_id, cb
-			# (cb)-> TrackChangesManager.archiveProject project_id, cb
 			(cb)-> ProjectUpdateHandler.markAsInactive project_id, cb
 			(cb)-> ProjectUpdateHandler.markAsInactive project_id, cb
 		]
 		]
 		async.series jobs, (err)->
 		async.series jobs, (err)->

+ 3 - 2
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -197,11 +197,11 @@ module.exports = ProjectController =
 			user_id = null
 			user_id = null
 
 
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
-		logger.log project_id:project_id, "loading editor"
+		logger.log project_id:project_id, anonymous:anonymous, user_id:user_id, "loading editor"
 
 
 		async.parallel {
 		async.parallel {
 			project: (cb)->
 			project: (cb)->
-				ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1}, cb
+				ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1, track_changes: 1 }, cb
 			user: (cb)->
 			user: (cb)->
 				if !user_id?
 				if !user_id?
 					cb null, defaultSettingsForAnonymousUser(user_id)
 					cb null, defaultSettingsForAnonymousUser(user_id)
@@ -267,6 +267,7 @@ module.exports = ProjectController =
 						pdfViewer : user.ace.pdfViewer
 						pdfViewer : user.ace.pdfViewer
 						syntaxValidation: user.ace.syntaxValidation
 						syntaxValidation: user.ace.syntaxValidation
 					}
 					}
+					trackChangesEnabled: !!project.track_changes
 					privilegeLevel: privilegeLevel
 					privilegeLevel: privilegeLevel
 					chatUrl: Settings.apis.chat.url
 					chatUrl: Settings.apis.chat.url
 					anonymous: anonymous
 					anonymous: anonymous

+ 6 - 0
services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee

@@ -19,6 +19,11 @@ module.exports = ProjectEditorHandler =
 
 
 		if !result.invites?
 		if !result.invites?
 			result.invites = []
 			result.invites = []
+			
+		hasTrackChanges = false
+		for member in members
+			if member.privilegeLevel == "owner" and member.user?.featureSwitches?.track_changes
+				hasTrackChanges = true
 
 
 		{owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members)
 		{owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members)
 		result.owner = owner
 		result.owner = owner
@@ -32,6 +37,7 @@ module.exports = ProjectEditorHandler =
 			compileGroup:"standard"
 			compileGroup:"standard"
 			templates: false
 			templates: false
 			references: false
 			references: false
+			trackChanges: hasTrackChanges
 		})
 		})
 
 
 		return result
 		return result

+ 3 - 3
services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee

@@ -126,7 +126,7 @@ module.exports = ProjectEntityHandler =
 			doc = new Doc name: docName
 			doc = new Doc name: docName
 			# Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
 			# Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
 			# which hasn't been created in docstore.
 			# which hasn't been created in docstore.
-			DocstoreManager.updateDoc project_id.toString(), doc._id.toString(), docLines, (err, modified, rev) ->
+			DocstoreManager.updateDoc project_id.toString(), doc._id.toString(), docLines, 0, {}, (err, modified, rev) ->
 				return callback(err) if err? 
 				return callback(err) if err? 
 
 
 				ProjectEntityHandler._putElement project, folder_id, doc, "doc", (err, result)=>
 				ProjectEntityHandler._putElement project, folder_id, doc, "doc", (err, result)=>
@@ -292,7 +292,7 @@ module.exports = ProjectEntityHandler =
 					return callback(err)
 					return callback(err)
 				callback(err, folder, parentFolder_id)
 				callback(err, folder, parentFolder_id)
 
 
-	updateDocLines : (project_id, doc_id, lines, callback = (error) ->)->
+	updateDocLines : (project_id, doc_id, lines, version, ranges, callback = (error) ->)->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
 			return callback(err) if err?
 			return callback(err) if err?
 			return callback(new Errors.NotFoundError("project not found")) if !project?
 			return callback(new Errors.NotFoundError("project not found")) if !project?
@@ -307,7 +307,7 @@ module.exports = ProjectEntityHandler =
 					return callback(error)
 					return callback(error)
 
 
 				logger.log project_id: project_id, doc_id: doc_id, "telling docstore manager to update doc"
 				logger.log project_id: project_id, doc_id: doc_id, "telling docstore manager to update doc"
-				DocstoreManager.updateDoc project_id, doc_id, lines, (err, modified, rev) ->
+				DocstoreManager.updateDoc project_id, doc_id, lines, version, ranges, (err, modified, rev) ->
 					if err?
 					if err?
 						logger.error err: err, doc_id: doc_id, project_id:project_id, lines: lines, "error sending doc to docstore"
 						logger.error err: err, doc_id: doc_id, project_id:project_id, lines: lines, "error sending doc to docstore"
 						return callback(err)
 						return callback(err)

+ 9 - 1
services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee

@@ -418,7 +418,15 @@ module.exports = RecurlyWrapper =
 			url: "subscriptions/#{subscriptionId}/cancel",
 			url: "subscriptions/#{subscriptionId}/cancel",
 			method: "put"
 			method: "put"
 		}, (error, response, body) ->
 		}, (error, response, body) ->
-			callback(error)
+			if error?
+				RecurlyWrapper._parseXml body, (_err, parsed) ->
+					if parsed?.error?.description == "A canceled subscription can't transition to canceled"
+						logger.log {subscriptionId, error, body}, "subscription already cancelled, not really an error, proceeding"
+						callback(null)
+					else
+						callback(error)
+			else
+				callback(null)
 		)
 		)
 
 
 	reactivateSubscription: (subscriptionId, callback) ->
 	reactivateSubscription: (subscriptionId, callback) ->

+ 0 - 5
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -14,10 +14,6 @@ module.exports = SubscriptionController =
 
 
 	plansPage: (req, res, next) ->
 	plansPage: (req, res, next) ->
 		plans = SubscriptionViewModelBuilder.buildViewModel()
 		plans = SubscriptionViewModelBuilder.buildViewModel()
-		if AuthenticationController.isUserLoggedIn(req)
-			baseUrl = ""
-		else
-			baseUrl = "/register?redir="
 		viewName = "subscriptions/plans"
 		viewName = "subscriptions/plans"
 		if req.query.v?
 		if req.query.v?
 			viewName = "#{viewName}_#{req.query.v}"
 			viewName = "#{viewName}_#{req.query.v}"
@@ -29,7 +25,6 @@ module.exports = SubscriptionController =
 				res.render viewName,
 				res.render viewName,
 					title: "plans_and_pricing"
 					title: "plans_and_pricing"
 					plans: plans
 					plans: plans
-					baseUrl: baseUrl
 					gaExperiments: Settings.gaExperiments.plansPage
 					gaExperiments: Settings.gaExperiments.plansPage
 					recomendedCurrency:recomendedCurrency
 					recomendedCurrency:recomendedCurrency
 					shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27')))
 					shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27')))

+ 23 - 0
services/web/app/coffee/Features/TrackChanges/RangesManager.coffee

@@ -0,0 +1,23 @@
+DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
+DocstoreManager = require "../Docstore/DocstoreManager"
+UserInfoManager = require "../User/UserInfoManager"
+async = require "async"
+
+module.exports = RangesManager =
+	getAllRanges: (project_id, callback = (error, docs) ->) ->
+		DocumentUpdaterHandler.flushProjectToMongo project_id, (error) ->
+			return callback(error) if error?
+			DocstoreManager.getAllRanges project_id, callback
+	
+	getAllChangesUsers: (project_id, callback = (error, users) ->) ->
+		user_ids = {}
+		RangesManager.getAllRanges project_id, (error, docs) ->
+			return callback(error) if error?
+			jobs = []
+			for doc in docs
+				for change in doc.ranges?.changes or []
+					user_ids[change.metadata.user_id] = true
+			
+			async.mapSeries Object.keys(user_ids), (user_id, cb) ->
+				UserInfoManager.getPersonalInfo user_id, cb
+			, callback

+ 39 - 17
services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee

@@ -1,20 +1,42 @@
+RangesManager = require "./RangesManager"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
-request = require "request"
-settings = require "settings-sharelatex"
-AuthenticationController = require "../Authentication/AuthenticationController"
+UserInfoController = require "../User/UserInfoController"
+DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+TrackChangesManager = require "./TrackChangesManager"
 
 
 module.exports = TrackChangesController =
 module.exports = TrackChangesController =
-	proxyToTrackChangesApi: (req, res, next = (error) ->) ->
-		user_id = AuthenticationController.getLoggedInUserId req
-		url = settings.apis.trackchanges.url + req.url
-		logger.log url: url, "proxying to track-changes api"
-		getReq = request(
-			url: url
-			method: req.method
-			headers:
-				"X-User-Id": user_id
-		)
-		getReq.pipe(res)
-		getReq.on "error", (error) ->
-			logger.error err: error, "track-changes API error"
-			next(error)
+	getAllRanges: (req, res, next) ->
+		project_id = req.params.project_id
+		logger.log {project_id}, "request for project ranges"
+		RangesManager.getAllRanges project_id, (error, docs = []) ->
+			return next(error) if error?
+			docs = ({id: d._id, ranges: d.ranges} for d in docs)
+			res.json docs
+	
+	getAllChangesUsers: (req, res, next) ->
+		project_id = req.params.project_id
+		logger.log {project_id}, "request for project range users"
+		RangesManager.getAllChangesUsers project_id, (error, users) ->
+			return next(error) if error?
+			users = (UserInfoController.formatPersonalInfo(user) for user in users)
+			# Get rid of any anonymous/deleted user objects
+			users = users.filter (u) -> u?.id?
+			res.json users
+	
+	acceptChange: (req, res, next) ->
+		{project_id, doc_id, change_id} = req.params
+		logger.log {project_id, doc_id, change_id}, "request to accept change"
+		DocumentUpdaterHandler.acceptChange project_id, doc_id, change_id, (error) ->
+			return next(error) if error?
+			EditorRealTimeController.emitToRoom project_id, "accept-change", doc_id, change_id, (err)->
+			res.send 204
+
+	toggleTrackChanges: (req, res, next) ->
+		{project_id} = req.params
+		track_changes_on = !!req.body.on
+		logger.log {project_id, track_changes_on}, "request to toggle track changes"
+		TrackChangesManager.toggleTrackChanges project_id, track_changes_on, (error) ->
+			return next(error) if error?
+			EditorRealTimeController.emitToRoom project_id, "toggle-track-changes", track_changes_on, (err)->
+			res.send 204

+ 3 - 26
services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee

@@ -1,28 +1,5 @@
-settings = require "settings-sharelatex"
-request = require "request"
-logger = require "logger-sharelatex"
+Project = require("../../models/Project").Project
 
 
 module.exports = TrackChangesManager =
 module.exports = TrackChangesManager =
-	flushProject: (project_id, callback = (error) ->) ->
-		logger.log project_id: project_id, "flushing project in track-changes api"
-		url = "#{settings.apis.trackchanges.url}/project/#{project_id}/flush"
-		request.post url, (error, res, body) ->
-			return callback(error) if error?
-			if 200 <= res.statusCode < 300
-				callback(null)
-			else
-				error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}")
-				logger.error err: error, project_id: project_id, "error flushing project in track-changes api"
-				callback(error)
-
-	archiveProject: (project_id, callback = ()->)->
-		logger.log project_id: project_id, "archving project in track-changes api"
-		url = "#{settings.apis.trackchanges.url}/project/#{project_id}/archive"
-		request.post url, (error, res, body) ->
-			return callback(error) if error?
-			if 200 <= res.statusCode < 300
-				callback(null)
-			else
-				error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}")
-				logger.error err: error, project_id: project_id, "error archving project in track-changes api"
-				callback(error)
+	toggleTrackChanges: (project_id, track_changes_on, callback = (error) ->) ->
+		Project.update {_id: project_id}, {track_changes: track_changes_on}, callback

+ 8 - 2
services/web/app/coffee/Features/User/UserController.coffee

@@ -33,8 +33,14 @@ module.exports = UserController =
 				if err?
 				if err?
 					logger.err {user_id}, "error while deleting user account"
 					logger.err {user_id}, "error while deleting user account"
 					return next(err)
 					return next(err)
-				req.session?.destroy()
-				res.sendStatus(200)
+				sessionId = req.sessionID
+				req.logout?()
+				req.session.destroy (err) ->
+					if err?
+						logger.err err: err, 'error destorying session'
+						return next(err)
+					UserSessionsManager.untrackSession(user, sessionId)
+					res.sendStatus(200)
 
 
 	unsubscribe: (req, res)->
 	unsubscribe: (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		user_id = AuthenticationController.getLoggedInUserId(req)

+ 10 - 13
services/web/app/coffee/Features/User/UserInfoController.coffee

@@ -26,17 +26,14 @@ module.exports = UserController =
 			UserController.sendFormattedPersonalInfo(user, res, next)
 			UserController.sendFormattedPersonalInfo(user, res, next)
 
 
 	sendFormattedPersonalInfo: (user, res, next = (error) ->) ->
 	sendFormattedPersonalInfo: (user, res, next = (error) ->) ->
-		UserController._formatPersonalInfo user, (error, info) ->
-			return next(error) if error?
-			res.send JSON.stringify(info)
+		info = UserController.formatPersonalInfo(user)
+		res.send JSON.stringify(info)
 
 
-	_formatPersonalInfo: (user, callback = (error, info) ->) ->
-		callback null, {
-			id: user._id.toString()
-			first_name: user.first_name
-			last_name: user.last_name
-			email: user.email
-			signUpDate: user.signUpDate
-			role: user.role
-			institution: user.institution
-		}
+	formatPersonalInfo: (user, callback = (error, info) ->) ->
+		if !user?
+			return {}
+		formatted_user = { id: user._id.toString() }
+		for key in ["first_name", "last_name", "email", "signUpDate", "role", "institution"]
+			if user[key]?
+				formatted_user[key] = user[key]
+		return formatted_user

+ 5 - 0
services/web/app/coffee/Features/User/UserInfoManager.coffee

@@ -0,0 +1,5 @@
+UserGetter = require "./UserGetter"
+
+module.exports = UserInfoManager =
+	getPersonalInfo: (user_id, callback = (error) ->) ->
+		UserGetter.getUser user_id, { _id: true, first_name: true, last_name: true, email: true }, callback

+ 7 - 2
services/web/app/coffee/Features/User/UserPagesController.coffee

@@ -20,7 +20,6 @@ module.exports =
 
 
 		res.render 'user/register',
 		res.render 'user/register',
 			title: 'register'
 			title: 'register'
-			redir: req.query.redir
 			sharedProjectData: sharedProjectData
 			sharedProjectData: sharedProjectData
 			newTemplateData: newTemplateData
 			newTemplateData: newTemplateData
 			new_email:req.query.new_email || ""
 			new_email:req.query.new_email || ""
@@ -49,19 +48,25 @@ module.exports =
 					token: req.query.token
 					token: req.query.token
 
 
 	loginPage : (req, res)->
 	loginPage : (req, res)->
+		# if user is being sent to /login with explicit redirect (redir=/foo),
+		# such as being sent from the editor to /login, then set the redirect explicitly
+		if req.query.redir? and !AuthenticationController._getRedirectFromSession(req)?
+			logger.log {redir: req.query.redir}, "setting explicit redirect from login page"
+			AuthenticationController._setRedirectInSession(req, req.query.redir)
 		res.render 'user/login',
 		res.render 'user/login',
 			title: 'login',
 			title: 'login',
-			redir: req.query.redir,
 			email: req.query.email
 			email: req.query.email
 
 
 	settingsPage : (req, res, next)->
 	settingsPage : (req, res, next)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user: user_id, "loading settings page"
 		logger.log user: user_id, "loading settings page"
+		shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin)
 		UserLocator.findById user_id, (err, user)->
 		UserLocator.findById user_id, (err, user)->
 			return next(err) if err?
 			return next(err) if err?
 			res.render 'user/settings',
 			res.render 'user/settings',
 				title:'account_settings'
 				title:'account_settings'
 				user: user,
 				user: user,
+				shouldAllowEditingDetails: shouldAllowEditingDetails
 				languages: Settings.languages,
 				languages: Settings.languages,
 				accountSettingsTabActive: true
 				accountSettingsTabActive: true
 
 

+ 2 - 0
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -263,6 +263,8 @@ module.exports = (app, webRouter, apiRouter)->
 		for key, value of Settings.nav
 		for key, value of Settings.nav
 			res.locals.nav[key] = _.clone(Settings.nav[key])
 			res.locals.nav[key] = _.clone(Settings.nav[key])
 		res.locals.templates = Settings.templateLinks
 		res.locals.templates = Settings.templateLinks
+		if res.locals.nav.header
+			console.error {}, "The `nav.header` setting is no longer supported, use `nav.header_extras` instead"
 		next()
 		next()
 
 
 	webRouter.use (req, res, next) ->
 	webRouter.use (req, res, next) ->

+ 1 - 0
services/web/app/coffee/infrastructure/Server.coffee

@@ -90,6 +90,7 @@ webRouter.use session
 		secure: Settings.secureCookie
 		secure: Settings.secureCookie
 	store: sessionStore
 	store: sessionStore
 	key: Settings.cookieName
 	key: Settings.cookieName
+	rolling: true
 
 
 # passport
 # passport
 webRouter.use passport.initialize()
 webRouter.use passport.initialize()

+ 1 - 0
services/web/app/coffee/models/Project.coffee

@@ -32,6 +32,7 @@ ProjectSchema = new Schema
 	archived          : { type: Boolean }
 	archived          : { type: Boolean }
 	deletedDocs       : [DeletedDocSchema]
 	deletedDocs       : [DeletedDocSchema]
 	imageName         : { type: String }
 	imageName         : { type: String }
+	track_changes     : { type: Boolean }
 
 
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 	if project_or_id._id?
 	if project_or_id._id?

+ 1 - 1
services/web/app/coffee/models/User.coffee

@@ -39,7 +39,7 @@ UserSchema = new Schema
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 						}
 						}
 	featureSwitches	  : {
 	featureSwitches	  : {
-		pdfng: { type: Boolean }
+		track_changes: { type: Boolean }
 	}
 	}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	refered_users: [ type:ObjectId, ref:'User' ]
 	refered_users: [ type:ObjectId, ref:'User' ]

+ 19 - 6
services/web/app/coffee/router.coffee

@@ -25,7 +25,7 @@ ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
 FileStoreController = require("./Features/FileStore/FileStoreController")
-TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+HistoryController = require("./Features/History/HistoryController")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
 ChatController = require("./Features/Chat/ChatController")
@@ -40,6 +40,8 @@ AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlew
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
 AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
+TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+CommentsController = require "./Features/Comments/CommentsController"
 
 
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
@@ -171,9 +173,14 @@ module.exports = class Router
 
 
 		webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject
 		webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject
 
 
-		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
-		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
-		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
+		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+
+		webRouter.get "/project/:project_id/ranges", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllRanges
+		webRouter.get "/project/:project_id/changes/users", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllChangesUsers
+		webRouter.post "/project/:project_id/doc/:doc_id/changes/:change_id/accept", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.acceptChange
+		webRouter.post "/project/:project_id/track_changes", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.toggleTrackChanges
 
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
@@ -223,8 +230,14 @@ module.exports = class Router
 		webRouter.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 
 
-		webRouter.get  "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
-		webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
+		webRouter.get  "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
+		webRouter.post "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
+		
+		# Note: Read only users can still comment
+		webRouter.post "/project/:project_id/thread/:thread_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.sendComment
+		webRouter.get  "/project/:project_id/threads", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.getThreads
+		webRouter.post "/project/:project_id/thread/:thread_id/resolve", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.resolveThread
+		webRouter.post "/project/:project_id/thread/:thread_id/reopen", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.reopenThread
 
 
 		webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
 		webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
 		webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
 		webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll

+ 1 - 1
services/web/app/views/general/closed.jade

@@ -11,4 +11,4 @@ block content
 						| Sorry, ShareLaTeX is briefly down for maintenance.
 						| Sorry, ShareLaTeX is briefly down for maintenance.
 						| We should be back within minutes, but if not, or you have
 						| We should be back within minutes, but if not, or you have
 						| an urgent request, please contact us at
 						| an urgent request, please contact us at
-						| support@sharelatex.com
+						| #{settings.adminEmail}

+ 35 - 4
services/web/app/views/layout/navbar.jade

@@ -24,7 +24,10 @@ nav.navbar.navbar-default
 							li
 							li
 								a(href="/admin/user") Manage Users
 								a(href="/admin/user") Manage Users
 
 
-				each item in nav.header
+
+				// loop over header_extras
+				each item in nav.header_extras
+
 					if ((item.only_when_logged_in && getSessionUser()) || (item.only_when_logged_out && (!getSessionUser())) || (!item.only_when_logged_out && !item.only_when_logged_in))
 					if ((item.only_when_logged_in && getSessionUser()) || (item.only_when_logged_out && (!getSessionUser())) || (!item.only_when_logged_out && !item.only_when_logged_in))
 						if item.dropdown
 						if item.dropdown
 							li.dropdown(class=item.class, dropdown)
 							li.dropdown(class=item.class, dropdown)
@@ -47,7 +50,35 @@ nav.navbar.navbar-default
 									a(href=item.url, class=item.class) !{translate(item.text)}
 									a(href=item.url, class=item.class) !{translate(item.text)}
 								else
 								else
 									| !{translate(item.text)}
 									| !{translate(item.text)}
-				
-				
-				
 
 
+				// logged out
+				if !getSessionUser()
+					// register link
+					if !externalAuthenticationSystemUsed()
+						li
+							a(href="/register") #{translate('register')}
+
+					// login link
+					li
+						a(href="/login") #{translate('log_in')}
+
+				// projects link and account menu
+				if getSessionUser()
+					li
+						a(href="/project") #{translate('Projects')}
+					li.dropdown(dropdown)
+						a.dropbodw-toggle(href, dropdown-toggle)
+							| #{translate('Account')}
+							b.caret
+						ul.dropdown-menu
+							li
+								div.subdued #{getUserEmail()}
+							li.divider
+							li
+								a(href="/user/settings") #{translate('Account Settings')}
+							if nav.showSubscriptionLink
+								li
+									a(href="/user/subscription") #{translate('subscription')}
+							li.divider
+							li
+								a(href="/logout") #{translate('log_out')}

+ 1 - 0
services/web/app/views/project/editor.jade

@@ -107,6 +107,7 @@ block requirejs
 		window.csrfToken = "!{csrfToken}";
 		window.csrfToken = "!{csrfToken}";
 		window.anonymous = #{anonymous};
 		window.anonymous = #{anonymous};
 		window.maxDocLength = #{maxDocLength};
 		window.maxDocLength = #{maxDocLength};
+		window.trackChangesEnabled = #{trackChangesEnabled};
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.requirejs = {
 		window.requirejs = {
 			"paths" : {
 			"paths" : {

+ 15 - 4
services/web/app/views/project/editor/editor.jade

@@ -17,7 +17,9 @@ div.full-size(
 			'rp-state-current-file-mini': (reviewPanel.subView === SubViews.CUR_FILE && !ui.reviewPanelOpen),\
 			'rp-state-current-file-mini': (reviewPanel.subView === SubViews.CUR_FILE && !ui.reviewPanelOpen),\
 			'rp-state-overview': (reviewPanel.subView === SubViews.OVERVIEW),\
 			'rp-state-overview': (reviewPanel.subView === SubViews.OVERVIEW),\
 			'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
 			'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
-			'rp-size-expanded': ui.reviewPanelOpen\
+			'rp-size-expanded': ui.reviewPanelOpen,\
+			'rp-layout-left': reviewPanel.layoutToLeft,\
+			'rp-loading-threads': reviewPanel.loadingThreads\
 		}"
 		}"
 	)
 	)
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
@@ -51,11 +53,20 @@ div.full-size(
 			syntax-validation="settings.syntaxValidation",
 			syntax-validation="settings.syntaxValidation",
 			review-panel="reviewPanel",
 			review-panel="reviewPanel",
 			events-bridge="reviewPanelEventsBridge"
 			events-bridge="reviewPanelEventsBridge"
-			track-changes-enabled="trackChangesFeatureFlag",
-			track-new-changes= "reviewPanel.trackNewChanges",
-			changes-tracker="reviewPanel.changesTracker",
+			track-changes-enabled="project.features.trackChanges",
+			track-changes= "editor.trackChanges",
 			doc-id="editor.open_doc_id"
 			doc-id="editor.open_doc_id"
+			renderer-data="reviewPanel.rendererData"
 		)
 		)
+
+		a.rp-track-changes-indicator(
+			href
+			ng-if="editor.wantTrackChanges"
+			ng-click="toggleReviewPanel();"
+			ng-class="{ 'rp-track-changes-indicator-on-dark' : darkTheme }"
+		) Track changes is 
+			strong on
+
 		
 		
 		include ./review-panel
 		include ./review-panel
 
 

+ 1 - 1
services/web/app/views/project/editor/header.jade

@@ -87,7 +87,7 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 
 
 		a.btn.btn-full-height(
 		a.btn.btn-full-height(
 			href,
 			href,
-			ng-if="trackChangesFeatureFlag",
+			ng-if="project.features.trackChanges",
 			ng-class="{ active: ui.reviewPanelOpen }"
 			ng-class="{ active: ui.reviewPanelOpen }"
 			ng-click="toggleReviewPanel()"
 			ng-click="toggleReviewPanel()"
 		)
 		)

+ 185 - 72
services/web/app/views/project/editor/review-panel.jade

@@ -1,10 +1,29 @@
 #review-panel
 #review-panel
 	.review-panel-toolbar
 	.review-panel-toolbar
-		span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = true;", ng-if="reviewPanel.trackNewChanges === false") Track Changes is 
-			strong off
-		span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = false;", ng-if="reviewPanel.trackNewChanges === true") Track Changes is 
-			strong on
-		review-panel-toggle(ng-model="reviewPanel.trackNewChanges")
+		resolved-comments-dropdown(
+			entries="reviewPanel.resolvedComments"
+			threads="reviewPanel.commentThreads"
+			resolved-ids="reviewPanel.resolvedThreadIds"
+			docs="docs"
+			on-open="refreshResolvedCommentsDropdown();"
+			on-unresolve="unresolveComment(threadId);"
+			on-delete="deleteComment(entryId, threadId);"
+			is-loading="reviewPanel.dropdown.loading"
+			permissions="permissions"
+		)
+		span.review-panel-toolbar-label(ng-if="permissions.write")
+			span(ng-click="toggleTrackChanges(true)", ng-if="editor.wantTrackChanges === false") Track Changes is 
+				strong off
+			span(ng-click="toggleTrackChanges(false)", ng-if="editor.wantTrackChanges === true") Track Changes is 
+				strong on
+			review-panel-toggle(ng-if="editor.wantTrackChanges == editor.trackChanges", ng-model="editor.wantTrackChanges", on-toggle="toggleTrackChanges")
+		span.review-panel-toolbar-label.review-panel-toolbar-label-disabled(ng-if="!permissions.write")
+			span(ng-if="editor.wantTrackChanges === false") Track Changes is 
+				strong off
+			span(ng-if="editor.wantTrackChanges === true") Track Changes is 
+				strong on
+		span.review-panel-toolbar-spinner(ng-if="editor.wantTrackChanges != editor.trackChanges")
+			i.fa.fa-spin.fa-spinner
 
 
 	.rp-entry-list(
 	.rp-entry-list(
 		review-panel-sorted
 		review-panel-sorted
@@ -13,6 +32,7 @@
 		.rp-entry-list-inner
 		.rp-entry-list-inner
 			.rp-entry-wrapper(
 			.rp-entry-wrapper(
 				ng-repeat="(entry_id, entry) in reviewPanel.entries[editor.open_doc_id]"
 				ng-repeat="(entry_id, entry) in reviewPanel.entries[editor.open_doc_id]"
+				ng-if="!(entry.type === 'comment' && reviewPanel.commentThreads[entry.thread_id].resolved === true)"
 			)
 			)
 				div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
 				div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
 					change-entry(
 					change-entry(
@@ -21,22 +41,21 @@
 						on-reject="rejectChange(entry_id);"
 						on-reject="rejectChange(entry_id);"
 						on-accept="acceptChange(entry_id);"
 						on-accept="acceptChange(entry_id);"
 						on-indicator-click="toggleReviewPanel();"
 						on-indicator-click="toggleReviewPanel();"
+						permissions="permissions"
 					)
 					)
 
 
 				div(ng-if="entry.type === 'comment'")
 				div(ng-if="entry.type === 'comment'")
 					comment-entry(
 					comment-entry(
 						entry="entry"
 						entry="entry"
-						users="users"
+						threads="reviewPanel.commentThreads"
 						on-resolve="resolveComment(entry, entry_id)"
 						on-resolve="resolveComment(entry, entry_id)"
-						on-unresolve="unresolveComment(entry_id)"
-						on-show-thread="showThread(entry)"
-						on-hide-thread="hideThread(entry)"
-						on-delete="deleteComment(entry_id)"
 						on-reply="submitReply(entry, entry_id);"
 						on-reply="submitReply(entry, entry_id);"
 						on-indicator-click="toggleReviewPanel();"
 						on-indicator-click="toggleReviewPanel();"
+						permissions="permissions"
+						ng-if="!reviewPanel.loadingThreads"
 					)
 					)
 				
 				
-				div(ng-if="entry.type === 'add-comment'")
+				div(ng-if="entry.type === 'add-comment' && permissions.comment")
 					add-comment-entry(
 					add-comment-entry(
 						on-start-new="startNewComment();"
 						on-start-new="startNewComment();"
 						on-submit="submitNewComment(content);"
 						on-submit="submitNewComment(content);"
@@ -47,34 +66,37 @@
 	.rp-entry-list(
 	.rp-entry-list(
 		ng-if="reviewPanel.subView === SubViews.OVERVIEW"
 		ng-if="reviewPanel.subView === SubViews.OVERVIEW"
 	)
 	)
+		.rp-loading(ng-if="reviewPanel.overview.loading")
+			i.fa.fa-spinner.fa-spin
 		.rp-overview-file(
 		.rp-overview-file(
-			ng-repeat="(doc_id, entries) in reviewPanel.entries"
+			ng-repeat="doc in docs"
+			ng-if="!reviewPanel.overview.loading"
 		)
 		)
-			.rp-overview-file-header
-				| {{ getFileName(doc_id) }}
+			.rp-overview-file-header(
+				ng-if="reviewPanel.entries[doc.doc.id] | notEmpty"
+			)
+				| {{ doc.path }}
 			.rp-entry-wrapper(
 			.rp-entry-wrapper(
-				ng-repeat="(entry_id, entry) in entries | orderOverviewEntries"
+				ng-repeat="(entry_id, entry) in reviewPanel.entries[doc.doc.id] | orderOverviewEntries"
+				ng-if="!(entry.type === 'comment' && reviewPanel.commentThreads[entry.thread_id].resolved === true)"
 			)
 			)
 				div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
 				div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
 					change-entry(
 					change-entry(
 						entry="entry"
 						entry="entry"
 						user="users[entry.metadata.user_id]"
 						user="users[entry.metadata.user_id]"
-						on-reject="rejectChange(entry.id);"
-						on-accept="acceptChange(entry.id);"
 						on-indicator-click="toggleReviewPanel();"
 						on-indicator-click="toggleReviewPanel();"
-						ng-click="gotoEntry(doc_id, entry)"
+						ng-click="gotoEntry(doc.doc.id, entry)"
+						permissions="permissions"
 					)
 					)
 
 
 				div(ng-if="entry.type === 'comment'")
 				div(ng-if="entry.type === 'comment'")
 					comment-entry(
 					comment-entry(
 						entry="entry"
 						entry="entry"
-						users="users"
-						on-resolve="resolveComment(entry, entry.id)"
-						on-unresolve="unresolveComment(entry.id)"
-						on-delete="deleteComment(entry.id)"
+						threads="reviewPanel.commentThreads"
 						on-reply="submitReply(entry, entry_id);"
 						on-reply="submitReply(entry, entry_id);"
 						on-indicator-click="toggleReviewPanel();"
 						on-indicator-click="toggleReviewPanel();"
-						ng-click="gotoEntry(doc_id, entry)"
+						ng-click="gotoEntry(doc.doc.id, entry)"
+						permissions="permissions"
 					)
 					)
 
 
 	.rp-nav
 	.rp-nav
@@ -109,20 +131,30 @@ script(type='text/ng-template', id='changeEntryTemplate')
 		.rp-entry(
 		.rp-entry(
 			ng-class="[ 'rp-entry-' + entry.type, (entry.focused ? 'rp-entry-focused' : '')]"
 			ng-class="[ 'rp-entry-' + entry.type, (entry.focused ? 'rp-entry-focused' : '')]"
 		)
 		)
-			.rp-entry-header
+			.rp-entry-body
 				.rp-entry-action-icon(ng-switch="entry.type")
 				.rp-entry-action-icon(ng-switch="entry.type")
 					i.fa.fa-pencil(ng-switch-when="insert")
 					i.fa.fa-pencil(ng-switch-when="insert")
 					i.rp-icon-delete(ng-switch-when="delete")
 					i.rp-icon-delete(ng-switch-when="delete")
-				.rp-entry-metadata
-					p.rp-entry-metadata-line(style="color: hsl({{ user.hue }}, 70%, 40%);") {{ user.name }} 
-					p.rp-entry-metadata-line {{ entry.metadata.ts | date : 'MMM d, y h:mm a' }}
-				.rp-avatar(style="background-color: hsl({{ user.hue }}, 70%, 50%);") {{ user.avatar_text | limitTo : 1 }}
-			.rp-entry-body(ng-switch="entry.type")
-					span(ng-switch-when="insert") Added&nbsp;
-						ins.rp-content-highlight {{ entry.content }} 
-					span(ng-switch-when="delete") Deleted&nbsp;
-						del.rp-content-highlight {{ entry.content }} 
-			.rp-entry-actions
+				.rp-entry-details
+					.rp-entry-description(ng-switch="entry.type")
+						span(ng-switch-when="insert") Added&nbsp;
+							ins.rp-content-highlight {{ entry.content | limitTo:(isCollapsed ? contentLimit : entry.content.length) }}
+							a.rp-collapse-toggle(
+								href
+								ng-if="needsCollapsing"
+								ng-click="toggleCollapse();"
+							) {{ isCollapsed ? '... (show all)' : ' (show less)' }}
+						span(ng-switch-when="delete") Deleted&nbsp;
+							del.rp-content-highlight {{ entry.content | limitTo:(isCollapsed ? contentLimit : entry.content.length) }}
+							a.rp-collapse-toggle(
+								href
+								ng-if="needsCollapsing"
+								ng-click="toggleCollapse();"
+							) {{ isCollapsed ? '... (show all)' : ' (show less)' }}
+					.rp-entry-metadata
+						| {{ entry.metadata.ts | date : 'MMM d, y h:mm a' }}&nbsp;&bull;&nbsp;
+						span.rp-entry-user(style="color: hsl({{ user.hue }}, 70%, 40%);") {{ user.name }} 
+			.rp-entry-actions(ng-if="permissions.write")
 				a.rp-entry-button(href, ng-click="onReject();") 
 				a.rp-entry-button(href, ng-click="onReject();") 
 					i.fa.fa-times
 					i.fa.fa-times
 					| &nbsp;Reject
 					| &nbsp;Reject
@@ -131,56 +163,98 @@ script(type='text/ng-template', id='changeEntryTemplate')
 					| &nbsp;Accept
 					| &nbsp;Accept
 
 
 script(type='text/ng-template', id='commentEntryTemplate')
 script(type='text/ng-template', id='commentEntryTemplate')
-	div
-		.rp-entry-callout.rp-entry-callout-comment(ng-if="!entry.resolved")
+	.rp-comment-wrapper(
+		ng-class="{ 'rp-comment-wrapper-resolving': state.animating }"
+	)
+		.rp-entry-callout.rp-entry-callout-comment
 		.rp-entry-indicator(
 		.rp-entry-indicator(
 			ng-class="{ 'rp-entry-indicator-focused': entry.focused }"
 			ng-class="{ 'rp-entry-indicator-focused': entry.focused }"
 			ng-click="onIndicatorClick();"
 			ng-click="onIndicatorClick();"
 		)
 		)
 			i.fa.fa-comment
 			i.fa.fa-comment
 		.rp-entry.rp-entry-comment(
 		.rp-entry.rp-entry-comment(
-			ng-class="{ 'rp-entry-focused': entry.focused, 'rp-entry-comment-resolved': entry.resolved}"
+			ng-class="{ 'rp-entry-focused': entry.focused, 'rp-entry-comment-resolving': state.animating }"
 		)
 		)
-			.rp-comment(
-				ng-if="!entry.resolved || entry.showWhenResolved"
-				ng-repeat="comment in entry.thread"
-				ng-class="users[comment.user_id].isSelf ? 'rp-comment-self' : '';"
-			)
-				.rp-avatar(
-					ng-if="!users[comment.user_id].isSelf;"
-					style="background-color: hsl({{ users[comment.user_id].hue }}, 70%, 50%);"
-				) {{ users[comment.user_id].avatar_text | limitTo : 1 }}
-				.rp-comment-body(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 90%);")
-					p.rp-comment-content {{ comment.content }}
-					p.rp-comment-metadata
-						| {{ comment.ts | date : 'MMM d, y h:mm a' }}
-						| &nbsp;&bull;&nbsp;
-						span(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 40%);") {{ users[comment.user_id].name }}
-			.rp-comment-reply(ng-if="!entry.resolved || entry.showWhenResolved")
+			div
+				.rp-comment(
+					ng-repeat="comment in threads[entry.thread_id].messages track by comment.id"
+				)
+					p.rp-comment-content 
+						span.rp-entry-user(
+							style="color: hsl({{ comment.user.hue }}, 70%, 40%);"
+						) {{ comment.user.name }}:&nbsp;
+						| {{ comment.content }}
+					.rp-entry-metadata
+						| {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
+			.rp-loading(ng-if="!threads[entry.thread_id] || threads[entry.thread_id].submitting")
+				i.fa.fa-spinner.fa-spin
+			.rp-comment-reply(ng-if="permissions.comment")
 				textarea.rp-comment-input(
 				textarea.rp-comment-input(
 					ng-model="entry.replyContent"
 					ng-model="entry.replyContent"
 					ng-keypress="handleCommentReplyKeyPress($event);"
 					ng-keypress="handleCommentReplyKeyPress($event);"
 					stop-propagation="click"
 					stop-propagation="click"
 					placeholder="{{ 'Hit \"Enter\" to reply' + (entry.resolved ? ' and re-open' : '') }}"
 					placeholder="{{ 'Hit \"Enter\" to reply' + (entry.resolved ? ' and re-open' : '') }}"
 				)
 				)
-			.rp-comment-resolved-description(ng-if="entry.resolved && !entry.showWhenResolved")
-				div
-					| Comment resolved by 
-					span(style="color: hsl({{ users[entry.resolved_data.user_id].hue }}, 70%, 40%);") {{ users[entry.resolved_data.user_id].name }}
-				div {{ entry.resolved_data.ts | date : 'MMM d, y h:mm a' }}
 			.rp-entry-actions
 			.rp-entry-actions
-				a.rp-entry-button(href, ng-click="onResolve();", ng-if="!entry.resolved") 
-					i.fa.fa-check
-					| &nbsp;Mark as resolved
-				a.rp-entry-button(href, ng-click="onShowThread();", ng-if="entry.resolved && !entry.showWhenResolved")
-					| &nbsp;Show
-				a.rp-entry-button(href, ng-click="onHideThread();", ng-if="entry.resolved && entry.showWhenResolved")
-					| &nbsp;Hide
-				a.rp-entry-button(href, ng-click="onUnresolve();", ng-if="entry.resolved") 
-					| &nbsp;Re-open
-				a.rp-entry-button(href, ng-click="onDelete();", ng-if="entry.resolved") 
-					| &nbsp;Delete
+				button.rp-entry-button(
+					ng-click="animateAndCallOnResolve();"
+					ng-if="permissions.comment && permissions.write"
+				) 
+					i.fa.fa-inbox
+					| &nbsp;Resolve
+				button.rp-entry-button(
+					ng-click="onReply();"
+					ng-if="permissions.comment"
+					ng-disabled="!entry.replyContent.length"
+				)
+					i.fa.fa-reply
+					| &nbsp;Reply
+	
+script(type='text/ng-template', id='resolvedCommentEntryTemplate')
+	.rp-resolved-comment
+		div
+			.rp-resolved-comment-context
+				| Quoted text on&nbsp;
+				span.rp-resolved-comment-context-file {{ thread.docName }}
+				p.rp-resolved-comment-context-quote
+					span {{ thread.content | limitTo:(isCollapsed ? contentLimit : thread.content.length)}}
+					a.rp-collapse-toggle(
+						href
+						ng-if="needsCollapsing"
+						ng-click="toggleCollapse();"
+					) &nbsp;{{ isCollapsed ? '... (show all)' : ' (show less)' }}
+			.rp-comment(
+				ng-repeat="comment in thread.messages track by comment.id"
+			)
+				p.rp-comment-content 
+					span.rp-entry-user(
+						style="color: hsl({{ comment.user.hue }}, 70%, 40%);"
+						ng-if="$first || comment.user.id !== thread.messages[$index - 1].user.id"
+					) {{ comment.user.name }}:&nbsp;
+					| {{ comment.content }}
+				.rp-entry-metadata
+					| {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
+			.rp-comment.rp-comment-resolver
+				p.rp-comment-resolver-content 
+					span.rp-entry-user(
+						style="color: hsl({{ thread.resolved_by_user.hue }}, 70%, 40%);"
+					) {{ thread.resolved_by_user.name }}:&nbsp;
+					| Marked as resolved.
+				.rp-entry-metadata
+					| {{ thread.resolved_at | date : 'MMM d, y h:mm a' }}
 				
 				
+		.rp-entry-actions(ng-if="permissions.comment && permissions.write")
+			a.rp-entry-button(
+				href
+				ng-click="onUnresolve({ 'threadId': thread.threadId });"
+			) 
+				| &nbsp;Re-open
+			//- a.rp-entry-button(
+			//- 	href
+			//- 	ng-click="onDelete({ 'entryId': thread.entryId, 'threadId': thread.threadId });"
+			//- )
+			//- 	| &nbsp;Delete
+
 
 
 script(type='text/ng-template', id='addCommentEntryTemplate')
 script(type='text/ng-template', id='addCommentEntryTemplate')
 	div
 	div
@@ -209,11 +283,50 @@ script(type='text/ng-template', id='addCommentEntryTemplate')
 						ng-model="state.content"
 						ng-model="state.content"
 						ng-keypress="handleCommentKeyPress($event);"
 						ng-keypress="handleCommentKeyPress($event);"
 						placeholder="Add your comment here"
 						placeholder="Add your comment here"
+						focus-on="comment:new:open"
 					)
 					)
 				.rp-entry-actions
 				.rp-entry-actions
-					a.rp-entry-button(href, ng-click="cancelNewComment();") 
+					button.rp-entry-button(
+						ng-click="cancelNewComment();"
+					) 
 						i.fa.fa-times
 						i.fa.fa-times
 						| &nbsp;Cancel
 						| &nbsp;Cancel
-					a.rp-entry-button(href, ng-click="submitNewComment()") 
+					button.rp-entry-button(
+						ng-click="submitNewComment()"
+						ng-disabled="!state.content.length"
+					) 
 						i.fa.fa-comment
 						i.fa.fa-comment
-						| &nbsp;Comment
+						| &nbsp;Comment
+
+script(type='text/ng-template', id='resolvedCommentsDropdownTemplate')
+	.resolved-comments
+		.resolved-comments-backdrop(
+			ng-class="{ 'resolved-comments-backdrop-visible' : state.isOpen }"
+			ng-click="state.isOpen = false"
+		)
+		a.resolved-comments-toggle(
+			href
+			ng-click="toggleOpenState();"
+			tooltip="Resolved Comments"
+			tooltip-placement="bottom"
+			tooltip-append-to-body="true"
+		)
+			i.fa.fa-inbox
+		.resolved-comments-dropdown(
+			ng-class="{ 'resolved-comments-dropdown-open' : state.isOpen }"
+		)
+			.rp-loading(ng-if="isLoading")
+				i.fa.fa-spinner.fa-spin
+			.resolved-comments-scroller(
+				ng-if="!isLoading"
+			)
+				resolved-comment-entry(
+					ng-repeat="thread in resolvedComments | orderBy:'resolved_at':true"
+					thread="thread"
+					on-unresolve="handleUnresolve(threadId);"
+					on-delete="handleDelete(entryId, threadId);"
+					permissions="permissions"
+				)
+				.rp-loading(ng-if="!resolvedComments.length")
+					| No resolved threads.
+			

+ 9 - 2
services/web/app/views/project/editor/share.jade

@@ -137,10 +137,17 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 					p.small(ng-show="startedFreeTrial")
 					p.small(ng-show="startedFreeTrial")
 						| #{translate("refresh_page_after_starting_free_trial")}.
 						| #{translate("refresh_page_after_starting_free_trial")}.
 
 
-	.modal-footer
+	.modal-footer.modal-footer-share
 		.modal-footer-left
 		.modal-footer-left
 			i.fa.fa-refresh.fa-spin(ng-show="state.inflight")
 			i.fa.fa-refresh.fa-spin(ng-show="state.inflight")
-			span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
+			span.text-danger.error(ng-show="state.error")
+				span(ng-switch="state.errorReason")
+					span(ng-switch-when="cannot_invite_non_user")
+						| #{translate("cannot_invite_non_user")}
+					span(ng-switch-when="cannot_invite_self")
+						| #{translate("cannot_invite_self")}
+					span(ng-switch-default)
+						| #{translate("generic_something_went_wrong")}
 		button.btn.btn-default(
 		button.btn.btn-default(
 			ng-click="done()"
 			ng-click="done()"
 		) #{translate("close")}
 		) #{translate("close")}

+ 39 - 5
services/web/app/views/project/list.jade

@@ -20,12 +20,46 @@ block content
 			}
 			}
 		};
 		};
 
 
-	.content.content-alt(ng-controller="ProjectPageController")
+	.content.content-alt.project-list-page(ng-controller="ProjectPageController")
 		.container
 		.container
-			
-			//- div(ng-controller="AnnouncementsController", ng-cloak)
-			//- 	.alert.alert-success(ng-show="dataRecived")
-			//- 		a(href, ng-click="openLink()") {{title}} and {{totalAnnouncements}} others
+			.announcements(
+				ng-controller="AnnouncementsController"
+				ng-class="{ 'announcements-open': ui.isOpen }"
+				ng-cloak
+			)
+				.announcements-backdrop(
+					ng-if="ui.isOpen"
+					ng-click="toggleAnnouncementsUI();"
+				)
+				a.announcements-btn(
+					href
+					ng-if="announcements.length"
+					ng-click="toggleAnnouncementsUI();"
+					ng-class="{ 'announcements-btn-open': ui.isOpen, 'announcements-btn-has-new': ui.newItems }"
+				)
+					span.announcements-badge(ng-if="ui.newItems") {{ ui.newItems }}
+				.announcements-body(
+					ng-if="ui.isOpen"
+				)
+					.announcements-scroller
+						.announcement(
+							ng-repeat="announcement in announcements | filter:(ui.newItems ? { read: false } : '') track by announcement.id"
+						)
+							h2.announcement-header {{ announcement.title }}
+							p.announcement-description(ng-bind-html="announcement.excerpt")
+							.announcement-meta
+								p.announcement-date {{ announcement.date | date:"longDate" }}
+								a.announcement-link(
+									ng-href="{{ announcement.url }}"
+									target="_blank"
+								) Read more
+						div.text-center(
+							ng-if="ui.newItems > 0 && ui.newItems < announcements.length"
+						)
+							a.btn.btn-default.btn-sm(
+								href
+								ng-click="showAll();"
+							) Show all
 
 
 			.row(ng-cloak)
 			.row(ng-cloak)
 				span(ng-if="projects.length > 0")
 				span(ng-if="projects.length > 0")

+ 1 - 1
services/web/app/views/sentry.jade

@@ -70,7 +70,7 @@
 			// whitelistUrls: ['example.com/scripts/']
 			// whitelistUrls: ['example.com/scripts/']
 			}).install();
 			}).install();
 		}
 		}
-	- if (typeof(user) != "undefined" && typeof (user.email) != "undefined")
+	- if (user && typeof(user) != "undefined" && typeof (user.email) != "undefined")
 		script(type="text/javascript").
 		script(type="text/javascript").
 			if (typeof(Raven) != "undefined" && Raven.setUserContext) {
 			if (typeof(Raven) != "undefined" && Raven.setUserContext) {
 				Raven.setUserContext({email: '#{user.email}'});
 				Raven.setUserContext({email: '#{user.email}'});

+ 4 - 4
services/web/app/views/subscriptions/plans.jade

@@ -100,7 +100,7 @@ block content
 												li
 												li
 													br
 													br
 													a.btn.btn-info(
 													a.btn.btn-info(
-														ng-href="#{baseUrl}/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}&currency={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
+														ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}&currency={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
 													)
 													)
 														span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
 														span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
 														span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
 														span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
@@ -124,7 +124,7 @@ block content
 												li
 												li
 													br
 													br
 													a.btn.btn-info(
 													a.btn.btn-info(
-														ng-href="#{baseUrl}/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}&currency={{currencyCode}}", ng-click="signUpNowClicked('professional')"
+														ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}&currency={{currencyCode}}", ng-click="signUpNowClicked('professional')"
 													)
 													)
 														span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
 														span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
 														span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
 														span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
@@ -166,7 +166,7 @@ block content
 												li
 												li
 													br
 													br
 													a.btn.btn-info(
 													a.btn.btn-info(
-														ng-href="#{baseUrl}/user/subscription/new?planCode=student{{ plansVariant == 'default' ? planQueryString : '_'+plansVariant }}&currency={{currencyCode}}",
+														ng-href="/user/subscription/new?planCode=student{{ plansVariant == 'default' ? planQueryString : '_'+plansVariant }}&currency={{currencyCode}}",
 														ng-click="signUpNowClicked('student')"
 														ng-click="signUpNowClicked('student')"
 													) #{translate("start_free_trial")}
 													) #{translate("start_free_trial")}
 
 
@@ -189,7 +189,7 @@ block content
 												li
 												li
 													br
 													br
 													a.btn.btn-info(
 													a.btn.btn-info(
-														ng-href="#{baseUrl}/user/subscription/new?planCode=student-annual{{ plansVariant == 'default' ? '' : '_'+plansVariant }}&currency={{currencyCode}}",
+														ng-href="/user/subscription/new?planCode=student-annual{{ plansVariant == 'default' ? '' : '_'+plansVariant }}&currency={{currencyCode}}",
 														ng-click="signUpNowClicked('student')"
 														ng-click="signUpNowClicked('student')"
 													) #{translate("buy_now")}
 													) #{translate("buy_now")}
 
 

+ 0 - 1
services/web/app/views/user/login.jade

@@ -10,7 +10,6 @@ block content
 							h1 #{translate("log_in")}
 							h1 #{translate("log_in")}
 						form(async-form="login", name="loginForm", action='/login', method="POST", ng-cloak)
 						form(async-form="login", name="loginForm", action='/login', method="POST", ng-cloak)
 							input(name='_csrf', type='hidden', value=csrfToken)
 							input(name='_csrf', type='hidden', value=csrfToken)
-							input(name='redir', type='hidden', value=redir)
 							form-messages(for="loginForm")
 							form-messages(for="loginForm")
 							.form-group
 							.form-group
 								input.form-control(
 								input.form-control(

+ 1 - 1
services/web/app/views/user/register.jade

@@ -12,7 +12,7 @@ block content
 							| #{translate("join_sl_to_view_project")}.
 							| #{translate("join_sl_to_view_project")}.
 						div
 						div
 							| #{translate("if_you_are_registered")},
 							| #{translate("if_you_are_registered")},
-							a(href="/login?redir=#{getReqQueryParam('redir')}")  #{translate("login_here")}
+							a(href="/login")  #{translate("login_here")}
 					else if newTemplateData.templateName !== undefined
 					else if newTemplateData.templateName !== undefined
 						h1 #{translate("register_to_edit_template", {templateName:newTemplateData.templateName})}
 						h1 #{translate("register_to_edit_template", {templateName:newTemplateData.templateName})}
 
 

+ 28 - 19
services/web/app/views/user/settings.jade

@@ -39,25 +39,34 @@ block content
 													label.control-label #{translate("email")}
 													label.control-label #{translate("email")}
 													div.form-control(readonly="true") #{user.email}
 													div.form-control(readonly="true") #{user.email}
 
 
-											.form-group
-												label(for='firstName').control-label #{translate("first_name")}
-												input.form-control(
-													type='text',
-													name='first_name',
-													value=user.first_name
-												)
-											.form-group
-												label(for='lastName').control-label #{translate("last_name")}
-												input.form-control(
-													type='text',
-													name='last_name',
-													value=user.last_name
-												)
-											.actions
-												button.btn.btn-primary(
-													type='submit',
-													ng-disabled="settingsForm.$invalid"
-												)  #{translate("update")}
+											if shouldAllowEditingDetails
+												.form-group
+													label(for='firstName').control-label #{translate("first_name")}
+													input.form-control(
+														type='text',
+														name='first_name',
+														value=user.first_name
+													)
+												.form-group
+													label(for='lastName').control-label #{translate("last_name")}
+													input.form-control(
+														type='text',
+														name='last_name',
+														value=user.last_name
+													)
+												.actions
+													button.btn.btn-primary(
+														type='submit',
+														ng-disabled="settingsForm.$invalid"
+													)  #{translate("update")}
+											else
+												.form-group
+													label.control-label #{translate("first_name")}
+													div.form-control(readonly="true") #{user.first_name}
+												.form-group
+													label.control-label #{translate("last_name")}
+													div.form-control(readonly="true") #{user.last_name}
+
 									if !externalAuthenticationSystemUsed()
 									if !externalAuthenticationSystemUsed()
 										.col-md-5.col-md-offset-1
 										.col-md-5.col-md-offset-1
 											h3  #{translate("change_password")}
 											h3  #{translate("change_password")}

+ 9 - 25
services/web/config/settings.defaults.coffee

@@ -276,6 +276,10 @@ module.exports = settings =
 	# Cookie max age (in milliseconds). Set to false for a browser session.
 	# Cookie max age (in milliseconds). Set to false for a browser session.
 	cookieSessionLength: 5 * 24 * 60 * 60 * 1000 # 5 days
 	cookieSessionLength: 5 * 24 * 60 * 60 * 1000 # 5 days
 
 
+	# When true, only allow invites to be sent to email addresses that
+	# already have user accounts
+	restrictInvitesToExistingAccounts: false
+
 	# Should we allow access to any page without logging in? This includes
 	# Should we allow access to any page without logging in? This includes
 	# public projects, /learn, /templates, about pages, etc.
 	# public projects, /learn, /templates, about pages, etc.
 	allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
 	allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
@@ -331,31 +335,11 @@ module.exports = settings =
 			url: "https://github.com/sharelatex/sharelatex"
 			url: "https://github.com/sharelatex/sharelatex"
 		}]
 		}]
 
 
-		header: [{
-			text: "Register"
-			url: "/register"
-			only_when_logged_out: true
-		}, {
-			text: "Log In"
-			url: "/login"
-			only_when_logged_out: true
-		}, {
-			text: "Projects"
-			url: "/project"
-			only_when_logged_in: true
-		}, {
-			text: "Account"
-			only_when_logged_in: true
-			dropdown: [{
-				text: "Account Settings"
-				url: "/user/settings"
-			}, {
-				divider: true
-			}, {
-				text: "Log out"
-				url: "/logout"
-			}]
-		}]
+		showSubscriptionLink: false
+
+		header_extras: []
+		# Example:
+		#   header_extras: [{text: "Some Page", url: "http://example.com/some/page", class: "subdued"}]
 
 
 	customisation: {}
 	customisation: {}
 
 

+ 11 - 0
services/web/public/coffee/directives/asyncForm.coffee

@@ -33,6 +33,11 @@ define [
 							response.success = true
 							response.success = true
 							response.error = false
 							response.error = false
 
 
+							onSuccessHandler = scope[attrs.onSuccess]
+							if onSuccessHandler
+								onSuccessHandler(data, status, headers, config)
+								return
+
 							if data.redir?
 							if data.redir?
 								ga('send', 'event', formName, 'success')
 								ga('send', 'event', formName, 'success')
 								window.location = data.redir
 								window.location = data.redir
@@ -50,6 +55,12 @@ define [
 							scope[attrs.name].inflight = false
 							scope[attrs.name].inflight = false
 							response.success = false
 							response.success = false
 							response.error = true
 							response.error = true
+
+							onErrorHandler = scope[attrs.onError]
+							if onErrorHandler
+								onErrorHandler(data, status, headers, config)
+								return
+
 							if status == 403 # Forbidden
 							if status == 403 # Forbidden
 								response.message =
 								response.message =
 									text: "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies."
 									text: "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies."

+ 2 - 4
services/web/public/coffee/ide.coffee

@@ -57,9 +57,6 @@ define [
 			else
 			else
 				this.$originalApply(fn);
 				this.$originalApply(fn);
 
 
-		if window.location.search.match /tcon=true/ # track changes on
-			$scope.trackChangesFeatureFlag = true
-	
 		$scope.state = {
 		$scope.state = {
 			loading: true
 			loading: true
 			load_progress: 40
 			load_progress: 40
@@ -70,7 +67,7 @@ define [
 			view: "editor"
 			view: "editor"
 			chatOpen: false
 			chatOpen: false
 			pdfLayout: 'sideBySide'
 			pdfLayout: 'sideBySide'
-			reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}") and $scope.trackChangesFeatureFlag
+			reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}")
 			showCodeCheckerOnboarding: !window.userSettings.syntaxValidation?
 			showCodeCheckerOnboarding: !window.userSettings.syntaxValidation?
 		}
 		}
 		$scope.user = window.user
 		$scope.user = window.user
@@ -86,6 +83,7 @@ define [
 
 
 		ide.toggleReviewPanel = $scope.toggleReviewPanel = () ->
 		ide.toggleReviewPanel = $scope.toggleReviewPanel = () ->
 			$scope.ui.reviewPanelOpen = !$scope.ui.reviewPanelOpen
 			$scope.ui.reviewPanelOpen = !$scope.ui.reviewPanelOpen
+			event_tracking.sendMB "rp-toggle-panel", { value : $scope.ui.reviewPanelOpen }
 
 
 		$scope.$watch "ui.reviewPanelOpen", (value) ->
 		$scope.$watch "ui.reviewPanelOpen", (value) ->
 			if value?
 			if value?

+ 8 - 2
services/web/public/coffee/ide/chat/services/chatMessages.coffee

@@ -1,5 +1,6 @@
 define [
 define [
 	"base"
 	"base"
+	"libs/md5"
 ], (App) ->
 ], (App) ->
 	App.factory "chatMessages", ($http, ide) ->
 	App.factory "chatMessages", ($http, ide) ->
 		MESSAGES_URL = "/project/#{ide.project_id}/messages"
 		MESSAGES_URL = "/project/#{ide.project_id}/messages"
@@ -72,7 +73,7 @@ define [
 				firstMessage.contents.unshift message.content
 				firstMessage.contents.unshift message.content
 			else
 			else
 				chat.state.messages.unshift({
 				chat.state.messages.unshift({
-					user: message.user
+					user: formatUser(message.user)
 					timestamp: message.timestamp
 					timestamp: message.timestamp
 					contents: [message.content]
 					contents: [message.content]
 				})
 				})
@@ -93,9 +94,14 @@ define [
 				lastMessage.contents.push message.content
 				lastMessage.contents.push message.content
 			else
 			else
 				chat.state.messages.push({
 				chat.state.messages.push({
-					user: message.user
+					user: formatUser(message.user)
 					timestamp: message.timestamp
 					timestamp: message.timestamp
 					contents: [message.content]
 					contents: [message.content]
 				})
 				})
+				
+		formatUser = (user) ->
+			hash = CryptoJS.MD5(user.email.toLowerCase())
+			user.gravatar_url = "//www.gravatar.com/avatar/#{hash}"
+			return user
 
 
 		return chat
 		return chat

+ 5 - 0
services/web/public/coffee/ide/directives/layout.coffee

@@ -117,5 +117,10 @@ define [
 								element.layout().hide("east")
 								element.layout().hide("east")
 							else
 							else
 								element.layout().show("east")
 								element.layout().show("east")
+
+				post: (scope, element, attrs) ->
+					name = attrs.layout
+					state = element.layout().readState()
+					scope.$broadcast "layout:#{name}:linked", state
 		}
 		}
 	]
 	]

+ 49 - 3
services/web/public/coffee/ide/editor/Document.coffee

@@ -1,7 +1,8 @@
 define [
 define [
 	"utils/EventEmitter"
 	"utils/EventEmitter"
 	"ide/editor/ShareJsDoc"
 	"ide/editor/ShareJsDoc"
-], (EventEmitter, ShareJsDoc) ->
+	"ide/review-panel/RangesTracker"
+], (EventEmitter, ShareJsDoc, RangesTracker) ->
 	class Document extends EventEmitter
 	class Document extends EventEmitter
 		@getDocument: (ide, doc_id) ->
 		@getDocument: (ide, doc_id) ->
 			@openDocs ||= {}
 			@openDocs ||= {}
@@ -40,6 +41,8 @@ define [
 			editorDoc = @ace?.getSession().getDocument()
 			editorDoc = @ace?.getSession().getDocument()
 			editorDoc?.off "change", @_checkConsistency
 			editorDoc?.off "change", @_checkConsistency
 			@ide.$scope.$emit 'document:closed', @doc
 			@ide.$scope.$emit 'document:closed', @doc
+		
+		submitOp: (args...) -> @doc?.submitOp(args...)
 
 
 		_checkConsistency: () ->
 		_checkConsistency: () ->
 			# We've been seeing a lot of errors when I think there shouldn't be
 			# We've been seeing a lot of errors when I think there shouldn't be
@@ -77,6 +80,12 @@ define [
 
 
 		hasBufferedOps: () ->
 		hasBufferedOps: () ->
 			@doc?.hasBufferedOps()
 			@doc?.hasBufferedOps()
+		
+		setTrackingChanges: (track_changes) ->
+			@doc.track_changes = track_changes
+		
+		setTrackChangesIdSeeds: (id_seeds) ->
+			@doc.track_changes_id_seeds = id_seeds
 
 
 		_bindToSocketEvents: () ->
 		_bindToSocketEvents: () ->
 			@_onUpdateAppliedHandler = (update) => @_onUpdateApplied(update)
 			@_onUpdateAppliedHandler = (update) => @_onUpdateApplied(update)
@@ -239,16 +248,18 @@ define [
 
 
 		_joinDoc: (callback = (error) ->) ->
 		_joinDoc: (callback = (error) ->) ->
 			if @doc?
 			if @doc?
-				@ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), (error, docLines, version, updates) =>
+				@ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), (error, docLines, version, updates, ranges) =>
 					return callback(error) if error?
 					return callback(error) if error?
 					@joined = true
 					@joined = true
 					@doc.catchUp( updates )
 					@doc.catchUp( updates )
+					@_catchUpRanges( ranges?.changes, ranges?.comments )
 					callback()
 					callback()
 			else
 			else
-				@ide.socket.emit 'joinDoc', @doc_id, (error, docLines, version) =>
+				@ide.socket.emit 'joinDoc', @doc_id, (error, docLines, version, updates, ranges) =>
 					return callback(error) if error?
 					return callback(error) if error?
 					@joined = true
 					@joined = true
 					@doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
 					@doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
+					@ranges = new RangesTracker(ranges?.changes, ranges?.comments)
 					@_bindToShareJsDocEvents()
 					@_bindToShareJsDocEvents()
 					callback()
 					callback()
 
 
@@ -307,6 +318,10 @@ define [
 					inflightOp: inflightOp,
 					inflightOp: inflightOp,
 					pendingOp: pendingOp
 					pendingOp: pendingOp
 					v: version
 					v: version
+			@doc.on "change", (ops, oldSnapshot, msg) =>
+				@_applyOpsToRanges(ops, oldSnapshot, msg)
+			@doc.on "flipped_pending_to_inflight", () =>
+				@trigger "flipped_pending_to_inflight"
 
 
 		_onError: (error, meta = {}) ->
 		_onError: (error, meta = {}) ->
 			meta.doc_id = @doc_id
 			meta.doc_id = @doc_id
@@ -319,3 +334,34 @@ define [
 			# the disconnect event, which means we try to leaveDoc when the connection comes back.
 			# the disconnect event, which means we try to leaveDoc when the connection comes back.
 			# This could intefere with the new connection of a new instance of this document.
 			# This could intefere with the new connection of a new instance of this document.
 			@_cleanUp()
 			@_cleanUp()
+
+		_applyOpsToRanges: (ops = [], oldSnapshot, msg) ->
+			track_changes_as = null
+			remote_op = msg?
+			if msg?.meta?.tc?
+				old_id_seed = @ranges.getIdSeed()
+				@ranges.setIdSeed(msg.meta.tc)
+			if remote_op and msg.meta?.tc
+				track_changes_as = msg.meta.user_id
+			else if !remote_op and @track_changes_as?
+				track_changes_as = @track_changes_as
+			@ranges.track_changes = track_changes_as?
+			for op in ops
+				@ranges.applyOp op, { user_id: track_changes_as }
+			if old_id_seed?
+				@ranges.setIdSeed(old_id_seed)
+		
+		_catchUpRanges: (changes = [], comments = []) ->
+			# We've just been given the current server's ranges, but need to apply any local ops we have.
+			# Reset to the server state then apply our local ops again.
+			@ranges.emit "clear"
+			@ranges.changes = changes
+			@ranges.comments = comments
+			@ranges.track_changes = @doc.track_changes
+			for op in @doc.getInflightOp() or []
+				@ranges.setIdSeed(@doc.track_changes_id_seeds.inflight)
+				@ranges.applyOp(op, { user_id: @track_changes_as })
+			for op in @doc.getPendingOp() or []
+				@ranges.setIdSeed(@doc.track_changes_id_seeds.pending)
+				@ranges.applyOp(op, { user_id: @track_changes_as })
+			@ranges.emit "redraw"

+ 30 - 0
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -10,6 +10,8 @@ define [
 				open_doc_id: null
 				open_doc_id: null
 				open_doc_name: null
 				open_doc_name: null
 				opening: true
 				opening: true
+				trackChanges: false
+				wantTrackChanges: window.trackChangesEnabled
 			}
 			}
 
 
 			@$scope.$on "entity:selected", (event, entity) =>
 			@$scope.$on "entity:selected", (event, entity) =>
@@ -31,6 +33,10 @@ define [
 
 
 			@$scope.$on "flush-changes", () =>
 			@$scope.$on "flush-changes", () =>
 				Document.flushAll()
 				Document.flushAll()
+			
+			@$scope.$watch "editor.wantTrackChanges", (value) =>
+				return if !value?
+				@_syncTrackChangesState(@$scope.editor.sharejs_doc)
 
 
 		autoOpenDoc: () ->
 		autoOpenDoc: () ->
 			open_doc_id = 
 			open_doc_id = 
@@ -83,6 +89,8 @@ define [
 						"Sorry, something went wrong opening this document. Please try again."
 						"Sorry, something went wrong opening this document. Please try again."
 					)
 					)
 					return
 					return
+				
+				@_syncTrackChangesState(sharejs_doc)
 
 
 				@$scope.$broadcast "doc:opened"
 				@$scope.$broadcast "doc:opened"
 
 
@@ -144,3 +152,25 @@ define [
 			
 			
 		stopIgnoringExternalUpdates: () ->
 		stopIgnoringExternalUpdates: () ->
 			@_ignoreExternalUpdates = false
 			@_ignoreExternalUpdates = false
+		
+		_syncTimeout: null
+		_syncTrackChangesState: (doc) ->
+			return if !doc?
+
+			if @_syncTimeout?
+				clearTimeout @_syncTimeout
+				@_syncTimeout = null
+
+			want = @$scope.editor.wantTrackChanges
+			have = @$scope.editor.trackChanges
+			if want == have
+				return
+
+			do tryToggle = () =>
+				saved = !doc.getInflightOp()? and !doc.getPendingOp()?
+				if saved
+					doc.setTrackingChanges(want)
+					@$scope.$apply () =>
+						@$scope.editor.trackChanges = want
+				else
+					@_syncTimeout = setTimeout tryToggle, 100

+ 12 - 18
services/web/public/coffee/ide/editor/ShareJsDoc.coffee

@@ -9,21 +9,9 @@ define [
 			# Dencode any binary bits of data
 			# Dencode any binary bits of data
 			# See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
 			# See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
 			@type = "text"
 			@type = "text"
-			docLines = for line in docLines
-				if line.text?
-					@type = "json"
-					line.text = decodeURIComponent(escape(line.text))
-				else
-					@type = "text"
-					line = decodeURIComponent(escape(line))
-				line
-
-			if @type == "text"
-				snapshot = docLines.join("\n")
-			else if @type == "json"
-				snapshot = { lines: docLines }
-			else
-				throw new Error("Unknown type: #{@type}")
+			docLines = (decodeURIComponent(escape(line)) for line in docLines)
+			snapshot = docLines.join("\n")
+			@track_changes = false
 
 
 			@connection = {
 			@connection = {
 				send: (update) =>
 				send: (update) =>
@@ -34,6 +22,9 @@ define [
 					if window.dropUpdates? and Math.random() < window.dropUpdates
 					if window.dropUpdates? and Math.random() < window.dropUpdates
 						sl_console.log "Simulating a lost update", update
 						sl_console.log "Simulating a lost update", update
 						return
 						return
+					if @track_changes
+						update.meta ?= {}
+						update.meta.tc = @track_changes_id_seeds.inflight
 					@socket.emit "applyOtUpdate", @doc_id, update, (error) =>
 					@socket.emit "applyOtUpdate", @doc_id, update, (error) =>
 						return @_handleError(error) if error?
 						return @_handleError(error) if error?
 				state: "ok"
 				state: "ok"
@@ -43,8 +34,8 @@ define [
 			@_doc = new ShareJs.Doc @connection, @doc_id,
 			@_doc = new ShareJs.Doc @connection, @doc_id,
 				type: @type
 				type: @type
 			@_doc.setFlushDelay(SINGLE_USER_FLUSH_DELAY)
 			@_doc.setFlushDelay(SINGLE_USER_FLUSH_DELAY)
-			@_doc.on "change", () =>
-				@trigger "change"
+			@_doc.on "change", (args...) =>
+				@trigger "change", args...
 			@_doc.on "acknowledge", () =>
 			@_doc.on "acknowledge", () =>
 				@lastAcked = new Date() # note time of last ack from server for an op we sent
 				@lastAcked = new Date() # note time of last ack from server for an op we sent
 				@trigger "acknowledge"
 				@trigger "acknowledge"
@@ -53,6 +44,8 @@ define [
 				# ops as quickly as possible for low latency.
 				# ops as quickly as possible for low latency.
 				@_doc.setFlushDelay(0)
 				@_doc.setFlushDelay(0)
 				@trigger "remoteop", args...
 				@trigger "remoteop", args...
+			@_doc.on "flipped_pending_to_inflight", () =>
+				@trigger "flipped_pending_to_inflight"
 			@_doc.on "error", (e) =>
 			@_doc.on "error", (e) =>
 				@_handleError(e)
 				@_handleError(e)
 
 
@@ -70,6 +63,7 @@ define [
 				@_doc._onMessage message
 				@_doc._onMessage message
 			catch error
 			catch error
 				# Version mismatches are thrown as errors
 				# Version mismatches are thrown as errors
+				console.log error
 				@_handleError(error)
 				@_handleError(error)
 
 
 			if message?.meta?.type == "external"
 			if message?.meta?.type == "external"
@@ -125,7 +119,7 @@ define [
 
 
 		attachToAce: (ace) -> @_doc.attach_ace(ace, false, window.maxDocLength)
 		attachToAce: (ace) -> @_doc.attach_ace(ace, false, window.maxDocLength)
 		detachFromAce: () -> @_doc.detach_ace?()
 		detachFromAce: () -> @_doc.detach_ace?()
-	
+
 		INFLIGHT_OP_TIMEOUT: 5000 # Retry sending ops after 5 seconds without an ack
 		INFLIGHT_OP_TIMEOUT: 5000 # Retry sending ops after 5 seconds without an ack
 		WAIT_FOR_CONNECTION_TIMEOUT: 500 # If we're waiting for the project to join, try again in 0.5 seconds
 		WAIT_FOR_CONNECTION_TIMEOUT: 500 # If we're waiting for the project to join, try again in 0.5 seconds
 		_startInflightOpTimeout: (update) ->
 		_startInflightOpTimeout: (update) ->

+ 14 - 2
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -54,10 +54,10 @@ define [
 				syntaxValidation: "="
 				syntaxValidation: "="
 				reviewPanel: "="
 				reviewPanel: "="
 				eventsBridge: "="
 				eventsBridge: "="
-				trackNewChanges: "="
+				trackChanges: "="
 				trackChangesEnabled: "="
 				trackChangesEnabled: "="
-				changesTracker: "="
 				docId: "="
 				docId: "="
+				rendererData: "="
 			}
 			}
 			link: (scope, element, attrs) ->
 			link: (scope, element, attrs) ->
 				# Don't freak out if we're already in an apply callback
 				# Don't freak out if we're already in an apply callback
@@ -270,6 +270,10 @@ define [
 					catch
 					catch
 						mode = "ace/mode/plain_text"
 						mode = "ace/mode/plain_text"
 
 
+					# Give beta users the next release of the syntax checker
+					if mode is "ace/mode/latex" and window.user?.betaProgram
+						mode = "ace/mode/latex_beta"
+
 					# create our new session
 					# create our new session
 					session = new EditSession(lines, mode)
 					session = new EditSession(lines, mode)
 
 
@@ -314,6 +318,14 @@ define [
 					
 					
 					doc = session.getDocument()
 					doc = session.getDocument()
 					doc.off "change", onChange
 					doc.off "change", onChange
+				
+				editor.renderer.on "changeCharacterSize", () ->
+					scope.$apply () ->
+						scope.rendererData.lineHeight = editor.renderer.lineHeight
+				
+				scope.$watch "rendererData", (rendererData) ->
+					if rendererData?
+						rendererData.lineHeight = editor.renderer.lineHeight
 
 
 			template: """
 			template: """
 				<div class="ace-editor-wrapper">
 				<div class="ace-editor-wrapper">

+ 102 - 126
services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee

@@ -10,18 +10,18 @@ define [
 		constructor: (@$scope, @editor, @element) ->
 		constructor: (@$scope, @editor, @element) ->
 			window.trackChangesManager ?= @
 			window.trackChangesManager ?= @
 
 
-			@$scope.$watch "changesTracker", (changesTracker) =>
-				return if !changesTracker?
-				@disconnectFromChangesTracker()
-				@changesTracker = changesTracker
-				@connectToChangesTracker()
-
-			@$scope.$watch "trackNewChanges", (track_new_changes) =>
-				return if !track_new_changes?
-				@changesTracker?.track_changes = track_new_changes
+			@$scope.$watch "trackChanges", (track_changes) =>
+				return if !track_changes?
+				@setTrackChanges(track_changes)
+			
+			@$scope.$watch "sharejsDoc", (doc) =>
+				return if !doc?
+				@disconnectFromRangesTracker()
+				@rangesTracker = doc.ranges
+				@connectToRangesTracker()
 			
 			
-			@$scope.$on "comment:add", (e, comment) =>
-				@addCommentToSelection(comment)
+			@$scope.$on "comment:add", (e, thread_id) =>
+				@addCommentToSelection(thread_id)
 
 
 			@$scope.$on "comment:select_line", (e) =>
 			@$scope.$on "comment:select_line", (e) =>
 				@selectLineIfNoSelection()
 				@selectLineIfNoSelection()
@@ -35,11 +35,11 @@ define [
 			@$scope.$on "comment:remove", (e, comment_id) =>
 			@$scope.$on "comment:remove", (e, comment_id) =>
 				@removeCommentId(comment_id)
 				@removeCommentId(comment_id)
 			
 			
-			@$scope.$on "comment:resolve", (e, comment_id, user_id) =>
-				@resolveCommentId(comment_id, user_id)
+			@$scope.$on "comment:resolve_thread", (e, thread_id) =>
+				@resolveCommentByThreadId(thread_id)
 			
 			
-			@$scope.$on "comment:unresolve", (e, comment_id) =>
-				@unresolveCommentId(comment_id)
+			@$scope.$on "comment:unresolve_thread", (e, thread_id) =>
+				@unresolveCommentByThreadId(thread_id)
 			
 			
 			@$scope.$on "review-panel:recalculate-screen-positions", () =>
 			@$scope.$on "review-panel:recalculate-screen-positions", () =>
 				@recalculateReviewEntriesScreenPositions()
 				@recalculateReviewEntriesScreenPositions()
@@ -58,48 +58,16 @@ define [
 			onResize = () =>
 			onResize = () =>
 				@recalculateReviewEntriesScreenPositions()
 				@recalculateReviewEntriesScreenPositions()
 
 
-			onChange = (e) =>
-				if !@editor.initing
-					# This change is trigger by a sharejs 'change' event, which is before the
-					# sharejs 'remoteop' event. So wait until the next event loop when the 'remoteop'
-					# will have fired, before we decide if it was a remote op.
-					setTimeout () =>
-						if @nextUpdateMetaData?
-							user_id = @nextUpdateMetaData.user_id
-							# The remote op may have contained multiple atomic ops, each of which is an Ace
-							# 'change' event (i.e. bulk commenting out of lines is a single remote op
-							# but gives us one event for each % inserted). These all come in a single event loop
-							# though, so wait until the next one before clearing the metadata.
-							setTimeout () =>
-								@nextUpdateMetaData = null
-						else
-							user_id = window.user.id
-						
-						was_tracking = @changesTracker.track_changes
-						if @dont_track_next_update
-							@changesTracker.track_changes = false
-							@dont_track_next_update = false
-						@applyChange(e, { user_id })
-						@changesTracker.track_changes = was_tracking
-						
-						# TODO: Just for debugging, remove before going live.
-						setTimeout () =>
-							@checkMapping()
-						, 100
-
 			onChangeSession = (e) =>
 			onChangeSession = (e) =>
-				e.oldSession?.getDocument().off "change", onChange
-				e.session.getDocument().on "change", onChange
+				@clearAnnotations()
 				@redrawAnnotations()
 				@redrawAnnotations()
 
 
 			bindToAce = () =>
 			bindToAce = () =>
-				@editor.getSession().getDocument().on "change", onChange
 				@editor.on "changeSelection", onChangeSelection
 				@editor.on "changeSelection", onChangeSelection
 				@editor.on "changeSession", onChangeSession
 				@editor.on "changeSession", onChangeSession
 				@editor.renderer.on "resize", onResize
 				@editor.renderer.on "resize", onResize
 
 
 			unbindFromAce = () =>
 			unbindFromAce = () =>
-				@editor.getSession().getDocument().off "change", onChange
 				@editor.off "changeSelection", onChangeSelection
 				@editor.off "changeSelection", onChangeSelection
 				@editor.off "changeSession", onChangeSession
 				@editor.off "changeSession", onChangeSession
 				@editor.renderer.off "resize", onResize
 				@editor.renderer.off "resize", onResize
@@ -111,94 +79,99 @@ define [
 				else
 				else
 					unbindFromAce()
 					unbindFromAce()
 		
 		
-		disconnectFromChangesTracker: () ->
+		disconnectFromRangesTracker: () ->
 			@changeIdToMarkerIdMap = {}
 			@changeIdToMarkerIdMap = {}
 
 
-			if @changesTracker?
-				@changesTracker.off "insert:added"
-				@changesTracker.off "insert:removed"
-				@changesTracker.off "delete:added"
-				@changesTracker.off "delete:removed"
-				@changesTracker.off "changes:moved"
-				@changesTracker.off "comment:added"
-				@changesTracker.off "comment:moved"
-				@changesTracker.off "comment:removed"
-				@changesTracker.off "comment:resolved"
-				@changesTracker.off "comment:unresolved"
+			if @rangesTracker?
+				@rangesTracker.off "insert:added"
+				@rangesTracker.off "insert:removed"
+				@rangesTracker.off "delete:added"
+				@rangesTracker.off "delete:removed"
+				@rangesTracker.off "changes:moved"
+				@rangesTracker.off "comment:added"
+				@rangesTracker.off "comment:moved"
+				@rangesTracker.off "comment:removed"
+
+		setTrackChanges: (value) ->
+			if value
+				@$scope.sharejsDoc?.track_changes_as = window.user.id or "anonymous"
+			else
+				@$scope.sharejsDoc?.track_changes_as = null
 		
 		
-		connectToChangesTracker: () ->
-			@changesTracker.track_changes = @$scope.trackNewChanges
+		connectToRangesTracker: () ->
+			@setTrackChanges(@$scope.trackChanges)
 			
 			
-			@changesTracker.on "insert:added", (change) =>
+			# Add a timeout because on remote ops, we get these notifications before
+			# ace has updated
+			@rangesTracker.on "insert:added", (change) =>
 				sl_console.log "[insert:added]", change
 				sl_console.log "[insert:added]", change
-				@_onInsertAdded(change)
-			@changesTracker.on "insert:removed", (change) =>
+				setTimeout () => @_onInsertAdded(change)
+			@rangesTracker.on "insert:removed", (change) =>
 				sl_console.log "[insert:removed]", change
 				sl_console.log "[insert:removed]", change
-				@_onInsertRemoved(change)
-			@changesTracker.on "delete:added", (change) =>
+				setTimeout () => @_onInsertRemoved(change)
+			@rangesTracker.on "delete:added", (change) =>
 				sl_console.log "[delete:added]", change
 				sl_console.log "[delete:added]", change
-				@_onDeleteAdded(change)
-			@changesTracker.on "delete:removed", (change) =>
+				setTimeout () => @_onDeleteAdded(change)
+			@rangesTracker.on "delete:removed", (change) =>
 				sl_console.log "[delete:removed]", change
 				sl_console.log "[delete:removed]", change
-				@_onDeleteRemoved(change)
-			@changesTracker.on "changes:moved", (changes) =>
+				setTimeout () => @_onDeleteRemoved(change)
+			@rangesTracker.on "changes:moved", (changes) =>
 				sl_console.log "[changes:moved]", changes
 				sl_console.log "[changes:moved]", changes
-				@_onChangesMoved(changes)
+				setTimeout () => @_onChangesMoved(changes)
 
 
-			@changesTracker.on "comment:added", (comment) =>
+			@rangesTracker.on "comment:added", (comment) =>
 				sl_console.log "[comment:added]", comment
 				sl_console.log "[comment:added]", comment
-				@_onCommentAdded(comment)
-			@changesTracker.on "comment:moved", (comment) =>
+				setTimeout () => @_onCommentAdded(comment)
+			@rangesTracker.on "comment:moved", (comment) =>
 				sl_console.log "[comment:moved]", comment
 				sl_console.log "[comment:moved]", comment
-				@_onCommentMoved(comment)
-			@changesTracker.on "comment:removed", (comment) =>
+				setTimeout () => @_onCommentMoved(comment)
+			@rangesTracker.on "comment:removed", (comment) =>
 				sl_console.log "[comment:removed]", comment
 				sl_console.log "[comment:removed]", comment
-				@_onCommentRemoved(comment)
-			@changesTracker.on "comment:resolved", (comment) =>
-				sl_console.log "[comment:resolved]", comment
-				@_onCommentRemoved(comment)
-			@changesTracker.on "comment:unresolved", (comment) =>
-				sl_console.log "[comment:unresolved]", comment
-				@_onCommentAdded(comment)
+				setTimeout () => @_onCommentRemoved(comment)
 			
 			
+			@rangesTracker.on "clear", () =>
+				@clearAnnotations()
+			@rangesTracker.on "redraw", () =>
+				@redrawAnnotations()
+		
+		clearAnnotations: () ->
+			session = @editor.getSession()
+			for change_id, markers of @changeIdToMarkerIdMap
+				for marker_name, marker_id of markers
+					session.removeMarker marker_id
+			@changeIdToMarkerIdMap = {}
+
 		redrawAnnotations: () ->
 		redrawAnnotations: () ->
-			for change in @changesTracker.changes
+			for change in @rangesTracker.changes
 				if change.op.i?
 				if change.op.i?
 					@_onInsertAdded(change)
 					@_onInsertAdded(change)
 				else if change.op.d?
 				else if change.op.d?
 					@_onDeleteAdded(change)
 					@_onDeleteAdded(change)
 
 
-			for comment in @changesTracker.comments
+			for comment in @rangesTracker.comments
 				@_onCommentAdded(comment)
 				@_onCommentAdded(comment)
 
 
-		addComment: (offset, length, content) ->
-			@changesTracker.addComment offset, length, {
-				thread: [{
-					content: content
-					user_id: window.user_id
-					ts: new Date()
-				}]
-			}
+		addComment: (offset, content, thread_id) ->
+			op = { c: content, p: offset, t: thread_id }
+			# @rangesTracker.applyOp op # Will apply via sharejs
+			@$scope.sharejsDoc.submitOp op
 		
 		
-		addCommentToSelection: (content) ->
+		addCommentToSelection: (thread_id) ->
 			range = @editor.getSelectionRange()
 			range = @editor.getSelectionRange()
+			content = @editor.getSelectedText()
 			offset = @_aceRangeToShareJs(range.start)
 			offset = @_aceRangeToShareJs(range.start)
-			end = @_aceRangeToShareJs(range.end)
-			length = end - offset
-			@addComment(offset, length, content)
+			@addComment(offset, content, thread_id)
 		
 		
 		selectLineIfNoSelection: () ->
 		selectLineIfNoSelection: () ->
 			if @editor.selection.isEmpty()
 			if @editor.selection.isEmpty()
 				@editor.selection.selectLine()
 				@editor.selection.selectLine()
 		
 		
 		acceptChangeId: (change_id) ->
 		acceptChangeId: (change_id) ->
-			@changesTracker.removeChangeId(change_id)
+			@rangesTracker.removeChangeId(change_id)
 		
 		
 		rejectChangeId: (change_id) ->
 		rejectChangeId: (change_id) ->
-			change = @changesTracker.getChange(change_id)
+			change = @rangesTracker.getChange(change_id)
 			return if !change?
 			return if !change?
-			@changesTracker.removeChangeId(change_id)
-			@dont_track_next_update = true
 			session = @editor.getSession()
 			session = @editor.getSession()
 			if change.op.d?
 			if change.op.d?
 				content = change.op.d
 				content = change.op.d
@@ -215,17 +188,20 @@ define [
 				throw new Error("unknown change: #{JSON.stringify(change)}")
 				throw new Error("unknown change: #{JSON.stringify(change)}")
 
 
 		removeCommentId: (comment_id) ->
 		removeCommentId: (comment_id) ->
-			@changesTracker.removeCommentId(comment_id)
+			@rangesTracker.removeCommentId(comment_id)
 
 
-		resolveCommentId: (comment_id, user_id) ->
-			@changesTracker.resolveCommentId(comment_id, {
-				user_id, ts: new Date()
-			})
+		resolveCommentByThreadId: (thread_id) ->
+			for comment in @rangesTracker?.comments or []
+				if comment.op.t == thread_id
+					@_onCommentRemoved(comment)
 			
 			
-		unresolveCommentId: (comment_id) ->
-			@changesTracker.unresolveCommentId(comment_id)
+		unresolveCommentByThreadId: (thread_id) ->
+			for comment in @rangesTracker?.comments or []
+				if comment.op.t == thread_id
+					@_onCommentAdded(comment)
 
 
 		checkMapping: () ->
 		checkMapping: () ->
+			# TODO: reintroduce this check
 			session = @editor.getSession()
 			session = @editor.getSession()
 
 
 			# Make a copy of session.getMarkers() so we can modify it
 			# Make a copy of session.getMarkers() so we can modify it
@@ -234,7 +210,7 @@ define [
 				markers[marker_id] = marker
 				markers[marker_id] = marker
 
 
 			expected_markers = []
 			expected_markers = []
-			for change in @changesTracker.changes
+			for change in @rangesTracker.changes
 				if @changeIdToMarkerIdMap[change.id]?
 				if @changeIdToMarkerIdMap[change.id]?
 					op = change.op
 					op = change.op
 					{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
 					{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
@@ -246,11 +222,11 @@ define [
 					expected_markers.push { marker_id: background_marker_id, start, end }
 					expected_markers.push { marker_id: background_marker_id, start, end }
 					expected_markers.push { marker_id: callout_marker_id, start, end: start }
 					expected_markers.push { marker_id: callout_marker_id, start, end: start }
 			
 			
-			for comment in @changesTracker.comments
+			for comment in @rangesTracker.comments
 				if @changeIdToMarkerIdMap[comment.id]?
 				if @changeIdToMarkerIdMap[comment.id]?
 					{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
 					{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
-					start = @_shareJsOffsetToAcePosition(comment.offset)
-					end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
+					start = @_shareJsOffsetToAcePosition(comment.op.p)
+					end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
 					expected_markers.push { marker_id: background_marker_id, start, end }
 					expected_markers.push { marker_id: background_marker_id, start, end }
 					expected_markers.push { marker_id: callout_marker_id, start, end: start }
 					expected_markers.push { marker_id: callout_marker_id, start, end: start }
 			
 			
@@ -267,16 +243,13 @@ define [
 				if marker.clazz.match("track-changes")
 				if marker.clazz.match("track-changes")
 					console.error "Orphaned ace marker", marker
 					console.error "Orphaned ace marker", marker
 		
 		
-		applyChange: (delta, metadata) ->
-			op = @_aceChangeToShareJs(delta)
-			@changesTracker.applyOp(op, metadata)
-		
 		updateFocus: () ->
 		updateFocus: () ->
 			selection = @editor.getSelectionRange()
 			selection = @editor.getSelectionRange()
-			cursor_offset = @_aceRangeToShareJs(selection.start)
+			selection_start = @_aceRangeToShareJs(selection.start)
+			selection_end = @_aceRangeToShareJs(selection.end)
 			entries = @_getCurrentDocEntries()
 			entries = @_getCurrentDocEntries()
-			selection = !(selection.start.column == selection.end.column and selection.start.row == selection.end.row)
-			@$scope.$emit "editor:focus:changed", cursor_offset, selection
+			is_selection = (selection_start != selection_end)
+			@$scope.$emit "editor:focus:changed", selection_start, selection_end, is_selection
 		
 		
 		broadcastChange: () ->
 		broadcastChange: () ->
 			@$scope.$emit "editor:track-changes:changed", @$scope.docId
 			@$scope.$emit "editor:track-changes:changed", @$scope.docId
@@ -364,10 +337,13 @@ define [
 			@broadcastChange()
 			@broadcastChange()
 		
 		
 		_onCommentAdded: (comment) ->
 		_onCommentAdded: (comment) ->
+			if @rangesTracker.resolvedThreadIds[comment.op.t]
+				# Comment is resolved so shouldn't be displayed.
+				return
 			if !@changeIdToMarkerIdMap[comment.id]?
 			if !@changeIdToMarkerIdMap[comment.id]?
 				# Only create new markers if they don't already exist
 				# Only create new markers if they don't already exist
-				start = @_shareJsOffsetToAcePosition(comment.offset)
-				end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
+				start = @_shareJsOffsetToAcePosition(comment.op.p)
+				end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
 				session = @editor.getSession()
 				session = @editor.getSession()
 				doc = session.getDocument()
 				doc = session.getDocument()
 				background_range = new Range(start.row, start.column, end.row, end.column)
 				background_range = new Range(start.row, start.column, end.row, end.column)
@@ -412,8 +388,8 @@ define [
 			@broadcastChange()
 			@broadcastChange()
 		
 		
 		_onCommentMoved: (comment) ->
 		_onCommentMoved: (comment) ->
-			start = @_shareJsOffsetToAcePosition(comment.offset)
-			end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
+			start = @_shareJsOffsetToAcePosition(comment.op.p)
+			end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
 			@_updateMarker(comment.id, start, end)
 			@_updateMarker(comment.id, start, end)
 			@editor.renderer.updateBackMarkers()
 			@editor.renderer.updateBackMarkers()
 			@broadcastChange()
 			@broadcastChange()
@@ -423,11 +399,11 @@ define [
 			session = @editor.getSession()
 			session = @editor.getSession()
 			markers = session.getMarkers()
 			markers = session.getMarkers()
 			{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change_id]
 			{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change_id]
-			if background_marker_id?
+			if background_marker_id? and markers[background_marker_id]?
 				background_marker = markers[background_marker_id]
 				background_marker = markers[background_marker_id]
 				background_marker.range.start = start
 				background_marker.range.start = start
 				background_marker.range.end = end
 				background_marker.range.end = end
-			if callout_marker_id?
+			if callout_marker_id? and markers[callout_marker_id]?
 				callout_marker = markers[callout_marker_id]
 				callout_marker = markers[callout_marker_id]
 				callout_marker.range.start = start
 				callout_marker.range.start = start
 				callout_marker.range.end = start
 				callout_marker.range.end = start

+ 5 - 2
services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee

@@ -71,7 +71,7 @@ class Doc
     # Its important that these event handlers are called with oldSnapshot.
     # Its important that these event handlers are called with oldSnapshot.
     # The reason is that the OT type APIs might need to access the snapshots to
     # The reason is that the OT type APIs might need to access the snapshots to
     # determine information about the received op.
     # determine information about the received op.
-    @emit 'change', docOp, oldSnapshot
+    @emit 'change', docOp, oldSnapshot, msg
     @emit 'remoteop', docOp, oldSnapshot, msg if isRemote
     @emit 'remoteop', docOp, oldSnapshot, msg if isRemote
   
   
   _connectionStateChanged: (state, data) ->
   _connectionStateChanged: (state, data) ->
@@ -266,6 +266,8 @@ class Doc
     @pendingOp = null
     @pendingOp = null
     @pendingCallbacks = []
     @pendingCallbacks = []
 
 
+    @emit "flipped_pending_to_inflight"
+
     #console.log "SENDING OP TO SERVER", @inflightOp, @version
     #console.log "SENDING OP TO SERVER", @inflightOp, @version
     @connection.send {doc:@name, op:@inflightOp, v:@version}
     @connection.send {doc:@name, op:@inflightOp, v:@version}
 
 
@@ -274,6 +276,7 @@ class Doc
   submitOp: (op, callback) ->
   submitOp: (op, callback) ->
     op = @type.normalize(op) if @type.normalize?
     op = @type.normalize(op) if @type.normalize?
 
 
+    oldSnapshot = @snapshot
     # If this throws an exception, no changes should have been made to the doc
     # If this throws an exception, no changes should have been made to the doc
     @snapshot = @type.apply @snapshot, op
     @snapshot = @type.apply @snapshot, op
 
 
@@ -284,7 +287,7 @@ class Doc
 
 
     @pendingCallbacks.push callback if callback
     @pendingCallbacks.push callback if callback
 
 
-    @emit 'change', op
+    @emit 'change', op, oldSnapshot
 
 
     @delayedFlush()
     @delayedFlush()
 
 

+ 1 - 1
services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee

@@ -28,5 +28,5 @@ text.api =
       for component in op
       for component in op
         if component.i != undefined
         if component.i != undefined
           @emit 'insert', component.p, component.i
           @emit 'insert', component.p, component.i
-        else
+        else if component.d != undefined
           @emit 'delete', component.p, component.d
           @emit 'delete', component.p, component.d

+ 60 - 6
services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee

@@ -31,7 +31,8 @@ checkValidComponent = (c) ->
 
 
   i_type = typeof c.i
   i_type = typeof c.i
   d_type = typeof c.d
   d_type = typeof c.d
-  throw new Error 'component needs an i or d field' unless (i_type == 'string') ^ (d_type == 'string')
+  c_type = typeof c.c
+  throw new Error 'component needs an i, d or c field' unless (i_type == 'string') ^ (d_type == 'string') ^ (c_type == 'string')
 
 
   throw new Error 'position cannot be negative' unless c.p >= 0
   throw new Error 'position cannot be negative' unless c.p >= 0
 
 
@@ -44,11 +45,15 @@ text.apply = (snapshot, op) ->
   for component in op
   for component in op
     if component.i?
     if component.i?
       snapshot = strInject snapshot, component.p, component.i
       snapshot = strInject snapshot, component.p, component.i
-    else
+    else if component.d?
       deleted = snapshot[component.p...(component.p + component.d.length)]
       deleted = snapshot[component.p...(component.p + component.d.length)]
       throw new Error "Delete component '#{component.d}' does not match deleted text '#{deleted}'" unless component.d == deleted
       throw new Error "Delete component '#{component.d}' does not match deleted text '#{deleted}'" unless component.d == deleted
       snapshot = snapshot[...component.p] + snapshot[(component.p + component.d.length)..]
       snapshot = snapshot[...component.p] + snapshot[(component.p + component.d.length)..]
-  
+    else if component.c?
+      comment = snapshot[component.p...(component.p + component.c.length)]
+      throw new Error "Comment component '#{component.c}' does not match commented text '#{comment}'" unless component.c == comment
+    else
+      throw new Error "Unknown op type"
   snapshot
   snapshot
 
 
 
 
@@ -112,7 +117,7 @@ transformPosition = (pos, c, insertAfter) ->
       pos + c.i.length
       pos + c.i.length
     else
     else
       pos
       pos
-  else
+  else if c.d?
     # I think this could also be written as: Math.min(c.p, Math.min(c.p - otherC.p, otherC.d.length))
     # I think this could also be written as: Math.min(c.p, Math.min(c.p - otherC.p, otherC.d.length))
     # but I think its harder to read that way, and it compiles using ternary operators anyway
     # but I think its harder to read that way, and it compiles using ternary operators anyway
     # so its no slower written like this.
     # so its no slower written like this.
@@ -122,6 +127,10 @@ transformPosition = (pos, c, insertAfter) ->
       c.p
       c.p
     else
     else
       pos - c.d.length
       pos - c.d.length
+  else if c.c?
+    pos
+  else
+    throw new Error("unknown op type")
 
 
 # Helper method to transform a cursor position as a result of an op.
 # Helper method to transform a cursor position as a result of an op.
 #
 #
@@ -143,7 +152,7 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
   if c.i?
   if c.i?
     append dest, {i:c.i, p:transformPosition(c.p, otherC, side == 'right')}
     append dest, {i:c.i, p:transformPosition(c.p, otherC, side == 'right')}
 
 
-  else # Delete
+  else if c.d? # Delete
     if otherC.i? # delete vs insert
     if otherC.i? # delete vs insert
       s = c.d
       s = c.d
       if c.p < otherC.p
       if c.p < otherC.p
@@ -152,7 +161,7 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
       if s != ''
       if s != ''
         append dest, {d:s, p:c.p + otherC.i.length}
         append dest, {d:s, p:c.p + otherC.i.length}
 
 
-    else # Delete vs delete
+    else if otherC.d? # Delete vs delete
       if c.p >= otherC.p + otherC.d.length
       if c.p >= otherC.p + otherC.d.length
         append dest, {d:c.d, p:c.p - otherC.d.length}
         append dest, {d:c.d, p:c.p - otherC.d.length}
       else if c.p + c.d.length <= otherC.p
       else if c.p + c.d.length <= otherC.p
@@ -177,6 +186,51 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
           # This could be rewritten similarly to insert v delete, above.
           # This could be rewritten similarly to insert v delete, above.
           newC.p = transformPosition newC.p, otherC
           newC.p = transformPosition newC.p, otherC
           append dest, newC
           append dest, newC
+    
+    else if otherC.c?
+      append dest, c
+    
+    else
+      throw new Error("unknown op type")
+
+  else if c.c? # Comment
+    if otherC.i?
+      if c.p < otherC.p < c.p + c.c.length
+        offset = otherC.p - c.p
+        new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...])
+        append dest, {c:new_c, p:c.p, t: c.t}
+      else
+        append dest, {c:c.c, p:transformPosition(c.p, otherC, true), t: c.t}
+    
+    else if otherC.d?
+      if c.p >= otherC.p + otherC.d.length
+        append dest, {c:c.c, p:c.p - otherC.d.length, t: c.t}
+      else if c.p + c.c.length <= otherC.p
+        append dest, c
+      else # Delete overlaps comment
+        # They overlap somewhere.
+        newC = {c:'', p:c.p, t: c.t}
+        if c.p < otherC.p
+          newC.c = c.c[...(otherC.p - c.p)]
+        if c.p + c.c.length > otherC.p + otherC.d.length
+          newC.c += c.c[(otherC.p + otherC.d.length - c.p)..]
+
+        # This is entirely optional - just for a check that the deleted
+        # text in the two ops matches
+        intersectStart = Math.max c.p, otherC.p
+        intersectEnd = Math.min c.p + c.c.length, otherC.p + otherC.d.length
+        cIntersect = c.c[intersectStart - c.p...intersectEnd - c.p]
+        otherIntersect = otherC.d[intersectStart - otherC.p...intersectEnd - otherC.p]
+        throw new Error 'Delete ops delete different text in the same region of the document' unless cIntersect == otherIntersect
+
+        newC.p = transformPosition newC.p, otherC
+        append dest, newC
+    
+    else if otherC.c?
+      append dest, c
+    
+    else
+      throw new Error("unknown op type")
   
   
   dest
   dest
 
 

+ 10 - 0
services/web/public/coffee/ide/file-tree/FileTreeManager.coffee

@@ -275,6 +275,16 @@ define [
 						doc:  entity
 						doc:  entity
 						path: path
 						path: path
 					}
 					}
+			# Keep list ordered by folders, then name
+			@$scope.docs.sort (a,b) ->
+				aDepth = (a.path.match(/\//g) || []).length
+				bDepth = (b.path.match(/\//g) || []).length
+				if aDepth - bDepth != 0
+					return -(aDepth - bDepth) # Deeper path == folder first
+				else if a.path < b.path
+					return -1
+				else
+					return 1
 			
 			
 		getEntityPath: (entity) ->
 		getEntityPath: (entity) ->
 			@_getEntityPathInFolder @$scope.rootFolder, entity
 			@_getEntityPathInFolder @$scope.rootFolder, entity

+ 7 - 0
services/web/public/coffee/ide/permissions/PermissionsManager.coffee

@@ -5,15 +5,22 @@ define [], () ->
 				read:  false
 				read:  false
 				write: false
 				write: false
 				admin: false
 				admin: false
+				comment: false
 			@$scope.$watch "permissionsLevel", (permissionsLevel) =>
 			@$scope.$watch "permissionsLevel", (permissionsLevel) =>
 
 
 				if permissionsLevel?
 				if permissionsLevel?
 					if permissionsLevel == "readOnly"
 					if permissionsLevel == "readOnly"
 						@$scope.permissions.read = true
 						@$scope.permissions.read = true
+						@$scope.permissions.comment = true
 					else if permissionsLevel == "readAndWrite"
 					else if permissionsLevel == "readAndWrite"
 						@$scope.permissions.read = true
 						@$scope.permissions.read = true
 						@$scope.permissions.write = true
 						@$scope.permissions.write = true
+						@$scope.permissions.comment = true
 					else if permissionsLevel == "owner"
 					else if permissionsLevel == "owner"
 						@$scope.permissions.read = true
 						@$scope.permissions.read = true
 						@$scope.permissions.write = true
 						@$scope.permissions.write = true
 						@$scope.permissions.admin = true
 						@$scope.permissions.admin = true
+						@$scope.permissions.comment = true
+
+				if @$scope.anonymous
+					@$scope.permissions.comment = false

+ 82 - 57
services/web/public/coffee/ide/review-panel/ChangesTracker.coffee → services/web/public/coffee/ide/review-panel/RangesTracker.coffee

@@ -1,7 +1,5 @@
-define [
-	"utils/EventEmitter"
-], (EventEmitter) ->
-	class ChangesTracker extends EventEmitter
+load = (EventEmitter) ->
+	class RangesTracker extends EventEmitter
 		# The purpose of this class is to track a set of inserts and deletes to a document, like
 		# The purpose of this class is to track a set of inserts and deletes to a document, like
 		# track changes in Word. We store these as a set of ShareJs style ranges:
 		# track changes in Word. We store these as a set of ShareJs style ranges:
 		#   {i: "foo", p: 42} # Insert 'foo' at offset 42
 		#   {i: "foo", p: 42} # Insert 'foo' at offset 42
@@ -36,30 +34,34 @@ define [
 		#   * Deletes by another user will consume deletes by the first user
 		#   * Deletes by another user will consume deletes by the first user
 		#   * Inserts by another user will not combine with inserts by the first user. If they are in the
 		#   * Inserts by another user will not combine with inserts by the first user. If they are in the
 		#     middle of a previous insert by the first user, the original insert will be split into two.
 		#     middle of a previous insert by the first user, the original insert will be split into two.
-		constructor: () ->
-			# Change objects have the following structure:
-			# {
-			#   id: ... # Uniquely generated by us
-			#   op: { # ShareJs style op tracking the offset (p) and content inserted (i) or deleted (d)
-			#     i: "..."
-			#     p: 42
-			#   }
-			# }
-			#
-			# Ids are used to uniquely identify a change, e.g. for updating it in the database, or keeping in
-			# sync with Ace ranges.
-			@changes = []
-			@comments = []
-			@id = 0
+		constructor: (@changes = [], @comments = []) ->
+			@setIdSeed(RangesTracker.generateIdSeed())
+
+		getIdSeed: () ->
+			return @id_seed
+
+		setIdSeed: (seed) ->
+			@id_seed = seed
+			@id_increment = 0
 		
 		
-		addComment: (offset, length, metadata) ->
-			# TODO: Don't allow overlapping comments?
-			@comments.push comment = {
-				id: @_newId()
-				offset, length, metadata
-			}
-			@emit "comment:added", comment
-			return comment
+		@generateIdSeed: () ->
+			# Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part
+			# Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js
+			pid = Math.floor(Math.random() * (32767)).toString(16)
+			machine = Math.floor(Math.random() * (16777216)).toString(16)
+			timestamp = Math.floor(new Date().valueOf() / 1000).toString(16)
+			return '00000000'.substr(0, 8 - timestamp.length) + timestamp +
+				'000000'.substr(0, 6 - machine.length) + machine +
+				'0000'.substr(0, 4 - pid.length) + pid
+		
+		@generateId: () ->
+			@generateIdSeed() + "000001"
+
+		newId: () ->
+			@id_increment++
+			increment = @id_increment.toString(16)
+			id = @id_seed + '000000'.substr(0, 6 - increment.length) + increment;
+			return id
 		
 		
 		getComment: (comment_id) ->
 		getComment: (comment_id) ->
 			comment = null
 			comment = null
@@ -69,19 +71,6 @@ define [
 					break
 					break
 			return comment
 			return comment
 		
 		
-		resolveCommentId: (comment_id, resolved_data) ->
-			comment = @getComment(comment_id)
-			return if !comment?
-			comment.metadata.resolved = true
-			comment.metadata.resolved_data = resolved_data
-			@emit "comment:resolved", comment
-		
-		unresolveCommentId: (comment_id) ->
-			comment = @getComment(comment_id)
-			return if !comment?
-			comment.metadata.resolved = false
-			@emit "comment:unresolved", comment
-		
 		removeCommentId: (comment_id) ->
 		removeCommentId: (comment_id) ->
 			comment = @getComment(comment_id)
 			comment = @getComment(comment_id)
 			return if !comment?
 			return if !comment?
@@ -101,7 +90,7 @@ define [
 			return if !change?
 			return if !change?
 			@_removeChange(change)
 			@_removeChange(change)
 
 
-		applyOp: (op, metadata) ->
+		applyOp: (op, metadata = {}) ->
 			metadata.ts ?= new Date()
 			metadata.ts ?= new Date()
 			# Apply an op that has been applied to the document to our changes to keep them up to date
 			# Apply an op that has been applied to the document to our changes to keep them up to date
 			if op.i?
 			if op.i?
@@ -110,14 +99,32 @@ define [
 			else if op.d?
 			else if op.d?
 				@applyDeleteToChanges(op, metadata)
 				@applyDeleteToChanges(op, metadata)
 				@applyDeleteToComments(op)
 				@applyDeleteToComments(op)
+			else if op.c?
+				@addComment(op, metadata)
+			else
+				throw new Error("unknown op type")
+			
+		addComment: (op, metadata) ->
+			# TODO: Don't allow overlapping comments?
+			@comments.push comment = {
+				id: @newId()
+				op: # Copy because we'll modify in place
+					c: op.c
+					p: op.p
+					t: op.t
+				metadata
+			}
+			@emit "comment:added", comment
+			return comment
 		
 		
 		applyInsertToComments: (op) ->
 		applyInsertToComments: (op) ->
 			for comment in @comments
 			for comment in @comments
-				if op.p <= comment.offset
-					comment.offset += op.i.length
+				if op.p <= comment.op.p
+					comment.op.p += op.i.length
 					@emit "comment:moved", comment
 					@emit "comment:moved", comment
-				else if op.p < comment.offset + comment.length
-					comment.length += op.i.length
+				else if op.p < comment.op.p + comment.op.c.length
+					offset = op.p - comment.op.p
+					comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...]
 					@emit "comment:moved", comment
 					@emit "comment:moved", comment
 
 
 		applyDeleteToComments: (op) ->
 		applyDeleteToComments: (op) ->
@@ -125,20 +132,35 @@ define [
 			op_length = op.d.length
 			op_length = op.d.length
 			op_end = op.p + op_length
 			op_end = op.p + op_length
 			for comment in @comments
 			for comment in @comments
-				comment_end = comment.offset + comment.length
-				if op_end <= comment.offset
+				comment_start = comment.op.p
+				comment_end = comment.op.p + comment.op.c.length
+				comment_length = comment_end - comment_start
+				if op_end <= comment_start
 					# delete is fully before comment
 					# delete is fully before comment
-					comment.offset -= op_length
+					comment.op.p -= op_length
 					@emit "comment:moved", comment
 					@emit "comment:moved", comment
 				else if op_start >= comment_end
 				else if op_start >= comment_end
 					# delete is fully after comment, nothing to do
 					# delete is fully after comment, nothing to do
 				else
 				else
 					# delete and comment overlap
 					# delete and comment overlap
-					delete_length_before = Math.max(0, comment.offset - op_start)
-					delete_length_after = Math.max(0, op_end - comment_end)
-					delete_length_overlapping = op_length - delete_length_before - delete_length_after
-					comment.offset = Math.min(comment.offset, op_start)
-					comment.length -= delete_length_overlapping
+					if op_start <= comment_start
+						remaining_before = ""
+					else
+						remaining_before = comment.op.c.slice(0, op_start - comment_start)
+					if op_end >= comment_end
+						remaining_after = ""
+					else
+						remaining_after = comment.op.c.slice(op_end - comment_start)
+					
+					# Check deleted content matches delete op
+					deleted_comment = comment.op.c.slice(remaining_before.length, comment_length - remaining_after.length)
+					offset = Math.max(0, comment_start - op_start)
+					deleted_op_content = op.d.slice(offset).slice(0, deleted_comment.length)
+					if deleted_comment != deleted_op_content
+						throw new Error("deleted content does not match comment content")
+					
+					comment.op.p = Math.min(comment_start, op_start)
+					comment.op.c = remaining_before + remaining_after
 					@emit "comment:moved", comment
 					@emit "comment:moved", comment
 
 
 		applyInsertToChanges: (op, metadata) ->
 		applyInsertToChanges: (op, metadata) ->
@@ -374,12 +396,9 @@ define [
 			if moved_changes.length > 0
 			if moved_changes.length > 0
 				@emit "changes:moved", moved_changes
 				@emit "changes:moved", moved_changes
 
 
-		_newId: () ->
-			(@id++).toString()
-
 		_addOp: (op, metadata) ->
 		_addOp: (op, metadata) ->
 			change = {
 			change = {
-				id: @_newId()
+				id: @newId()
 				op: op
 				op: op
 				metadata: metadata
 				metadata: metadata
 			}
 			}
@@ -453,3 +472,9 @@ define [
 				else # Only update to the current change if we haven't removed it.
 				else # Only update to the current change if we haven't removed it.
 					previous_change = change
 					previous_change = change
 			return { moved_changes, remove_changes }
 			return { moved_changes, remove_changes }
+
+if define?
+	define ["utils/EventEmitter"], load
+else
+	EventEmitter = require("events").EventEmitter
+	module.exports = load(EventEmitter)

+ 3 - 0
services/web/public/coffee/ide/review-panel/ReviewPanelManager.coffee

@@ -5,5 +5,8 @@ define [
 	"ide/review-panel/directives/changeEntry"
 	"ide/review-panel/directives/changeEntry"
 	"ide/review-panel/directives/commentEntry"
 	"ide/review-panel/directives/commentEntry"
 	"ide/review-panel/directives/addCommentEntry"
 	"ide/review-panel/directives/addCommentEntry"
+	"ide/review-panel/directives/resolvedCommentEntry"
+	"ide/review-panel/directives/resolvedCommentsDropdown"
+	"ide/review-panel/filters/notEmpty"
 	"ide/review-panel/filters/orderOverviewEntries"
 	"ide/review-panel/filters/orderOverviewEntries"
 ], () ->
 ], () ->

+ 315 - 229
services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee

@@ -2,9 +2,9 @@ define [
 	"base",
 	"base",
 	"utils/EventEmitter"
 	"utils/EventEmitter"
 	"ide/colors/ColorManager"
 	"ide/colors/ColorManager"
-	"ide/review-panel/ChangesTracker"
-], (App, EventEmitter, ColorManager, ChangesTracker) ->
-	App.controller "ReviewPanelController", ($scope, $element, ide, $timeout) ->
+	"ide/review-panel/RangesTracker"
+], (App, EventEmitter, ColorManager, RangesTracker) ->
+	App.controller "ReviewPanelController", ($scope, $element, ide, $timeout, $http, event_tracking) ->
 		$reviewPanelEl = $element.find "#review-panel"
 		$reviewPanelEl = $element.find "#review-panel"
 
 
 		$scope.SubViews =
 		$scope.SubViews =
@@ -13,131 +13,90 @@ define [
 
 
 		$scope.reviewPanel =
 		$scope.reviewPanel =
 			entries: {}
 			entries: {}
-			trackNewChanges: false
+			resolvedComments: {}
 			hasEntries: false
 			hasEntries: false
 			subView: $scope.SubViews.CUR_FILE
 			subView: $scope.SubViews.CUR_FILE
 			openSubView: $scope.SubViews.CUR_FILE
 			openSubView: $scope.SubViews.CUR_FILE
+			overview:
+				loading: false
+			dropdown:
+				loading: false
+			commentThreads: {}
+			resolvedThreadIds: {}
+			layoutToLeft: false
+			rendererData: {}
+			loadingThreads: false
+
+		$scope.$on "layout:pdf:linked", (event, state) ->
+			$scope.reviewPanel.layoutToLeft = (state.east?.size < 220 || state.east?.initClosed)
+
+		$scope.$on "layout:pdf:resize", (event, state) ->
+			$scope.reviewPanel.layoutToLeft = (state.east?.size < 220 || state.east?.initClosed)
+
+		$scope.$watch "ui.pdfLayout", (layout) ->
+			$scope.reviewPanel.layoutToLeft = (layout == "flat")
 
 
 		$scope.commentState =
 		$scope.commentState =
 			adding: false
 			adding: false
 			content: ""
 			content: ""
 
 
+		$scope.users = {}
+
 		$scope.reviewPanelEventsBridge = new EventEmitter()
 		$scope.reviewPanelEventsBridge = new EventEmitter()
+		
+		ide.socket.on "new-comment", (thread_id, comment) ->
+			thread = getThread(thread_id)
+			delete thread.submitting
+			thread.messages.push(formatComment(comment))
+			$scope.$apply()
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+		
+		ide.socket.on "accept-change", (doc_id, change_id) ->
+			if doc_id != $scope.editor.open_doc_id
+				getChangeTracker(doc_id).removeChangeId(change_id)
+			else
+				$scope.$broadcast "change:accept", change_id
+			updateEntries(doc_id)
+			$scope.$apply () ->
+		
+		ide.socket.on "resolve-thread", (thread_id, user) ->
+			_onCommentResolved(thread_id, user)
+			
+		ide.socket.on "reopen-thread", (thread_id) ->
+			_onCommentReopened(thread_id)
 
 
-		changesTrackers = {}
+		rangesTrackers = {}
 
 
 		getDocEntries = (doc_id) ->
 		getDocEntries = (doc_id) ->
 			$scope.reviewPanel.entries[doc_id] ?= {}
 			$scope.reviewPanel.entries[doc_id] ?= {}
 			return $scope.reviewPanel.entries[doc_id]
 			return $scope.reviewPanel.entries[doc_id]
 
 
+		getDocResolvedComments = (doc_id) ->
+			$scope.reviewPanel.resolvedComments[doc_id] ?= {}
+			return $scope.reviewPanel.resolvedComments[doc_id]
+		
+		getThread = (thread_id) ->
+			$scope.reviewPanel.commentThreads[thread_id] ?= { messages: [] }
+			return $scope.reviewPanel.commentThreads[thread_id]
+
 		getChangeTracker = (doc_id) ->
 		getChangeTracker = (doc_id) ->
-			changesTrackers[doc_id] ?= new ChangesTracker()
-			return changesTrackers[doc_id]
-
-		# TODO Just for prototyping purposes; remove afterwards.
-		mockedUserId = 'mock_user_id_1'
-		mockedUserId2 = 'mock_user_id_2'
-
-		if window.location.search.match /mocktc=true/
-			mock_changes = {
-				"main.tex":
-					changes: [{
-						op: { i: "Habitat loss and conflicts with humans are the greatest causes of concern.", p: 925 - 38 }
-						metadata: { user_id: mockedUserId, ts: new Date(Date.now() - 30 * 60 * 1000) }
-					}, {
-						op: { d: "The lion is now a vulnerable species. ", p: 778 }
-						metadata: { user_id: mockedUserId, ts: new Date(Date.now() - 31 * 60 * 1000) }
-					}]
-					comments: [{
-						offset: 1375 - 38
-						length: 79
-						metadata:
-							thread: [{
-								content: "Do we have a source for this?"
-								user_id: mockedUserId
-								ts: new Date(Date.now() - 45 * 60 * 1000) 
-							}]
-					}]
-				"chapter_1.tex":
-					changes: [{
-						"op":{"p":740,"d":", to take down large animals"},
-						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 15 * 60 * 1000)}
-					}, {
-						"op":{"i":", to keep hold of the prey","p":920},
-						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 130 * 60 * 1000)}
-					}, {
-						"op":{"i":" being","p":1057},
-						"metadata":{"user_id":mockedUserId2, ts: new Date(Date.now() - 72 * 60 * 1000)}
-					}]
-					comments:[{
-						"offset":111,"length":5,
-						"metadata":{
-							"thread": [
-								{"content":"Have we used 'pride' too much here?","user_id":mockedUserId, ts: new Date(Date.now() - 12 * 60 * 1000)},
-								{"content":"No, I think this is OK","user_id":mockedUserId2, ts: new Date(Date.now() - 9 * 60 * 1000)}
-							]
-						}
-					},{
-						"offset":452,"length":21,
-						"metadata":{
-							"thread":[
-								{"content":"TODO: Don't use as many parentheses!","user_id":mockedUserId2, ts: new Date(Date.now() - 99 * 60 * 1000)}
-							]
-						}
-					}]
-				"chapter_2.tex":
-					changes: [{
-						"op":{"p":458,"d":"other"},
-						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 133 * 60 * 1000)}
-					},{
-						"op":{"i":"usually 2-3, ","p":928},
-						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 27 * 60 * 1000)}
-					},{
-						"op":{"i":"If the parents are a male lion and a female tiger, it is called a liger. A tigon comes from a male tiger and a female lion.","p":1126},
-						"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 152 * 60 * 1000)}
-					}]
-					comments: [{
-						"offset":299,"length":10,
-						"metadata":{
-							"thread":[{
-								"content":"Should we use a different word here if 'den' needs clarifying?","user_id":mockedUserId,"ts": new Date(Date.now() - 430 * 60 * 1000)
-							}]
-						}
-					},{
-						"offset":843,"length":66,
-						"metadata":{
-							"thread":[{
-								"content":"This sentence is a little ambiguous","user_id":mockedUserId,"ts": new Date(Date.now() - 430 * 60 * 1000)
-							}]
-						}
-					}]
-			}
-			ide.$scope.$on "file-tree:initialized", () ->
-				ide.fileTreeManager.forEachEntity (entity) ->
-					if mock_changes[entity.name]?
-						changesTracker = getChangeTracker(entity.id)
-						for change in mock_changes[entity.name].changes
-							changesTracker._addOp change.op, change.metadata 
-						for comment in mock_changes[entity.name].comments
-							changesTracker.addComment comment.offset, comment.length, comment.metadata 
-						for doc_id, changesTracker of changesTrackers
-							updateEntries(doc_id)
+			if !rangesTrackers[doc_id]?
+				rangesTrackers[doc_id] = new RangesTracker()
+				rangesTrackers[doc_id].resolvedThreadIds = $scope.reviewPanel.resolvedThreadIds
+			return rangesTrackers[doc_id]
 
 
 		scrollbar = {}
 		scrollbar = {}
 		$scope.reviewPanelEventsBridge.on "aceScrollbarVisibilityChanged", (isVisible, scrollbarWidth) ->
 		$scope.reviewPanelEventsBridge.on "aceScrollbarVisibilityChanged", (isVisible, scrollbarWidth) ->
 			scrollbar = {isVisible, scrollbarWidth}
 			scrollbar = {isVisible, scrollbarWidth}
 			updateScrollbar()
 			updateScrollbar()
-		
+
 		updateScrollbar = () ->
 		updateScrollbar = () ->
 			if scrollbar.isVisible and $scope.reviewPanel.subView == $scope.SubViews.CUR_FILE
 			if scrollbar.isVisible and $scope.reviewPanel.subView == $scope.SubViews.CUR_FILE
 				$reviewPanelEl.css "right", "#{ scrollbar.scrollbarWidth }px"
 				$reviewPanelEl.css "right", "#{ scrollbar.scrollbarWidth }px"
 			else
 			else
 				$reviewPanelEl.css "right", "0"
 				$reviewPanelEl.css "right", "0"
-		
-		$scope.$watch "reviewPanel.subView", (subView) ->
-			return if !subView?
-			updateScrollbar()
-		
+
 		$scope.$watch "ui.reviewPanelOpen", (open) ->
 		$scope.$watch "ui.reviewPanelOpen", (open) ->
 			return if !open?
 			return if !open?
 			if !open
 			if !open
@@ -147,33 +106,88 @@ define [
 			else
 			else
 				# Reset back to what we had when previously open
 				# Reset back to what we had when previously open
 				$scope.reviewPanel.subView = $scope.reviewPanel.openSubView
 				$scope.reviewPanel.subView = $scope.reviewPanel.openSubView
+		
+		$scope.$watch "reviewPanel.subView", (view) ->
+			return if !view?
+			updateScrollbar()
+			if view == $scope.SubViews.OVERVIEW
+				refreshOverviewPanel()
 
 
-		$scope.$watch "editor.open_doc_id", (open_doc_id) ->
-			return if !open_doc_id?
-			changesTrackers[open_doc_id] ?= new ChangesTracker()
-			$scope.reviewPanel.changesTracker = changesTrackers[open_doc_id]
+		$scope.$watch "editor.sharejs_doc", (doc, old_doc) ->
+			return if !doc?
+			# The open doc range tracker is kept up to date in real-time so
+			# replace any outdated info with this
+			rangesTrackers[doc.doc_id] = doc.ranges
+			rangesTrackers[doc.doc_id].resolvedThreadIds = $scope.reviewPanel.resolvedThreadIds
+			$scope.reviewPanel.rangesTracker = rangesTrackers[doc.doc_id]
+			if old_doc?
+				old_doc.off "flipped_pending_to_inflight"
+			doc.on "flipped_pending_to_inflight", () ->
+				regenerateTrackChangesId(doc)
+			regenerateTrackChangesId(doc)
 
 
 		$scope.$watch (() ->
 		$scope.$watch (() ->
 			entries = $scope.reviewPanel.entries[$scope.editor.open_doc_id] or {}
 			entries = $scope.reviewPanel.entries[$scope.editor.open_doc_id] or {}
 			Object.keys(entries).length
 			Object.keys(entries).length
 		), (nEntries) ->
 		), (nEntries) ->
-			$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.trackChangesFeatureFlag
+			$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.project.features.trackChanges
 
 
 		$scope.$watch "ui.reviewPanelOpen", (reviewPanelOpen) ->
 		$scope.$watch "ui.reviewPanelOpen", (reviewPanelOpen) ->
 			return if !reviewPanelOpen?
 			return if !reviewPanelOpen?
 			$timeout () ->
 			$timeout () ->
 				$scope.$broadcast "review-panel:toggle"
 				$scope.$broadcast "review-panel:toggle"
 				$scope.$broadcast "review-panel:layout"
 				$scope.$broadcast "review-panel:layout"
+
+		regenerateTrackChangesId = (doc) ->
+			old_id = getChangeTracker(doc.doc_id).getIdSeed()
+			new_id = RangesTracker.generateIdSeed()
+			getChangeTracker(doc.doc_id).setIdSeed(new_id)
+			doc.setTrackChangesIdSeeds({pending: new_id, inflight: old_id})
 		
 		
+		refreshRanges = () ->
+			$http.get "/project/#{$scope.project_id}/ranges"
+				.success (docs) ->
+					for doc in docs
+						if doc.id != $scope.editor.open_doc_id # this is kept up to date in real-time, don't overwrite
+							rangesTracker = getChangeTracker(doc.id)
+							rangesTracker.comments = doc.ranges?.comments or []
+							rangesTracker.changes = doc.ranges?.changes or []
+						updateEntries(doc.id)
+
+		refreshOverviewPanel = () ->
+			$scope.reviewPanel.overview.loading = true
+			refreshRanges()
+				.then () ->
+					$scope.reviewPanel.overview.loading = false
+				.catch () ->
+					$scope.reviewPanel.overview.loading = false
+
+		$scope.refreshResolvedCommentsDropdown = () ->
+			$scope.reviewPanel.dropdown.loading = true
+			q = refreshRanges()
+			q.then () ->
+				$scope.reviewPanel.dropdown.loading = false
+			q.catch () ->
+				$scope.reviewPanel.dropdown.loading = false
+			return q
+
 		updateEntries = (doc_id) ->
 		updateEntries = (doc_id) ->
-			changesTracker = getChangeTracker(doc_id)
+			rangesTracker = getChangeTracker(doc_id)
 			entries = getDocEntries(doc_id)
 			entries = getDocEntries(doc_id)
+			resolvedComments = getDocResolvedComments(doc_id)
 			
 			
+			changed = false
+
 			# Assume we'll delete everything until we see it, then we'll remove it from this object
 			# Assume we'll delete everything until we see it, then we'll remove it from this object
 			delete_changes = {}
 			delete_changes = {}
-			delete_changes[change_id] = true for change_id, change of entries
+			for change_id, change of entries
+				if change_id != "add-comment"
+					delete_changes[change_id] = true 
+			for change_id, change of resolvedComments
+				delete_changes[change_id] = true 
 
 
-			for change in changesTracker.changes
+			for change in rangesTracker.changes
+				changed = true
 				delete delete_changes[change.id]
 				delete delete_changes[change.id]
 				entries[change.id] ?= {}
 				entries[change.id] ?= {}
 					
 					
@@ -189,22 +203,35 @@ define [
 				for key, value of new_entry
 				for key, value of new_entry
 					entries[change.id][key] = value
 					entries[change.id][key] = value
 
 
-			for comment in changesTracker.comments
+				if !$scope.users[change.metadata.user_id]?
+					refreshChangeUsers(change.metadata.user_id)
+
+			if rangesTracker.comments.length > 0
+				ensureThreadsAreLoaded()
+
+			for comment in rangesTracker.comments
+				changed = true
 				delete delete_changes[comment.id]
 				delete delete_changes[comment.id]
-				entries[comment.id] ?= {}
+				if $scope.reviewPanel.resolvedThreadIds[comment.op.t]
+					new_comment = resolvedComments[comment.id] ?= {}
+				else
+					new_comment = entries[comment.id] ?= {}
 				new_entry = {
 				new_entry = {
 					type: "comment"
 					type: "comment"
-					thread: comment.metadata.thread
-					resolved: comment.metadata.resolved
-					resolved_data: comment.metadata.resolved_data
-					offset: comment.offset
-					length: comment.length
+					thread_id: comment.op.t
+					content: comment.op.c
+					offset: comment.op.p
 				}
 				}
 				for key, value of new_entry
 				for key, value of new_entry
-					entries[comment.id][key] = value
+					new_comment[key] = value
 
 
 			for change_id, _ of delete_changes
 			for change_id, _ of delete_changes
+				changed = true
 				delete entries[change_id]
 				delete entries[change_id]
+				delete resolvedComments[change_id]
+			
+			if changed
+				$scope.$broadcast "entries:changed"
 
 
 		$scope.$on "editor:track-changes:changed", () ->
 		$scope.$on "editor:track-changes:changed", () ->
 			doc_id = $scope.editor.open_doc_id
 			doc_id = $scope.editor.open_doc_id
@@ -212,53 +239,64 @@ define [
 			$scope.$broadcast "review-panel:recalculate-screen-positions"
 			$scope.$broadcast "review-panel:recalculate-screen-positions"
 			$scope.$broadcast "review-panel:layout"
 			$scope.$broadcast "review-panel:layout"
 		
 		
-		$scope.$on "editor:focus:changed", (e, cursor_offset, selection) ->
+		$scope.$on "editor:focus:changed", (e, selection_offset_start, selection_offset_end, selection) ->
 			doc_id = $scope.editor.open_doc_id
 			doc_id = $scope.editor.open_doc_id
 			entries = getDocEntries(doc_id)
 			entries = getDocEntries(doc_id)
 
 
-			if !selection
-				delete entries["add-comment"]
-			else
-				entries["add-comment"] = {
-					type: "add-comment"
-					offset: cursor_offset
-				}
+			delete entries["add-comment"]
+			if selection
+				# Only show add comment if we're not already overlapping one
+				overlapping_comment = false
+				for id, entry of entries
+					if entry.type == "comment" and not $scope.reviewPanel.resolvedThreadIds[entry.thread_id]
+						unless entry.offset >= selection_offset_end or entry.offset + entry.content.length <= selection_offset_start
+							overlapping_comment = true
+				if !overlapping_comment
+					entries["add-comment"] = {
+						type: "add-comment"
+						offset: selection_offset_start
+					}
 			
 			
 			for id, entry of entries
 			for id, entry of entries
-				if entry.type == "comment" and not entry.resolved
-					entry.focused = (entry.offset <= cursor_offset <= entry.offset + entry.length)
+				if entry.type == "comment" and not $scope.reviewPanel.resolvedThreadIds[entry.thread_id]
+					entry.focused = (entry.offset <= selection_offset_start <= entry.offset + entry.content.length)
 				else if entry.type == "insert"
 				else if entry.type == "insert"
-					entry.focused = (entry.offset <= cursor_offset <= entry.offset + entry.content.length)
+					entry.focused = (entry.offset <= selection_offset_start <= entry.offset + entry.content.length)
 				else if entry.type == "delete"
 				else if entry.type == "delete"
-					entry.focused = (entry.offset == cursor_offset)
+					entry.focused = (entry.offset == selection_offset_start)
 				else if entry.type == "add-comment" and selection
 				else if entry.type == "add-comment" and selection
 					entry.focused = true
 					entry.focused = true
 			
 			
 			$scope.$broadcast "review-panel:recalculate-screen-positions"
 			$scope.$broadcast "review-panel:recalculate-screen-positions"
 			$scope.$broadcast "review-panel:layout"
 			$scope.$broadcast "review-panel:layout"
-		
+
 		$scope.acceptChange = (entry_id) ->
 		$scope.acceptChange = (entry_id) ->
+			$http.post "/project/#{$scope.project_id}/doc/#{$scope.editor.open_doc_id}/changes/#{entry_id}/accept", {_csrf: window.csrfToken}
 			$scope.$broadcast "change:accept", entry_id
 			$scope.$broadcast "change:accept", entry_id
+			event_tracking.sendMB "rp-change-accepted", { view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' }
 		
 		
 		$scope.rejectChange = (entry_id) ->
 		$scope.rejectChange = (entry_id) ->
 			$scope.$broadcast "change:reject", entry_id
 			$scope.$broadcast "change:reject", entry_id
+			event_tracking.sendMB "rp-change-rejected", { view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' }
 		
 		
 		$scope.startNewComment = () ->
 		$scope.startNewComment = () ->
-			# $scope.commentState.adding = true
 			$scope.$broadcast "comment:select_line"
 			$scope.$broadcast "comment:select_line"
 			$timeout () ->
 			$timeout () ->
 				$scope.$broadcast "review-panel:layout"
 				$scope.$broadcast "review-panel:layout"
 		
 		
 		$scope.submitNewComment = (content) ->
 		$scope.submitNewComment = (content) ->
-			# $scope.commentState.adding = false
-			$scope.$broadcast "comment:add", content
-			# $scope.commentState.content = ""
+			thread_id = RangesTracker.generateId()
+			thread = getThread(thread_id)
+			thread.submitting = true
+			$scope.$broadcast "comment:add", thread_id
+			$http.post("/project/#{$scope.project_id}/thread/#{thread_id}/messages", {content, _csrf: window.csrfToken})
+				.error (error) ->
+					ide.showGenericMessageModal("Error submitting comment", "Sorry, there was a problem submitting your comment")
 			$timeout () ->
 			$timeout () ->
 				$scope.$broadcast "review-panel:layout"
 				$scope.$broadcast "review-panel:layout"
-		
+			event_tracking.sendMB "rp-new-comment", { size: content.length }
+
 		$scope.cancelNewComment = (entry) ->
 		$scope.cancelNewComment = (entry) ->
-			# $scope.commentState.adding = false
-			# $scope.commentState.content = ""
 			$timeout () ->
 			$timeout () ->
 				$scope.$broadcast "review-panel:layout"
 				$scope.$broadcast "review-panel:layout"
 		
 		
@@ -267,117 +305,165 @@ define [
 			$timeout () ->
 			$timeout () ->
 				$scope.$broadcast "review-panel:layout"
 				$scope.$broadcast "review-panel:layout"
 
 
-		# $scope.handleCommentReplyKeyPress = (ev, entry) ->
-		# 	if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
-		# 		ev.preventDefault()
-		# 		ev.target.blur()
-		# 		$scope.submitReply(entry)
-
-		$scope.submitReply = (entry, entry_id) ->
-			$scope.unresolveComment(entry_id)
-			entry.thread.push {
-				content: entry.replyContent
-				ts: new Date()
-				user_id: window.user_id
-			}
-			entry.replyContent = ""
-			entry.replying = false
-			$timeout () ->
-				$scope.$broadcast "review-panel:layout"
-			# TODO Just for prototyping purposes; remove afterwards
-			window.setTimeout((() -> 
-				$scope.$applyAsync(() -> submitMockedReply(entry))
-			), 1000 * 2)
-
-		# TODO Just for prototyping purposes; remove afterwards.
-		submitMockedReply = (entry) ->
-			entry.thread.push {
-				content: 'Sounds good!'
-				ts: new Date()
-				user_id: mockedUserId
-			}
+		$scope.submitReply = (entry, entry_id) ->			
+			thread_id = entry.thread_id
+			content   = entry.replyContent
+			$http.post("/project/#{$scope.project_id}/thread/#{thread_id}/messages", {content, _csrf: window.csrfToken})
+				.error (error) ->
+					ide.showGenericMessageModal("Error submitting comment", "Sorry, there was a problem submitting your comment")
+			
+			trackingMetadata =
+				view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini'
+				size: entry.replyContent.length
+				thread: thread_id
+
+			thread = getThread(thread_id)
+			thread.submitting = true
 			entry.replyContent = ""
 			entry.replyContent = ""
 			entry.replying = false
 			entry.replying = false
 			$timeout () ->
 			$timeout () ->
 				$scope.$broadcast "review-panel:layout"
 				$scope.$broadcast "review-panel:layout"
-		
+			event_tracking.sendMB "rp-comment-reply", trackingMetadata
+
 		$scope.cancelReply = (entry) ->
 		$scope.cancelReply = (entry) ->
 			entry.replying = false
 			entry.replying = false
 			entry.replyContent = ""
 			entry.replyContent = ""
 			$scope.$broadcast "review-panel:layout"
 			$scope.$broadcast "review-panel:layout"
 
 
 		$scope.resolveComment = (entry, entry_id) ->
 		$scope.resolveComment = (entry, entry_id) ->
-			entry.showWhenResolved = false
 			entry.focused = false
 			entry.focused = false
-			$scope.$broadcast "comment:resolve", entry_id, window.user_id
+			$http.post "/project/#{$scope.project_id}/thread/#{entry.thread_id}/resolve", {_csrf: window.csrfToken}
+			_onCommentResolved(entry.thread_id, ide.$scope.user)
+			event_tracking.sendMB "rp-comment-resolve", { view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' }
+
+		$scope.unresolveComment = (thread_id) ->
+			_onCommentReopened(thread_id)
+			$http.post "/project/#{$scope.project_id}/thread/#{thread_id}/reopen", {_csrf: window.csrfToken}
+			event_tracking.sendMB "rp-comment-reopen"
 		
 		
-		$scope.unresolveComment = (entry_id) ->
-			$scope.$broadcast "comment:unresolve", entry_id
+		_onCommentResolved = (thread_id, user) ->
+			thread = $scope.reviewPanel.commentThreads[thread_id]
+			thread.resolved = true
+			thread.resolved_by_user = formatUser(user)
+			thread.resolved_at = new Date()
+			$scope.reviewPanel.resolvedThreadIds[thread_id] = true
+			$scope.$broadcast "comment:resolve_thread", thread_id
 		
 		
-		$scope.deleteComment = (entry_id) ->
-			$scope.$broadcast "comment:remove", entry_id
-
-		$scope.showThread = (entry) ->
-			entry.showWhenResolved = true
-			$timeout () ->
-				$scope.$broadcast "review-panel:layout"
+		_onCommentReopened = (thread_id) ->
+			thread = $scope.reviewPanel.commentThreads[thread_id]
+			delete thread.resolved
+			delete thread.resolved_by_user
+			delete thread.resolved_at
+			delete $scope.reviewPanel.resolvedThreadIds[thread_id]
+			$scope.$broadcast "comment:unresolve_thread", thread_id
 
 
-		$scope.hideThread = (entry) ->
-			entry.showWhenResolved = false
-			$timeout () ->
-				$scope.$broadcast "review-panel:layout"
+		_onCommentDeleted = (thread_id) ->
+			if $scope.reviewPanel.resolvedThreadIds[thread_id]?
+				delete $scope.reviewPanel.resolvedThreadIds[thread_id]
+				
+			delete $scope.reviewPanel.commentThreads[thread_id]
+		
+		$scope.deleteComment = (entry_id, thread_id) ->
+			_onCommentDeleted(thread_id)
+			$scope.$broadcast "comment:remove", entry_id
+			event_tracking.sendMB "rp-comment-delete"
 
 
 		$scope.setSubView = (subView) -> 
 		$scope.setSubView = (subView) -> 
 			$scope.reviewPanel.subView = subView
 			$scope.reviewPanel.subView = subView
+			event_tracking.sendMB "rp-subview-change", { subView }
 						
 						
 		$scope.gotoEntry = (doc_id, entry) ->
 		$scope.gotoEntry = (doc_id, entry) ->
 			ide.editorManager.openDocId(doc_id, { gotoOffset: entry.offset })
 			ide.editorManager.openDocId(doc_id, { gotoOffset: entry.offset })
+		
+		$scope.toggleTrackChanges = (value) ->
+			$scope.editor.wantTrackChanges = value
+			$http.post "/project/#{$scope.project_id}/track_changes", {_csrf: window.csrfToken, on: value}
+			event_tracking.sendMB "rp-trackchanges-toggle", { value }
+		
+		ide.socket.on "toggle-track-changes", (value) ->
+			$scope.$apply () ->
+				$scope.editor.wantTrackChanges = value
 
 
-		DOC_ID_NAMES = {} 
-		$scope.getFileName = (doc_id) ->
-			# This is called a lot and is relatively expensive, so cache the result
-			if !DOC_ID_NAMES[doc_id]?
-				entity = ide.fileTreeManager.findEntityById(doc_id)
-				return if !entity?
-				DOC_ID_NAMES[doc_id] = ide.fileTreeManager.getEntityPath(entity)
-			return DOC_ID_NAMES[doc_id]
-
-		# TODO: Eventually we need to get this from the server, and update it 
-		# when we get an id we don't know. This'll do for client side testing
-		refreshUsers = () ->
-			$scope.users = {}
-			# TODO Just for prototyping purposes; remove afterwards.
-			$scope.users[mockedUserId] = {
-				email: "paulo@sharelatex.com"
-				name: "Paulo Reis"
-				isSelf: false
-				hue: 70
-				avatar_text: "PR"
-			}
-			$scope.users[mockedUserId2] = {
-				email: "james@sharelatex.com"
-				name: "James Allen"
-				isSelf: false
-				hue: 320
-				avatar_text: "JA"
-			}
+		_refreshingRangeUsers = false
+		_refreshedForUserIds = {}
+		refreshChangeUsers = (refresh_for_user_id) ->
+			if refresh_for_user_id?
+				if _refreshedForUserIds[refresh_for_user_id]?
+					# We've already tried to refresh to get this user id, so stop it looping
+					return
+				_refreshedForUserIds[refresh_for_user_id] = true
 
 
-			for member in $scope.project.members.concat($scope.project.owner)
-				if member._id == window.user_id
-					name = "You"
-					isSelf = true
-				else
-					name = "#{member.first_name} #{member.last_name}"
-					isSelf = false
-
-				$scope.users[member._id] = {
-					email: member.email
-					name: name
-					isSelf: isSelf
-					hue: ColorManager.getHueForUserId(member._id)
-					avatar_text: [member.first_name, member.last_name].filter((n) -> n?).map((n) -> n[0]).join ""
+			# Only do one refresh at once
+			if _refreshingRangeUsers
+				return
+			_refreshingRangeUsers = true
+
+			$http.get "/project/#{$scope.project_id}/changes/users"
+				.success (users) ->
+					_refreshingRangeUsers = false
+					$scope.users = {}
+					# Always include ourself, since if we submit an op, we might need to display info
+					# about it locally before it has been flushed through the server
+					if ide.$scope.user?.id?
+						$scope.users[ide.$scope.user.id] = formatUser(ide.$scope.user)
+					for user in users
+						if user.id?
+							$scope.users[user.id] = formatUser(user)
+				.error () ->
+					_refreshingRangeUsers = false
+
+		_threadsLoaded = false
+		ensureThreadsAreLoaded = () ->
+			if _threadsLoaded
+				# We get any updates in real time so only need to load them once.
+				return
+			_threadsLoaded = true
+			$scope.reviewPanel.loadingThreads = true
+			$http.get "/project/#{$scope.project_id}/threads"
+				.success (threads) ->
+					$scope.reviewPanel.loadingThreads = false
+					for thread_id, _ of $scope.reviewPanel.resolvedThreadIds
+						delete $scope.reviewPanel.resolvedThreadIds[thread_id]
+					for thread_id, thread of threads
+						for comment in thread.messages
+							formatComment(comment)
+						if thread.resolved_by_user?
+							$scope.$broadcast "comment:resolve_thread", thread_id
+							thread.resolved_by_user = formatUser(thread.resolved_by_user)
+							$scope.reviewPanel.resolvedThreadIds[thread_id] = true
+					$scope.reviewPanel.commentThreads = threads
+					$timeout () ->
+						$scope.$broadcast "review-panel:layout"
+
+		formatComment = (comment) ->
+			comment.user = formatUser(comment.user)
+			comment.timestamp = new Date(comment.timestamp)
+			return comment
+
+		formatUser = (user) ->
+			id = user?._id or user?.id
+
+			if !id?
+				return {
+					email: null
+					name: "Anonymous"
+					isSelf: false
+					hue: ColorManager.ANONYMOUS_HUE
+					avatar_text: "A"
 				}
 				}
-		
-		$scope.$watch "project.members", (members) ->
-			return if !members?
-			refreshUsers()
+			if id == window.user_id
+				name = "You"
+				isSelf = true
+			else
+				name = [user.first_name, user.last_name].filter((n) -> n? and n != "").join(" ")
+				if name == ""
+					name = user.email?.split("@")[0] or "Unknown"
+				isSelf = false
+			return {
+				id: id
+				email: user.email
+				name: name
+				isSelf: isSelf
+				hue: ColorManager.getHueForUserId(id)
+				avatar_text: [user.first_name, user.last_name].filter((n) -> n?).map((n) -> n[0]).join ""
+			}

+ 5 - 3
services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee

@@ -17,6 +17,8 @@ define [
 			scope.startNewComment = () ->
 			scope.startNewComment = () ->
 				scope.state.isAdding = true
 				scope.state.isAdding = true
 				scope.onStartNew()
 				scope.onStartNew()
+				setTimeout () ->
+					scope.$broadcast "comment:new:open"
 
 
 			scope.cancelNewComment = () ->
 			scope.cancelNewComment = () ->
 				scope.state.isAdding = false
 				scope.state.isAdding = false
@@ -25,11 +27,11 @@ define [
 			scope.handleCommentKeyPress = (ev) ->
 			scope.handleCommentKeyPress = (ev) ->
 				if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
 				if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
 					ev.preventDefault()
 					ev.preventDefault()
-					ev.target.blur()
-					scope.submitNewComment()
+					if scope.state.content.length > 0 
+						ev.target.blur()
+						scope.submitNewComment()
 
 
 			scope.submitNewComment = () ->
 			scope.submitNewComment = () ->
-				console.log scope.state.content
 				scope.onSubmit { content: scope.state.content }
 				scope.onSubmit { content: scope.state.content }
 				scope.state.isAdding = false
 				scope.state.isAdding = false
 				scope.state.content = ""
 				scope.state.content = ""

+ 14 - 2
services/web/public/coffee/ide/review-panel/directives/changeEntry.coffee

@@ -1,13 +1,25 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.directive "changeEntry", () ->
+	App.directive "changeEntry", ($timeout) ->
 		restrict: "E"
 		restrict: "E"
 		templateUrl: "changeEntryTemplate"
 		templateUrl: "changeEntryTemplate"
 		scope: 
 		scope: 
 			entry: "="
 			entry: "="
 			user: "="
 			user: "="
+			permissions: "="
 			onAccept: "&"
 			onAccept: "&"
 			onReject: "&"
 			onReject: "&"
 			onIndicatorClick: "&"
 			onIndicatorClick: "&"
-		
+		link: (scope, element, attrs) ->
+			scope.contentLimit = 40
+			scope.isCollapsed = true
+			scope.needsCollapsing = false
+
+			scope.toggleCollapse = () ->
+				scope.isCollapsed = !scope.isCollapsed
+				$timeout () ->
+					scope.$emit "review-panel:layout"
+
+			scope.$watch "entry.content.length", (contentLength) ->
+				scope.needsCollapsing = contentLength > scope.contentLimit

+ 15 - 9
services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee

@@ -1,23 +1,29 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.directive "commentEntry", () ->
+	App.directive "commentEntry", ($timeout) ->
 		restrict: "E"
 		restrict: "E"
 		templateUrl: "commentEntryTemplate"
 		templateUrl: "commentEntryTemplate"
 		scope: 
 		scope: 
 			entry: "="
 			entry: "="
-			users: "="
+			threads: "="
+			permissions: "="
 			onResolve: "&"
 			onResolve: "&"
 			onReply: "&"
 			onReply: "&"
 			onIndicatorClick: "&"
 			onIndicatorClick: "&"
-			onDelete: "&"
-			onUnresolve: "&"
-			onShowThread: "&"
-			onHideThread: "&"
 		link: (scope, element, attrs) ->
 		link: (scope, element, attrs) ->
+			scope.state =
+				animating: false
+
 			scope.handleCommentReplyKeyPress = (ev) ->
 			scope.handleCommentReplyKeyPress = (ev) ->
 				if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
 				if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
 					ev.preventDefault()
 					ev.preventDefault()
-					ev.target.blur()
-					scope.onReply()
-		
+					if scope.entry.replyContent.length > 0 
+						ev.target.blur()
+						scope.onReply()
+			
+			scope.animateAndCallOnResolve = () ->
+				scope.state.animating = true
+				element.find(".rp-entry").css("top", 0)
+				$timeout((() -> scope.onResolve()), 350)
+				return true

+ 21 - 0
services/web/public/coffee/ide/review-panel/directives/resolvedCommentEntry.coffee

@@ -0,0 +1,21 @@
+define [
+	"base"
+], (App) ->
+	App.directive "resolvedCommentEntry", () ->
+		restrict: "E"
+		templateUrl: "resolvedCommentEntryTemplate"
+		scope:
+			thread: "="
+			permissions: "="
+			onUnresolve: "&"
+			onDelete: "&"
+		link: (scope, element, attrs) ->
+			scope.contentLimit = 40
+			scope.needsCollapsing = false
+			scope.isCollapsed = true
+
+			scope.toggleCollapse = () ->
+				scope.isCollapsed = !scope.isCollapsed
+
+			scope.$watch "thread.content.length", (contentLength) ->
+				scope.needsCollapsing = contentLength > scope.contentLimit

+ 58 - 0
services/web/public/coffee/ide/review-panel/directives/resolvedCommentsDropdown.coffee

@@ -0,0 +1,58 @@
+define [
+	"base"
+], (App) ->
+	App.directive "resolvedCommentsDropdown", (_) ->
+		restrict: "E"
+		templateUrl: "resolvedCommentsDropdownTemplate"
+		scope: 
+			entries 	: "="
+			threads 	: "="
+			resolvedIds	: "="
+			docs		: "="
+			permissions: "="
+			onOpen		: "&"
+			onUnresolve	: "&"
+			onDelete	: "&"
+			isLoading	: "="
+
+		link: (scope, element, attrs) ->
+			scope.state = 
+				isOpen: false
+
+			scope.toggleOpenState = () ->
+				scope.state.isOpen = !scope.state.isOpen
+				if (scope.state.isOpen)
+					scope.onOpen()
+						.then () -> filterResolvedComments()
+
+			scope.resolvedComments = []
+
+			scope.handleUnresolve = (threadId) ->
+				scope.onUnresolve({ threadId })
+				scope.resolvedComments = scope.resolvedComments.filter (c) -> c.threadId != threadId
+
+			scope.handleDelete = (entryId, threadId) ->
+				scope.onDelete({ entryId, threadId })
+
+			getDocNameById = (docId) ->
+				doc = _.find(scope.docs, (doc) -> doc.doc.id == docId)
+				if doc?
+					return doc.path
+				else 
+					return null
+
+			filterResolvedComments = () ->
+				scope.resolvedComments = []
+
+				for docId, docEntries of scope.entries
+					for entryId, entry of docEntries
+						if entry.type == "comment" and scope.threads[entry.thread_id]?.resolved?
+							resolvedComment = angular.copy scope.threads[entry.thread_id]
+
+							resolvedComment.content = entry.content
+							resolvedComment.threadId = entry.thread_id
+							resolvedComment.entryId = entryId
+							resolvedComment.docId = docId
+							resolvedComment.docName = getDocNameById(docId)
+
+							scope.resolvedComments.push(resolvedComment)

+ 36 - 14
services/web/public/coffee/ide/review-panel/directives/reviewPanelSorted.coffee

@@ -32,6 +32,8 @@ define [
 					
 					
 					return if entries.length == 0
 					return if entries.length == 0
 					
 					
+					line_height = scope.reviewPanel.rendererData.lineHeight
+
 					focused_entry_index = Math.min(previous_focused_entry_index, entries.length - 1)
 					focused_entry_index = Math.min(previous_focused_entry_index, entries.length - 1)
 					for entry, i in entries
 					for entry, i in entries
 						if entry.scope.entry.focused
 						if entry.scope.entry.focused
@@ -43,15 +45,34 @@ define [
 					previous_focused_entry_index = focused_entry_index
 					previous_focused_entry_index = focused_entry_index
 					
 					
 					sl_console.log "focused_entry_index", focused_entry_index
 					sl_console.log "focused_entry_index", focused_entry_index
-					
-					line_height = 15
-					
-					# Put the focused entry exactly where it wants to be
-					focused_entry_top = Math.max(TOOLBAR_HEIGHT, focused_entry.scope.entry.screenPos.y)
+
+					# As we go backwards, we run the risk of pushing things off the top of the editor.
+					# If we go through the entries before and assume they are as pushed together as they
+					# could be, we can work out the 'ceiling' that each one can't go through. I.e. the first
+					# on can't go beyond the toolbar height, the next one can't go beyond the bottom of the first
+					# one at this minimum height, etc.
+					heights = (entry.$layout_el.height() for entry in entries_before)
+					previousMinTop = TOOLBAR_HEIGHT
+					min_tops = []
+					for height in heights
+						min_tops.push previousMinTop
+						previousMinTop += PADDING + height
+					min_tops.reverse()
+
+					positionLayoutEl = ($callout_el, original_top, top) ->
+						if original_top <= top
+							$callout_el.removeClass("rp-entry-callout-inverted")
+							$callout_el.css(top: original_top + line_height - 1, height: top - original_top)
+						else
+							$callout_el.addClass("rp-entry-callout-inverted")
+							$callout_el.css(top: top + line_height, height: original_top - top)
+
+					# Put the focused entry as close to where it wants to be as possible
+					focused_entry_top = Math.max(previousMinTop, focused_entry.scope.entry.screenPos.y)
 					focused_entry.$box_el.css(top: focused_entry_top)
 					focused_entry.$box_el.css(top: focused_entry_top)
 					focused_entry.$indicator_el.css(top: focused_entry_top)
 					focused_entry.$indicator_el.css(top: focused_entry_top)
-					focused_entry.$callout_el.css(top: focused_entry_top + line_height, height: 0)
-					
+					positionLayoutEl(focused_entry.$callout_el, focused_entry.scope.entry.screenPos.y, focused_entry_top)
+
 					previousBottom = focused_entry_top + focused_entry.$layout_el.height()
 					previousBottom = focused_entry_top + focused_entry.$layout_el.height()
 					for entry in entries_after
 					for entry in entries_after
 						original_top = entry.scope.entry.screenPos.y
 						original_top = entry.scope.entry.screenPos.y
@@ -60,23 +81,21 @@ define [
 						previousBottom = top + height
 						previousBottom = top + height
 						entry.$box_el.css(top: top)
 						entry.$box_el.css(top: top)
 						entry.$indicator_el.css(top: top)
 						entry.$indicator_el.css(top: top)
-						entry.$callout_el.removeClass("rp-entry-callout-inverted")
-						entry.$callout_el.css(top: original_top + line_height, height: top - original_top)
+						positionLayoutEl(entry.$callout_el, original_top, top)
 						sl_console.log "ENTRY", {entry: entry.scope.entry, top}
 						sl_console.log "ENTRY", {entry: entry.scope.entry, top}
-					
+
 					previousTop = focused_entry_top
 					previousTop = focused_entry_top
 					entries_before.reverse() # Work through backwards, starting with the one just above
 					entries_before.reverse() # Work through backwards, starting with the one just above
-					for entry in entries_before
+					for entry, i in entries_before
 						original_top = entry.scope.entry.screenPos.y
 						original_top = entry.scope.entry.screenPos.y
 						height = entry.$layout_el.height()
 						height = entry.$layout_el.height()
 						original_bottom = original_top + height
 						original_bottom = original_top + height
 						bottom = Math.min(original_bottom, previousTop - PADDING)
 						bottom = Math.min(original_bottom, previousTop - PADDING)
-						top = bottom - height
+						top = Math.max(bottom - height, min_tops[i])
 						previousTop = top
 						previousTop = top
 						entry.$box_el.css(top: top)
 						entry.$box_el.css(top: top)
 						entry.$indicator_el.css(top: top)
 						entry.$indicator_el.css(top: top)
-						entry.$callout_el.addClass("rp-entry-callout-inverted")
-						entry.$callout_el.css(top: top + line_height + 1, height: original_top - top)
+						positionLayoutEl(entry.$callout_el, original_top, top)
 						sl_console.log "ENTRY", {entry: entry.scope.entry, top}
 						sl_console.log "ENTRY", {entry: entry.scope.entry, top}
 				
 				
 				scope.$applyAsync () ->
 				scope.$applyAsync () ->
@@ -85,6 +104,9 @@ define [
 				scope.$on "review-panel:layout", () ->
 				scope.$on "review-panel:layout", () ->
 					scope.$applyAsync () ->
 					scope.$applyAsync () ->
 						layout()
 						layout()
+				
+				scope.$watch "reviewPanel.rendererData.lineHeight", () ->
+					layout()
 
 
 				## Scroll lock with Ace
 				## Scroll lock with Ace
 				scroller = element
 				scroller = element

+ 10 - 2
services/web/public/coffee/ide/review-panel/directives/reviewPanelToggle.coffee

@@ -4,10 +4,18 @@ define [
 	App.directive "reviewPanelToggle", () ->
 	App.directive "reviewPanelToggle", () ->
 		restrict: "E"
 		restrict: "E"
 		scope: 
 		scope: 
-			innerModel: '=ngModel'
+			onToggle: '='
+			ngModel: '='
+		link: (scope) ->
+			scope.onChange = (args...) ->
+				scope.onToggle(scope.localModel)
+			scope.localModel = scope.ngModel
+			scope.$watch "ngModel", (value) ->
+				scope.localModel = value
+
 		template: """
 		template: """
 <div class="rp-toggle">
 <div class="rp-toggle">
-	<input id="rp-toggle-{{$id}}" type="checkbox" class="rp-toggle-hidden-input" ng-model="innerModel" />
+	<input id="rp-toggle-{{$id}}" type="checkbox" class="rp-toggle-hidden-input" ng-model="localModel" ng-change="onChange()" />
 	<label for="rp-toggle-{{$id}}" class="rp-toggle-btn"></label>
 	<label for="rp-toggle-{{$id}}" class="rp-toggle-btn"></label>
 </div>
 </div>
 """
 """

+ 5 - 0
services/web/public/coffee/ide/review-panel/filters/notEmpty.coffee

@@ -0,0 +1,5 @@
+define [
+	"base"
+], (App) ->
+	app.filter 'notEmpty', () ->
+		(object) -> !angular.equals({}, object)

+ 18 - 10
services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee

@@ -8,6 +8,7 @@ define [
 		}
 		}
 		$scope.state = {
 		$scope.state = {
 			error: null
 			error: null
+			errorReason: null
 			inflight: false
 			inflight: false
 			startedFreeTrial: false
 			startedFreeTrial: false
 			invites: []
 			invites: []
@@ -69,7 +70,8 @@ define [
 
 
 				members = $scope.inputs.contacts
 				members = $scope.inputs.contacts
 				$scope.inputs.contacts = []
 				$scope.inputs.contacts = []
-				$scope.state.error = null
+				$scope.state.error = false
+				$scope.state.errorReason = null
 				$scope.state.inflight = true
 				$scope.state.inflight = true
 
 
 				if !$scope.project.invites?
 				if !$scope.project.invites?
@@ -101,17 +103,22 @@ define [
 
 
 					request
 					request
 						.success (data) ->
 						.success (data) ->
-							if data.invite
-								invite = data.invite
-								$scope.project.invites.push invite
+							if data.error
+								$scope.state.error = true
+								$scope.state.errorReason = "#{data.error}"
+								$scope.state.inflight = false
 							else
 							else
-								if data.users?
-									users = data.users
-								else if data.user?
-									users = [data.user]
+								if data.invite
+									invite = data.invite
+									$scope.project.invites.push invite
 								else
 								else
-									users = []
-								$scope.project.members.push users...
+									if data.users?
+										users = data.users
+									else if data.user?
+										users = [data.user]
+									else
+										users = []
+									$scope.project.members.push users...
 
 
 							setTimeout () ->
 							setTimeout () ->
 								# Give $scope a chance to update $scope.canAddCollaborators
 								# Give $scope a chance to update $scope.canAddCollaborators
@@ -121,6 +128,7 @@ define [
 						.error () ->
 						.error () ->
 							$scope.state.inflight = false
 							$scope.state.inflight = false
 							$scope.state.error = true
 							$scope.state.error = true
+							$scope.state.errorReason = null
 
 
 			$timeout addMembers, 50 # Give email list a chance to update
 			$timeout addMembers, 50 # Give email list a chance to update
 
 

+ 15 - 1
services/web/public/coffee/main.coffee

@@ -33,4 +33,18 @@ define [
 	"filters/formatDate"
 	"filters/formatDate"
 	"__MAIN_CLIENTSIDE_INCLUDES__"
 	"__MAIN_CLIENTSIDE_INCLUDES__"
 ], () ->
 ], () ->
-	angular.bootstrap(document.body, ["SharelatexApp"])
+	angular.module('SharelatexApp').config(
+		($locationProvider) ->
+			try
+				$locationProvider.html5Mode({
+					enabled: false,
+					requireBase: false,
+					rewriteLinks: false
+				})
+			catch e
+				console.error "Error while trying to fix '#' links: ", e
+	)
+	angular.bootstrap(
+		document.body,
+		["SharelatexApp"]
+	)

+ 5 - 1
services/web/public/coffee/main/account-settings.coffee

@@ -64,7 +64,11 @@ define [
 						$scope.state.inflight = false
 						$scope.state.inflight = false
 						$scope.state.error = false
 						$scope.state.error = false
 						$scope.state.invalidCredentials = false
 						$scope.state.invalidCredentials = false
-						window.location = "/"
+						setTimeout(
+							() ->
+								window.location = "/login"
+							, 1000
+						)
 					.error (data, status) ->
 					.error (data, status) ->
 						$scope.state.inflight = false
 						$scope.state.inflight = false
 						if status == 403
 						if status == 403

+ 23 - 14
services/web/public/coffee/main/announcements.coffee

@@ -1,20 +1,29 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.controller "AnnouncementsController", ($scope, $http, event_tracking, $window) ->
+	App.controller "AnnouncementsController", ($scope, $http, event_tracking, $window, _) ->
+		$scope.announcements = []
+		$scope.ui =
+			isOpen: false
+			newItems: 0
+		
+		refreshAnnouncements = ->
+			$http.get("/announcements").success (announcements) ->
+				$scope.announcements = announcements
+				$scope.ui.newItems = _.filter(announcements, (announcement) -> !announcement.read).length
+				
+		markAnnouncementsAsRead = ->
+			event_tracking.sendMB "announcement-alert-dismissed", { blogPostId: $scope.announcements[0].id }
 
 
-		$scope.dataRecived = false
-		announcement = null
-		$http.get("/announcements").success (announcements) ->
-			if announcements?[0]?
-				announcement = announcements[0]
-				$scope.title = announcement.title
-				$scope.totalAnnouncements =  announcements.length
-				$scope.dataRecived = true
+		refreshAnnouncements()
 
 
-		dismissannouncement = ->
-			event_tracking.sendMB "announcement-alert-dismissed", {blogPostId:announcement.id}
+		$scope.toggleAnnouncementsUI = ->
+			$scope.ui.isOpen = !$scope.ui.isOpen
+
+			if !$scope.ui.isOpen and $scope.ui.newItems
+				$scope.ui.newItems = 0
+				markAnnouncementsAsRead()
+
+		$scope.showAll = -> 
+			$scope.ui.newItems = 0
 
 
-		$scope.openLink = ->
-			dismissannouncement()
-			$window.location.href = announcement.url

BIN
services/web/public/img/about/chris.jpg


BIN
services/web/public/img/about/geri.jpg


BIN
services/web/public/img/spellcheck-underline.png


BIN
services/web/public/img/spellcheck-underline@2x.png


+ 10 - 4
services/web/public/js/ace-1.2.5/mode-latex.js

@@ -242,6 +242,10 @@ var createLatexWorker = function (session) {
         var annotations = [];
         var annotations = [];
         var newRange = {};
         var newRange = {};
         var cursor = selection.getCursor();
         var cursor = selection.getCursor();
+        var maxRow = session.getLength() - 1;
+        var maxCol = (maxRow > 0) ? session.getLine(maxRow).length : 0;
+        var cursorAtEndOfDocument = (cursor.row == maxRow) && (cursor.column === maxCol);
+
         suppressions = [];
         suppressions = [];
 
 
         for (var i = 0, len = hints.length; i<len; i++) {
         for (var i = 0, len = hints.length; i<len; i++) {
@@ -250,8 +254,8 @@ var createLatexWorker = function (session) {
             var suppressedChanges = 0;
             var suppressedChanges = 0;
             var hintRange = new Range(hint.start_row, hint.start_col, hint.end_row, hint.end_col);
             var hintRange = new Range(hint.start_row, hint.start_col, hint.end_row, hint.end_col);
 
 
-            var cursorInRange = hintRange.insideStart(cursor.row, cursor.column);
-            var cursorAtStart = hintRange.isStart(cursor.row, cursor.column);
+            var cursorInRange = hintRange.insideEnd(cursor.row, cursor.column);
+            var cursorAtStart = hintRange.isStart(cursor.row, cursor.column - 1); // cursor after start not before
             var cursorAtEnd = hintRange.isEnd(cursor.row, cursor.column);
             var cursorAtEnd = hintRange.isEnd(cursor.row, cursor.column);
             if (hint.suppressIfEditing && (cursorAtStart || cursorAtEnd)) {
             if (hint.suppressIfEditing && (cursorAtStart || cursorAtEnd)) {
                 suppressions.push(hintRange);
                 suppressions.push(hintRange);
@@ -289,8 +293,10 @@ var createLatexWorker = function (session) {
                 cursorInRange = newRange[key].cursorInRange;
                 cursorInRange = newRange[key].cursorInRange;
                 hint = newRange[key].hint;
                 hint = newRange[key].hint;
                 var errorAtStart = (hint.row === hint.start_row && hint.column === hint.start_col);
                 var errorAtStart = (hint.row === hint.start_row && hint.column === hint.start_col);
-                var a = (cursorInRange && !errorAtStart) ? cursorAnchor : doc.createAnchor(new_range.start);
-                var b = (cursorInRange && errorAtStart) ? cursorAnchor : doc.createAnchor(new_range.end);
+                var movableStart = (cursorInRange && !errorAtStart) && !cursorAtEndOfDocument;
+                var movableEnd = (cursorInRange && errorAtStart) && !cursorAtEndOfDocument;
+                var a = movableStart ? cursorAnchor : doc.createAnchor(new_range.start);
+                var b = movableEnd ? cursorAnchor : doc.createAnchor(new_range.end);
                 var range = new Range();
                 var range = new Range();
                 range.start = a;
                 range.start = a;
                 range.end = b;
                 range.end = b;

+ 378 - 0
services/web/public/js/ace-1.2.5/mode-latex_beta.js

@@ -0,0 +1,378 @@
+ace.define("ace/mode/latex_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"], function(require, exports, module) {
+"use strict";
+
+var oop = require("../lib/oop");
+var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules;
+
+var LatexHighlightRules = function() {  
+
+    this.$rules = {
+        "start" : [{
+            token : "comment",
+            regex : "%.*$"
+        }, {
+            token : ["keyword", "lparen", "variable.parameter", "rparen", "lparen", "storage.type", "rparen"],
+            regex : "(\\\\(?:documentclass|usepackage|input))(?:(\\[)([^\\]]*)(\\]))?({)([^}]*)(})"
+        }, {
+            token : ["keyword","lparen", "variable.parameter", "rparen"],
+            regex : "(\\\\(?:label|v?ref|cite(?:[^{]*)))(?:({)([^}]*)(}))?"
+        }, {
+            token : ["storage.type", "lparen", "variable.parameter", "rparen"],
+            regex : "(\\\\(?:begin|end))({)(\\w*)(})"
+        }, {
+            token : "storage.type",
+            regex : "\\\\[a-zA-Z]+"
+        }, {
+            token : "lparen",
+            regex : "[[({]"
+        }, {
+            token : "rparen",
+            regex : "[\\])}]"
+        }, {
+            token : "constant.character.escape",
+            regex : "\\\\[^a-zA-Z]?"
+        }, {
+            token : "string",
+            regex : "\\${1,2}",
+            next  : "equation"
+        }],
+        "equation" : [{
+            token : "comment",
+            regex : "%.*$"
+        }, {
+            token : "string",
+            regex : "\\${1,2}",
+            next  : "start"
+        }, {
+            token : "constant.character.escape",
+            regex : "\\\\(?:[^a-zA-Z]|[a-zA-Z]+)"
+        }, {
+            token : "error", 
+            regex : "^\\s*$", 
+            next : "start" 
+        }, {
+            defaultToken : "string"
+        }]
+
+    };
+};
+oop.inherits(LatexHighlightRules, TextHighlightRules);
+
+exports.LatexHighlightRules = LatexHighlightRules;
+
+});
+
+ace.define("ace/mode/folding/latex",["require","exports","module","ace/lib/oop","ace/mode/folding/fold_mode","ace/range","ace/token_iterator"], function(require, exports, module) {
+"use strict";
+
+var oop = require("../../lib/oop");
+var BaseFoldMode = require("./fold_mode").FoldMode;
+var Range = require("../../range").Range;
+var TokenIterator = require("../../token_iterator").TokenIterator;
+
+var FoldMode = exports.FoldMode = function() {};
+
+oop.inherits(FoldMode, BaseFoldMode);
+
+(function() {
+
+    this.foldingStartMarker = /^\s*\\(begin)|(section|subsection|paragraph)\b|{\s*$/;
+    this.foldingStopMarker = /^\s*\\(end)\b|^\s*}/;
+
+    this.getFoldWidgetRange = function(session, foldStyle, row) {
+        var line = session.doc.getLine(row);
+        var match = this.foldingStartMarker.exec(line);
+        if (match) {
+            if (match[1])
+                return this.latexBlock(session, row, match[0].length - 1);
+            if (match[2])
+                return this.latexSection(session, row, match[0].length - 1);
+
+            return this.openingBracketBlock(session, "{", row, match.index);
+        }
+
+        var match = this.foldingStopMarker.exec(line);
+        if (match) {
+            if (match[1])
+                return this.latexBlock(session, row, match[0].length - 1);
+
+            return this.closingBracketBlock(session, "}", row, match.index + match[0].length);
+        }
+    };
+
+    this.latexBlock = function(session, row, column) {
+        var keywords = {
+            "\\begin": 1,
+            "\\end": -1
+        };
+
+        var stream = new TokenIterator(session, row, column);
+        var token = stream.getCurrentToken();
+        if (!token || !(token.type == "storage.type" || token.type == "constant.character.escape"))
+            return;
+
+        var val = token.value;
+        var dir = keywords[val];
+
+        var getType = function() {
+            var token = stream.stepForward();
+            var type = token.type == "lparen" ?stream.stepForward().value : "";
+            if (dir === -1) {
+                stream.stepBackward();
+                if (type)
+                    stream.stepBackward();
+            }
+            return type;
+        };
+        var stack = [getType()];
+        var startColumn = dir === -1 ? stream.getCurrentTokenColumn() : session.getLine(row).length;
+        var startRow = row;
+
+        stream.step = dir === -1 ? stream.stepBackward : stream.stepForward;
+        while(token = stream.step()) {
+            if (!token || !(token.type == "storage.type" || token.type == "constant.character.escape"))
+                continue;
+            var level = keywords[token.value];
+            if (!level)
+                continue;
+            var type = getType();
+            if (level === dir)
+                stack.unshift(type);
+            else if (stack.shift() !== type || !stack.length)
+                break;
+        }
+
+        if (stack.length)
+            return;
+
+        var row = stream.getCurrentTokenRow();
+        if (dir === -1)
+            return new Range(row, session.getLine(row).length, startRow, startColumn);
+        stream.stepBackward();
+        return new Range(startRow, startColumn, row, stream.getCurrentTokenColumn());
+    };
+
+    this.latexSection = function(session, row, column) {
+        var keywords = ["\\subsection", "\\section", "\\begin", "\\end", "\\paragraph"];
+
+        var stream = new TokenIterator(session, row, column);
+        var token = stream.getCurrentToken();
+        if (!token || token.type != "storage.type")
+            return;
+
+        var startLevel = keywords.indexOf(token.value);
+        var stackDepth = 0
+        var endRow = row;
+
+        while(token = stream.stepForward()) {
+            if (token.type !== "storage.type")
+                continue;
+            var level = keywords.indexOf(token.value);
+
+            if (level >= 2) {
+                if (!stackDepth)
+                    endRow = stream.getCurrentTokenRow() - 1;
+                stackDepth += level == 2 ? 1 : - 1;
+                if (stackDepth < 0)
+                    break
+            } else if (level >= startLevel)
+                break;
+        }
+
+        if (!stackDepth)
+            endRow = stream.getCurrentTokenRow() - 1;
+
+        while (endRow > row && !/\S/.test(session.getLine(endRow)))
+            endRow--;
+
+        return new Range(
+            row, session.getLine(row).length,
+            endRow, session.getLine(endRow).length
+        );
+    };
+
+}).call(FoldMode.prototype);
+
+});
+
+ace.define("ace/mode/latex_beta",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/latex_highlight_rules","ace/mode/folding/latex","ace/range","ace/worker/worker_client"], function(require, exports, module) {
+"use strict";
+
+var oop = require("../lib/oop");
+var TextMode = require("./text").Mode;
+var LatexHighlightRules = require("./latex_highlight_rules").LatexHighlightRules;
+var LatexFoldMode = require("./folding/latex").FoldMode;
+var Range = require("../range").Range;
+var WorkerClient = require("ace/worker/worker_client").WorkerClient;
+
+var createLatexWorker = function (session) {
+    var doc = session.getDocument();
+    var selection = session.getSelection();
+    var cursorAnchor = selection.lead;
+
+    var savedRange = {};
+    var suppressions = [];
+    var hints = [];
+    var changeHandler = null;
+    var docChangePending = false;
+    var firstPass = true;
+
+    var worker = new WorkerClient(["ace"], "ace/mode/latex_beta_worker", "LatexWorker");
+    worker.attachToDocument(doc);
+    var docChangeHandler = doc.on("change", function () {
+        docChangePending = true;
+        if(changeHandler) {
+            clearTimeout(changeHandler);
+            changeHandler = null;
+        }
+    });
+
+    var cursorHandler = selection.on("changeCursor", function () {
+        if (docChangePending) { return; } ;
+        changeHandler = setTimeout(function () {
+            updateMarkers({cursorMoveOnly:true});
+            suppressions = [];
+            changeHandler = null;
+        }, 100);
+    });
+
+    var updateMarkers = function (options) {
+        if (!options) { options = {};};
+        var cursorMoveOnly = options.cursorMoveOnly;
+        var annotations = [];
+        var newRange = {};
+        var cursor = selection.getCursor();
+        var maxRow = session.getLength() - 1;
+        var maxCol = (maxRow > 0) ? session.getLine(maxRow).length : 0;
+        var cursorAtEndOfDocument = (cursor.row == maxRow) && (cursor.column === maxCol);
+
+        suppressions = [];
+
+        for (var i = 0, len = hints.length; i<len; i++) {
+            var hint = hints[i];
+
+            var suppressedChanges = 0;
+            var hintRange = new Range(hint.start_row, hint.start_col, hint.end_row, hint.end_col);
+
+            var cursorInRange = hintRange.insideEnd(cursor.row, cursor.column);
+            var cursorAtStart = hintRange.isStart(cursor.row, cursor.column - 1); // cursor after start not before
+            var cursorAtEnd = hintRange.isEnd(cursor.row, cursor.column);
+            if (hint.suppressIfEditing && (cursorAtStart || cursorAtEnd)) {
+                suppressions.push(hintRange);
+                if (!hint.suppressed) { suppressedChanges++; };
+                hint.suppressed = true;
+                continue;
+            }
+            var isCascadeError = false;
+            for (var j = 0, suplen = suppressions.length; j < suplen; j++) {
+                var badRange = suppressions[j];
+                if (badRange.intersects(hintRange)) {
+                    isCascadeError = true;
+                    break;
+                }
+            }
+            if(isCascadeError) {
+                if (!hint.suppressed) { suppressedChanges++; };
+                hint.suppressed = true;
+                continue;
+            };
+
+            if (hint.suppressed) { suppressedChanges++; };
+            hint.suppressed = false;
+
+            annotations.push(hint);
+            if (hint.type === "info") {
+                continue;
+            };
+            var key = hintRange.toString() + (cursorInRange ? "+cursor" : "");
+            newRange[key] = {hint: hint, cursorInRange: cursorInRange, range: hintRange};
+        }
+        for (key in newRange) {
+            if (!savedRange[key]) {  // doesn't exist in already displayed errors
+                var new_range = newRange[key].range;
+                cursorInRange = newRange[key].cursorInRange;
+                hint = newRange[key].hint;
+                var errorAtStart = (hint.row === hint.start_row && hint.column === hint.start_col);
+                var movableStart = (cursorInRange && !errorAtStart) && !cursorAtEndOfDocument;
+                var movableEnd = (cursorInRange && errorAtStart) && !cursorAtEndOfDocument;
+                var a = movableStart ? cursorAnchor : doc.createAnchor(new_range.start);
+                var b = movableEnd ? cursorAnchor : doc.createAnchor(new_range.end);
+                var range = new Range();
+                range.start = a;
+                range.end = b;
+                var cssClass = "ace_error-marker";
+                if (hint.type === "warning") { cssClass = "ace_highlight-marker"; };
+                range.id = session.addMarker(range, cssClass, "text");
+                savedRange[key] = range;
+            }
+        }
+        for (key in savedRange) {
+            if (!newRange[key]) {  // no longer present in list of errors to display
+                range = savedRange[key];
+                if (range.start !== cursorAnchor) { range.start.detach(); }
+                if (range.end !== cursorAnchor) { range.end.detach(); }
+                session.removeMarker(range.id);
+                delete savedRange[key];
+            }
+        }
+        if (!cursorMoveOnly || suppressedChanges) {
+            if (firstPass) {
+                if (annotations.length > 0) {
+                    var originalAnnotations = session.getAnnotations();
+                    session.setAnnotations(originalAnnotations.concat(annotations));
+                };
+                firstPass = false;
+            } else {
+                session.setAnnotations(annotations);
+            }
+        };
+
+    };
+    worker.on("lint", function(results) {
+        if(docChangePending) { docChangePending = false; };
+        hints = results.data;
+        if (hints.length > 100) {
+            hints = hints.slice(0, 100); // limit to 100 errors
+        };
+        updateMarkers();
+    });
+    worker.on("terminate", function() {
+        if(changeHandler) {
+            clearTimeout(changeHandler);
+            changeHandler = null;
+        }
+        doc.off("change", docChangeHandler);
+        selection.off("changeCursor", cursorHandler);
+        for (var key in savedRange) {
+            var range = savedRange[key];
+            if (range.start !== cursorAnchor) { range.start.detach(); }
+            if (range.end !== cursorAnchor) { range.end.detach(); }
+            session.removeMarker(range.id);
+        }
+        savedRange = {};
+        hints = [];
+        suppressions = [];
+        session.clearAnnotations();
+    });
+
+    return worker;
+};
+
+var Mode = function() {
+    this.HighlightRules = LatexHighlightRules;
+    this.foldingRules = new LatexFoldMode();
+    this.createWorker = createLatexWorker;
+};
+oop.inherits(Mode, TextMode);
+
+(function() {
+    this.type = "text";
+
+    this.lineCommentStart = "%";
+
+    this.$id = "ace/mode/latex_beta";
+}).call(Mode.prototype);
+
+exports.Mode = Mode;
+
+});

+ 7 - 0
services/web/public/js/ace-1.2.5/snippets/latex_beta.js

@@ -0,0 +1,7 @@
+ace.define("ace/snippets/latex_beta",["require","exports","module"], function(require, exports, module) {
+"use strict";
+
+exports.snippetText =undefined;
+exports.scope = "latex";
+
+});

+ 565 - 112
services/web/public/js/ace-1.2.5/worker-latex.js

@@ -1490,7 +1490,7 @@ var Tokenise = function (text) {
             var controlSequence = NEXTCS.exec(text);
             var controlSequence = NEXTCS.exec(text);
             var nextSpecialPos = controlSequence === null ? idx : controlSequence.index;
             var nextSpecialPos = controlSequence === null ? idx : controlSequence.index;
             if (nextSpecialPos === idx) {
             if (nextSpecialPos === idx) {
-                Tokens.push([lineNumber, code, pos, idx + 1, text[idx]]);
+                Tokens.push([lineNumber, code, pos, idx + 1, text[idx], "control-symbol"]);
                 idx = SPECIAL.lastIndex = idx + 1;
                 idx = SPECIAL.lastIndex = idx + 1;
                 char = text[nextSpecialPos];
                 char = text[nextSpecialPos];
                 if (char === '\n') { lineNumber++; linePosition[lineNumber] = nextSpecialPos;};
                 if (char === '\n') { lineNumber++; linePosition[lineNumber] = nextSpecialPos;};
@@ -1508,12 +1508,7 @@ var Tokenise = function (text) {
         } else if (code === "}") {  // close group
         } else if (code === "}") {  // close group
             Tokens.push([lineNumber, code, pos]);
             Tokens.push([lineNumber, code, pos]);
         } else if (code === "$") {  // math mode
         } else if (code === "$") {  // math mode
-            if (text[idx] === "$") {
-                idx = SPECIAL.lastIndex = idx + 1;
-                Tokens.push([lineNumber, "$$", pos]);
-            } else {
-                Tokens.push([lineNumber, code, pos]);
-            }
+            Tokens.push([lineNumber, code, pos]);
         } else if (code === "&") {  // tabalign
         } else if (code === "&") {  // tabalign
             Tokens.push([lineNumber, code, pos]);
             Tokens.push([lineNumber, code, pos]);
         } else if (code === "#") {  // macro parameter
         } else if (code === "#") {  // macro parameter
@@ -1559,6 +1554,25 @@ var read1arg = function (TokeniseResult, k, options) {
     }
     }
 };
 };
 
 
+var readLetDefinition = function (TokeniseResult, k) {
+
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var first = Tokens[k+1];
+    var second = Tokens[k+2];
+    var third = Tokens[k+3];
+
+    if(first && first[1] === "\\" && second && second[1] === "\\") {
+        return k + 2;
+    } else if(first && first[1] === "\\" &&
+              second && second[1] === "Text" && text.substring(second[2], second[3]) === "=" &&
+              third && third[1] === "\\") {
+        return k + 3;
+    } else {
+        return null;
+    }
+};
 
 
 var read1name = function (TokeniseResult, k) {
 var read1name = function (TokeniseResult, k) {
     var Tokens = TokeniseResult.tokens;
     var Tokens = TokeniseResult.tokens;
@@ -1594,19 +1608,88 @@ var read1name = function (TokeniseResult, k) {
     }
     }
 };
 };
 
 
+var read1filename = function (TokeniseResult, k) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var fileName = "";
+    for (var j = k + 1, tok; (tok = Tokens[j]); j++) {
+        if (tok[1] === "Text") {
+            var str = text.substring(tok[2], tok[3]);
+            if (!str.match(/^\S*$/)) { break; }
+            fileName = fileName + str;
+        } else if (tok[1] === "_") {
+            fileName = fileName + "_";
+        } else {
+            break;
+        }
+    }
+    if (fileName.length > 0) {
+        return  j; // advance past these tokens
+    } else {
+        return null;
+    }
+};
+
 var readOptionalParams = function(TokeniseResult, k) {
 var readOptionalParams = function(TokeniseResult, k) {
     var Tokens = TokeniseResult.tokens;
     var Tokens = TokeniseResult.tokens;
     var text = TokeniseResult.text;
     var text = TokeniseResult.text;
 
 
     var params = Tokens[k+1];
     var params = Tokens[k+1];
-
     if(params && params[1] === "Text") {
     if(params && params[1] === "Text") {
         var paramNum = text.substring(params[2], params[3]);
         var paramNum = text.substring(params[2], params[3]);
         if (paramNum.match(/^\[\d+\](\[[^\]]*\])*\s*$/)) {
         if (paramNum.match(/^\[\d+\](\[[^\]]*\])*\s*$/)) {
             return k + 1; // got it
             return k + 1; // got it
         };
         };
     };
     };
+    var count = 0;
+    var nextToken = Tokens[k+1];
+    var pos = nextToken[2];
+
+    for (var i = pos, end = text.length; i < end; i++) {
+        var char = text[i];
+        if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];};
+        if (char === "[") { count++; }
+        if (char === "]") { count--; }
+        if (count === 0 && char === "{") { return k - 1; }
+        if (count > 0 && (char  === '\r' || char === '\n')) { return null; }
+    };
+    return null;
+};
+
+var readOptionalGeneric = function(TokeniseResult, k) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var params = Tokens[k+1];
+
+    if(params && params[1] === "Text") {
+        var paramNum = text.substring(params[2], params[3]);
+        if (paramNum.match(/^(\[[^\]]*\])+\s*$/)) {
+            return k + 1; // got it
+        };
+    };
+    return null;
+};
+
+var readOptionalDef = function (TokeniseResult, k) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var defToken = Tokens[k];
+    var pos = defToken[3];
+
+    var openBrace = "{";
+    var nextToken = Tokens[k+1];
+    for (var i = pos, end = text.length; i < end; i++) {
+        var char = text[i];
+        if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];};
+        if (char === openBrace) { return k - 1; }; // move back to the last token of the optional arguments
+        if (char  === '\r' || char === '\n') { return null; }
+    };
+
     return null;
     return null;
+
 };
 };
 
 
 var readDefinition = function(TokeniseResult, k) {
 var readDefinition = function(TokeniseResult, k) {
@@ -1697,7 +1780,6 @@ var readUrl = function(TokeniseResult, k) {
     return null;
     return null;
 };
 };
 
 
-
 var InterpretTokens = function (TokeniseResult, ErrorReporter) {
 var InterpretTokens = function (TokeniseResult, ErrorReporter) {
     var Tokens = TokeniseResult.tokens;
     var Tokens = TokeniseResult.tokens;
     var linePosition = TokeniseResult.linePosition;
     var linePosition = TokeniseResult.linePosition;
@@ -1706,11 +1788,30 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) {
 
 
     var TokenErrorFromTo = ErrorReporter.TokenErrorFromTo;
     var TokenErrorFromTo = ErrorReporter.TokenErrorFromTo;
     var TokenError = ErrorReporter.TokenError;
     var TokenError = ErrorReporter.TokenError;
-    var Environments = [];
+    var Environments = new EnvHandler(ErrorReporter);
+
+    var nextGroupMathMode = null; // if the next group should have
+    var nextGroupMathModeStack = [] ; // tracking all nextGroupMathModes
+    var seenUserDefinedBeginEquation = false; // if we have seen macros like \beq
+    var seenUserDefinedEndEquation = false; // if we have seen macros like \eeq
 
 
     for (var i = 0, len = Tokens.length; i < len; i++) {
     for (var i = 0, len = Tokens.length; i < len; i++) {
         var token = Tokens[i];
         var token = Tokens[i];
         var line = token[0], type = token[1], start = token[2], end = token[3], seq = token[4];
         var line = token[0], type = token[1], start = token[2], end = token[3], seq = token[4];
+
+        if (type === "{") {
+            Environments.push({command:"{", token:token, mathMode: nextGroupMathMode});
+            nextGroupMathModeStack.push(nextGroupMathMode);
+            nextGroupMathMode = null;
+            continue;
+        } else if (type === "}") {
+            Environments.push({command:"}", token:token});
+            nextGroupMathMode = nextGroupMathModeStack.pop();
+            continue;
+        } else {
+            nextGroupMathMode = null;
+        };
+
         if (type === "\\") {
         if (type === "\\") {
             if (seq === "begin" || seq === "end") {
             if (seq === "begin" || seq === "end") {
                 var open = Tokens[i+1];
                 var open = Tokens[i+1];
@@ -1759,15 +1860,31 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) {
                     } else {
                     } else {
                         TokenError(token, "invalid environment command");
                         TokenError(token, "invalid environment command");
                     };
                     };
-                    }
-            } else if (seq === "newcommand" || seq === "renewcommand" || seq === "def" || seq === "DeclareRobustCommand") {
-                var newPos = read1arg(TokeniseResult, i, {allowStar: (seq != "def")});
+                }
+            } else if (typeof seq === "string" && seq.match(/^(be|beq|beqa|bea)$/i)) {
+                seenUserDefinedBeginEquation = true;
+            } else if (typeof seq === "string" && seq.match(/^(ee|eeq|eeqn|eeqa|eeqan|eea)$/i)) {
+                seenUserDefinedEndEquation = true;
+            } else if (seq === "newcommand" || seq === "renewcommand" || seq === "DeclareRobustCommand") {
+                var newPos = read1arg(TokeniseResult, i, {allowStar: true});
                 if (newPos === null) { continue; } else {i = newPos;};
                 if (newPos === null) { continue; } else {i = newPos;};
                 newPos = readOptionalParams(TokeniseResult, i);
                 newPos = readOptionalParams(TokeniseResult, i);
                 if (newPos === null) { /* do nothing */ } else {i = newPos;};
                 if (newPos === null) { /* do nothing */ } else {i = newPos;};
                 newPos = readDefinition(TokeniseResult, i);
                 newPos = readDefinition(TokeniseResult, i);
                 if (newPos === null) { /* do nothing */ } else {i = newPos;};
                 if (newPos === null) { /* do nothing */ } else {i = newPos;};
 
 
+            } else if (seq === "def") {
+                newPos = read1arg(TokeniseResult, i);
+                if (newPos === null) { continue; } else {i = newPos;};
+                newPos = readOptionalDef(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+
+            } else if (seq === "let") {
+                newPos = readLetDefinition(TokeniseResult, i);
+                if (newPos === null) { continue; } else {i = newPos;};
+
             } else if (seq === "newcolumntype") {
             } else if (seq === "newcolumntype") {
                 newPos = read1name(TokeniseResult, i);
                 newPos = read1name(TokeniseResult, i);
                 if (newPos === null) { continue; } else {i = newPos;};
                 if (newPos === null) { continue; } else {i = newPos;};
@@ -1791,128 +1908,435 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) {
             } else if (seq === "url") {
             } else if (seq === "url") {
                 newPos = readUrl(TokeniseResult, i);
                 newPos = readUrl(TokeniseResult, i);
                 if (newPos === null) { TokenError(token, "invalid url command"); } else {i = newPos;};
                 if (newPos === null) { TokenError(token, "invalid url command"); } else {i = newPos;};
+            } else if (seq === "left" || seq === "right") {
+                var nextToken = Tokens[i+1];
+                char = "";
+                if (nextToken && nextToken[1] === "Text") {
+                    char = text.substring(nextToken[2], nextToken[2] + 1);
+                } else if (nextToken && nextToken[1] === "\\" && nextToken[5] == "control-symbol") {
+                    char = nextToken[4];
+                } else if (nextToken && nextToken[1] === "\\") {
+                    char = "unknown";
+                }
+                if (char === "" || (char !== "unknown" && "(){}[]<>/|\\.".indexOf(char) === -1)) {
+                    TokenError(token, "invalid bracket command");
+                } else {
+                    i = i + 1;
+                    Environments.push({command:seq, token:token});
+                };
+            } else if (seq === "(" || seq === ")" || seq === "[" || seq === "]") {
+                Environments.push({command:seq, token:token});
+            } else if (seq === "input") {
+                newPos = read1filename(TokeniseResult, i);
+                if (newPos === null) { continue; } else {i = newPos;};
+            } else if (seq === "hbox" || seq === "text" || seq === "mbox" || seq === "footnote" || seq === "intertext" || seq === "shortintertext" || seq === "textnormal" || seq === "tag" || seq === "reflectbox" || seq === "textrm") {
+                nextGroupMathMode = false;
+            } else if (seq === "rotatebox" || seq === "scalebox") {
+                newPos = readOptionalGeneric(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                nextGroupMathMode = false;
+            } else if (seq === "resizebox") {
+                newPos = readOptionalGeneric(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+
+                nextGroupMathMode = false;
+            } else if (seq === "DeclareMathOperator") {
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+            } else if (seq === "DeclarePairedDelimiter") {
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+            } else if (typeof seq === "string" && seq.match(/^(alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|vartheta|iota|kappa|lambda|mu|nu|xi|pi|varpi|rho|varrho|sigma|varsigma|tau|upsilon|phi|varphi|chi|psi|omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega)$/)) {
+                var currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display)
+                if (currentMathMode === null) {
+                    TokenError(token, type + seq + " must be inside math mode", {mathMode:true});
+                };
+            } else if (typeof seq === "string" && seq.match(/^(chapter|section|subsection|subsubsection)$/)) {
+                currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display)
+                if (currentMathMode) {
+                    TokenError(token, type + seq + " used inside math mode", {mathMode:true});
+                    Environments.resetMathMode();
+                };
+            } else if (typeof seq === "string" && seq.match(/^[a-z]+$/)) {
+                nextGroupMathMode = undefined;
+            };
+
+        } else if (type === "$") {
+            var lookAhead = Tokens[i+1];
+            var nextIsDollar = lookAhead && lookAhead[1] === "$";
+            currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display)
+            if (nextIsDollar && (!currentMathMode || currentMathMode.command == "$$")) {
+                Environments.push({command:"$$", token:token});
+                i = i + 1;
+            } else {
+                Environments.push({command:"$", token:token});
             }
             }
-        } else if (type === "{") {
-            Environments.push({command:"{", token:token});
-        } else if (type === "}") {
-            Environments.push({command:"}", token:token});
-        };
+        } else if (type === "^" || type === "_") {
+            currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display)
+            var insideGroup = Environments.insideGroup();  // true if inside {....}
+            if (currentMathMode === null && !insideGroup) {
+                TokenError(token, type + " must be inside math mode", {mathMode:true});
+            };
+        }
+    };
+
+    if (seenUserDefinedBeginEquation && seenUserDefinedEndEquation) {
+        ErrorReporter.filterMath = true;
     };
     };
+
     return Environments;
     return Environments;
 };
 };
 
 
-
-var CheckEnvironments = function (Environments, ErrorReporter) {
+var EnvHandler = function (ErrorReporter) {
     var ErrorTo = ErrorReporter.EnvErrorTo;
     var ErrorTo = ErrorReporter.EnvErrorTo;
     var ErrorFromTo = ErrorReporter.EnvErrorFromTo;
     var ErrorFromTo = ErrorReporter.EnvErrorFromTo;
     var ErrorFrom = ErrorReporter.EnvErrorFrom;
     var ErrorFrom = ErrorReporter.EnvErrorFrom;
 
 
+    var envs = [];
+
     var state = [];
     var state = [];
     var documentClosed = null;
     var documentClosed = null;
     var inVerbatim = false;
     var inVerbatim = false;
     var verbatimRanges = [];
     var verbatimRanges = [];
-    for (var i = 0, len = Environments.length; i < len; i++) {
-        var name = Environments[i].name ;
-        if (name && name.match(/^(verbatim|boxedverbatim|lstlisting|minted)$/)) {
-            Environments[i].verbatim = true;
+
+    this.Environments = envs;
+
+    this.push = function (newEnv) {
+        this.setEnvProps(newEnv);
+        this.checkAndUpdateState(newEnv);
+        envs.push(newEnv);
+    };
+
+    this._endVerbatim = function (thisEnv) {
+        var lastEnv = state.pop();
+        if (lastEnv && lastEnv.name === thisEnv.name) {
+            inVerbatim = false;
+            verbatimRanges.push({start: lastEnv.token[2], end: thisEnv.token[2]});
+        } else {
+            if(lastEnv) { state.push(lastEnv); } ;
         }
         }
-    }
-    for (i = 0, len = Environments.length; i < len; i++) {
-        var thisEnv = Environments[i];
-        if(thisEnv.command === "begin" || thisEnv.command === "{") {
-            if (inVerbatim) { continue; } // ignore anything in verbatim environments
-            if (thisEnv.verbatim) {inVerbatim = true;};
-            state.push(thisEnv);
-        } else if (thisEnv.command === "end" || thisEnv.command === "}") {
-            var lastEnv = state.pop();
+    };
 
 
-            if (inVerbatim) {
-                if (lastEnv && lastEnv.name === thisEnv.name) {
-                    inVerbatim = false;
-                    verbatimRanges.push({start: lastEnv.token[2], end: thisEnv.token[2]});
-                    continue;
-                } else {
-                    if(lastEnv) { state.push(lastEnv); } ;
-                    continue;  // ignore all other commands
-                }
-            };
+    var invalidEnvs = [];
+
+    this._end = function (thisEnv) {
+        do {
+            var lastEnv = state.pop();
+            var retry = false;
+            var i;
 
 
-            if (lastEnv && lastEnv.command === "{" && thisEnv.command === "}") {
-                continue;
-            } else if (lastEnv && lastEnv.name === thisEnv.name) {
-                if (thisEnv.name === "document" && !documentClosed) {
+            if (closedBy(lastEnv, thisEnv)) {
+                if (thisEnv.command === "end" && thisEnv.name === "document" && !documentClosed) {
                     documentClosed = thisEnv;
                     documentClosed = thisEnv;
                 };
                 };
-                continue;
+                return;
             } else if (!lastEnv) {
             } else if (!lastEnv) {
-                if (thisEnv.command === "}") {
-                    if (documentClosed) {
-                        ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected end group }",{errorAtStart: true, type: "info"});
-                    } else {
-                        ErrorTo(thisEnv, "unexpected end group }");
-                    };
-                } else if (thisEnv.command === "end") {
-                    if (documentClosed) {
-                        ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected content",{errorAtStart: true, type: "info"});
-                    } else {
-                        ErrorTo(thisEnv, "unexpected \\end{" + thisEnv.name + "}");
-                    }
-                }
-            } else if (lastEnv.command === "begin" && thisEnv.command === "}") {
-                ErrorFromTo(lastEnv, thisEnv, "unexpected end group } after \\begin{" + lastEnv.name +"}");
-                state.push(lastEnv);
-            } else if (lastEnv.command === "{" && thisEnv.command === "end") {
-                ErrorFromTo(lastEnv, thisEnv,
-                            "unclosed group { found at \\end{" + thisEnv.name + "}",
-                            {suppressIfEditing:true, errorAtStart: true, type:"warning"});
-                i--;
-            } else if (lastEnv.command === "begin" && thisEnv.command === "end") {
-                ErrorFromTo(lastEnv, thisEnv,
-                            "unclosed \\begin{" + lastEnv.name + "} found at \\end{" + thisEnv.name + "} " ,
-                            {errorAtStart: true});
-                for (var j = i + 1; j < len; j++) {
-                    var futureEnv = Environments[j];
-                    if (futureEnv.command === "end" && futureEnv.name === lastEnv.name) {
-                        state.push(lastEnv);
-                        continue;
-                    }
+                if (documentClosed) {
+                    ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected content",{errorAtStart: true, type: "info"});
+                } else {
+                    ErrorTo(thisEnv, "unexpected " + getName(thisEnv));
                 }
                 }
-                lastEnv = state.pop();
-                if(lastEnv) {
-                    if (thisEnv.name === lastEnv.name) {
-                        continue;
-                    } else {
-                        state.push(lastEnv);
+            } else if (invalidEnvs.length > 0 && (i = indexOfClosingEnvInArray(invalidEnvs, thisEnv) > -1)) {
+                invalidEnvs.splice(i, 1);
+                if (lastEnv) { state.push(lastEnv); } ;
+                return;
+            } else {
+                var status = reportError(lastEnv, thisEnv);
+                if (envPrecedence(lastEnv) < envPrecedence(thisEnv)) {
+                    invalidEnvs.push(lastEnv);
+                    retry = true;
+                } else {
+                    var prevLastEnv = state.pop();
+                    if(prevLastEnv) {
+                        if (thisEnv.name === prevLastEnv.name) {
+                            return;
+                        } else {
+                            state.push(prevLastEnv);
+                        }
                     }
                     }
+                    invalidEnvs.push(lastEnv);
                 }
                 }
 
 
             }
             }
+        } while (retry === true);
+    };
+
+    var CLOSING_DELIMITER = {
+        "{" : "}",
+        "left" : "right",
+        "[" : "]",
+        "(" : ")",
+        "$" : "$",
+        "$$": "$$"
+    };
+
+    var closedBy = function (lastEnv, thisEnv) {
+        if (!lastEnv) {
+            return false ;
+        } else if (thisEnv.command === "end") {
+            return lastEnv.command === "begin" && lastEnv.name === thisEnv.name;
+        } else if (thisEnv.command === CLOSING_DELIMITER[lastEnv.command]) {
+            return true;
+        } else {
+            return false;
+        }
+    };
+
+    var indexOfClosingEnvInArray = function (envs, thisEnv) {
+        for (var i = 0, n = envs.length; i < n ; i++) {
+            if (closedBy(envs[i], thisEnv)) {
+                return i;
+            }
         }
         }
-    }
-    while (state.length > 0) {
-        thisEnv = state.pop();
+        return -1;
+    };
+
+    var envPrecedence = function (env) {
+        var openScore = {
+            "{" : 1,
+            "left" : 2,
+            "$" : 3,
+            "$$" : 4,
+            "begin": 4
+        };
+        var closeScore = {
+            "}" : 1,
+            "right" : 2,
+            "$" : 3,
+            "$$" : 5,
+            "end": 4
+        };
+        if (env.command) {
+            return openScore[env.command] || closeScore[env.command];
+        } else {
+            return 0;
+        }
+    };
+
+    var getName = function(env) {
+        var description = {
+            "{" : "open group {",
+            "}" : "close group }",
+            "[" : "open display math \\[",
+            "]" : "close display math \\]",
+            "(" : "open inline math \\(",
+            ")" : "close inline math \\)",
+            "$" : "$",
+            "$$" : "$$",
+            "left" : "\\left",
+            "right" : "\\right"
+        };
+        if (env.command === "begin" || env.command === "end") {
+            return "\\" + env.command + "{" + env.name + "}";
+        } else if (env.command in description) {
+            return description[env.command];
+        } else {
+            return env.command;
+        }
+    };
+
+    var EXTRA_CLOSE = 1;
+    var UNCLOSED_GROUP = 2;
+    var UNCLOSED_ENV = 3;
+
+    var reportError = function(lastEnv, thisEnv) {
+        if (!lastEnv) { // unexpected close, nothing was open!
+            if (documentClosed) {
+                ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected end group }",{errorAtStart: true, type: "info"});
+            } else {
+                ErrorTo(thisEnv, "unexpected " + getName(thisEnv));
+            };
+            return EXTRA_CLOSE;
+        } else if (lastEnv.command === "{" && thisEnv.command === "end") {
+            ErrorFromTo(lastEnv, thisEnv, "unclosed " + getName(lastEnv) + " found at " + getName(thisEnv),
+                        {suppressIfEditing:true, errorAtStart: true, type:"warning"});
+            return UNCLOSED_GROUP;
+        } else {
+            var pLast = envPrecedence(lastEnv);
+            var pThis = envPrecedence(thisEnv);
+            if (pThis > pLast) {
+                ErrorFromTo(lastEnv, thisEnv, "unclosed " + getName(lastEnv) + " found at " + getName(thisEnv),
+                           {suppressIfEditing:true, errorAtStart: true});
+            } else {
+                ErrorFromTo(lastEnv, thisEnv, "unexpected " + getName(thisEnv) + " after " + getName(lastEnv));
+            }
+            return UNCLOSED_ENV;
+        };
+    };
+
+    this._beginMathMode = function (thisEnv) {
+        var currentMathMode = this.getMathMode(); // undefined, null, $, $$, name of mathmode env
+        if (currentMathMode) {
+            ErrorFrom(thisEnv, thisEnv.name + " used inside existing math mode " + getName(currentMathMode),
+                      {suppressIfEditing:true, errorAtStart: true, mathMode:true});
+        };
+        thisEnv.mathMode = thisEnv;
+        state.push(thisEnv);
+    };
+
+    this._toggleMathMode = function (thisEnv) {
+        var lastEnv = state.pop();
+        if (closedBy(lastEnv, thisEnv)) {
+            return;
+        } else {
+            if (lastEnv) {state.push(lastEnv);}
+            if (lastEnv && lastEnv.mathMode) {
+                this._end(thisEnv);
+            } else {
+                thisEnv.mathMode = thisEnv;
+                state.push(thisEnv);
+            }
+        };
+    };
+
+    this.getMathMode = function () {
+        var n = state.length;
+        if (n > 0) {
+            return state[n-1].mathMode;
+        } else {
+            return null;
+        }
+    };
+
+    this.insideGroup = function () {
+        var n = state.length;
+        if (n > 0) {
+            return (state[n-1].command === "{");
+        } else {
+            return null;
+        }
+    };
+
+    var resetMathMode = function () {
+        var n = state.length;
+        if (n > 0) {
+            var lastMathMode = state[n-1].mathMode;
+            do {
+                var lastEnv = state.pop();
+            } while (lastEnv && lastEnv !== lastMathMode);
+        } else {
+            return;
+        }
+    };
+
+    this.resetMathMode = resetMathMode;
+
+    var getNewMathMode = function (currentMathMode, thisEnv) {
+        var newMathMode = null;
+
         if (thisEnv.command === "{") {
         if (thisEnv.command === "{") {
-            ErrorFrom(thisEnv, "unclosed group {", {type:"warning"});
+            if (thisEnv.mathMode !== null) {
+                newMathMode = thisEnv.mathMode;
+            } else {
+                newMathMode = currentMathMode;
+            }
+        } else if (thisEnv.command === "left") {
+            if (currentMathMode === null) {
+                ErrorFrom(thisEnv, "\\left can only be used in math mode", {mathMode: true});
+            };
+            newMathMode = currentMathMode;
         } else if (thisEnv.command === "begin") {
         } else if (thisEnv.command === "begin") {
-            ErrorFrom(thisEnv, "unclosed environment \\begin{" + thisEnv.name + "}");
+            var name = thisEnv.name;
+            if (name) {
+                if (name.match(/^(document|figure|center|enumerate|itemize|table|abstract|proof|lemma|theorem|definition|proposition|corollary|remark|notation|thebibliography)$/)) {
+                    if (currentMathMode) {
+                        ErrorFromTo(currentMathMode, thisEnv, thisEnv.name + " used inside " + getName(currentMathMode),
+                                    {suppressIfEditing:true, errorAtStart: true, mathMode: true});
+                        resetMathMode();
+                    };
+                    newMathMode = null;
+                } else if (name.match(/^(array|gathered|split|aligned|alignedat)\*?$/)) {
+                    if (currentMathMode === null) {
+                        ErrorFrom(thisEnv, thisEnv.name + " not inside math mode", {mathMode: true});
+                    };
+                    newMathMode = currentMathMode;
+                } else if (name.match(/^(math|displaymath|equation|eqnarray|multline|align|gather|flalign|alignat)\*?$/)) {
+                    if (currentMathMode) {
+                        ErrorFromTo(currentMathMode, thisEnv, thisEnv.name + " used inside " + getName(currentMathMode),
+                                    {suppressIfEditing:true, errorAtStart: true, mathMode: true});
+                        resetMathMode();
+                    };
+                    newMathMode = thisEnv;
+                } else {
+                    newMathMode = undefined;  // undefined means we don't know if we are in math mode or not
+                }
+            }
         };
         };
-    }
-    var vlen = verbatimRanges.length;
-    len = ErrorReporter.tokenErrors.length;
-    if (vlen >0 && len > 0) {
-        for (i = 0; i < len; i++) {
-            var tokenError = ErrorReporter.tokenErrors[i];
-            var startPos = tokenError.startPos;
-            var endPos = tokenError.endPos;
-            for (j = 0; j < vlen; j++) {
-                if (startPos > verbatimRanges[j].start && startPos < verbatimRanges[j].end) {
-                    tokenError.ignore = true;
-                    break;
+        return newMathMode;
+    };
+
+    this.checkAndUpdateState = function (thisEnv) {
+        if (inVerbatim) {
+            if (thisEnv.command === "end") {
+                this._endVerbatim(thisEnv);
+            } else {
+                return; // ignore anything in verbatim environments
+            }
+        } else if(thisEnv.command === "begin" || thisEnv.command === "{" || thisEnv.command === "left") {
+            if (thisEnv.verbatim) {inVerbatim = true;};
+            var currentMathMode = this.getMathMode(); // undefined, null, $, $$, name of mathmode env
+            var newMathMode = getNewMathMode(currentMathMode, thisEnv);
+            thisEnv.mathMode = newMathMode;
+            state.push(thisEnv);
+        } else if (thisEnv.command === "end") {
+            this._end(thisEnv);
+        } else if (thisEnv.command === "(" || thisEnv.command === "[") {
+            this._beginMathMode(thisEnv);
+        } else if (thisEnv.command === ")" || thisEnv.command === "]") {
+            this._end(thisEnv);
+        } else if (thisEnv.command === "}") {
+            this._end(thisEnv);
+        } else if (thisEnv.command === "right") {
+            this._end(thisEnv);
+        } else if (thisEnv.command === "$" || thisEnv.command === "$$") {
+            this._toggleMathMode(thisEnv);
+        }
+    };
+
+    this.close = function () {
+        while (state.length > 0) {
+            var thisEnv = state.pop();
+            if (thisEnv.command === "{") {
+                ErrorFrom(thisEnv, "unclosed group {", {type:"warning"});
+            } else {
+                ErrorFrom(thisEnv, "unclosed " + getName(thisEnv));
+            }
+        }
+        var vlen = verbatimRanges.length;
+        var len = ErrorReporter.tokenErrors.length;
+        if (vlen >0 && len > 0) {
+            for (var i = 0; i < len; i++) {
+                var tokenError = ErrorReporter.tokenErrors[i];
+                var startPos = tokenError.startPos;
+                var endPos = tokenError.endPos;
+                for (var j = 0; j < vlen; j++) {
+                    if (startPos > verbatimRanges[j].start && startPos < verbatimRanges[j].end) {
+                        tokenError.ignore = true;
+                        break;
+                    }
                 }
                 }
             }
             }
         }
         }
-    }
+    };
 
 
+    this.setEnvProps = function (env) {
+        var name = env.name ;
+        if (name && name.match(/^(verbatim|boxedverbatim|lstlisting|minted|Verbatim)$/)) {
+            env.verbatim = true;
+        }
+    };
 };
 };
 var ErrorReporter = function (TokeniseResult) {
 var ErrorReporter = function (TokeniseResult) {
     var text = TokeniseResult.text;
     var text = TokeniseResult.text;
@@ -1922,18 +2346,41 @@ var ErrorReporter = function (TokeniseResult) {
     var errors = [], tokenErrors = [];
     var errors = [], tokenErrors = [];
     this.errors = errors;
     this.errors = errors;
     this.tokenErrors = tokenErrors;
     this.tokenErrors = tokenErrors;
+    this.filterMath = false;
 
 
     this.getErrors = function () {
     this.getErrors = function () {
         var returnedErrors = [];
         var returnedErrors = [];
         for (var i = 0, len = tokenErrors.length; i < len; i++) {
         for (var i = 0, len = tokenErrors.length; i < len; i++) {
             if (!tokenErrors[i].ignore) { returnedErrors.push(tokenErrors[i]); }
             if (!tokenErrors[i].ignore) { returnedErrors.push(tokenErrors[i]); }
         }
         }
-        return returnedErrors.concat(errors);
+        var allErrors = returnedErrors.concat(errors);
+        var result = [];
+        var mathErrorCount = 0;
+        for (i = 0, len = allErrors.length; i < len; i++) {
+            if (allErrors[i].mathMode) {
+                mathErrorCount++;
+            }
+            if (mathErrorCount > 10) {
+                return [];
+            }
+        }
+        if (this.filterMath && mathErrorCount > 0) {
+            for (i = 0, len = allErrors.length; i < len; i++) {
+                if (!allErrors[i].mathMode) {
+                    result.push(allErrors[i]);
+                }
+            }
+            return result;
+        } else {
+            return allErrors;
+        }
     };
     };
 
 
-    this.TokenError = function (token, message) {
+    this.TokenError = function (token, message, options) {
+        if(!options) { options = { suppressIfEditing:true } ; };
         var line = token[0], type = token[1], start = token[2], end = token[3];
         var line = token[0], type = token[1], start = token[2], end = token[3];
         var start_col = start - linePosition[line];
         var start_col = start - linePosition[line];
+        if (!end) { end = start + 1; } ;
         var end_col = end - linePosition[line];
         var end_col = end - linePosition[line];
         tokenErrors.push({row: line,
         tokenErrors.push({row: line,
                           column: start_col,
                           column: start_col,
@@ -1945,10 +2392,12 @@ var ErrorReporter = function (TokeniseResult) {
                           text:message,
                           text:message,
                           startPos: start,
                           startPos: start,
                           endPos: end,
                           endPos: end,
-                          suppressIfEditing:true});
+                          suppressIfEditing:options.suppressIfEditing,
+                          mathMode: options.mathMode});
     };
     };
 
 
-    this.TokenErrorFromTo = function (fromToken, toToken, message) {
+    this.TokenErrorFromTo = function (fromToken, toToken, message, options) {
+        if(!options) { options = {suppressIfEditing:true } ; };
         var fromLine = fromToken[0], fromStart = fromToken[2], fromEnd = fromToken[3];
         var fromLine = fromToken[0], fromStart = fromToken[2], fromEnd = fromToken[3];
         var toLine = toToken[0], toStart = toToken[2], toEnd = toToken[3];
         var toLine = toToken[0], toStart = toToken[2], toEnd = toToken[3];
         if (!toEnd) { toEnd = toStart + 1;};
         if (!toEnd) { toEnd = toStart + 1;};
@@ -1965,7 +2414,8 @@ var ErrorReporter = function (TokeniseResult) {
                           text:message,
                           text:message,
                           startPos: fromStart,
                           startPos: fromStart,
                           endPos: toEnd,
                           endPos: toEnd,
-                          suppressIfEditing:true});
+                          suppressIfEditing:options.suppressIfEditing,
+                          mathMode: options.mathMode});
     };
     };
 
 
 
 
@@ -1986,7 +2436,8 @@ var ErrorReporter = function (TokeniseResult) {
                      end_col: end_col,
                      end_col: end_col,
                      type: options.type ? options.type : "error",
                      type: options.type ? options.type : "error",
                      text:message,
                      text:message,
-                     suppressIfEditing:options.suppressIfEditing});
+                     suppressIfEditing:options.suppressIfEditing,
+                     mathMode: options.mathMode});
     };
     };
 
 
     this.EnvErrorTo = function (toEnv, message, options) {
     this.EnvErrorTo = function (toEnv, message, options) {
@@ -2002,7 +2453,8 @@ var ErrorReporter = function (TokeniseResult) {
                    end_row: line,
                    end_row: line,
                    end_col: end_col,
                    end_col: end_col,
                    type: options.type ? options.type : "error",
                    type: options.type ? options.type : "error",
-                   text:message};
+                   text:message,
+                   mathMode: options.mathMode};
         errors.push(err);
         errors.push(err);
     };
     };
 
 
@@ -2019,7 +2471,8 @@ var ErrorReporter = function (TokeniseResult) {
                      end_row: lineNumber,
                      end_row: lineNumber,
                      end_col: end_col,
                      end_col: end_col,
                      type: options.type ? options.type : "error",
                      type: options.type ? options.type : "error",
-                     text:message});
+                     text:message,
+                     mathMode: options.mathMode});
     };
     };
 };
 };
 
 
@@ -2027,7 +2480,7 @@ var Parse = function (text) {
     var TokeniseResult = Tokenise(text);
     var TokeniseResult = Tokenise(text);
     var Reporter = new ErrorReporter(TokeniseResult);
     var Reporter = new ErrorReporter(TokeniseResult);
     var Environments = InterpretTokens(TokeniseResult, Reporter);
     var Environments = InterpretTokens(TokeniseResult, Reporter);
-    CheckEnvironments(Environments, Reporter);
+    Environments.close();
     return Reporter.getErrors();
     return Reporter.getErrors();
 };
 };
 
 

+ 3207 - 0
services/web/public/js/ace-1.2.5/worker-latex_beta.js

@@ -0,0 +1,3207 @@
+"no use strict";
+;(function(window) {
+if (typeof window.window != "undefined" && window.document)
+    return;
+if (window.require && window.define)
+    return;
+
+if (!window.console) {
+    window.console = function() {
+        var msgs = Array.prototype.slice.call(arguments, 0);
+        postMessage({type: "log", data: msgs});
+    };
+    window.console.error =
+    window.console.warn = 
+    window.console.log =
+    window.console.trace = window.console;
+}
+window.window = window;
+window.ace = window;
+
+window.onerror = function(message, file, line, col, err) {
+    postMessage({type: "error", data: {
+        message: message,
+        data: err.data,
+        file: file,
+        line: line, 
+        col: col,
+        stack: err.stack
+    }});
+};
+
+window.normalizeModule = function(parentId, moduleName) {
+    // normalize plugin requires
+    if (moduleName.indexOf("!") !== -1) {
+        var chunks = moduleName.split("!");
+        return window.normalizeModule(parentId, chunks[0]) + "!" + window.normalizeModule(parentId, chunks[1]);
+    }
+    // normalize relative requires
+    if (moduleName.charAt(0) == ".") {
+        var base = parentId.split("/").slice(0, -1).join("/");
+        moduleName = (base ? base + "/" : "") + moduleName;
+        
+        while (moduleName.indexOf(".") !== -1 && previous != moduleName) {
+            var previous = moduleName;
+            moduleName = moduleName.replace(/^\.\//, "").replace(/\/\.\//, "/").replace(/[^\/]+\/\.\.\//, "");
+        }
+    }
+    
+    return moduleName;
+};
+
+window.require = function require(parentId, id) {
+    if (!id) {
+        id = parentId;
+        parentId = null;
+    }
+    if (!id.charAt)
+        throw new Error("worker.js require() accepts only (parentId, id) as arguments");
+
+    id = window.normalizeModule(parentId, id);
+
+    var module = window.require.modules[id];
+    if (module) {
+        if (!module.initialized) {
+            module.initialized = true;
+            module.exports = module.factory().exports;
+        }
+        return module.exports;
+    }
+   
+    if (!window.require.tlns)
+        return console.log("unable to load " + id);
+    
+    var path = resolveModuleId(id, window.require.tlns);
+    if (path.slice(-3) != ".js") path += ".js";
+    
+    window.require.id = id;
+    window.require.modules[id] = {}; // prevent infinite loop on broken modules
+    importScripts(path);
+    return window.require(parentId, id);
+};
+function resolveModuleId(id, paths) {
+    var testPath = id, tail = "";
+    while (testPath) {
+        var alias = paths[testPath];
+        if (typeof alias == "string") {
+            return alias + tail;
+        } else if (alias) {
+            return  alias.location.replace(/\/*$/, "/") + (tail || alias.main || alias.name);
+        } else if (alias === false) {
+            return "";
+        }
+        var i = testPath.lastIndexOf("/");
+        if (i === -1) break;
+        tail = testPath.substr(i) + tail;
+        testPath = testPath.slice(0, i);
+    }
+    return id;
+}
+window.require.modules = {};
+window.require.tlns = {};
+
+window.define = function(id, deps, factory) {
+    if (arguments.length == 2) {
+        factory = deps;
+        if (typeof id != "string") {
+            deps = id;
+            id = window.require.id;
+        }
+    } else if (arguments.length == 1) {
+        factory = id;
+        deps = [];
+        id = window.require.id;
+    }
+    
+    if (typeof factory != "function") {
+        window.require.modules[id] = {
+            exports: factory,
+            initialized: true
+        };
+        return;
+    }
+
+    if (!deps.length)
+        // If there is no dependencies, we inject "require", "exports" and
+        // "module" as dependencies, to provide CommonJS compatibility.
+        deps = ["require", "exports", "module"];
+
+    var req = function(childId) {
+        return window.require(id, childId);
+    };
+
+    window.require.modules[id] = {
+        exports: {},
+        factory: function() {
+            var module = this;
+            var returnExports = factory.apply(this, deps.map(function(dep) {
+                switch (dep) {
+                    // Because "require", "exports" and "module" aren't actual
+                    // dependencies, we must handle them seperately.
+                    case "require": return req;
+                    case "exports": return module.exports;
+                    case "module":  return module;
+                    // But for all other dependencies, we can just go ahead and
+                    // require them.
+                    default:        return req(dep);
+                }
+            }));
+            if (returnExports)
+                module.exports = returnExports;
+            return module;
+        }
+    };
+};
+window.define.amd = {};
+require.tlns = {};
+window.initBaseUrls  = function initBaseUrls(topLevelNamespaces) {
+    for (var i in topLevelNamespaces)
+        require.tlns[i] = topLevelNamespaces[i];
+};
+
+window.initSender = function initSender() {
+
+    var EventEmitter = window.require("ace/lib/event_emitter").EventEmitter;
+    var oop = window.require("ace/lib/oop");
+    
+    var Sender = function() {};
+    
+    (function() {
+        
+        oop.implement(this, EventEmitter);
+                
+        this.callback = function(data, callbackId) {
+            postMessage({
+                type: "call",
+                id: callbackId,
+                data: data
+            });
+        };
+    
+        this.emit = function(name, data) {
+            postMessage({
+                type: "event",
+                name: name,
+                data: data
+            });
+        };
+        
+    }).call(Sender.prototype);
+    
+    return new Sender();
+};
+
+var main = window.main = null;
+var sender = window.sender = null;
+
+window.onmessage = function(e) {
+    var msg = e.data;
+    if (msg.event && sender) {
+        sender._signal(msg.event, msg.data);
+    }
+    else if (msg.command) {
+        if (main[msg.command])
+            main[msg.command].apply(main, msg.args);
+        else if (window[msg.command])
+            window[msg.command].apply(window, msg.args);
+        else
+            throw new Error("Unknown command:" + msg.command);
+    }
+    else if (msg.init) {
+        window.initBaseUrls(msg.tlns);
+        require("ace/lib/es5-shim");
+        sender = window.sender = window.initSender();
+        var clazz = require(msg.module)[msg.classname];
+        main = window.main = new clazz(sender);
+    }
+};
+})(this);
+
+ace.define("ace/lib/oop",["require","exports","module"], function(require, exports, module) {
+"use strict";
+
+exports.inherits = function(ctor, superCtor) {
+    ctor.super_ = superCtor;
+    ctor.prototype = Object.create(superCtor.prototype, {
+        constructor: {
+            value: ctor,
+            enumerable: false,
+            writable: true,
+            configurable: true
+        }
+    });
+};
+
+exports.mixin = function(obj, mixin) {
+    for (var key in mixin) {
+        obj[key] = mixin[key];
+    }
+    return obj;
+};
+
+exports.implement = function(proto, mixin) {
+    exports.mixin(proto, mixin);
+};
+
+});
+
+ace.define("ace/range",["require","exports","module"], function(require, exports, module) {
+"use strict";
+var comparePoints = function(p1, p2) {
+    return p1.row - p2.row || p1.column - p2.column;
+};
+var Range = function(startRow, startColumn, endRow, endColumn) {
+    this.start = {
+        row: startRow,
+        column: startColumn
+    };
+
+    this.end = {
+        row: endRow,
+        column: endColumn
+    };
+};
+
+(function() {
+    this.isEqual = function(range) {
+        return this.start.row === range.start.row &&
+            this.end.row === range.end.row &&
+            this.start.column === range.start.column &&
+            this.end.column === range.end.column;
+    };
+    this.toString = function() {
+        return ("Range: [" + this.start.row + "/" + this.start.column +
+            "] -> [" + this.end.row + "/" + this.end.column + "]");
+    };
+
+    this.contains = function(row, column) {
+        return this.compare(row, column) == 0;
+    };
+    this.compareRange = function(range) {
+        var cmp,
+            end = range.end,
+            start = range.start;
+
+        cmp = this.compare(end.row, end.column);
+        if (cmp == 1) {
+            cmp = this.compare(start.row, start.column);
+            if (cmp == 1) {
+                return 2;
+            } else if (cmp == 0) {
+                return 1;
+            } else {
+                return 0;
+            }
+        } else if (cmp == -1) {
+            return -2;
+        } else {
+            cmp = this.compare(start.row, start.column);
+            if (cmp == -1) {
+                return -1;
+            } else if (cmp == 1) {
+                return 42;
+            } else {
+                return 0;
+            }
+        }
+    };
+    this.comparePoint = function(p) {
+        return this.compare(p.row, p.column);
+    };
+    this.containsRange = function(range) {
+        return this.comparePoint(range.start) == 0 && this.comparePoint(range.end) == 0;
+    };
+    this.intersects = function(range) {
+        var cmp = this.compareRange(range);
+        return (cmp == -1 || cmp == 0 || cmp == 1);
+    };
+    this.isEnd = function(row, column) {
+        return this.end.row == row && this.end.column == column;
+    };
+    this.isStart = function(row, column) {
+        return this.start.row == row && this.start.column == column;
+    };
+    this.setStart = function(row, column) {
+        if (typeof row == "object") {
+            this.start.column = row.column;
+            this.start.row = row.row;
+        } else {
+            this.start.row = row;
+            this.start.column = column;
+        }
+    };
+    this.setEnd = function(row, column) {
+        if (typeof row == "object") {
+            this.end.column = row.column;
+            this.end.row = row.row;
+        } else {
+            this.end.row = row;
+            this.end.column = column;
+        }
+    };
+    this.inside = function(row, column) {
+        if (this.compare(row, column) == 0) {
+            if (this.isEnd(row, column) || this.isStart(row, column)) {
+                return false;
+            } else {
+                return true;
+            }
+        }
+        return false;
+    };
+    this.insideStart = function(row, column) {
+        if (this.compare(row, column) == 0) {
+            if (this.isEnd(row, column)) {
+                return false;
+            } else {
+                return true;
+            }
+        }
+        return false;
+    };
+    this.insideEnd = function(row, column) {
+        if (this.compare(row, column) == 0) {
+            if (this.isStart(row, column)) {
+                return false;
+            } else {
+                return true;
+            }
+        }
+        return false;
+    };
+    this.compare = function(row, column) {
+        if (!this.isMultiLine()) {
+            if (row === this.start.row) {
+                return column < this.start.column ? -1 : (column > this.end.column ? 1 : 0);
+            }
+        }
+
+        if (row < this.start.row)
+            return -1;
+
+        if (row > this.end.row)
+            return 1;
+
+        if (this.start.row === row)
+            return column >= this.start.column ? 0 : -1;
+
+        if (this.end.row === row)
+            return column <= this.end.column ? 0 : 1;
+
+        return 0;
+    };
+    this.compareStart = function(row, column) {
+        if (this.start.row == row && this.start.column == column) {
+            return -1;
+        } else {
+            return this.compare(row, column);
+        }
+    };
+    this.compareEnd = function(row, column) {
+        if (this.end.row == row && this.end.column == column) {
+            return 1;
+        } else {
+            return this.compare(row, column);
+        }
+    };
+    this.compareInside = function(row, column) {
+        if (this.end.row == row && this.end.column == column) {
+            return 1;
+        } else if (this.start.row == row && this.start.column == column) {
+            return -1;
+        } else {
+            return this.compare(row, column);
+        }
+    };
+    this.clipRows = function(firstRow, lastRow) {
+        if (this.end.row > lastRow)
+            var end = {row: lastRow + 1, column: 0};
+        else if (this.end.row < firstRow)
+            var end = {row: firstRow, column: 0};
+
+        if (this.start.row > lastRow)
+            var start = {row: lastRow + 1, column: 0};
+        else if (this.start.row < firstRow)
+            var start = {row: firstRow, column: 0};
+
+        return Range.fromPoints(start || this.start, end || this.end);
+    };
+    this.extend = function(row, column) {
+        var cmp = this.compare(row, column);
+
+        if (cmp == 0)
+            return this;
+        else if (cmp == -1)
+            var start = {row: row, column: column};
+        else
+            var end = {row: row, column: column};
+
+        return Range.fromPoints(start || this.start, end || this.end);
+    };
+
+    this.isEmpty = function() {
+        return (this.start.row === this.end.row && this.start.column === this.end.column);
+    };
+    this.isMultiLine = function() {
+        return (this.start.row !== this.end.row);
+    };
+    this.clone = function() {
+        return Range.fromPoints(this.start, this.end);
+    };
+    this.collapseRows = function() {
+        if (this.end.column == 0)
+            return new Range(this.start.row, 0, Math.max(this.start.row, this.end.row-1), 0)
+        else
+            return new Range(this.start.row, 0, this.end.row, 0)
+    };
+    this.toScreenRange = function(session) {
+        var screenPosStart = session.documentToScreenPosition(this.start);
+        var screenPosEnd = session.documentToScreenPosition(this.end);
+
+        return new Range(
+            screenPosStart.row, screenPosStart.column,
+            screenPosEnd.row, screenPosEnd.column
+        );
+    };
+    this.moveBy = function(row, column) {
+        this.start.row += row;
+        this.start.column += column;
+        this.end.row += row;
+        this.end.column += column;
+    };
+
+}).call(Range.prototype);
+Range.fromPoints = function(start, end) {
+    return new Range(start.row, start.column, end.row, end.column);
+};
+Range.comparePoints = comparePoints;
+
+Range.comparePoints = function(p1, p2) {
+    return p1.row - p2.row || p1.column - p2.column;
+};
+
+
+exports.Range = Range;
+});
+
+ace.define("ace/apply_delta",["require","exports","module"], function(require, exports, module) {
+"use strict";
+
+function throwDeltaError(delta, errorText){
+    console.log("Invalid Delta:", delta);
+    throw "Invalid Delta: " + errorText;
+}
+
+function positionInDocument(docLines, position) {
+    return position.row    >= 0 && position.row    <  docLines.length &&
+           position.column >= 0 && position.column <= docLines[position.row].length;
+}
+
+function validateDelta(docLines, delta) {
+    if (delta.action != "insert" && delta.action != "remove")
+        throwDeltaError(delta, "delta.action must be 'insert' or 'remove'");
+    if (!(delta.lines instanceof Array))
+        throwDeltaError(delta, "delta.lines must be an Array");
+    if (!delta.start || !delta.end)
+       throwDeltaError(delta, "delta.start/end must be an present");
+    var start = delta.start;
+    if (!positionInDocument(docLines, delta.start))
+        throwDeltaError(delta, "delta.start must be contained in document");
+    var end = delta.end;
+    if (delta.action == "remove" && !positionInDocument(docLines, end))
+        throwDeltaError(delta, "delta.end must contained in document for 'remove' actions");
+    var numRangeRows = end.row - start.row;
+    var numRangeLastLineChars = (end.column - (numRangeRows == 0 ? start.column : 0));
+    if (numRangeRows != delta.lines.length - 1 || delta.lines[numRangeRows].length != numRangeLastLineChars)
+        throwDeltaError(delta, "delta.range must match delta lines");
+}
+
+exports.applyDelta = function(docLines, delta, doNotValidate) {
+    
+    var row = delta.start.row;
+    var startColumn = delta.start.column;
+    var line = docLines[row] || "";
+    switch (delta.action) {
+        case "insert":
+            var lines = delta.lines;
+            if (lines.length === 1) {
+                docLines[row] = line.substring(0, startColumn) + delta.lines[0] + line.substring(startColumn);
+            } else {
+                var args = [row, 1].concat(delta.lines);
+                docLines.splice.apply(docLines, args);
+                docLines[row] = line.substring(0, startColumn) + docLines[row];
+                docLines[row + delta.lines.length - 1] += line.substring(startColumn);
+            }
+            break;
+        case "remove":
+            var endColumn = delta.end.column;
+            var endRow = delta.end.row;
+            if (row === endRow) {
+                docLines[row] = line.substring(0, startColumn) + line.substring(endColumn);
+            } else {
+                docLines.splice(
+                    row, endRow - row + 1,
+                    line.substring(0, startColumn) + docLines[endRow].substring(endColumn)
+                );
+            }
+            break;
+    }
+}
+});
+
+ace.define("ace/lib/event_emitter",["require","exports","module"], function(require, exports, module) {
+"use strict";
+
+var EventEmitter = {};
+var stopPropagation = function() { this.propagationStopped = true; };
+var preventDefault = function() { this.defaultPrevented = true; };
+
+EventEmitter._emit =
+EventEmitter._dispatchEvent = function(eventName, e) {
+    this._eventRegistry || (this._eventRegistry = {});
+    this._defaultHandlers || (this._defaultHandlers = {});
+
+    var listeners = this._eventRegistry[eventName] || [];
+    var defaultHandler = this._defaultHandlers[eventName];
+    if (!listeners.length && !defaultHandler)
+        return;
+
+    if (typeof e != "object" || !e)
+        e = {};
+
+    if (!e.type)
+        e.type = eventName;
+    if (!e.stopPropagation)
+        e.stopPropagation = stopPropagation;
+    if (!e.preventDefault)
+        e.preventDefault = preventDefault;
+
+    listeners = listeners.slice();
+    for (var i=0; i<listeners.length; i++) {
+        listeners[i](e, this);
+        if (e.propagationStopped)
+            break;
+    }
+    
+    if (defaultHandler && !e.defaultPrevented)
+        return defaultHandler(e, this);
+};
+
+
+EventEmitter._signal = function(eventName, e) {
+    var listeners = (this._eventRegistry || {})[eventName];
+    if (!listeners)
+        return;
+    listeners = listeners.slice();
+    for (var i=0; i<listeners.length; i++)
+        listeners[i](e, this);
+};
+
+EventEmitter.once = function(eventName, callback) {
+    var _self = this;
+    callback && this.addEventListener(eventName, function newCallback() {
+        _self.removeEventListener(eventName, newCallback);
+        callback.apply(null, arguments);
+    });
+};
+
+
+EventEmitter.setDefaultHandler = function(eventName, callback) {
+    var handlers = this._defaultHandlers
+    if (!handlers)
+        handlers = this._defaultHandlers = {_disabled_: {}};
+    
+    if (handlers[eventName]) {
+        var old = handlers[eventName];
+        var disabled = handlers._disabled_[eventName];
+        if (!disabled)
+            handlers._disabled_[eventName] = disabled = [];
+        disabled.push(old);
+        var i = disabled.indexOf(callback);
+        if (i != -1) 
+            disabled.splice(i, 1);
+    }
+    handlers[eventName] = callback;
+};
+EventEmitter.removeDefaultHandler = function(eventName, callback) {
+    var handlers = this._defaultHandlers
+    if (!handlers)
+        return;
+    var disabled = handlers._disabled_[eventName];
+    
+    if (handlers[eventName] == callback) {
+        var old = handlers[eventName];
+        if (disabled)
+            this.setDefaultHandler(eventName, disabled.pop());
+    } else if (disabled) {
+        var i = disabled.indexOf(callback);
+        if (i != -1)
+            disabled.splice(i, 1);
+    }
+};
+
+EventEmitter.on =
+EventEmitter.addEventListener = function(eventName, callback, capturing) {
+    this._eventRegistry = this._eventRegistry || {};
+
+    var listeners = this._eventRegistry[eventName];
+    if (!listeners)
+        listeners = this._eventRegistry[eventName] = [];
+
+    if (listeners.indexOf(callback) == -1)
+        listeners[capturing ? "unshift" : "push"](callback);
+    return callback;
+};
+
+EventEmitter.off =
+EventEmitter.removeListener =
+EventEmitter.removeEventListener = function(eventName, callback) {
+    this._eventRegistry = this._eventRegistry || {};
+
+    var listeners = this._eventRegistry[eventName];
+    if (!listeners)
+        return;
+
+    var index = listeners.indexOf(callback);
+    if (index !== -1)
+        listeners.splice(index, 1);
+};
+
+EventEmitter.removeAllListeners = function(eventName) {
+    if (this._eventRegistry) this._eventRegistry[eventName] = [];
+};
+
+exports.EventEmitter = EventEmitter;
+
+});
+
+ace.define("ace/anchor",["require","exports","module","ace/lib/oop","ace/lib/event_emitter"], function(require, exports, module) {
+"use strict";
+
+var oop = require("./lib/oop");
+var EventEmitter = require("./lib/event_emitter").EventEmitter;
+
+var Anchor = exports.Anchor = function(doc, row, column) {
+    this.$onChange = this.onChange.bind(this);
+    this.attach(doc);
+    
+    if (typeof column == "undefined")
+        this.setPosition(row.row, row.column);
+    else
+        this.setPosition(row, column);
+};
+
+(function() {
+
+    oop.implement(this, EventEmitter);
+    this.getPosition = function() {
+        return this.$clipPositionToDocument(this.row, this.column);
+    };
+    this.getDocument = function() {
+        return this.document;
+    };
+    this.$insertRight = false;
+    this.onChange = function(delta) {
+        if (delta.start.row == delta.end.row && delta.start.row != this.row)
+            return;
+
+        if (delta.start.row > this.row)
+            return;
+            
+        var point = $getTransformedPoint(delta, {row: this.row, column: this.column}, this.$insertRight);
+        this.setPosition(point.row, point.column, true);
+    };
+    
+    function $pointsInOrder(point1, point2, equalPointsInOrder) {
+        var bColIsAfter = equalPointsInOrder ? point1.column <= point2.column : point1.column < point2.column;
+        return (point1.row < point2.row) || (point1.row == point2.row && bColIsAfter);
+    }
+            
+    function $getTransformedPoint(delta, point, moveIfEqual) {
+        var deltaIsInsert = delta.action == "insert";
+        var deltaRowShift = (deltaIsInsert ? 1 : -1) * (delta.end.row    - delta.start.row);
+        var deltaColShift = (deltaIsInsert ? 1 : -1) * (delta.end.column - delta.start.column);
+        var deltaStart = delta.start;
+        var deltaEnd = deltaIsInsert ? deltaStart : delta.end; // Collapse insert range.
+        if ($pointsInOrder(point, deltaStart, moveIfEqual)) {
+            return {
+                row: point.row,
+                column: point.column
+            };
+        }
+        if ($pointsInOrder(deltaEnd, point, !moveIfEqual)) {
+            return {
+                row: point.row + deltaRowShift,
+                column: point.column + (point.row == deltaEnd.row ? deltaColShift : 0)
+            };
+        }
+        
+        return {
+            row: deltaStart.row,
+            column: deltaStart.column
+        };
+    }
+    this.setPosition = function(row, column, noClip) {
+        var pos;
+        if (noClip) {
+            pos = {
+                row: row,
+                column: column
+            };
+        } else {
+            pos = this.$clipPositionToDocument(row, column);
+        }
+
+        if (this.row == pos.row && this.column == pos.column)
+            return;
+
+        var old = {
+            row: this.row,
+            column: this.column
+        };
+
+        this.row = pos.row;
+        this.column = pos.column;
+        this._signal("change", {
+            old: old,
+            value: pos
+        });
+    };
+    this.detach = function() {
+        this.document.removeEventListener("change", this.$onChange);
+    };
+    this.attach = function(doc) {
+        this.document = doc || this.document;
+        this.document.on("change", this.$onChange);
+    };
+    this.$clipPositionToDocument = function(row, column) {
+        var pos = {};
+
+        if (row >= this.document.getLength()) {
+            pos.row = Math.max(0, this.document.getLength() - 1);
+            pos.column = this.document.getLine(pos.row).length;
+        }
+        else if (row < 0) {
+            pos.row = 0;
+            pos.column = 0;
+        }
+        else {
+            pos.row = row;
+            pos.column = Math.min(this.document.getLine(pos.row).length, Math.max(0, column));
+        }
+
+        if (column < 0)
+            pos.column = 0;
+
+        return pos;
+    };
+
+}).call(Anchor.prototype);
+
+});
+
+ace.define("ace/document",["require","exports","module","ace/lib/oop","ace/apply_delta","ace/lib/event_emitter","ace/range","ace/anchor"], function(require, exports, module) {
+"use strict";
+
+var oop = require("./lib/oop");
+var applyDelta = require("./apply_delta").applyDelta;
+var EventEmitter = require("./lib/event_emitter").EventEmitter;
+var Range = require("./range").Range;
+var Anchor = require("./anchor").Anchor;
+
+var Document = function(textOrLines) {
+    this.$lines = [""];
+    if (textOrLines.length === 0) {
+        this.$lines = [""];
+    } else if (Array.isArray(textOrLines)) {
+        this.insertMergedLines({row: 0, column: 0}, textOrLines);
+    } else {
+        this.insert({row: 0, column:0}, textOrLines);
+    }
+};
+
+(function() {
+
+    oop.implement(this, EventEmitter);
+    this.setValue = function(text) {
+        var len = this.getLength() - 1;
+        this.remove(new Range(0, 0, len, this.getLine(len).length));
+        this.insert({row: 0, column: 0}, text);
+    };
+    this.getValue = function() {
+        return this.getAllLines().join(this.getNewLineCharacter());
+    };
+    this.createAnchor = function(row, column) {
+        return new Anchor(this, row, column);
+    };
+    if ("aaa".split(/a/).length === 0) {
+        this.$split = function(text) {
+            return text.replace(/\r\n|\r/g, "\n").split("\n");
+        };
+    } else {
+        this.$split = function(text) {
+            return text.split(/\r\n|\r|\n/);
+        };
+    }
+
+
+    this.$detectNewLine = function(text) {
+        var match = text.match(/^.*?(\r\n|\r|\n)/m);
+        this.$autoNewLine = match ? match[1] : "\n";
+        this._signal("changeNewLineMode");
+    };
+    this.getNewLineCharacter = function() {
+        switch (this.$newLineMode) {
+          case "windows":
+            return "\r\n";
+          case "unix":
+            return "\n";
+          default:
+            return this.$autoNewLine || "\n";
+        }
+    };
+
+    this.$autoNewLine = "";
+    this.$newLineMode = "auto";
+    this.setNewLineMode = function(newLineMode) {
+        if (this.$newLineMode === newLineMode)
+            return;
+
+        this.$newLineMode = newLineMode;
+        this._signal("changeNewLineMode");
+    };
+    this.getNewLineMode = function() {
+        return this.$newLineMode;
+    };
+    this.isNewLine = function(text) {
+        return (text == "\r\n" || text == "\r" || text == "\n");
+    };
+    this.getLine = function(row) {
+        return this.$lines[row] || "";
+    };
+    this.getLines = function(firstRow, lastRow) {
+        return this.$lines.slice(firstRow, lastRow + 1);
+    };
+    this.getAllLines = function() {
+        return this.getLines(0, this.getLength());
+    };
+    this.getLength = function() {
+        return this.$lines.length;
+    };
+    this.getTextRange = function(range) {
+        return this.getLinesForRange(range).join(this.getNewLineCharacter());
+    };
+    this.getLinesForRange = function(range) {
+        var lines;
+        if (range.start.row === range.end.row) {
+            lines = [this.getLine(range.start.row).substring(range.start.column, range.end.column)];
+        } else {
+            lines = this.getLines(range.start.row, range.end.row);
+            lines[0] = (lines[0] || "").substring(range.start.column);
+            var l = lines.length - 1;
+            if (range.end.row - range.start.row == l)
+                lines[l] = lines[l].substring(0, range.end.column);
+        }
+        return lines;
+    };
+    this.insertLines = function(row, lines) {
+        console.warn("Use of document.insertLines is deprecated. Use the insertFullLines method instead.");
+        return this.insertFullLines(row, lines);
+    };
+    this.removeLines = function(firstRow, lastRow) {
+        console.warn("Use of document.removeLines is deprecated. Use the removeFullLines method instead.");
+        return this.removeFullLines(firstRow, lastRow);
+    };
+    this.insertNewLine = function(position) {
+        console.warn("Use of document.insertNewLine is deprecated. Use insertMergedLines(position, ['', '']) instead.");
+        return this.insertMergedLines(position, ["", ""]);
+    };
+    this.insert = function(position, text) {
+        if (this.getLength() <= 1)
+            this.$detectNewLine(text);
+        
+        return this.insertMergedLines(position, this.$split(text));
+    };
+    this.insertInLine = function(position, text) {
+        var start = this.clippedPos(position.row, position.column);
+        var end = this.pos(position.row, position.column + text.length);
+        
+        this.applyDelta({
+            start: start,
+            end: end,
+            action: "insert",
+            lines: [text]
+        }, true);
+        
+        return this.clonePos(end);
+    };
+    
+    this.clippedPos = function(row, column) {
+        var length = this.getLength();
+        if (row === undefined) {
+            row = length;
+        } else if (row < 0) {
+            row = 0;
+        } else if (row >= length) {
+            row = length - 1;
+            column = undefined;
+        }
+        var line = this.getLine(row);
+        if (column == undefined)
+            column = line.length;
+        column = Math.min(Math.max(column, 0), line.length);
+        return {row: row, column: column};
+    };
+    
+    this.clonePos = function(pos) {
+        return {row: pos.row, column: pos.column};
+    };
+    
+    this.pos = function(row, column) {
+        return {row: row, column: column};
+    };
+    
+    this.$clipPosition = function(position) {
+        var length = this.getLength();
+        if (position.row >= length) {
+            position.row = Math.max(0, length - 1);
+            position.column = this.getLine(length - 1).length;
+        } else {
+            position.row = Math.max(0, position.row);
+            position.column = Math.min(Math.max(position.column, 0), this.getLine(position.row).length);
+        }
+        return position;
+    };
+    this.insertFullLines = function(row, lines) {
+        row = Math.min(Math.max(row, 0), this.getLength());
+        var column = 0;
+        if (row < this.getLength()) {
+            lines = lines.concat([""]);
+            column = 0;
+        } else {
+            lines = [""].concat(lines);
+            row--;
+            column = this.$lines[row].length;
+        }
+        this.insertMergedLines({row: row, column: column}, lines);
+    };    
+    this.insertMergedLines = function(position, lines) {
+        var start = this.clippedPos(position.row, position.column);
+        var end = {
+            row: start.row + lines.length - 1,
+            column: (lines.length == 1 ? start.column : 0) + lines[lines.length - 1].length
+        };
+        
+        this.applyDelta({
+            start: start,
+            end: end,
+            action: "insert",
+            lines: lines
+        });
+        
+        return this.clonePos(end);
+    };
+    this.remove = function(range) {
+        var start = this.clippedPos(range.start.row, range.start.column);
+        var end = this.clippedPos(range.end.row, range.end.column);
+        this.applyDelta({
+            start: start,
+            end: end,
+            action: "remove",
+            lines: this.getLinesForRange({start: start, end: end})
+        });
+        return this.clonePos(start);
+    };
+    this.removeInLine = function(row, startColumn, endColumn) {
+        var start = this.clippedPos(row, startColumn);
+        var end = this.clippedPos(row, endColumn);
+        
+        this.applyDelta({
+            start: start,
+            end: end,
+            action: "remove",
+            lines: this.getLinesForRange({start: start, end: end})
+        }, true);
+        
+        return this.clonePos(start);
+    };
+    this.removeFullLines = function(firstRow, lastRow) {
+        firstRow = Math.min(Math.max(0, firstRow), this.getLength() - 1);
+        lastRow  = Math.min(Math.max(0, lastRow ), this.getLength() - 1);
+        var deleteFirstNewLine = lastRow == this.getLength() - 1 && firstRow > 0;
+        var deleteLastNewLine  = lastRow  < this.getLength() - 1;
+        var startRow = ( deleteFirstNewLine ? firstRow - 1                  : firstRow                    );
+        var startCol = ( deleteFirstNewLine ? this.getLine(startRow).length : 0                           );
+        var endRow   = ( deleteLastNewLine  ? lastRow + 1                   : lastRow                     );
+        var endCol   = ( deleteLastNewLine  ? 0                             : this.getLine(endRow).length ); 
+        var range = new Range(startRow, startCol, endRow, endCol);
+        var deletedLines = this.$lines.slice(firstRow, lastRow + 1);
+        
+        this.applyDelta({
+            start: range.start,
+            end: range.end,
+            action: "remove",
+            lines: this.getLinesForRange(range)
+        });
+        return deletedLines;
+    };
+    this.removeNewLine = function(row) {
+        if (row < this.getLength() - 1 && row >= 0) {
+            this.applyDelta({
+                start: this.pos(row, this.getLine(row).length),
+                end: this.pos(row + 1, 0),
+                action: "remove",
+                lines: ["", ""]
+            });
+        }
+    };
+    this.replace = function(range, text) {
+        if (!(range instanceof Range))
+            range = Range.fromPoints(range.start, range.end);
+        if (text.length === 0 && range.isEmpty())
+            return range.start;
+        if (text == this.getTextRange(range))
+            return range.end;
+
+        this.remove(range);
+        var end;
+        if (text) {
+            end = this.insert(range.start, text);
+        }
+        else {
+            end = range.start;
+        }
+        
+        return end;
+    };
+    this.applyDeltas = function(deltas) {
+        for (var i=0; i<deltas.length; i++) {
+            this.applyDelta(deltas[i]);
+        }
+    };
+    this.revertDeltas = function(deltas) {
+        for (var i=deltas.length-1; i>=0; i--) {
+            this.revertDelta(deltas[i]);
+        }
+    };
+    this.applyDelta = function(delta, doNotValidate) {
+        var isInsert = delta.action == "insert";
+        if (isInsert ? delta.lines.length <= 1 && !delta.lines[0]
+            : !Range.comparePoints(delta.start, delta.end)) {
+            return;
+        }
+        
+        if (isInsert && delta.lines.length > 20000)
+            this.$splitAndapplyLargeDelta(delta, 20000);
+        applyDelta(this.$lines, delta, doNotValidate);
+        this._signal("change", delta);
+    };
+    
+    this.$splitAndapplyLargeDelta = function(delta, MAX) {
+        var lines = delta.lines;
+        var l = lines.length;
+        var row = delta.start.row; 
+        var column = delta.start.column;
+        var from = 0, to = 0;
+        do {
+            from = to;
+            to += MAX - 1;
+            var chunk = lines.slice(from, to);
+            if (to > l) {
+                delta.lines = chunk;
+                delta.start.row = row + from;
+                delta.start.column = column;
+                break;
+            }
+            chunk.push("");
+            this.applyDelta({
+                start: this.pos(row + from, column),
+                end: this.pos(row + to, column = 0),
+                action: delta.action,
+                lines: chunk
+            }, true);
+        } while(true);
+    };
+    this.revertDelta = function(delta) {
+        this.applyDelta({
+            start: this.clonePos(delta.start),
+            end: this.clonePos(delta.end),
+            action: (delta.action == "insert" ? "remove" : "insert"),
+            lines: delta.lines.slice()
+        });
+    };
+    this.indexToPosition = function(index, startRow) {
+        var lines = this.$lines || this.getAllLines();
+        var newlineLength = this.getNewLineCharacter().length;
+        for (var i = startRow || 0, l = lines.length; i < l; i++) {
+            index -= lines[i].length + newlineLength;
+            if (index < 0)
+                return {row: i, column: index + lines[i].length + newlineLength};
+        }
+        return {row: l-1, column: lines[l-1].length};
+    };
+    this.positionToIndex = function(pos, startRow) {
+        var lines = this.$lines || this.getAllLines();
+        var newlineLength = this.getNewLineCharacter().length;
+        var index = 0;
+        var row = Math.min(pos.row, lines.length);
+        for (var i = startRow || 0; i < row; ++i)
+            index += lines[i].length + newlineLength;
+
+        return index + pos.column;
+    };
+
+}).call(Document.prototype);
+
+exports.Document = Document;
+});
+
+ace.define("ace/lib/lang",["require","exports","module"], function(require, exports, module) {
+"use strict";
+
+exports.last = function(a) {
+    return a[a.length - 1];
+};
+
+exports.stringReverse = function(string) {
+    return string.split("").reverse().join("");
+};
+
+exports.stringRepeat = function (string, count) {
+    var result = '';
+    while (count > 0) {
+        if (count & 1)
+            result += string;
+
+        if (count >>= 1)
+            string += string;
+    }
+    return result;
+};
+
+var trimBeginRegexp = /^\s\s*/;
+var trimEndRegexp = /\s\s*$/;
+
+exports.stringTrimLeft = function (string) {
+    return string.replace(trimBeginRegexp, '');
+};
+
+exports.stringTrimRight = function (string) {
+    return string.replace(trimEndRegexp, '');
+};
+
+exports.copyObject = function(obj) {
+    var copy = {};
+    for (var key in obj) {
+        copy[key] = obj[key];
+    }
+    return copy;
+};
+
+exports.copyArray = function(array){
+    var copy = [];
+    for (var i=0, l=array.length; i<l; i++) {
+        if (array[i] && typeof array[i] == "object")
+            copy[i] = this.copyObject(array[i]);
+        else 
+            copy[i] = array[i];
+    }
+    return copy;
+};
+
+exports.deepCopy = function deepCopy(obj) {
+    if (typeof obj !== "object" || !obj)
+        return obj;
+    var copy;
+    if (Array.isArray(obj)) {
+        copy = [];
+        for (var key = 0; key < obj.length; key++) {
+            copy[key] = deepCopy(obj[key]);
+        }
+        return copy;
+    }
+    if (Object.prototype.toString.call(obj) !== "[object Object]")
+        return obj;
+    
+    copy = {};
+    for (var key in obj)
+        copy[key] = deepCopy(obj[key]);
+    return copy;
+};
+
+exports.arrayToMap = function(arr) {
+    var map = {};
+    for (var i=0; i<arr.length; i++) {
+        map[arr[i]] = 1;
+    }
+    return map;
+
+};
+
+exports.createMap = function(props) {
+    var map = Object.create(null);
+    for (var i in props) {
+        map[i] = props[i];
+    }
+    return map;
+};
+exports.arrayRemove = function(array, value) {
+  for (var i = 0; i <= array.length; i++) {
+    if (value === array[i]) {
+      array.splice(i, 1);
+    }
+  }
+};
+
+exports.escapeRegExp = function(str) {
+    return str.replace(/([.*+?^${}()|[\]\/\\])/g, '\\$1');
+};
+
+exports.escapeHTML = function(str) {
+    return str.replace(/&/g, "&#38;").replace(/"/g, "&#34;").replace(/'/g, "&#39;").replace(/</g, "&#60;");
+};
+
+exports.getMatchOffsets = function(string, regExp) {
+    var matches = [];
+
+    string.replace(regExp, function(str) {
+        matches.push({
+            offset: arguments[arguments.length-2],
+            length: str.length
+        });
+    });
+
+    return matches;
+};
+exports.deferredCall = function(fcn) {
+    var timer = null;
+    var callback = function() {
+        timer = null;
+        fcn();
+    };
+
+    var deferred = function(timeout) {
+        deferred.cancel();
+        timer = setTimeout(callback, timeout || 0);
+        return deferred;
+    };
+
+    deferred.schedule = deferred;
+
+    deferred.call = function() {
+        this.cancel();
+        fcn();
+        return deferred;
+    };
+
+    deferred.cancel = function() {
+        clearTimeout(timer);
+        timer = null;
+        return deferred;
+    };
+    
+    deferred.isPending = function() {
+        return timer;
+    };
+
+    return deferred;
+};
+
+
+exports.delayedCall = function(fcn, defaultTimeout) {
+    var timer = null;
+    var callback = function() {
+        timer = null;
+        fcn();
+    };
+
+    var _self = function(timeout) {
+        if (timer == null)
+            timer = setTimeout(callback, timeout || defaultTimeout);
+    };
+
+    _self.delay = function(timeout) {
+        timer && clearTimeout(timer);
+        timer = setTimeout(callback, timeout || defaultTimeout);
+    };
+    _self.schedule = _self;
+
+    _self.call = function() {
+        this.cancel();
+        fcn();
+    };
+
+    _self.cancel = function() {
+        timer && clearTimeout(timer);
+        timer = null;
+    };
+
+    _self.isPending = function() {
+        return timer;
+    };
+
+    return _self;
+};
+});
+
+ace.define("ace/worker/mirror",["require","exports","module","ace/range","ace/document","ace/lib/lang"], function(require, exports, module) {
+"use strict";
+
+var Range = require("../range").Range;
+var Document = require("../document").Document;
+var lang = require("../lib/lang");
+    
+var Mirror = exports.Mirror = function(sender) {
+    this.sender = sender;
+    var doc = this.doc = new Document("");
+    
+    var deferredUpdate = this.deferredUpdate = lang.delayedCall(this.onUpdate.bind(this));
+    
+    var _self = this;
+    sender.on("change", function(e) {
+        var data = e.data;
+        if (data[0].start) {
+            doc.applyDeltas(data);
+        } else {
+            for (var i = 0; i < data.length; i += 2) {
+                if (Array.isArray(data[i+1])) {
+                    var d = {action: "insert", start: data[i], lines: data[i+1]};
+                } else {
+                    var d = {action: "remove", start: data[i], end: data[i+1]};
+                }
+                doc.applyDelta(d, true);
+            }
+        }
+        if (_self.$timeout)
+            return deferredUpdate.schedule(_self.$timeout);
+        _self.onUpdate();
+    });
+};
+
+(function() {
+    
+    this.$timeout = 500;
+    
+    this.setTimeout = function(timeout) {
+        this.$timeout = timeout;
+    };
+    
+    this.setValue = function(value) {
+        this.doc.setValue(value);
+        this.deferredUpdate.schedule(this.$timeout);
+    };
+    
+    this.getValue = function(callbackId) {
+        this.sender.callback(this.doc.getValue(), callbackId);
+    };
+    
+    this.onUpdate = function() {
+    };
+    
+    this.isPending = function() {
+        return this.deferredUpdate.isPending();
+    };
+    
+}).call(Mirror.prototype);
+
+});
+
+ace.define("ace/mode/latex_beta_worker",["require","exports","module","ace/lib/oop","ace/worker/mirror"], function(require, exports, module) {
+"use strict";
+
+var oop = require("../lib/oop");
+var Mirror = require("../worker/mirror").Mirror;
+
+var LatexWorker = exports.LatexWorker = function(sender) {
+    Mirror.call(this, sender);
+    this.setTimeout(250);
+};
+
+oop.inherits(LatexWorker, Mirror);
+
+var Tokenise = function (text) {
+    var Tokens = [];
+    var Comments = [];
+    var pos = -1;
+    var SPECIAL = /[\\\{\}\$\&\#\^\_\~\%]/g;  // match TeX special characters
+    var NEXTCS = /[^a-zA-Z]/g;  // match characters which aren't part of a TeX control sequence
+    var idx = 0;
+
+    var lineNumber = 0;   // current line number when parsing tokens (zero-based)
+    var linePosition = [];  // mapping from line number to absolute offset of line in text[]
+    linePosition[0] = 0;
+
+    var checkingDisabled = false;
+    var count = 0;  // number of tokens parses
+    var MAX_TOKENS = 100000;
+
+    while (true) {
+        count++;
+        if (count > MAX_TOKENS) {
+            throw new Error("exceed max token count of " + MAX_TOKENS);
+            break;
+        };
+        var result = SPECIAL.exec(text);
+        if (result == null) {
+            if (idx < text.length) {
+                Tokens.push([lineNumber, "Text", idx, text.length]);
+            }
+            break;
+        }
+        if (result && result.index <= pos) {
+            throw new Error("infinite loop in parsing");
+            break;
+        };
+        pos = result.index;
+        if (pos > idx) {
+            Tokens.push([lineNumber, "Text", idx, pos]);
+        }
+        for (var i = idx; i < pos; i++) {
+            if (text[i] === "\n") {
+                lineNumber++;
+                linePosition[lineNumber] = i+1;
+            }
+        }
+
+        var newIdx = SPECIAL.lastIndex;
+        idx = newIdx;
+        var code = result[0];
+        if (code === "%") { // comment character
+            var newLinePos = text.indexOf("\n", idx);
+            if (newLinePos === -1) {
+                newLinePos = text.length;
+            };
+            var commentString = text.substring(idx, newLinePos);
+            if (commentString.indexOf("%novalidate") === 0) {
+                return [];
+            } else if(!checkingDisabled && commentString.indexOf("%begin novalidate") === 0) {
+                checkingDisabled = true;
+            } else if (checkingDisabled && commentString.indexOf("%end novalidate") === 0) {
+                checkingDisabled = false;
+            };
+            idx = SPECIAL.lastIndex = newLinePos + 1;
+            Comments.push([lineNumber, idx, newLinePos]);
+            lineNumber++;
+            linePosition[lineNumber] = idx;
+        } else if (checkingDisabled) {
+            continue;
+        } else if (code === '\\') { // escape character
+            NEXTCS.lastIndex = idx;
+            var controlSequence = NEXTCS.exec(text);
+            var nextSpecialPos = controlSequence === null ? idx : controlSequence.index;
+            if (nextSpecialPos === idx) {
+                Tokens.push([lineNumber, code, pos, idx + 1, text[idx], "control-symbol"]);
+                idx = SPECIAL.lastIndex = idx + 1;
+                char = text[nextSpecialPos];
+                if (char === '\n') { lineNumber++; linePosition[lineNumber] = nextSpecialPos;};
+            } else {
+                Tokens.push([lineNumber, code, pos, nextSpecialPos, text.slice(idx, nextSpecialPos)]);
+                var char;
+                while ((char = text[nextSpecialPos]) === ' ' || char === '\t' || char  === '\r' || char === '\n') {
+                    nextSpecialPos++;
+                    if (char === '\n') { lineNumber++; linePosition[lineNumber] = nextSpecialPos;};
+                }
+                idx = SPECIAL.lastIndex = nextSpecialPos;
+            }
+        } else if (code === "{") {  // open group
+            Tokens.push([lineNumber, code, pos]);
+        } else if (code === "}") {  // close group
+            Tokens.push([lineNumber, code, pos]);
+        } else if (code === "$") {  // math mode
+            Tokens.push([lineNumber, code, pos]);
+        } else if (code === "&") {  // tabalign
+            Tokens.push([lineNumber, code, pos]);
+        } else if (code === "#") {  // macro parameter
+            Tokens.push([lineNumber, code, pos]);
+        } else if (code === "^") {  // superscript
+            Tokens.push([lineNumber, code, pos]);
+        } else if (code === "_") {  // subscript
+            Tokens.push([lineNumber, code, pos]);
+        } else if (code === "~") {  // active character (space)
+            Tokens.push([lineNumber, code, pos]);
+        } else {
+            throw "unrecognised character " + code;
+        }
+    }
+
+    return {tokens: Tokens, comments: Comments, linePosition: linePosition, lineNumber: lineNumber, text: text};
+};
+
+var read1arg = function (TokeniseResult, k, options) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+    if (options && options.allowStar) {
+        var optional = Tokens[k+1];
+        if (optional && optional[1] === "Text") {
+            var optionalstr = text.substring(optional[2], optional[3]);
+            if (optionalstr === "*") { k++;}
+        };
+    };
+
+    var open = Tokens[k+1];
+    var env = Tokens[k+2];
+    var close = Tokens[k+3];
+    var envName;
+
+    if(open && open[1] === "\\") {
+        envName = open[4]; // array element 4 is command sequence
+        return k + 1;
+    } else if(open && open[1] === "{" && env && env[1] === "\\" && close && close[1] === "}") {
+        envName = env[4]; // NOTE: if we were actually using this, keep track of * above
+        return k + 3; // array element 4 is command sequence
+    } else {
+        return null;
+    }
+};
+
+var readLetDefinition = function (TokeniseResult, k) {
+
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var first = Tokens[k+1];
+    var second = Tokens[k+2];
+    var third = Tokens[k+3];
+
+    if(first && first[1] === "\\" && second && second[1] === "\\") {
+        return k + 2;
+    } else if(first && first[1] === "\\" &&
+              second && second[1] === "Text" && text.substring(second[2], second[3]) === "=" &&
+              third && third[1] === "\\") {
+        return k + 3;
+    } else {
+        return null;
+    }
+};
+
+var read1name = function (TokeniseResult, k) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var open = Tokens[k+1];
+    var env = Tokens[k+2];
+    var close = Tokens[k+3];
+
+    if(open && open[1] === "{" && env && env[1] === "Text" && close && close[1] === "}") {
+        var envName = text.substring(env[2], env[3]);
+        return k + 3;
+    } else if (open && open[1] === "{" && env && env[1] === "Text") {
+        envName = "";
+        for (var j = k + 2, tok; (tok = Tokens[j]); j++) {
+            if (tok[1] === "Text") {
+                var str = text.substring(tok[2], tok[3]);
+                if (!str.match(/^\S*$/)) { break; }
+                envName = envName + str;
+            } else if (tok[1] === "_") {
+                envName = envName + "_";
+            } else {
+                break;
+            }
+        }
+        if (tok && tok[1] === "}") {
+            return  j; // advance past these tokens
+        } else {
+            return null;
+        }
+    } else {
+        return null;
+    }
+};
+
+var read1filename = function (TokeniseResult, k) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var fileName = "";
+    for (var j = k + 1, tok; (tok = Tokens[j]); j++) {
+        if (tok[1] === "Text") {
+            var str = text.substring(tok[2], tok[3]);
+            if (!str.match(/^\S*$/)) { break; }
+            fileName = fileName + str;
+        } else if (tok[1] === "_") {
+            fileName = fileName + "_";
+        } else {
+            break;
+        }
+    }
+    if (fileName.length > 0) {
+        return  j; // advance past these tokens
+    } else {
+        return null;
+    }
+};
+
+var readOptionalParams = function(TokeniseResult, k) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var params = Tokens[k+1];
+
+    if(params && params[1] === "Text") {
+        var paramNum = text.substring(params[2], params[3]);
+        if (paramNum.match(/^\[\d+\](\[[^\]]*\])*\s*$/)) {
+            return k + 1; // got it
+        };
+    };
+    var count = 0;
+    var nextToken = Tokens[k+1];
+    var pos = nextToken[2];
+
+    for (var i = pos, end = text.length; i < end; i++) {
+        var char = text[i];
+        if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];};
+        if (char === "[") { count++; }
+        if (char === "]") { count--; }
+        if (count === 0 && char === "{") { return k - 1; }
+        if (count > 0 && (char  === '\r' || char === '\n')) { return null; }
+    };
+    return null;
+};
+
+var readOptionalGeneric = function(TokeniseResult, k) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var params = Tokens[k+1];
+
+    if(params && params[1] === "Text") {
+        var paramNum = text.substring(params[2], params[3]);
+        if (paramNum.match(/^(\[[^\]]*\])+\s*$/)) {
+            return k + 1; // got it
+        };
+    };
+    return null;
+};
+
+var readOptionalDef = function (TokeniseResult, k) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var defToken = Tokens[k];
+    var pos = defToken[3];
+
+    var openBrace = "{";
+    var nextToken = Tokens[k+1];
+    for (var i = pos, end = text.length; i < end; i++) {
+        var char = text[i];
+        if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];};
+        if (char === openBrace) { return k - 1; }; // move back to the last token of the optional arguments
+        if (char  === '\r' || char === '\n') { return null; }
+    };
+
+    return null;
+
+};
+
+var readDefinition = function(TokeniseResult, k) {
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    k = k + 1;
+    var count = 0;
+    var nextToken = Tokens[k];
+    while (nextToken && nextToken[1] === "Text") {
+        var start = nextToken[2], end = nextToken[3];
+        for (var i = start; i < end; i++) {
+            var char = text[i];
+            if (char === ' ' || char === '\t' || char  === '\r' || char === '\n') { continue; }
+            return null; // bail out, should begin with a {
+        }
+        k++;
+        nextToken = Tokens[k];
+    }
+    if (nextToken && nextToken[1] === "{") {
+        count++;
+        while (count>0) {
+            k++;
+            nextToken = Tokens[k];
+            if(!nextToken) { break; };
+            if (nextToken[1] === "}") { count--; }
+            if (nextToken[1] === "{") { count++; }
+        }
+        return k;
+    }
+
+    return null;
+};
+
+var readVerb = function(TokeniseResult, k) {
+
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var verbToken = Tokens[k];
+    var verbStr = text.substring(verbToken[2], verbToken[3]);
+    var pos = verbToken[3];
+    if (text[pos] === "*") { pos++; } // \verb* form of command
+    var delimiter = text[pos];
+    pos++;
+
+    var nextToken = Tokens[k+1];
+    for (var i = pos, end = text.length; i < end; i++) {
+        var char = text[i];
+        if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];};
+        if (char === delimiter) { return k; };
+        if (char  === '\r' || char === '\n') { return null; }
+    };
+
+    return null;
+};
+
+var readUrl = function(TokeniseResult, k) {
+
+    var Tokens = TokeniseResult.tokens;
+    var text = TokeniseResult.text;
+
+    var urlToken = Tokens[k];
+    var urlStr = text.substring(urlToken[2], urlToken[3]);
+    var pos = urlToken[3];
+    var openDelimiter = text[pos];
+    var closeDelimiter =  (openDelimiter === "{") ? "}" : openDelimiter;
+    var nextToken = Tokens[k+1];
+    if (nextToken && pos === nextToken[2]) {
+        k++;
+        nextToken = Tokens[k+1];
+    };
+    pos++;
+
+    var count = 1;
+    for (var i = pos, end = text.length; count > 0 && i < end; i++) {
+        var char = text[i];
+        if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];};
+        if (char === closeDelimiter) {
+            count--;
+        } else if (char === openDelimiter) {
+            count++;
+        };
+        if (count === 0) { return k; };
+        if (char  === '\r' || char === '\n') { return null; }
+    };
+
+    return null;
+};
+
+var InterpretTokens = function (TokeniseResult, ErrorReporter) {
+    var Tokens = TokeniseResult.tokens;
+    var linePosition = TokeniseResult.linePosition;
+    var lineNumber = TokeniseResult.lineNumber;
+    var text = TokeniseResult.text;
+
+    var TokenErrorFromTo = ErrorReporter.TokenErrorFromTo;
+    var TokenError = ErrorReporter.TokenError;
+    var Environments = new EnvHandler(ErrorReporter);
+
+    var nextGroupMathMode = null; // if the next group should have math mode on or off (for \hbox)
+    var nextGroupMathModeStack = [] ; // tracking all nextGroupMathModes
+    var seenUserDefinedBeginEquation = false; // if we have seen macros like \beq
+    var seenUserDefinedEndEquation = false; // if we have seen macros like \eeq
+
+    for (var i = 0, len = Tokens.length; i < len; i++) {
+        var token = Tokens[i];
+        var line = token[0], type = token[1], start = token[2], end = token[3], seq = token[4];
+
+        if (type === "{") {
+            Environments.push({command:"{", token:token, mathMode: nextGroupMathMode});
+            nextGroupMathModeStack.push(nextGroupMathMode);
+            nextGroupMathMode = null;
+            continue;
+        } else if (type === "}") {
+            Environments.push({command:"}", token:token});
+            nextGroupMathMode = nextGroupMathModeStack.pop();
+            continue;
+        } else {
+            nextGroupMathMode = null;
+        };
+
+        if (type === "\\") {
+            if (seq === "begin" || seq === "end") {
+                var open = Tokens[i+1];
+                var env = Tokens[i+2];
+                var close = Tokens[i+3];
+                if(open && open[1] === "{" && env && env[1] === "Text" && close && close[1] === "}") {
+                    var envName = text.substring(env[2], env[3]);
+                    Environments.push({command: seq, name: envName, token: token, closeToken: close});
+                    i = i + 3; // advance past these tokens
+                } else {
+                    if (open && open[1] === "{" && env && env[1] === "Text") {
+                        envName = "";
+                        for (var j = i + 2, tok; (tok = Tokens[j]); j++) {
+                            if (tok[1] === "Text") {
+                                var str = text.substring(tok[2], tok[3]);
+                                if (!str.match(/^\S*$/)) { break; }
+                                envName = envName + str;
+                            } else if (tok[1] === "_") {
+                                envName = envName + "_";
+                            } else {
+                                break;
+                            }
+                        }
+                        if (tok && tok[1] === "}") {
+                            Environments.push({command: seq, name: envName, token: token, closeToken: close});
+                            i = j; // advance past these tokens
+                            continue;
+                        }
+                    }
+                    var endToken = null;
+                    if (open && open[1] === "{") {
+                        endToken = open; // we've got a {
+                        if (env && env[1] === "Text") {
+                            endToken = env.slice(); // we've got some text following the {
+                            start = endToken[2]; end = endToken[3];
+                            for (j = start; j < end; j++) {
+                                var char = text[j];
+                                if (char === ' ' || char === '\t' || char  === '\r' || char === '\n') { break; }
+                            }
+                            endToken[3] = j; // the end of partial token is as far as we got looking ahead
+                        };
+                    };
+
+                    if (endToken) {
+                        TokenErrorFromTo(token, endToken, "invalid environment command " + text.substring(token[2], endToken[3] || endToken[2]));
+                    } else {
+                        TokenError(token, "invalid environment command");
+                    };
+                }
+            } else if (typeof seq === "string" && seq.match(/^(be|beq|beqa|bea)$/i)) {
+                seenUserDefinedBeginEquation = true;
+            } else if (typeof seq === "string" && seq.match(/^(ee|eeq|eeqn|eeqa|eeqan|eea)$/i)) {
+                seenUserDefinedEndEquation = true;
+            } else if (seq === "newcommand" || seq === "renewcommand" || seq === "DeclareRobustCommand") {
+                var newPos = read1arg(TokeniseResult, i, {allowStar: true});
+                if (newPos === null) { continue; } else {i = newPos;};
+                newPos = readOptionalParams(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+
+            } else if (seq === "def") {
+                newPos = read1arg(TokeniseResult, i);
+                if (newPos === null) { continue; } else {i = newPos;};
+                newPos = readOptionalDef(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+
+            } else if (seq === "let") {
+                newPos = readLetDefinition(TokeniseResult, i);
+                if (newPos === null) { continue; } else {i = newPos;};
+
+            } else if (seq === "newcolumntype") {
+                newPos = read1name(TokeniseResult, i);
+                if (newPos === null) { continue; } else {i = newPos;};
+                newPos = readOptionalParams(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+
+            } else if (seq === "newenvironment" || seq === "renewenvironment") {
+                newPos = read1name(TokeniseResult, i);
+                if (newPos === null) { continue; } else {i = newPos;};
+                newPos = readOptionalParams(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+            } else if (seq === "verb") {
+                newPos = readVerb(TokeniseResult, i);
+                if (newPos === null) { TokenError(token, "invalid verbatim command"); } else {i = newPos;};
+            } else if (seq === "url") {
+                newPos = readUrl(TokeniseResult, i);
+                if (newPos === null) { TokenError(token, "invalid url command"); } else {i = newPos;};
+            } else if (seq === "left" || seq === "right") {
+                var nextToken = Tokens[i+1];
+                char = "";
+                if (nextToken && nextToken[1] === "Text") {
+                    char = text.substring(nextToken[2], nextToken[2] + 1);
+                } else if (nextToken && nextToken[1] === "\\" && nextToken[5] == "control-symbol") {
+                    char = nextToken[4];
+                } else if (nextToken && nextToken[1] === "\\") {
+                    char = "unknown";
+                }
+                if (char === "" || (char !== "unknown" && "(){}[]<>/|\\.".indexOf(char) === -1)) {
+                    TokenError(token, "invalid bracket command");
+                } else {
+                    i = i + 1;
+                    Environments.push({command:seq, token:token});
+                };
+            } else if (seq === "(" || seq === ")" || seq === "[" || seq === "]") {
+                Environments.push({command:seq, token:token});
+            } else if (seq === "input") {
+                newPos = read1filename(TokeniseResult, i);
+                if (newPos === null) { continue; } else {i = newPos;};
+            } else if (seq === "hbox" || seq === "text" || seq === "mbox" || seq === "footnote" || seq === "intertext" || seq === "shortintertext" || seq === "textnormal" || seq === "tag" || seq === "reflectbox" || seq === "textrm") {
+                nextGroupMathMode = false;
+            } else if (seq === "rotatebox" || seq === "scalebox") {
+                newPos = readOptionalGeneric(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                nextGroupMathMode = false;
+            } else if (seq === "resizebox") {
+                newPos = readOptionalGeneric(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+
+                nextGroupMathMode = false;
+            } else if (seq === "DeclareMathOperator") {
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+            } else if (seq === "DeclarePairedDelimiter") {
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+                newPos = readDefinition(TokeniseResult, i);
+                if (newPos === null) { /* do nothing */ } else {i = newPos;};
+            } else if (typeof seq === "string" && seq.match(/^(alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|vartheta|iota|kappa|lambda|mu|nu|xi|pi|varpi|rho|varrho|sigma|varsigma|tau|upsilon|phi|varphi|chi|psi|omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega)$/)) {
+                var currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display)
+                if (currentMathMode === null) {
+                    TokenError(token, type + seq + " must be inside math mode", {mathMode:true});
+                };
+            } else if (typeof seq === "string" && seq.match(/^(chapter|section|subsection|subsubsection)$/)) {
+                currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display)
+                if (currentMathMode) {
+                    TokenError(token, type + seq + " used inside math mode", {mathMode:true});
+                    Environments.resetMathMode();
+                };
+            } else if (typeof seq === "string" && seq.match(/^[a-z]+$/)) {
+                nextGroupMathMode = undefined;
+            };
+
+        } else if (type === "$") {
+            var lookAhead = Tokens[i+1];
+            var nextIsDollar = lookAhead && lookAhead[1] === "$";
+            currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display)
+            if (nextIsDollar && (!currentMathMode || currentMathMode.command == "$$")) {
+                Environments.push({command:"$$", token:token});
+                i = i + 1;
+            } else {
+                Environments.push({command:"$", token:token});
+            }
+        } else if (type === "^" || type === "_") {
+            currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display)
+            var insideGroup = Environments.insideGroup();  // true if inside {....}
+            if (currentMathMode === null && !insideGroup) {
+                TokenError(token, type + " must be inside math mode", {mathMode:true});
+            };
+        }
+    };
+
+    if (seenUserDefinedBeginEquation && seenUserDefinedEndEquation) {
+        ErrorReporter.filterMath = true;
+    };
+
+    return Environments;
+};
+
+var EnvHandler = function (ErrorReporter) {
+    var ErrorTo = ErrorReporter.EnvErrorTo;
+    var ErrorFromTo = ErrorReporter.EnvErrorFromTo;
+    var ErrorFrom = ErrorReporter.EnvErrorFrom;
+
+    var envs = [];
+
+    var state = [];
+    var documentClosed = null;
+    var inVerbatim = false;
+    var verbatimRanges = [];
+
+    this.Environments = envs;
+
+    this.push = function (newEnv) {
+        this.setEnvProps(newEnv);
+        this.checkAndUpdateState(newEnv);
+        envs.push(newEnv);
+    };
+
+    this._endVerbatim = function (thisEnv) {
+        var lastEnv = state.pop();
+        if (lastEnv && lastEnv.name === thisEnv.name) {
+            inVerbatim = false;
+            verbatimRanges.push({start: lastEnv.token[2], end: thisEnv.token[2]});
+        } else {
+            if(lastEnv) { state.push(lastEnv); } ;
+        }
+    };
+
+    var invalidEnvs = [];
+
+    this._end = function (thisEnv) {
+        do {
+            var lastEnv = state.pop();
+            var retry = false;
+            var i;
+
+            if (closedBy(lastEnv, thisEnv)) {
+                if (thisEnv.command === "end" && thisEnv.name === "document" && !documentClosed) {
+                    documentClosed = thisEnv;
+                };
+                return;
+            } else if (!lastEnv) {
+                if (documentClosed) {
+                    ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected content",{errorAtStart: true, type: "info"});
+                } else {
+                    ErrorTo(thisEnv, "unexpected " + getName(thisEnv));
+                }
+            } else if (invalidEnvs.length > 0 && (i = indexOfClosingEnvInArray(invalidEnvs, thisEnv) > -1)) {
+                invalidEnvs.splice(i, 1);
+                if (lastEnv) { state.push(lastEnv); } ;
+                return;
+            } else {
+                var status = reportError(lastEnv, thisEnv);
+                if (envPrecedence(lastEnv) < envPrecedence(thisEnv)) {
+                    invalidEnvs.push(lastEnv);
+                    retry = true;
+                } else {
+                    var prevLastEnv = state.pop();
+                    if(prevLastEnv) {
+                        if (thisEnv.name === prevLastEnv.name) {
+                            return;
+                        } else {
+                            state.push(prevLastEnv);
+                        }
+                    }
+                    invalidEnvs.push(lastEnv);
+                }
+
+            }
+        } while (retry === true);
+    };
+
+    var CLOSING_DELIMITER = {
+        "{" : "}",
+        "left" : "right",
+        "[" : "]",
+        "(" : ")",
+        "$" : "$",
+        "$$": "$$"
+    };
+
+    var closedBy = function (lastEnv, thisEnv) {
+        if (!lastEnv) {
+            return false ;
+        } else if (thisEnv.command === "end") {
+            return lastEnv.command === "begin" && lastEnv.name === thisEnv.name;
+        } else if (thisEnv.command === CLOSING_DELIMITER[lastEnv.command]) {
+            return true;
+        } else {
+            return false;
+        }
+    };
+
+    var indexOfClosingEnvInArray = function (envs, thisEnv) {
+        for (var i = 0, n = envs.length; i < n ; i++) {
+            if (closedBy(envs[i], thisEnv)) {
+                return i;
+            }
+        }
+        return -1;
+    };
+
+    var envPrecedence = function (env) {
+        var openScore = {
+            "{" : 1,
+            "left" : 2,
+            "$" : 3,
+            "$$" : 4,
+            "begin": 4
+        };
+        var closeScore = {
+            "}" : 1,
+            "right" : 2,
+            "$" : 3,
+            "$$" : 5,
+            "end": 4
+        };
+        if (env.command) {
+            return openScore[env.command] || closeScore[env.command];
+        } else {
+            return 0;
+        }
+    };
+
+    var getName = function(env) {
+        var description = {
+            "{" : "open group {",
+            "}" : "close group }",
+            "[" : "open display math \\[",
+            "]" : "close display math \\]",
+            "(" : "open inline math \\(",
+            ")" : "close inline math \\)",
+            "$" : "$",
+            "$$" : "$$",
+            "left" : "\\left",
+            "right" : "\\right"
+        };
+        if (env.command === "begin" || env.command === "end") {
+            return "\\" + env.command + "{" + env.name + "}";
+        } else if (env.command in description) {
+            return description[env.command];
+        } else {
+            return env.command;
+        }
+    };
+
+    var EXTRA_CLOSE = 1;
+    var UNCLOSED_GROUP = 2;
+    var UNCLOSED_ENV = 3;
+
+    var reportError = function(lastEnv, thisEnv) {
+        if (!lastEnv) { // unexpected close, nothing was open!
+            if (documentClosed) {
+                ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected end group }",{errorAtStart: true, type: "info"});
+            } else {
+                ErrorTo(thisEnv, "unexpected " + getName(thisEnv));
+            };
+            return EXTRA_CLOSE;
+        } else if (lastEnv.command === "{" && thisEnv.command === "end") {
+            ErrorFromTo(lastEnv, thisEnv, "unclosed " + getName(lastEnv) + " found at " + getName(thisEnv),
+                        {suppressIfEditing:true, errorAtStart: true, type:"warning"});
+            return UNCLOSED_GROUP;
+        } else {
+            var pLast = envPrecedence(lastEnv);
+            var pThis = envPrecedence(thisEnv);
+            if (pThis > pLast) {
+                ErrorFromTo(lastEnv, thisEnv, "unclosed " + getName(lastEnv) + " found at " + getName(thisEnv),
+                           {suppressIfEditing:true, errorAtStart: true});
+            } else {
+                ErrorFromTo(lastEnv, thisEnv, "unexpected " + getName(thisEnv) + " after " + getName(lastEnv));
+            }
+            return UNCLOSED_ENV;
+        };
+    };
+
+    this._beginMathMode = function (thisEnv) {
+        var currentMathMode = this.getMathMode(); // undefined, null, $, $$, name of mathmode env
+        if (currentMathMode) {
+            ErrorFrom(thisEnv, thisEnv.name + " used inside existing math mode " + getName(currentMathMode),
+                      {suppressIfEditing:true, errorAtStart: true, mathMode:true});
+        };
+        thisEnv.mathMode = thisEnv;
+        state.push(thisEnv);
+    };
+
+    this._toggleMathMode = function (thisEnv) {
+        var lastEnv = state.pop();
+        if (closedBy(lastEnv, thisEnv)) {
+            return;
+        } else {
+            if (lastEnv) {state.push(lastEnv);}
+            if (lastEnv && lastEnv.mathMode) {
+                this._end(thisEnv);
+            } else {
+                thisEnv.mathMode = thisEnv;
+                state.push(thisEnv);
+            }
+        };
+    };
+
+    this.getMathMode = function () {
+        var n = state.length;
+        if (n > 0) {
+            return state[n-1].mathMode;
+        } else {
+            return null;
+        }
+    };
+
+    this.insideGroup = function () {
+        var n = state.length;
+        if (n > 0) {
+            return (state[n-1].command === "{");
+        } else {
+            return null;
+        }
+    };
+
+    var resetMathMode = function () {
+        var n = state.length;
+        if (n > 0) {
+            var lastMathMode = state[n-1].mathMode;
+            do {
+                var lastEnv = state.pop();
+            } while (lastEnv && lastEnv !== lastMathMode);
+        } else {
+            return;
+        }
+    };
+
+    this.resetMathMode = resetMathMode;
+
+    var getNewMathMode = function (currentMathMode, thisEnv) {
+        var newMathMode = null;
+
+        if (thisEnv.command === "{") {
+            if (thisEnv.mathMode !== null) {
+                newMathMode = thisEnv.mathMode;
+            } else {
+                newMathMode = currentMathMode;
+            }
+        } else if (thisEnv.command === "left") {
+            if (currentMathMode === null) {
+                ErrorFrom(thisEnv, "\\left can only be used in math mode", {mathMode: true});
+            };
+            newMathMode = currentMathMode;
+        } else if (thisEnv.command === "begin") {
+            var name = thisEnv.name;
+            if (name) {
+                if (name.match(/^(document|figure|center|enumerate|itemize|table|abstract|proof|lemma|theorem|definition|proposition|corollary|remark|notation|thebibliography)$/)) {
+                    if (currentMathMode) {
+                        ErrorFromTo(currentMathMode, thisEnv, thisEnv.name + " used inside " + getName(currentMathMode),
+                                    {suppressIfEditing:true, errorAtStart: true, mathMode: true});
+                        resetMathMode();
+                    };
+                    newMathMode = null;
+                } else if (name.match(/^(array|gathered|split|aligned|alignedat)\*?$/)) {
+                    if (currentMathMode === null) {
+                        ErrorFrom(thisEnv, thisEnv.name + " not inside math mode", {mathMode: true});
+                    };
+                    newMathMode = currentMathMode;
+                } else if (name.match(/^(math|displaymath|equation|eqnarray|multline|align|gather|flalign|alignat)\*?$/)) {
+                    if (currentMathMode) {
+                        ErrorFromTo(currentMathMode, thisEnv, thisEnv.name + " used inside " + getName(currentMathMode),
+                                    {suppressIfEditing:true, errorAtStart: true, mathMode: true});
+                        resetMathMode();
+                    };
+                    newMathMode = thisEnv;
+                } else {
+                    newMathMode = undefined;  // undefined means we don't know if we are in math mode or not
+                }
+            }
+        };
+        return newMathMode;
+    };
+
+    this.checkAndUpdateState = function (thisEnv) {
+        if (inVerbatim) {
+            if (thisEnv.command === "end") {
+                this._endVerbatim(thisEnv);
+            } else {
+                return; // ignore anything in verbatim environments
+            }
+        } else if(thisEnv.command === "begin" || thisEnv.command === "{" || thisEnv.command === "left") {
+            if (thisEnv.verbatim) {inVerbatim = true;};
+            var currentMathMode = this.getMathMode(); // undefined, null, $, $$, name of mathmode env
+            var newMathMode = getNewMathMode(currentMathMode, thisEnv);
+            thisEnv.mathMode = newMathMode;
+            state.push(thisEnv);
+        } else if (thisEnv.command === "end") {
+            this._end(thisEnv);
+        } else if (thisEnv.command === "(" || thisEnv.command === "[") {
+            this._beginMathMode(thisEnv);
+        } else if (thisEnv.command === ")" || thisEnv.command === "]") {
+            this._end(thisEnv);
+        } else if (thisEnv.command === "}") {
+            this._end(thisEnv);
+        } else if (thisEnv.command === "right") {
+            this._end(thisEnv);
+        } else if (thisEnv.command === "$" || thisEnv.command === "$$") {
+            this._toggleMathMode(thisEnv);
+        }
+    };
+
+    this.close = function () {
+        while (state.length > 0) {
+            var thisEnv = state.pop();
+            if (thisEnv.command === "{") {
+                ErrorFrom(thisEnv, "unclosed group {", {type:"warning"});
+            } else {
+                ErrorFrom(thisEnv, "unclosed " + getName(thisEnv));
+            }
+        }
+        var vlen = verbatimRanges.length;
+        var len = ErrorReporter.tokenErrors.length;
+        if (vlen >0 && len > 0) {
+            for (var i = 0; i < len; i++) {
+                var tokenError = ErrorReporter.tokenErrors[i];
+                var startPos = tokenError.startPos;
+                var endPos = tokenError.endPos;
+                for (var j = 0; j < vlen; j++) {
+                    if (startPos > verbatimRanges[j].start && startPos < verbatimRanges[j].end) {
+                        tokenError.ignore = true;
+                        break;
+                    }
+                }
+            }
+        }
+    };
+
+    this.setEnvProps = function (env) {
+        var name = env.name ;
+        if (name && name.match(/^(verbatim|boxedverbatim|lstlisting|minted|Verbatim)$/)) {
+            env.verbatim = true;
+        }
+    };
+};
+var ErrorReporter = function (TokeniseResult) {
+    var text = TokeniseResult.text;
+    var linePosition = TokeniseResult.linePosition;
+    var lineNumber = TokeniseResult.lineNumber;
+
+    var errors = [], tokenErrors = [];
+    this.errors = errors;
+    this.tokenErrors = tokenErrors;
+    this.filterMath = false;
+
+    this.getErrors = function () {
+        var returnedErrors = [];
+        for (var i = 0, len = tokenErrors.length; i < len; i++) {
+            if (!tokenErrors[i].ignore) { returnedErrors.push(tokenErrors[i]); }
+        }
+        var allErrors = returnedErrors.concat(errors);
+        var result = [];
+
+        var mathErrorCount = 0;
+        for (i = 0, len = allErrors.length; i < len; i++) {
+            if (allErrors[i].mathMode) {
+                mathErrorCount++;
+            }
+            if (mathErrorCount > 10) {
+                return [];
+            }
+        }
+
+        if (this.filterMath && mathErrorCount > 0) {
+            for (i = 0, len = allErrors.length; i < len; i++) {
+                if (!allErrors[i].mathMode) {
+                    result.push(allErrors[i]);
+                }
+            }
+            return result;
+        } else {
+            return allErrors;
+        }
+    };
+
+    this.TokenError = function (token, message, options) {
+        if(!options) { options = { suppressIfEditing:true } ; };
+        var line = token[0], type = token[1], start = token[2], end = token[3];
+        var start_col = start - linePosition[line];
+        if (!end) { end = start + 1; } ;
+        var end_col = end - linePosition[line];
+        tokenErrors.push({row: line,
+                          column: start_col,
+                          start_row:line,
+                          start_col: start_col,
+                          end_row:line,
+                          end_col: end_col,
+                          type:"error",
+                          text:message,
+                          startPos: start,
+                          endPos: end,
+                          suppressIfEditing:options.suppressIfEditing,
+                          mathMode: options.mathMode});
+    };
+
+    this.TokenErrorFromTo = function (fromToken, toToken, message, options) {
+        if(!options) { options = {suppressIfEditing:true } ; };
+        var fromLine = fromToken[0], fromStart = fromToken[2], fromEnd = fromToken[3];
+        var toLine = toToken[0], toStart = toToken[2], toEnd = toToken[3];
+        if (!toEnd) { toEnd = toStart + 1;};
+        var start_col = fromStart - linePosition[fromLine];
+        var end_col = toEnd - linePosition[toLine];
+
+        tokenErrors.push({row: fromLine,
+                          column: start_col,
+                          start_row: fromLine,
+                          start_col: start_col,
+                          end_row: toLine,
+                          end_col: end_col,
+                          type:"error",
+                          text:message,
+                          startPos: fromStart,
+                          endPos: toEnd,
+                          suppressIfEditing:options.suppressIfEditing,
+                          mathMode: options.mathMode});
+    };
+
+
+    this.EnvErrorFromTo = function (fromEnv, toEnv, message, options) {
+        if(!options) { options = {} ; };
+        var fromToken = fromEnv.token, toToken = toEnv.closeToken || toEnv.token;
+        var fromLine = fromToken[0], fromStart = fromToken[2], fromEnd = fromToken[3];
+        if (!toToken) {toToken = fromToken;};
+        var toLine = toToken[0], toStart = toToken[2], toEnd = toToken[3];
+        if (!toEnd) { toEnd = toStart + 1;};
+        var start_col = fromStart - linePosition[fromLine];
+        var end_col = toEnd - linePosition[toLine];
+        errors.push({row: options.errorAtStart ? fromLine : toLine,
+                     column: options.errorAtStart ? start_col: end_col,
+                     start_row:fromLine,
+                     start_col: start_col,
+                     end_row:toLine,
+                     end_col: end_col,
+                     type: options.type ? options.type : "error",
+                     text:message,
+                     suppressIfEditing:options.suppressIfEditing,
+                     mathMode: options.mathMode});
+    };
+
+    this.EnvErrorTo = function (toEnv, message, options) {
+        if(!options) { options = {} ; };
+        var token = toEnv.closeToken || toEnv.token;
+        var line = token[0], type = token[1], start = token[2], end = token[3];
+        if (!end) { end = start + 1; };
+        var end_col = end - linePosition[line];
+        var err = {row: line,
+                   column: end_col,
+                   start_row:0,
+                   start_col: 0,
+                   end_row: line,
+                   end_col: end_col,
+                   type: options.type ? options.type : "error",
+                   text:message,
+                   mathMode: options.mathMode};
+        errors.push(err);
+    };
+
+    this.EnvErrorFrom = function (env, message, options) {
+        if(!options) { options = {} ; };
+        var token = env.token;
+        var line = token[0], type = token[1], start = token[2], end = token[3];
+        var start_col = start - linePosition[line];
+        var end_col = Infinity;
+        errors.push({row: line,
+                     column: start_col,
+                     start_row:line,
+                     start_col: start_col,
+                     end_row: lineNumber,
+                     end_col: end_col,
+                     type: options.type ? options.type : "error",
+                     text:message,
+                     mathMode: options.mathMode});
+    };
+};
+
+var Parse = function (text) {
+    var TokeniseResult = Tokenise(text);
+    var Reporter = new ErrorReporter(TokeniseResult);
+    var Environments = InterpretTokens(TokeniseResult, Reporter);
+    Environments.close();
+    return Reporter.getErrors();
+};
+
+(function() {
+    var disabled = false;
+
+    this.onUpdate = function() {
+        if (disabled) { return ; };
+
+        var value = this.doc.getValue();
+        var errors = [];
+        try {
+            if (value)
+                errors = Parse(value);
+        } catch (e) {
+            disabled = true;
+            errors = [];
+        }
+        this.sender.emit("lint", errors);
+    };
+
+}).call(LatexWorker.prototype);
+
+});
+
+ace.define("ace/lib/es5-shim",["require","exports","module"], function(require, exports, module) {
+
+function Empty() {}
+
+if (!Function.prototype.bind) {
+    Function.prototype.bind = function bind(that) { // .length is 1
+        var target = this;
+        if (typeof target != "function") {
+            throw new TypeError("Function.prototype.bind called on incompatible " + target);
+        }
+        var args = slice.call(arguments, 1); // for normal call
+        var bound = function () {
+
+            if (this instanceof bound) {
+
+                var result = target.apply(
+                    this,
+                    args.concat(slice.call(arguments))
+                );
+                if (Object(result) === result) {
+                    return result;
+                }
+                return this;
+
+            } else {
+                return target.apply(
+                    that,
+                    args.concat(slice.call(arguments))
+                );
+
+            }
+
+        };
+        if(target.prototype) {
+            Empty.prototype = target.prototype;
+            bound.prototype = new Empty();
+            Empty.prototype = null;
+        }
+        return bound;
+    };
+}
+var call = Function.prototype.call;
+var prototypeOfArray = Array.prototype;
+var prototypeOfObject = Object.prototype;
+var slice = prototypeOfArray.slice;
+var _toString = call.bind(prototypeOfObject.toString);
+var owns = call.bind(prototypeOfObject.hasOwnProperty);
+var defineGetter;
+var defineSetter;
+var lookupGetter;
+var lookupSetter;
+var supportsAccessors;
+if ((supportsAccessors = owns(prototypeOfObject, "__defineGetter__"))) {
+    defineGetter = call.bind(prototypeOfObject.__defineGetter__);
+    defineSetter = call.bind(prototypeOfObject.__defineSetter__);
+    lookupGetter = call.bind(prototypeOfObject.__lookupGetter__);
+    lookupSetter = call.bind(prototypeOfObject.__lookupSetter__);
+}
+if ([1,2].splice(0).length != 2) {
+    if(function() { // test IE < 9 to splice bug - see issue #138
+        function makeArray(l) {
+            var a = new Array(l+2);
+            a[0] = a[1] = 0;
+            return a;
+        }
+        var array = [], lengthBefore;
+        
+        array.splice.apply(array, makeArray(20));
+        array.splice.apply(array, makeArray(26));
+
+        lengthBefore = array.length; //46
+        array.splice(5, 0, "XXX"); // add one element
+
+        lengthBefore + 1 == array.length
+
+        if (lengthBefore + 1 == array.length) {
+            return true;// has right splice implementation without bugs
+        }
+    }()) {//IE 6/7
+        var array_splice = Array.prototype.splice;
+        Array.prototype.splice = function(start, deleteCount) {
+            if (!arguments.length) {
+                return [];
+            } else {
+                return array_splice.apply(this, [
+                    start === void 0 ? 0 : start,
+                    deleteCount === void 0 ? (this.length - start) : deleteCount
+                ].concat(slice.call(arguments, 2)))
+            }
+        };
+    } else {//IE8
+        Array.prototype.splice = function(pos, removeCount){
+            var length = this.length;
+            if (pos > 0) {
+                if (pos > length)
+                    pos = length;
+            } else if (pos == void 0) {
+                pos = 0;
+            } else if (pos < 0) {
+                pos = Math.max(length + pos, 0);
+            }
+
+            if (!(pos+removeCount < length))
+                removeCount = length - pos;
+
+            var removed = this.slice(pos, pos+removeCount);
+            var insert = slice.call(arguments, 2);
+            var add = insert.length;            
+            if (pos === length) {
+                if (add) {
+                    this.push.apply(this, insert);
+                }
+            } else {
+                var remove = Math.min(removeCount, length - pos);
+                var tailOldPos = pos + remove;
+                var tailNewPos = tailOldPos + add - remove;
+                var tailCount = length - tailOldPos;
+                var lengthAfterRemove = length - remove;
+
+                if (tailNewPos < tailOldPos) { // case A
+                    for (var i = 0; i < tailCount; ++i) {
+                        this[tailNewPos+i] = this[tailOldPos+i];
+                    }
+                } else if (tailNewPos > tailOldPos) { // case B
+                    for (i = tailCount; i--; ) {
+                        this[tailNewPos+i] = this[tailOldPos+i];
+                    }
+                } // else, add == remove (nothing to do)
+
+                if (add && pos === lengthAfterRemove) {
+                    this.length = lengthAfterRemove; // truncate array
+                    this.push.apply(this, insert);
+                } else {
+                    this.length = lengthAfterRemove + add; // reserves space
+                    for (i = 0; i < add; ++i) {
+                        this[pos+i] = insert[i];
+                    }
+                }
+            }
+            return removed;
+        };
+    }
+}
+if (!Array.isArray) {
+    Array.isArray = function isArray(obj) {
+        return _toString(obj) == "[object Array]";
+    };
+}
+var boxedString = Object("a"),
+    splitString = boxedString[0] != "a" || !(0 in boxedString);
+
+if (!Array.prototype.forEach) {
+    Array.prototype.forEach = function forEach(fun /*, thisp*/) {
+        var object = toObject(this),
+            self = splitString && _toString(this) == "[object String]" ?
+                this.split("") :
+                object,
+            thisp = arguments[1],
+            i = -1,
+            length = self.length >>> 0;
+        if (_toString(fun) != "[object Function]") {
+            throw new TypeError(); // TODO message
+        }
+
+        while (++i < length) {
+            if (i in self) {
+                fun.call(thisp, self[i], i, object);
+            }
+        }
+    };
+}
+if (!Array.prototype.map) {
+    Array.prototype.map = function map(fun /*, thisp*/) {
+        var object = toObject(this),
+            self = splitString && _toString(this) == "[object String]" ?
+                this.split("") :
+                object,
+            length = self.length >>> 0,
+            result = Array(length),
+            thisp = arguments[1];
+        if (_toString(fun) != "[object Function]") {
+            throw new TypeError(fun + " is not a function");
+        }
+
+        for (var i = 0; i < length; i++) {
+            if (i in self)
+                result[i] = fun.call(thisp, self[i], i, object);
+        }
+        return result;
+    };
+}
+if (!Array.prototype.filter) {
+    Array.prototype.filter = function filter(fun /*, thisp */) {
+        var object = toObject(this),
+            self = splitString && _toString(this) == "[object String]" ?
+                this.split("") :
+                    object,
+            length = self.length >>> 0,
+            result = [],
+            value,
+            thisp = arguments[1];
+        if (_toString(fun) != "[object Function]") {
+            throw new TypeError(fun + " is not a function");
+        }
+
+        for (var i = 0; i < length; i++) {
+            if (i in self) {
+                value = self[i];
+                if (fun.call(thisp, value, i, object)) {
+                    result.push(value);
+                }
+            }
+        }
+        return result;
+    };
+}
+if (!Array.prototype.every) {
+    Array.prototype.every = function every(fun /*, thisp */) {
+        var object = toObject(this),
+            self = splitString && _toString(this) == "[object String]" ?
+                this.split("") :
+                object,
+            length = self.length >>> 0,
+            thisp = arguments[1];
+        if (_toString(fun) != "[object Function]") {
+            throw new TypeError(fun + " is not a function");
+        }
+
+        for (var i = 0; i < length; i++) {
+            if (i in self && !fun.call(thisp, self[i], i, object)) {
+                return false;
+            }
+        }
+        return true;
+    };
+}
+if (!Array.prototype.some) {
+    Array.prototype.some = function some(fun /*, thisp */) {
+        var object = toObject(this),
+            self = splitString && _toString(this) == "[object String]" ?
+                this.split("") :
+                object,
+            length = self.length >>> 0,
+            thisp = arguments[1];
+        if (_toString(fun) != "[object Function]") {
+            throw new TypeError(fun + " is not a function");
+        }
+
+        for (var i = 0; i < length; i++) {
+            if (i in self && fun.call(thisp, self[i], i, object)) {
+                return true;
+            }
+        }
+        return false;
+    };
+}
+if (!Array.prototype.reduce) {
+    Array.prototype.reduce = function reduce(fun /*, initial*/) {
+        var object = toObject(this),
+            self = splitString && _toString(this) == "[object String]" ?
+                this.split("") :
+                object,
+            length = self.length >>> 0;
+        if (_toString(fun) != "[object Function]") {
+            throw new TypeError(fun + " is not a function");
+        }
+        if (!length && arguments.length == 1) {
+            throw new TypeError("reduce of empty array with no initial value");
+        }
+
+        var i = 0;
+        var result;
+        if (arguments.length >= 2) {
+            result = arguments[1];
+        } else {
+            do {
+                if (i in self) {
+                    result = self[i++];
+                    break;
+                }
+                if (++i >= length) {
+                    throw new TypeError("reduce of empty array with no initial value");
+                }
+            } while (true);
+        }
+
+        for (; i < length; i++) {
+            if (i in self) {
+                result = fun.call(void 0, result, self[i], i, object);
+            }
+        }
+
+        return result;
+    };
+}
+if (!Array.prototype.reduceRight) {
+    Array.prototype.reduceRight = function reduceRight(fun /*, initial*/) {
+        var object = toObject(this),
+            self = splitString && _toString(this) == "[object String]" ?
+                this.split("") :
+                object,
+            length = self.length >>> 0;
+        if (_toString(fun) != "[object Function]") {
+            throw new TypeError(fun + " is not a function");
+        }
+        if (!length && arguments.length == 1) {
+            throw new TypeError("reduceRight of empty array with no initial value");
+        }
+
+        var result, i = length - 1;
+        if (arguments.length >= 2) {
+            result = arguments[1];
+        } else {
+            do {
+                if (i in self) {
+                    result = self[i--];
+                    break;
+                }
+                if (--i < 0) {
+                    throw new TypeError("reduceRight of empty array with no initial value");
+                }
+            } while (true);
+        }
+
+        do {
+            if (i in this) {
+                result = fun.call(void 0, result, self[i], i, object);
+            }
+        } while (i--);
+
+        return result;
+    };
+}
+if (!Array.prototype.indexOf || ([0, 1].indexOf(1, 2) != -1)) {
+    Array.prototype.indexOf = function indexOf(sought /*, fromIndex */ ) {
+        var self = splitString && _toString(this) == "[object String]" ?
+                this.split("") :
+                toObject(this),
+            length = self.length >>> 0;
+
+        if (!length) {
+            return -1;
+        }
+
+        var i = 0;
+        if (arguments.length > 1) {
+            i = toInteger(arguments[1]);
+        }
+        i = i >= 0 ? i : Math.max(0, length + i);
+        for (; i < length; i++) {
+            if (i in self && self[i] === sought) {
+                return i;
+            }
+        }
+        return -1;
+    };
+}
+if (!Array.prototype.lastIndexOf || ([0, 1].lastIndexOf(0, -3) != -1)) {
+    Array.prototype.lastIndexOf = function lastIndexOf(sought /*, fromIndex */) {
+        var self = splitString && _toString(this) == "[object String]" ?
+                this.split("") :
+                toObject(this),
+            length = self.length >>> 0;
+
+        if (!length) {
+            return -1;
+        }
+        var i = length - 1;
+        if (arguments.length > 1) {
+            i = Math.min(i, toInteger(arguments[1]));
+        }
+        i = i >= 0 ? i : length - Math.abs(i);
+        for (; i >= 0; i--) {
+            if (i in self && sought === self[i]) {
+                return i;
+            }
+        }
+        return -1;
+    };
+}
+if (!Object.getPrototypeOf) {
+    Object.getPrototypeOf = function getPrototypeOf(object) {
+        return object.__proto__ || (
+            object.constructor ?
+            object.constructor.prototype :
+            prototypeOfObject
+        );
+    };
+}
+if (!Object.getOwnPropertyDescriptor) {
+    var ERR_NON_OBJECT = "Object.getOwnPropertyDescriptor called on a " +
+                         "non-object: ";
+    Object.getOwnPropertyDescriptor = function getOwnPropertyDescriptor(object, property) {
+        if ((typeof object != "object" && typeof object != "function") || object === null)
+            throw new TypeError(ERR_NON_OBJECT + object);
+        if (!owns(object, property))
+            return;
+
+        var descriptor, getter, setter;
+        descriptor =  { enumerable: true, configurable: true };
+        if (supportsAccessors) {
+            var prototype = object.__proto__;
+            object.__proto__ = prototypeOfObject;
+
+            var getter = lookupGetter(object, property);
+            var setter = lookupSetter(object, property);
+            object.__proto__ = prototype;
+
+            if (getter || setter) {
+                if (getter) descriptor.get = getter;
+                if (setter) descriptor.set = setter;
+                return descriptor;
+            }
+        }
+        descriptor.value = object[property];
+        return descriptor;
+    };
+}
+if (!Object.getOwnPropertyNames) {
+    Object.getOwnPropertyNames = function getOwnPropertyNames(object) {
+        return Object.keys(object);
+    };
+}
+if (!Object.create) {
+    var createEmpty;
+    if (Object.prototype.__proto__ === null) {
+        createEmpty = function () {
+            return { "__proto__": null };
+        };
+    } else {
+        createEmpty = function () {
+            var empty = {};
+            for (var i in empty)
+                empty[i] = null;
+            empty.constructor =
+            empty.hasOwnProperty =
+            empty.propertyIsEnumerable =
+            empty.isPrototypeOf =
+            empty.toLocaleString =
+            empty.toString =
+            empty.valueOf =
+            empty.__proto__ = null;
+            return empty;
+        }
+    }
+
+    Object.create = function create(prototype, properties) {
+        var object;
+        if (prototype === null) {
+            object = createEmpty();
+        } else {
+            if (typeof prototype != "object")
+                throw new TypeError("typeof prototype["+(typeof prototype)+"] != 'object'");
+            var Type = function () {};
+            Type.prototype = prototype;
+            object = new Type();
+            object.__proto__ = prototype;
+        }
+        if (properties !== void 0)
+            Object.defineProperties(object, properties);
+        return object;
+    };
+}
+
+function doesDefinePropertyWork(object) {
+    try {
+        Object.defineProperty(object, "sentinel", {});
+        return "sentinel" in object;
+    } catch (exception) {
+    }
+}
+if (Object.defineProperty) {
+    var definePropertyWorksOnObject = doesDefinePropertyWork({});
+    var definePropertyWorksOnDom = typeof document == "undefined" ||
+        doesDefinePropertyWork(document.createElement("div"));
+    if (!definePropertyWorksOnObject || !definePropertyWorksOnDom) {
+        var definePropertyFallback = Object.defineProperty;
+    }
+}
+
+if (!Object.defineProperty || definePropertyFallback) {
+    var ERR_NON_OBJECT_DESCRIPTOR = "Property description must be an object: ";
+    var ERR_NON_OBJECT_TARGET = "Object.defineProperty called on non-object: "
+    var ERR_ACCESSORS_NOT_SUPPORTED = "getters & setters can not be defined " +
+                                      "on this javascript engine";
+
+    Object.defineProperty = function defineProperty(object, property, descriptor) {
+        if ((typeof object != "object" && typeof object != "function") || object === null)
+            throw new TypeError(ERR_NON_OBJECT_TARGET + object);
+        if ((typeof descriptor != "object" && typeof descriptor != "function") || descriptor === null)
+            throw new TypeError(ERR_NON_OBJECT_DESCRIPTOR + descriptor);
+        if (definePropertyFallback) {
+            try {
+                return definePropertyFallback.call(Object, object, property, descriptor);
+            } catch (exception) {
+            }
+        }
+        if (owns(descriptor, "value")) {
+
+            if (supportsAccessors && (lookupGetter(object, property) ||
+                                      lookupSetter(object, property)))
+            {
+                var prototype = object.__proto__;
+                object.__proto__ = prototypeOfObject;
+                delete object[property];
+                object[property] = descriptor.value;
+                object.__proto__ = prototype;
+            } else {
+                object[property] = descriptor.value;
+            }
+        } else {
+            if (!supportsAccessors)
+                throw new TypeError(ERR_ACCESSORS_NOT_SUPPORTED);
+            if (owns(descriptor, "get"))
+                defineGetter(object, property, descriptor.get);
+            if (owns(descriptor, "set"))
+                defineSetter(object, property, descriptor.set);
+        }
+
+        return object;
+    };
+}
+if (!Object.defineProperties) {
+    Object.defineProperties = function defineProperties(object, properties) {
+        for (var property in properties) {
+            if (owns(properties, property))
+                Object.defineProperty(object, property, properties[property]);
+        }
+        return object;
+    };
+}
+if (!Object.seal) {
+    Object.seal = function seal(object) {
+        return object;
+    };
+}
+if (!Object.freeze) {
+    Object.freeze = function freeze(object) {
+        return object;
+    };
+}
+try {
+    Object.freeze(function () {});
+} catch (exception) {
+    Object.freeze = (function freeze(freezeObject) {
+        return function freeze(object) {
+            if (typeof object == "function") {
+                return object;
+            } else {
+                return freezeObject(object);
+            }
+        };
+    })(Object.freeze);
+}
+if (!Object.preventExtensions) {
+    Object.preventExtensions = function preventExtensions(object) {
+        return object;
+    };
+}
+if (!Object.isSealed) {
+    Object.isSealed = function isSealed(object) {
+        return false;
+    };
+}
+if (!Object.isFrozen) {
+    Object.isFrozen = function isFrozen(object) {
+        return false;
+    };
+}
+if (!Object.isExtensible) {
+    Object.isExtensible = function isExtensible(object) {
+        if (Object(object) === object) {
+            throw new TypeError(); // TODO message
+        }
+        var name = '';
+        while (owns(object, name)) {
+            name += '?';
+        }
+        object[name] = true;
+        var returnValue = owns(object, name);
+        delete object[name];
+        return returnValue;
+    };
+}
+if (!Object.keys) {
+    var hasDontEnumBug = true,
+        dontEnums = [
+            "toString",
+            "toLocaleString",
+            "valueOf",
+            "hasOwnProperty",
+            "isPrototypeOf",
+            "propertyIsEnumerable",
+            "constructor"
+        ],
+        dontEnumsLength = dontEnums.length;
+
+    for (var key in {"toString": null}) {
+        hasDontEnumBug = false;
+    }
+
+    Object.keys = function keys(object) {
+
+        if (
+            (typeof object != "object" && typeof object != "function") ||
+            object === null
+        ) {
+            throw new TypeError("Object.keys called on a non-object");
+        }
+
+        var keys = [];
+        for (var name in object) {
+            if (owns(object, name)) {
+                keys.push(name);
+            }
+        }
+
+        if (hasDontEnumBug) {
+            for (var i = 0, ii = dontEnumsLength; i < ii; i++) {
+                var dontEnum = dontEnums[i];
+                if (owns(object, dontEnum)) {
+                    keys.push(dontEnum);
+                }
+            }
+        }
+        return keys;
+    };
+
+}
+if (!Date.now) {
+    Date.now = function now() {
+        return new Date().getTime();
+    };
+}
+var ws = "\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003" +
+    "\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028" +
+    "\u2029\uFEFF";
+if (!String.prototype.trim || ws.trim()) {
+    ws = "[" + ws + "]";
+    var trimBeginRegexp = new RegExp("^" + ws + ws + "*"),
+        trimEndRegexp = new RegExp(ws + ws + "*$");
+    String.prototype.trim = function trim() {
+        return String(this).replace(trimBeginRegexp, "").replace(trimEndRegexp, "");
+    };
+}
+
+function toInteger(n) {
+    n = +n;
+    if (n !== n) { // isNaN
+        n = 0;
+    } else if (n !== 0 && n !== (1/0) && n !== -(1/0)) {
+        n = (n > 0 || -1) * Math.floor(Math.abs(n));
+    }
+    return n;
+}
+
+function isPrimitive(input) {
+    var type = typeof input;
+    return (
+        input === null ||
+        type === "undefined" ||
+        type === "boolean" ||
+        type === "number" ||
+        type === "string"
+    );
+}
+
+function toPrimitive(input) {
+    var val, valueOf, toString;
+    if (isPrimitive(input)) {
+        return input;
+    }
+    valueOf = input.valueOf;
+    if (typeof valueOf === "function") {
+        val = valueOf.call(input);
+        if (isPrimitive(val)) {
+            return val;
+        }
+    }
+    toString = input.toString;
+    if (typeof toString === "function") {
+        val = toString.call(input);
+        if (isPrimitive(val)) {
+            return val;
+        }
+    }
+    throw new TypeError();
+}
+var toObject = function (o) {
+    if (o == null) { // this matches both null and undefined
+        throw new TypeError("can't convert "+o+" to object");
+    }
+    return Object(o);
+};
+
+});

+ 4 - 0
services/web/public/stylesheets/app/editor.less

@@ -138,6 +138,10 @@
 	.spelling-highlight {
 	.spelling-highlight {
 		position: absolute;
 		position: absolute;
 		background-image: url(/img/spellcheck-underline.png);
 		background-image: url(/img/spellcheck-underline.png);
+		@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
+			background-image: url(/img/spellcheck-underline@2x.png);
+			background-size: 5px 4px;
+		}
 		background-repeat: repeat-x;
 		background-repeat: repeat-x;
 		background-position: bottom left;
 		background-position: bottom left;
 	}
 	}

+ 325 - 158
services/web/public/stylesheets/app/editor/review-panel.less

@@ -1,42 +1,60 @@
-@rp-base-font-size		: 12px;
-@rp-small-font-size		: 10px;
-@rp-icon-large-size		: 22px;
+@rp-base-font-size      : 12px;
+@rp-small-font-size     : 10px;
+@rp-icon-large-size     : 18px;
 
 
-@rp-bg-blue				: #dadfed;
-@rp-bg-dim-blue			: #fafafa;
-@rp-highlight-blue		: #8a96b5;
+@rp-bg-blue             : #dadfed;
+@rp-bg-dim-blue         : #fafafa;
+@rp-highlight-blue      : #8a96b5;
 
 
-@rp-border-grey			: #d9d9d9;
+@rp-border-grey         : #d9d9d9;
 
 
-@rp-green				: #2c8e30;
-@rp-dim-green			: #cae3cb;
-@rp-red					: #c5060b;
-@rp-dim-red				: #f3cdce;
-@rp-yellow				: #f3b111;
-@rp-dim-yellow			: #ffe9b2;
-@rp-grey				: #aaaaaa;
+@rp-green               : #2c8e30;
+@rp-dim-green           : #cae3cb;
+@rp-green-on-dark		: rgba(37, 107, 41, 0.5);
+@rp-red                 : #c5060b;
+@rp-dim-red             : #f3cdce;
+@rp-yellow              : #f3b111;
+@rp-yellow-on-dark		: rgba(194, 93, 11, 0.5);
+@rp-dim-yellow          : #ffe9b2;
+@rp-grey                : #aaaaaa;
 
 
-@rp-type-blue			: #6b7797;
-@rp-type-darkgrey		: #3f3f3f;
+@rp-type-blue           : #6b7797;
+@rp-type-darkgrey       : #3f3f3f;
+
+@rp-entry-ribbon-width  : 4px;
+@rp-entry-arrow-width   : 6px;
+@rp-semibold-weight     : 600;
+@review-panel-width     : 230px;
+@review-off-width       : 22px;
+
+@rp-toolbar-height      : 32px;
 
 
-@rp-entry-ribbon-width	: 4px;
-@rp-entry-arrow-width	: 6px;
-@rp-semibold-weight		: 600;
-@review-panel-width		: 230px;
-@review-off-width		: 22px;
 
 
-@rp-toolbar-height: 32px;
 
 
 .rp-button() {
 .rp-button() {
 	background-color: @rp-highlight-blue;
 	background-color: @rp-highlight-blue;
 	color: #FFF;
 	color: #FFF;
 	text-align: center;
 	text-align: center;
+	line-height: 1.3;
+	user-select: none;
+	border: 0;
+
 	&:hover,
 	&:hover,
 	&:focus {
 	&:focus {
+		outline: 0;
 		background-color: darken(@rp-highlight-blue, 5%);
 		background-color: darken(@rp-highlight-blue, 5%);
 		text-decoration: none;
 		text-decoration: none;
 		color: #FFF;
 		color: #FFF;
 	}
 	}
+
+	&[disabled] {
+		opacity: 0.5;
+
+		&:hover,
+		&:focus {
+			background-color: @rp-highlight-blue;
+		}
+	}
 }
 }
 
 
 .triangle(@_, @width, @height, @color) {
 .triangle(@_, @width, @height, @color) {
@@ -82,6 +100,7 @@
 	.rp-size-mini & {
 	.rp-size-mini & {
 		display: block;
 		display: block;
 		width: @review-off-width;
 		width: @review-off-width;
+		z-index: 6;
 	}
 	}
 	
 	
 	position: absolute;
 	position: absolute;
@@ -92,6 +111,7 @@
 	border-left: solid 1px @rp-border-grey;
 	border-left: solid 1px @rp-border-grey;
 	font-size: @rp-base-font-size;
 	font-size: @rp-base-font-size;
 	color: @rp-type-blue;
 	color: @rp-type-blue;
+	z-index: 6;
 }
 }
 
 
 .review-panel-toolbar {
 .review-panel-toolbar {
@@ -99,26 +119,33 @@
 	.rp-size-expanded & {
 	.rp-size-expanded & {
 		display: flex;
 		display: flex;
 		align-items: center;
 		align-items: center;
-		justify-content: space-between;
 		padding: 0 5px;
 		padding: 0 5px;
 	}
 	}
-	.rp-state-current-file & {
-		position: absolute;
-		top: 0;
-		left: 0;
-		right: 0;
-	}
+	// .rp-state-current-file & {
+	// 	position: absolute;
+	// 	top: 0;
+	// 	left: 0;
+	// 	right: 0;
+	// }
 	height: @rp-toolbar-height;
 	height: @rp-toolbar-height;
 	border-bottom: 1px solid @rp-border-grey;
 	border-bottom: 1px solid @rp-border-grey;
 	background-color: @rp-bg-dim-blue;
 	background-color: @rp-bg-dim-blue;
 	text-align: center;
 	text-align: center;
-	z-index: 2;
+	z-index: 3;
 	flex-basis: 32px;
 	flex-basis: 32px;
 	flex-shrink: 0;
 	flex-shrink: 0;
 }
 }
 	.review-panel-toolbar-label {
 	.review-panel-toolbar-label {
 		cursor: pointer;
 		cursor: pointer;
-		margin-right: 5px;
+		text-align: right;
+		flex-grow: 1;
+	}
+		.review-panel-toolbar-label-disabled {
+			cursor: auto;
+			margin-right: 5px;
+		}
+	.review-panel-toolbar-spinner {
+		margin-left: 5px;
 	}
 	}
 
 
 .rp-entry-list {
 .rp-entry-list {
@@ -135,7 +162,6 @@
 		bottom: 0;
 		bottom: 0;
 	}
 	}
 	
 	
-	
 	.rp-state-overview & {
 	.rp-state-overview & {
 		flex-grow: 2;
 		flex-grow: 2;
 		overflow-y: auto;
 		overflow-y: auto;
@@ -150,7 +176,6 @@
 	display: none;
 	display: none;
 	.rp-size-mini & {
 	.rp-size-mini & {
 		display: block;
 		display: block;
-		z-index: 12;
 	}
 	}
 	position: absolute;
 	position: absolute;
 	left: 2px;
 	left: 2px;
@@ -186,21 +211,39 @@
 		display: none;
 		display: none;
 		left: @review-off-width + @rp-entry-arrow-width;
 		left: @review-off-width + @rp-entry-arrow-width;
 		box-shadow: 0 0 10px 5px rgba(0, 0, 0, .2);
 		box-shadow: 0 0 10px 5px rgba(0, 0, 0, .2);
-		z-index: 11;
+		z-index: 1;
 
 
 		&::before {
 		&::before {
-			.triangle(left, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit);
-			top: (@review-off-width / 2) - @rp-entry-arrow-width;
-			left: -(@rp-entry-ribbon-width  + @rp-entry-arrow-width);
-			content: '';
-		}
-		&::after {
 			content: '';
 			content: '';
 			position: absolute;
 			position: absolute;
 			top:  -(@review-off-width + @rp-entry-arrow-width);
 			top:  -(@review-off-width + @rp-entry-arrow-width);
 			right: -(@review-off-width + @rp-entry-arrow-width);
 			right: -(@review-off-width + @rp-entry-arrow-width);
 			bottom: -(@review-off-width + @rp-entry-arrow-width);
 			bottom: -(@review-off-width + @rp-entry-arrow-width);
+			left: -(2 * @rp-entry-arrow-width + 2);
+			z-index: -1;
+		}
+		&::after {
+			.triangle(left, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit);
+			top: (@review-off-width / 2) - @rp-entry-arrow-width;
+			left: -(@rp-entry-ribbon-width  + @rp-entry-arrow-width);
+			content: '';
+		}
+	}
+	.rp-state-current-file-mini.rp-layout-left & {
+		left: auto;
+		right: @review-off-width + @rp-entry-arrow-width;
+		border-left-width: 0;
+		border-right-width: @rp-entry-ribbon-width;
+		border-right-style: solid;
+
+		&::before {
 			left: -(@review-off-width + @rp-entry-arrow-width);
 			left: -(@review-off-width + @rp-entry-arrow-width);
+			right: -(2 * @rp-entry-arrow-width + 2);
+		}
+		&::after {
+			.triangle(right, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit);
+			right: -(@rp-entry-ribbon-width  + @rp-entry-arrow-width);
+			left: auto;
 		}
 		}
 	}
 	}
 	.rp-state-current-file-expanded & {
 	.rp-state-current-file-expanded & {
@@ -224,10 +267,13 @@
 	}
 	}
 	.rp-state-overview & {
 	.rp-state-overview & {
 		border-radius: 0;
 		border-radius: 0;
-		padding: 2px 5px;
 		border-bottom: solid 1px @rp-border-grey;
 		border-bottom: solid 1px @rp-border-grey;
 		cursor: pointer;
 		cursor: pointer;
 	}
 	}
+	.resolved-comments-dropdown & {
+		position: static;
+		margin-bottom: 5px;
+	}
 
 
 	border-left: solid @rp-entry-ribbon-width transparent;
 	border-left: solid @rp-entry-ribbon-width transparent;
 	border-radius: 3px;
 	border-radius: 3px;
@@ -246,6 +292,16 @@
 		border-color: @rp-yellow;
 		border-color: @rp-yellow;
 	}
 	}
 	
 	
+	&-comment-resolving {
+		top: 4px;
+		left: 6px;
+		opacity: 0;
+		z-index: 3;
+		transform: scale(.1);
+		transform-origin: 0 0;
+		transition: top .35s ease-out, left .35s ease-out, transform .35s ease-out, opacity .35s ease-out .2s;
+	}
+	
 	&-comment-resolved {
 	&-comment-resolved {
 		border-color: @rp-grey;
 		border-color: @rp-grey;
 		background-color: #efefef;
 		background-color: #efefef;
@@ -264,69 +320,52 @@
 		}
 		}
 	}
 	}
 }
 }
-
-	.rp-entry-header {
+	.rp-entry-body {
 		display: flex;
 		display: flex;
 		align-items: center;
 		align-items: center;
-		padding: 5px;
-
-		.rp-state-overview & {
-			padding: 0px;
-		}
+		padding: 4px 5px;
 	}
 	}
 		.rp-entry-action-icon {
 		.rp-entry-action-icon {
 			font-size: @rp-icon-large-size;
 			font-size: @rp-icon-large-size;
-			padding: 0 5px;
+			padding: 0 3px;
 			line-height: 0;
 			line-height: 0;
 
 
 			.rp-state-overview & {
 			.rp-state-overview & {
-				font-size: @rp-base-font-size;
-				padding: 0px;
-				margin-right: 5px;
+				display: none;
 			}
 			}
 		}
 		}
 
 
-		.rp-entry-metadata {
-			flex-grow: 1;
-			padding: 0 5px;
-			line-height: 1.2;
+		.rp-entry-details {
+			line-height: 1.4;
+			margin-left: 5px;
 
 
 			.rp-state-overview & {
 			.rp-state-overview & {
-				display: flex;
-				line-height: inherit;
-				padding: 0;
+				margin-left: 0;
 			}
 			}
 		}
 		}
-			.rp-entry-metadata-line {
-				margin: 0;
 
 
-				.rp-state-overview &:last-of-type {
-					flex-grow: 1;
-					text-align: right;
-				}
+			.rp-entry-metadata {
+				font-size: @rp-small-font-size;
 			}
 			}
+				.rp-entry-user {
+					font-weight: @rp-semibold-weight;
+					font-style: normal;
+				}
 
 
-	.rp-entry-body {
-		padding: 5px;
-
-		.rp-state-overview & {
-			padding: 0;
-		}
-	}
-		.rp-content-highlight {
-			color: @rp-type-darkgrey;
-			font-weight: @rp-semibold-weight;
-			text-decoration: none;
+			.rp-content-highlight {
+				color: @rp-type-darkgrey;
+				font-weight: @rp-semibold-weight;
+				text-decoration: none;
 
 
-			.rp-entry-delete & {
-				text-decoration: line-through;
+				.rp-entry-delete & {
+					text-decoration: line-through;
+				}
 			}
 			}
-		}
 
 
 	.rp-entry-actions {
 	.rp-entry-actions {
 		display: flex;
 		display: flex;
 
 
-		.rp-state-overview & {
+		.rp-state-overview .rp-entry-list & {
 			display: none;
 			display: none;
 		}
 		}
 	}
 	}
@@ -340,71 +379,59 @@
 				border-bottom-right-radius: 3px;
 				border-bottom-right-radius: 3px;
 				border-right-width: 0;
 				border-right-width: 0;
 			}
 			}
+
+			.rp-layout-left & {
+				&:first-child {
+					border-bottom-left-radius: 3px;
+				}
+				&:last-child {
+					border-bottom-right-radius: 0;
+				}
+			}
 		}
 		}
 
 
 	.rp-comment {
 	.rp-comment {
-		display: flex;
-		align-items: flex-start;
-		padding: 5px;
+		margin: 2px 5px;
+		padding-bottom: 3px;
+		line-height: 1.4;
+		border-bottom: solid 1px @rp-border-grey;
 
 
-		.rp-state-overview & {
-			padding: 3px 0;
-			line-height: 1.2;
+		&:last-child {
+			margin-bottom: 2px;
+			border-bottom-width: 0;
 		}
 		}
-	}
-		.rp-comment-body {
-			position: relative;
-			background-color: currentColor;
-			flex-grow: 1;
-			padding: 2px 5px;
-			margin-left: @rp-entry-arrow-width;
-			border-radius: 3px;
-
-			.rp-comment-self & {
-				margin-left: 0;
-				margin-right: @rp-entry-arrow-width;
-			}
-
-			&::after {
-				.triangle(left, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit);
-				top: (@review-off-width / 2) - @rp-entry-arrow-width;
-				left: -@rp-entry-arrow-width;
-				content: '';
 
 
-				.rp-comment-self & {
-					.triangle(right, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit);
-					right: -@rp-entry-arrow-width;
-					left: auto;
-				}
+		.rp-state-overview .rp-entry-list & {
+			margin: 4px 5px;
 
 
+			&:first-child {
+				margin-top: 0;
+				padding-top: 4px;
 			}
 			}
 		}
 		}
-			.rp-comment-content {
-				margin: 0;
-				color: @rp-type-darkgrey;
-			}
-
-			.rp-comment-metadata {
-				color: @rp-type-blue;
-				font-size: @rp-small-font-size;
-				margin: 0;
-			}
-
-		.rp-comment-reply {
-			padding: 0 5px;
-
-			.rp-state-overview & {
-				padding: 3px 0 0;
-			}
+	}
+		.rp-comment-content {
+			margin: 0;
+			color: @rp-type-darkgrey;
 		}
 		}
 
 
-		.rp-comment-resolved-description {
-			padding: 5px;
-
-			.rp-state-overview & {
-				padding: 0px;
-			}
+		.rp-comment-metadata {
+			color: @rp-type-blue;
+			font-size: @rp-small-font-size;
+			margin: 0;
 		}
 		}
+	
+	.rp-comment-resolver {
+		color: @rp-type-blue;
+	}
+		.rp-comment-resolver-content {
+			font-style: italic;
+			margin: 0;
+		}
+
+	.rp-comment-reply {
+		padding: 0 5px;
+	}
 
 
 	.rp-add-comment-btn {
 	.rp-add-comment-btn {
 		.rp-button();
 		.rp-button();
@@ -424,26 +451,9 @@
 		border-radius: 3px;
 		border-radius: 3px;
 		border: solid 1px @rp-border-grey;
 		border: solid 1px @rp-border-grey;
 		resize: vertical;
 		resize: vertical;
+		color: @rp-type-darkgrey;
 	}
 	}
 
 
-.rp-avatar {
-	border-radius: 3px;
-	font-weight: @rp-semibold-weight;
-	font-size: @rp-icon-large-size;
-	line-height: 1.2;
-	text-transform: uppercase;
-	color: #FFF;
-	width: 1.3em;
-	height: 1.3em;
-	text-align: center;
-	flex-grow: 0;
-	flex-shrink: 0;
-
-	.rp-state-overview & {
-		display: none;
-	}
-}
-
 .rp-icon-delete {
 .rp-icon-delete {
 	display: inline-block;
 	display: inline-block;
 	line-height: 1;
 	line-height: 1;
@@ -456,6 +466,26 @@
 	}
 	}
 }
 }
 
 
+.rp-resolved-comment {
+	border-left: solid @rp-entry-ribbon-width @rp-yellow;
+	border-radius: 3px;
+	background-color: #FFF;
+	margin-bottom: 5px;
+}
+	.rp-resolved-comment-context {
+		background-color: lighten(@rp-yellow, 35%);
+		padding: 4px 5px;
+	}
+		.rp-resolved-comment-context-file {
+			font-weight: @rp-semibold-weight;
+		}
+
+		.rp-resolved-comment-context-quote {
+			color: #000;
+			font-family: @font-family-monospace;
+			margin: 0;
+		}
+
 .rp-entry-callout {
 .rp-entry-callout {
 	.rp-state-current-file & {
 	.rp-state-current-file & {
 		position: absolute;
 		position: absolute;
@@ -524,10 +554,24 @@
 	padding: 2px 5px;
 	padding: 2px 5px;
 	border-top: solid 1px @rp-border-grey;
 	border-top: solid 1px @rp-border-grey;
 	border-bottom: solid 1px @rp-border-grey;
 	border-bottom: solid 1px @rp-border-grey;
-	background-color: #FFF;
+	background-color: @rp-bg-dim-blue;
 	margin-top: 10px;
 	margin-top: 10px;
 	font-weight: @rp-semibold-weight;
 	font-weight: @rp-semibold-weight;
-	border-left: solid @rp-entry-ribbon-width currentColor;
+	text-align: center;
+}
+
+.rp-comment-wrapper {
+	transition: .35s opacity ease-out .2s;
+
+	&-resolving {
+		opacity: 0;
+	}
+}
+
+.rp-loading,
+.rp-empty {
+	text-align: center;
+	padding: 5px;
 }
 }
 
 
 .rp-nav {
 .rp-nav {
@@ -599,6 +643,7 @@
 .rp-toggle {
 .rp-toggle {
 	display: inline-block;
 	display: inline-block;
 	vertical-align: middle;
 	vertical-align: middle;
+	margin-left: 5px;
 }
 }
 	.rp-toggle-hidden-input {
 	.rp-toggle-hidden-input {
 		display: none;
 		display: none;
@@ -647,7 +692,7 @@
 	.track-changes-marker-callout {
 	.track-changes-marker-callout {
 		border-radius: 0;
 		border-radius: 0;
 		position: absolute;
 		position: absolute;
-		.rp-state-overview & {
+		.rp-state-overview &, .rp-loading-threads & {
 			display: none;
 			display: none;
 		}
 		}
 	}
 	}
@@ -664,6 +709,9 @@
 	.track-changes-marker {
 	.track-changes-marker {
 		border-radius: 0;
 		border-radius: 0;
 		position: absolute;
 		position: absolute;
+		.rp-loading-threads & {
+			display: none;
+		}
 	}
 	}
 
 
 	.track-changes-comment-marker {
 	.track-changes-comment-marker {
@@ -676,13 +724,25 @@
 		border-left: 2px dotted @rp-red;
 		border-left: 2px dotted @rp-red;
 		margin-left: -1px;
 		margin-left: -1px;
 	}
 	}
+
+	.ace_dark {
+		.track-changes-comment-marker {
+			background-color: @rp-yellow-on-dark
+		}
+		.track-changes-added-marker {
+			background-color: @rp-green-on-dark;
+		}	
+	}
 }
 }
 
 
 .review-icon {
 .review-icon {
-	position: absolute;
+	display: inline-block;
 	background: url('/img/review-icon-sprite.png') top/30px no-repeat;
 	background: url('/img/review-icon-sprite.png') top/30px no-repeat;
 	width: 30px;
 	width: 30px;
-	height: 30px;
+
+	&::before {
+		content: '\00a0'; // Non-breakable space. A non-breakable character here makes this icon work like font-awesome.
+	}
 
 
 	.toolbar .btn-full-height:hover & {
 	.toolbar .btn-full-height:hover & {
 		background-position-y: -30px;
 		background-position-y: -30px;
@@ -692,9 +752,116 @@
 	.toolbar .btn-full-height:active & {
 	.toolbar .btn-full-height:active & {
 		background-position-y: -60px;
 		background-position-y: -60px;
 	}
 	}
+}
+
+.resolved-comments-toggle {
+	font-size: 14px;
+	color: lighten(@rp-type-blue, 25%);
+	border: solid 1px @rp-border-grey;
+	border-radius: 3px;
+	padding: 0 4px;
+	display: block;
+	height: 22px;
+	width: 22px;
+	line-height: 1.4;
+
+	&:hover,
+	&:focus {
+		text-decoration: none;
+		color: @rp-type-blue;
+	}
+}
+
+.resolved-comments-backdrop {
+	display: none;
+	position: fixed;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+
+	&-visible {
+		display: block;
+	}
+}
+
+.resolved-comments-dropdown {
+	display: none;
+	position: absolute;
+	width: 300px;
+	left: -150px;
+	max-height: 90%;
+	margin-top: @rp-entry-arrow-width * 1.5;
+	margin-left: 1em;
+	background-color: @rp-bg-blue;
+	text-align: left;
+	align-items: stretch;
+	justify-content: center;
+	border-radius: 3px;
+	box-shadow: 0 0 20px 10px rgba(0, 0, 0, .3);
+
+	&::before {
+		content: '';
+		.triangle(top, @rp-entry-arrow-width * 3, @rp-entry-arrow-width * 1.5, @rp-bg-blue);
+		top: -@rp-entry-ribbon-width * 2;
+		left: 50%;
+		margin-left: -@rp-entry-arrow-width * .75;
+	}
+
+	&-open {
+		display: flex;
+	}
+}
+	.resolved-comments-scroller {
+		flex: 0 0 100%;
+		padding: 5px;
+		overflow-y: auto;
+	}
+
+.rp-collapse-toggle {
+	color: @rp-type-blue;
+	font-weight: @rp-semibold-weight;
+
+	&:hover,
+	&:focus {
+		color: darken(@rp-type-blue, 5%);
+		text-decoration: none;
+	}
+}
+
+.rp-track-changes-indicator {
+	display: none;
+	position: absolute;
+	top: 0;
+	right: @review-off-width;
+	padding: 5px 10px;
+	background-color: rgba(240, 240, 240, 0.9);
+	color: @rp-type-blue;
+	text-align: center;
+	border-bottom-left-radius: 3px;
+	font-size: 10px;
+	z-index: 2;
 
 
-	& + .toolbar-label {
-		margin-left: 34px;
+	&.rp-track-changes-indicator-on-dark {
+		background-color: rgba(88, 88, 88, .8);
+		color: #FFF;
+		
+		&:hover,
+		&:focus {
+			background-color: rgba(88, 88, 88, 1);
+			color: #FFF;
+		}
+	}
+
+	&:hover,
+	&:focus {
+		outline: 0;
+		text-decoration: none;
+		background-color: rgba(240, 240, 240, 1);
+		color: @rp-type-blue;
 	}
 	}
 
 
+	.rp-size-mini & {
+		display: block;
+	}
 }
 }

+ 7 - 1
services/web/public/stylesheets/app/editor/share.less

@@ -47,4 +47,10 @@
 			}
 			}
 		}
 		}
 	}
 	}
-}
+}
+.modal-footer-share {
+	.modal-footer-left {
+		max-width: 70%;
+		text-align: left;
+	}
+}

+ 166 - 0
services/web/public/stylesheets/app/project-list.less

@@ -1,3 +1,26 @@
+@announcements-shadow: 0 2px 20px rgba(0, 0, 0, 0.5);
+
+@keyframes pulse {
+	0% {
+		opacity: .7;
+	}
+	100% {
+		opacity: .9;
+	}
+}
+@keyframes fade-in {
+	0% {
+		opacity: 0;
+	}
+	100% {
+		opacity: 1;
+	}
+}
+
+.project-list-page {
+	position: relative;
+}
+
 .project-header {
 .project-header {
 	.btn-group > .btn {
 	.btn-group > .btn {
 		padding-left: @line-height-base / 2;
 		padding-left: @line-height-base / 2;
@@ -293,3 +316,146 @@ ul.project-list {
 		margin-left:-100px;
 		margin-left:-100px;
 	}
 	}
 }
 }
+
+.announcements {
+	position: absolute;
+	bottom: 0;
+	right: 0;
+	height: 150px;
+	width: 100%;
+	pointer-events: none;
+	overflow: hidden;
+
+	&-open {
+		top: -100%;
+		height: auto;
+		pointer-events: all;
+	}
+}
+
+.announcements-backdrop {
+	position: fixed;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	background-color: rgba(0, 0, 0, 0.35);
+	opacity: 0;
+	animation: fade-in 0.35s forwards;
+	z-index: 1;
+}
+
+.announcements-btn {
+	position: absolute;
+	bottom: -50px;
+	right: 3%;
+	width: 80px;
+	height: 80px;
+	background: url(/img/lion-128.png) no-repeat center/80% transparent;
+	border-radius: 50%;
+	box-shadow: none;
+	z-index: 1;
+	pointer-events: all;
+	transition: bottom 0.25s cubic-bezier(0.68, -0.55, 0.265, 1.55),
+				background 0.25s ease,
+				box-shadow 0.25s ease;
+
+	&:hover {
+		bottom: -45px;
+	}
+
+	&-open, &-open:hover,
+	&-has-new, &-has-new:hover {
+		background-color: #FFF;
+		box-shadow: @announcements-shadow;
+		bottom: 30px;
+	}
+}
+	.announcements-badge {
+		display: inline-block;
+		position: absolute;
+		font-size: 11px;
+		height: 1.8em;
+		min-width: 1.8em;
+		border-radius: 0.9em;
+		line-height: 1.8;
+		padding: 0 2px;
+		top: 1px;
+		right: 1px;
+		font-weight: bold;
+		color: #FFF;
+		background-color: @red;
+		vertical-align: baseline;
+		white-space: nowrap;
+		text-align: center;
+		z-index: 1;
+		animation: pulse 1s alternate infinite;
+	}
+
+.announcements-body {
+	display: flex;
+	flex-direction: column;
+	align-items: stretch;
+	position: absolute;
+	right: 3%;
+	margin-right: 95px;
+	bottom: 30px;
+	width: 700px;
+	max-height: 52%;
+	min-height: 100px;
+	background: #FFF;
+	z-index: 1;
+	box-shadow: @announcements-shadow;
+	border-radius: @border-radius-base;
+	animation: fade-in 0.35s forwards;
+
+	&::after {
+		content: "\25b8";
+		position: absolute;
+		left: 100%;
+		bottom: 17px;
+		width: 30px;
+		color: #FFF;
+		text-shadow: @announcements-shadow;
+		font-size: 2em;
+		overflow: hidden;
+		text-indent: -7px;
+	}
+}
+
+	.announcements-scroller {
+		padding: @line-height-computed;
+		flex-grow: 0;
+		overflow-x: hidden;
+		overflow-y: auto;
+	}
+		.announcement {
+			margin-bottom: @line-height-computed * 1.5;
+			&:last-child {
+				margin-bottom: 0;
+			}
+		}	
+			.announcement-header {
+				.page-header;
+				margin: 0;
+			}
+
+			.announcement-description {
+				margin: (@line-height-computed / 4) 0 (@line-height-computed / 2);
+			}
+
+			.announcement-meta {
+				.clearfix;
+				font-size: 0.9em;
+			}
+
+				.announcement-date {
+					float: left;
+					color: @gray;
+					margin: 0;
+				}
+
+				.announcement-link {
+					float: right;
+					margin: 0;
+				}

+ 6 - 3
services/web/public/stylesheets/components/dropdowns.less

@@ -58,8 +58,8 @@
     .nav-divider(@dropdown-divider-bg);
     .nav-divider(@dropdown-divider-bg);
   }
   }
 
 
-  // Links within the dropdown menu
-  > li > a {
+  // Links and other items within the dropdown menu
+  > li > a,div {
     display: block;
     display: block;
     padding: 3px 20px;
     padding: 3px 20px;
     clear: both;
     clear: both;
@@ -67,8 +67,11 @@
     line-height: @line-height-base;
     line-height: @line-height-base;
     color: @dropdown-link-color;
     color: @dropdown-link-color;
     white-space: nowrap; // prevent links from randomly breaking onto new lines
     white-space: nowrap; // prevent links from randomly breaking onto new lines
+    &.subdued {
+      color: #7a7a7a
+    }
     .subdued {
     .subdued {
-        color: #7a7a7a
+      color: #7a7a7a
     }
     }
   }
   }
 }
 }

+ 23 - 6
services/web/test/UnitTests/coffee/Announcement/AnnouncementsHandlerTests.coffee

@@ -42,10 +42,13 @@ describe 'AnnouncementsHandler', ->
 			@BlogHandler.getLatestAnnouncements.callsArgWith(0, null, @stubbedAnnouncements)
 			@BlogHandler.getLatestAnnouncements.callsArgWith(0, null, @stubbedAnnouncements)
 
 
 
 
-		it "should return all announcements if there are no getLastOccurance", (done)->
+		it "should mark all announcements as read is false", (done)->
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, [])
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, [])
 			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
 			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
-				announcements.length.should.equal 4
+				announcements[0].read.should.equal false
+				announcements[1].read.should.equal false
+				announcements[2].read.should.equal false
+				announcements[3].read.should.equal false
 				done()
 				done()
 
 
 		it "should should be sorted again to ensure correct order", (done)->
 		it "should should be sorted again to ensure correct order", (done)->
@@ -57,16 +60,30 @@ describe 'AnnouncementsHandler', ->
 				announcements[0].should.equal @stubbedAnnouncements[0]
 				announcements[0].should.equal @stubbedAnnouncements[0]
 				done()
 				done()
 
 
-		it "should return ones older than the last blog id", (done)->
+		it "should return older ones marked as read as well", (done)->
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, {segmentation:{blogPostId:"/2014/04/12/title-date-irrelivant"}})
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, {segmentation:{blogPostId:"/2014/04/12/title-date-irrelivant"}})
 			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
 			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
-				announcements.length.should.equal 2
 				announcements[0].id.should.equal @stubbedAnnouncements[0].id
 				announcements[0].id.should.equal @stubbedAnnouncements[0].id
+				announcements[0].read.should.equal false
+
 				announcements[1].id.should.equal @stubbedAnnouncements[1].id
 				announcements[1].id.should.equal @stubbedAnnouncements[1].id
+				announcements[1].read.should.equal false
+
+				announcements[2].id.should.equal @stubbedAnnouncements[3].id
+				announcements[2].read.should.equal true
+
+				announcements[3].id.should.equal @stubbedAnnouncements[2].id
+				announcements[3].read.should.equal true
+
 				done()
 				done()
 
 
-		it "should return none when the latest id is the first element", (done)->
+		it "should return all of them marked as read", (done)->
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, {segmentation:{blogPostId:"/2016/11/01/introducting-latex-code-checker"}})
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, {segmentation:{blogPostId:"/2016/11/01/introducting-latex-code-checker"}})
 			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
 			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
-				announcements.length.should.equal 0
+				announcements[0].read.should.equal true
+				announcements[1].read.should.equal true
+				announcements[2].read.should.equal true
+				announcements[3].read.should.equal true
 				done()
 				done()
+
+

+ 62 - 23
services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -91,7 +91,10 @@ describe "AuthenticationController", ->
 			@info = null
 			@info = null
 			@req.login = sinon.stub().callsArgWith(1, null)
 			@req.login = sinon.stub().callsArgWith(1, null)
 			@res.json = sinon.stub()
 			@res.json = sinon.stub()
-			@req.session = @session = {passport: {user: @user}}
+			@req.session = @session = {
+				passport: {user: @user},
+				postLoginRedirect: "/path/to/redir/to"
+			}
 			@req.session.destroy = sinon.stub().callsArgWith(0, null)
 			@req.session.destroy = sinon.stub().callsArgWith(0, null)
 			@req.session.save = sinon.stub().callsArgWith(0, null)
 			@req.session.save = sinon.stub().callsArgWith(0, null)
 			@req.sessionStore = {generate: sinon.stub()}
 			@req.sessionStore = {generate: sinon.stub()}
@@ -114,11 +117,11 @@ describe "AuthenticationController", ->
 		describe 'when authenticate produces a user', ->
 		describe 'when authenticate produces a user', ->
 
 
 			beforeEach ->
 			beforeEach ->
-				@req._redir = 'some_redirect'
+				@req.session.postLoginRedirect = 'some_redirect'
 				@passport.authenticate.callsArgWith(1, null, @user, @info)
 				@passport.authenticate.callsArgWith(1, null, @user, @info)
 
 
 			afterEach ->
 			afterEach ->
-				delete @req._redir
+				delete @req.session.postLoginRedirect
 
 
 			it 'should call req.login', () ->
 			it 'should call req.login', () ->
 				@AuthenticationController.passportLogin @req, @res, @next
 				@AuthenticationController.passportLogin @req, @res, @next
@@ -128,7 +131,7 @@ describe "AuthenticationController", ->
 			it 'should send a json response with redirect', () ->
 			it 'should send a json response with redirect', () ->
 				@AuthenticationController.passportLogin @req, @res, @next
 				@AuthenticationController.passportLogin @req, @res, @next
 				@res.json.callCount.should.equal 1
 				@res.json.callCount.should.equal 1
-				@res.json.calledWith({redir: @req._redir}).should.equal true
+				@res.json.calledWith({redir: 'some_redirect'}).should.equal true
 
 
 			describe 'when session.save produces an error', () ->
 			describe 'when session.save produces an error', () ->
 				beforeEach ->
 				beforeEach ->
@@ -152,10 +155,11 @@ describe "AuthenticationController", ->
 				@AuthenticationController.passportLogin @req, @res, @next
 				@AuthenticationController.passportLogin @req, @res, @next
 				@req.login.callCount.should.equal 0
 				@req.login.callCount.should.equal 0
 
 
-			it 'should send a json response with redirect', () ->
+			it 'should not send a json response with redirect', () ->
 				@AuthenticationController.passportLogin @req, @res, @next
 				@AuthenticationController.passportLogin @req, @res, @next
 				@res.json.callCount.should.equal 1
 				@res.json.callCount.should.equal 1
 				@res.json.calledWith({message: @info}).should.equal true
 				@res.json.calledWith({message: @info}).should.equal true
+				expect(@res.json.lastCall.args[0].redir?).to.equal false
 
 
 	describe 'afterLoginSessionSetup', ->
 	describe 'afterLoginSessionSetup', ->
 
 
@@ -230,7 +234,8 @@ describe "AuthenticationController", ->
 			@req.body =
 			@req.body =
 				email: @email
 				email: @email
 				password: @password
 				password: @password
-				redir: @redir = "/path/to/redir/to"
+				session:
+					postLoginRedirect: "/path/to/redir/to"
 			@cb = sinon.stub()
 			@cb = sinon.stub()
 
 
 		describe "when the users rate limit", ->
 		describe "when the users rate limit", ->
@@ -265,9 +270,6 @@ describe "AuthenticationController", ->
 			it "should set res.session.justLoggedIn", ->
 			it "should set res.session.justLoggedIn", ->
 				@req.session.justLoggedIn.should.equal true
 				@req.session.justLoggedIn.should.equal true
 
 
-			it "should redirect the user to the specified location", ->
-				expect(@req._redir).to.deep.equal @redir
-
 			it "should record the successful login", ->
 			it "should record the successful login", ->
 				@AuthenticationController._recordSuccessfulLogin
 				@AuthenticationController._recordSuccessfulLogin
 					.calledWith(@user._id)
 					.calledWith(@user._id)
@@ -313,17 +315,6 @@ describe "AuthenticationController", ->
 					.calledWith(email: @email.toLowerCase(), "failed log in")
 					.calledWith(email: @email.toLowerCase(), "failed log in")
 					.should.equal true
 					.should.equal true
 
 
-		describe "with a URL to a different domain", ->
-			beforeEach ->
-				@LoginRateLimiter.processLoginRequest.callsArgWith(1, null, true)
-				@req.body.redir = "http://www.facebook.com/test"
-				@AuthenticationManager.authenticate = sinon.stub().callsArgWith(2, null, @user)
-				@cb = sinon.stub()
-				@AuthenticationController.doPassportLogin(@req, @req.body.email, @req.body.password, @cb)
-
-			it "should only redirect to the local path", ->
-				expect(@req._redir).to.equal "/test"
-
 	describe "getLoggedInUserId", ->
 	describe "getLoggedInUserId", ->
 
 
 		beforeEach ->
 		beforeEach ->
@@ -396,6 +387,10 @@ describe "AuthenticationController", ->
 		beforeEach ->
 		beforeEach ->
 			@req.headers = {}
 			@req.headers = {}
 			@AuthenticationController.httpAuth = sinon.stub()
 			@AuthenticationController.httpAuth = sinon.stub()
+			@_setRedirect = sinon.spy(@AuthenticationController, '_setRedirectInSession')
+
+		afterEach ->
+			@_setRedirect.restore()
 
 
 		describe "with white listed url", ->
 		describe "with white listed url", ->
 			beforeEach ->
 			beforeEach ->
@@ -440,6 +435,9 @@ describe "AuthenticationController", ->
 				@req.session = {}
 				@req.session = {}
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
 
 
+			it 'should have called setRedirectInSession', ->
+				@_setRedirect.callCount.should.equal 1
+
 			it "should redirect to the /login page", ->
 			it "should redirect to the /login page", ->
 				@res.redirectedTo.should.equal "/login"
 				@res.redirectedTo.should.equal "/login"
 
 
@@ -488,8 +486,8 @@ describe "AuthenticationController", ->
 			@AuthenticationController._redirectToRegisterPage(@req, @res)
 			@AuthenticationController._redirectToRegisterPage(@req, @res)
 
 
 		it "should redirect to the register page with a query string attached", ->
 		it "should redirect to the register page with a query string attached", ->
-			@res.redirectedTo
-				.should.equal "/register?extra_query=foo&redir=%2Ftarget%2Furl"
+			@req.session.postLoginRedirect.should.equal '/target/url?extra_query=foo'
+			@res.redirectedTo.should.equal "/register?extra_query=foo"
 
 
 		it "should log out a message", ->
 		it "should log out a message", ->
 			@logger.log
 			@logger.log
@@ -504,7 +502,8 @@ describe "AuthenticationController", ->
 			@AuthenticationController._redirectToLoginPage(@req, @res)
 			@AuthenticationController._redirectToLoginPage(@req, @res)
 
 
 		it "should redirect to the register page with a query string attached", ->
 		it "should redirect to the register page with a query string attached", ->
-			@res.redirectedTo.should.equal "/login?extra_query=foo&redir=%2Ftarget%2Furl"
+			@req.session.postLoginRedirect.should.equal '/target/url?extra_query=foo'
+			@res.redirectedTo.should.equal "/login?extra_query=foo"
 
 
 
 
 	describe "_recordSuccessfulLogin", ->
 	describe "_recordSuccessfulLogin", ->
@@ -535,3 +534,43 @@ describe "AuthenticationController", ->
 
 
 		it "should call the callback", ->
 		it "should call the callback", ->
 			@callback.called.should.equal true
 			@callback.called.should.equal true
+
+
+	describe '_setRedirectInSession', ->
+		beforeEach ->
+			@req = {session: {}}
+			@req.path = "/somewhere"
+			@req.query = {one: "1"}
+
+		it 'should set redirect property on session', ->
+			@AuthenticationController._setRedirectInSession(@req)
+			expect(@req.session.postLoginRedirect).to.equal "/somewhere?one=1"
+
+		it 'should set the supplied value', ->
+			@AuthenticationController._setRedirectInSession(@req, '/somewhere/specific')
+			expect(@req.session.postLoginRedirect).to.equal "/somewhere/specific"
+
+		describe 'with a js path', ->
+
+			beforeEach ->
+				@req = {session: {}}
+
+			it 'should not set the redirect', ->
+				@AuthenticationController._setRedirectInSession(@req, '/js/something.js')
+				expect(@req.session.postLoginRedirect).to.equal undefined
+
+	describe '_getRedirectFromSession', ->
+		beforeEach ->
+			@req = {session: {postLoginRedirect: "/a?b=c"}}
+
+		it 'should get redirect property from session', ->
+			expect(@AuthenticationController._getRedirectFromSession(@req)).to.equal "/a?b=c"
+
+	describe '_clearRedirectFromSession', ->
+		beforeEach ->
+			@req = {session: {postLoginRedirect: "/a?b=c"}}
+
+		it 'should remove the redirect property from session', ->
+			@AuthenticationController._clearRedirectFromSession(@req)
+			expect(@req.session.postLoginRedirect).to.equal undefined
+

+ 92 - 0
services/web/test/UnitTests/coffee/Chat/ChatApiHandlerTests.coffee

@@ -0,0 +1,92 @@
+should = require('chai').should()
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+path = require('path')
+sinon = require('sinon')
+modulePath = path.join __dirname, "../../../../app/js/Features/Chat/ChatApiHandler"
+expect = require("chai").expect
+
+describe "ChatApiHandler", ->
+	beforeEach ->
+		@settings = 
+			apis:
+				chat:
+					internal_url:"chat.sharelatex.env"
+		@request = sinon.stub()
+		@ChatApiHandler = SandboxedModule.require modulePath, requires:
+			"settings-sharelatex": @settings
+			"logger-sharelatex": { log: sinon.stub(), error: sinon.stub() }
+			"request": @request
+		@project_id = "3213213kl12j"
+		@user_id = "2k3jlkjs9"
+		@content = "my message here"
+		@callback = sinon.stub()
+
+	describe "sendGlobalMessage", ->
+		describe "successfully", ->
+			beforeEach ->
+				@message = { "mock": "message" }
+				@request.callsArgWith(1, null, {statusCode: 200}, @message)
+				@ChatApiHandler.sendGlobalMessage @project_id, @user_id, @content, @callback
+				
+			it "should post the data to the chat api", ->
+				@request.calledWith({
+					url: "#{@settings.apis.chat.internal_url}/project/#{@project_id}/messages"
+					method: "POST"
+					json:
+						content: @content
+						user_id: @user_id
+				}).should.equal true
+
+			it "should return the message from the post", ->
+				@callback.calledWith(null, @message).should.equal true
+	
+		describe "with a non-success status code", ->
+			beforeEach ->
+				@request.callsArgWith(1, null, {statusCode: 500})
+				@ChatApiHandler.sendGlobalMessage @project_id, @user_id, @content, @callback
+			
+			it "should return an error", ->
+				error = new Error()
+				error.statusCode = 500
+				@callback.calledWith(error).should.equal true
+
+	describe "getGlobalMessages", ->
+		beforeEach ->
+			@messages = [{ "mock": "message" }]
+			@limit = 30
+			@before = "1234"
+
+		describe "successfully", ->
+			beforeEach ->
+				@request.callsArgWith(1, null, {statusCode: 200}, @messages)
+				@ChatApiHandler.getGlobalMessages @project_id, @limit, @before, @callback
+	
+			it "should make get request for room to chat api", ->
+				@request.calledWith({
+					method: "GET"
+					url: "#{@settings.apis.chat.internal_url}/project/#{@project_id}/messages"
+					qs:
+						limit: @limit
+						before: @before
+					json: true
+				}).should.equal true
+	
+			it "should return the messages from the request", ->
+				@callback.calledWith(null, @messages).should.equal true
+
+		describe "with failure error code", ->
+			beforeEach ->
+				@request.callsArgWith(1, null, {statusCode: 500}, null)
+				@ChatApiHandler.getGlobalMessages @project_id, @limit, @before, @callback
+			
+			it "should return an error", ->
+				error = new Error()
+				error.statusCode = 500
+				@callback.calledWith(error).should.equal true
+
+
+
+
+
+

+ 53 - 52
services/web/test/UnitTests/coffee/Chat/ChatControllerTests.coffee

@@ -7,75 +7,76 @@ modulePath = path.join __dirname, "../../../../app/js/Features/Chat/ChatControll
 expect = require("chai").expect
 expect = require("chai").expect
 
 
 describe "ChatController", ->
 describe "ChatController", ->
-
 	beforeEach ->
 	beforeEach ->
-
-		@user_id = 'ier_'
+		@user_id = 'mock-user-id'
 		@settings = {}
 		@settings = {}
-		@ChatHandler =
-			sendMessage:sinon.stub()
-			getMessages:sinon.stub()
-
+		@ChatApiHandler = {}
 		@EditorRealTimeController =
 		@EditorRealTimeController =
 			emitToRoom:sinon.stub().callsArgWith(3)
 			emitToRoom:sinon.stub().callsArgWith(3)
-
 		@AuthenticationController =
 		@AuthenticationController =
 			getLoggedInUserId: sinon.stub().returns(@user_id)
 			getLoggedInUserId: sinon.stub().returns(@user_id)
 		@ChatController = SandboxedModule.require modulePath, requires:
 		@ChatController = SandboxedModule.require modulePath, requires:
-			"settings-sharelatex":@settings
-			"logger-sharelatex": log:->
-			"./ChatHandler":@ChatHandler
-			"../Editor/EditorRealTimeController":@EditorRealTimeController
+			"settings-sharelatex": @settings
+			"logger-sharelatex": log: ->
+			"./ChatApiHandler": @ChatApiHandler
+			"../Editor/EditorRealTimeController": @EditorRealTimeController
 			'../Authentication/AuthenticationController': @AuthenticationController
 			'../Authentication/AuthenticationController': @AuthenticationController
-		@query =
-			before:"some time"
-
+			'../User/UserInfoManager': @UserInfoManager = {}
+			'../User/UserInfoController': @UserInfoController = {}
+			'../Comments/CommentsController': @CommentsController = {}
 		@req =
 		@req =
 			params:
 			params:
-				Project_id:@project_id
-			session:
-				user:
-					_id:@user_id
-			body:
-				content:@messageContent
+				project_id: @project_id
 		@res =
 		@res =
-			set:sinon.stub()
+			json: sinon.stub()
+			send: sinon.stub()
 
 
 	describe "sendMessage", ->
 	describe "sendMessage", ->
-
-		it "should tell the chat handler about the message", (done)->
-			@ChatHandler.sendMessage.callsArgWith(3)
-			@res.send = =>
-				@ChatHandler.sendMessage.calledWith(@project_id, @user_id, @messageContent).should.equal true
-				done()
+		beforeEach ->
+			@req.body =
+				content: @content = "message-content"
+			@UserInfoManager.getPersonalInfo = sinon.stub().yields(null, @user = {"unformatted": "user"})
+			@UserInfoController.formatPersonalInfo = sinon.stub().returns(@formatted_user = {"formatted": "user"})
+			@ChatApiHandler.sendGlobalMessage = sinon.stub().yields(null, @message = {"mock": "message", user_id: @user_id})
 			@ChatController.sendMessage @req, @res
 			@ChatController.sendMessage @req, @res
 
 
-		it "should tell the editor real time controller about the update with the data from the chat handler", (done)->
-			@chatMessage =
-				content:"hello world"
-			@ChatHandler.sendMessage.callsArgWith(3, null, @chatMessage)
-			@res.send = =>
-				@EditorRealTimeController.emitToRoom.calledWith(@project_id, "new-chat-message", @chatMessage).should.equal true
-				done()
-			@ChatController.sendMessage @req, @res
+		it "should look up the user", ->
+			@UserInfoManager.getPersonalInfo
+				.calledWith(@user_id)
+				.should.equal true
 
 
-	describe "getMessages", ->
-		beforeEach ->
-			@req.query = @query
+		it "should format and inject the user into the message", ->
+			@UserInfoController.formatPersonalInfo
+				.calledWith(@user)
+				.should.equal true
+			@message.user.should.deep.equal @formatted_user
 
 
-		it "should ask the chat handler about the request", (done)->
+		it "should tell the chat handler about the message", ->
+			@ChatApiHandler.sendGlobalMessage
+				.calledWith(@project_id, @user_id, @content)
+				.should.equal true
 
 
-			@ChatHandler.getMessages.callsArgWith(2)
-			@res.send = =>
-				@ChatHandler.getMessages.calledWith(@project_id, @query).should.equal true
-				done()
-			@ChatController.getMessages @req, @res
+		it "should tell the editor real time controller about the update with the data from the chat handler", ->
+			@EditorRealTimeController.emitToRoom
+				.calledWith(@project_id, "new-chat-message", @message)
+				.should.equal true
+				
+		it "should return a 204 status code", ->
+			@res.send.calledWith(204).should.equal true
 
 
-		it "should return the messages", (done)->
-			messages = [{content:"hello"}]
-			@ChatHandler.getMessages.callsArgWith(2, null, messages)
-			@res.send = (sentMessages)=>
-				@res.set.calledWith('Content-Type', 'application/json').should.equal true
-				sentMessages.should.deep.equal messages
-				done()
+	describe "getMessages", ->
+		beforeEach ->
+			@req.query =
+				limit: @limit = "30"
+				before: @before = "12345"
+			@CommentsController._injectUserInfoIntoThreads = sinon.stub().yields()
+			@ChatApiHandler.getGlobalMessages = sinon.stub().yields(null, @messages = ["mock", "messages"])
 			@ChatController.getMessages @req, @res
 			@ChatController.getMessages @req, @res
+
+		it "should ask the chat handler about the request", ->
+			@ChatApiHandler.getGlobalMessages
+				.calledWith(@project_id, @limit, @before)
+				.should.equal true
+
+		it "should return the messages", ->
+			@res.json.calledWith(@messages).should.equal true

+ 0 - 89
services/web/test/UnitTests/coffee/Chat/ChatHandlerTests.coffee

@@ -1,89 +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/Chat/ChatHandler"
-expect = require("chai").expect
-
-describe "ChatHandler", ->
-
-	beforeEach ->
-
-		@settings = 
-			apis:
-				chat:
-					internal_url:"chat.sharelatex.env"
-		@request = sinon.stub()
-		@ChatHandler = SandboxedModule.require modulePath, requires:
-			"settings-sharelatex":@settings
-			"logger-sharelatex": log:->
-			"request": @request
-		@project_id = "3213213kl12j"
-		@user_id = "2k3jlkjs9"
-		@messageContent = "my message here"
-
-	describe "sending message", ->
-
-		beforeEach ->
-			@messageResponse = 
-				message:"Details"
-			@request.callsArgWith(1, null, null, @messageResponse)
-
-		it "should post the data to the chat api", (done)->
-
-			@ChatHandler.sendMessage @project_id, @user_id, @messageContent, (err)=>
-				@opts =
-					method:"post"
-					json:
-						content:@messageContent
-						user_id:@user_id
-					uri:"#{@settings.apis.chat.internal_url}/room/#{@project_id}/messages"
-				@request.calledWith(@opts).should.equal true
-				done()
-
-		it "should return the message from the post", (done)->			
-			@ChatHandler.sendMessage @project_id, @user_id, @messageContent, (err, returnedMessage)=>
-				returnedMessage.should.equal @messageResponse
-				done()
-
-	describe "get messages", ->
-
-		beforeEach ->
-			@returnedMessages = [{content:"hello world"}]
-			@request.callsArgWith(1, null, null, @returnedMessages)
-			@query = {}
-
-		it "should make get request for room to chat api", (done)->
-
-			@ChatHandler.getMessages @project_id, @query, (err)=>
-				@opts =
-					method:"get"
-					uri:"#{@settings.apis.chat.internal_url}/room/#{@project_id}/messages"
-					qs:{}
-				@request.calledWith(@opts).should.equal true
-				done()
-
-		it "should make get request for room to chat api with query string", (done)->
-			@query = {limit:5, before:12345, ignore:"this"}
-
-			@ChatHandler.getMessages @project_id, @query, (err)=>
-				@opts =
-					method:"get"
-					uri:"#{@settings.apis.chat.internal_url}/room/#{@project_id}/messages"
-					qs:
-						limit:5
-						before:12345
-				@request.calledWith(@opts).should.equal true
-				done()
-
-		it "should return the messages from the request", (done)->
-			@ChatHandler.getMessages @project_id, @query, (err, returnedMessages)=>
-				returnedMessages.should.equal @returnedMessages
-				done()
-
-
-
-
-
-

Неке датотеке нису приказане због велике количине промена