Просмотр исходного кода

Merge branch 'master' into node-6.9

Shane Kilkelly 9 лет назад
Родитель
Сommit
bb65da88fe
100 измененных файлов с 824 добавлено и 555 удалено
  1. 1 0
      services/web/.nvmrc
  2. 11 3
      services/web/app/coffee/Features/Analytics/AnalyticsController.coffee
  3. 11 2
      services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee
  4. 2 1
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  5. 4 0
      services/web/app/coffee/Features/Authorization/AuthorizationManager.coffee
  6. 1 1
      services/web/app/coffee/Features/BetaProgram/BetaProgramHandler.coffee
  7. 2 2
      services/web/app/coffee/Features/Blog/BlogController.coffee
  8. 1 2
      services/web/app/coffee/Features/Blog/BlogHandler.coffee
  9. 34 3
      services/web/app/coffee/Features/Chat/ChatController.coffee
  10. 0 111
      services/web/app/coffee/Features/Comments/CommentsController.coffee
  11. 1 1
      services/web/app/coffee/Features/Compile/CompileController.coffee
  12. 1 1
      services/web/app/coffee/Features/Compile/CompileManager.coffee
  13. 1 1
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  14. 1 1
      services/web/app/coffee/Features/Downloads/ProjectDownloadsController.coffee
  15. 1 1
      services/web/app/coffee/Features/Editor/EditorController.coffee
  16. 4 2
      services/web/app/coffee/Features/Editor/EditorHttpController.coffee
  17. 2 1
      services/web/app/coffee/Features/Email/EmailSender.coffee
  18. 10 1
      services/web/app/coffee/Features/Errors/Errors.coffee
  19. 14 2
      services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee
  20. 2 4
      services/web/app/coffee/Features/Project/ProjectApiController.coffee
  21. 6 1
      services/web/app/coffee/Features/Project/ProjectController.coffee
  22. 8 2
      services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee
  23. 2 0
      services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee
  24. 12 5
      services/web/app/coffee/Features/Project/ProjectDuplicator.coffee
  25. 3 6
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  26. 8 0
      services/web/app/coffee/Features/Project/ProjectGetter.coffee
  27. 2 0
      services/web/app/coffee/Features/Project/ProjectLocator.coffee
  28. 4 1
      services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee
  29. 24 18
      services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
  30. 33 25
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  31. 16 3
      services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee
  32. 2 2
      services/web/app/coffee/Features/SystemMessages/SystemMessageManager.coffee
  33. 1 1
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee
  34. 1 1
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee
  35. 0 23
      services/web/app/coffee/Features/TrackChanges/RangesManager.coffee
  36. 0 42
      services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee
  37. 0 5
      services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee
  38. 1 1
      services/web/app/coffee/Features/Uploads/ArchiveManager.coffee
  39. 1 1
      services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee
  40. 1 1
      services/web/app/coffee/Features/User/UserController.coffee
  41. 9 1
      services/web/app/coffee/Features/User/UserCreator.coffee
  42. 10 1
      services/web/app/coffee/Features/User/UserGetter.coffee
  43. 10 2
      services/web/app/coffee/Features/User/UserLocator.coffee
  44. 3 0
      services/web/app/coffee/Features/User/UserUpdater.coffee
  45. 1 1
      services/web/app/coffee/infrastructure/CrawlerLogger.coffee
  46. 1 1
      services/web/app/coffee/infrastructure/LockManager.coffee
  47. 0 1
      services/web/app/coffee/infrastructure/Metrics.coffee
  48. 1 1
      services/web/app/coffee/infrastructure/PackageVersions.coffee
  49. 1 1
      services/web/app/coffee/infrastructure/RandomLogging.coffee
  50. 1 3
      services/web/app/coffee/infrastructure/Server.coffee
  51. 0 3
      services/web/app/coffee/models/User.coffee
  52. 1 17
      services/web/app/coffee/router.coffee
  53. 1 3
      services/web/app/views/beta_program/opt_in.pug
  54. 10 8
      services/web/app/views/general/404.pug
  55. 29 17
      services/web/app/views/general/500.pug
  56. 5 1
      services/web/app/views/layout.pug
  57. 12 7
      services/web/app/views/project/editor.pug
  58. 2 1
      services/web/app/views/project/editor/editor.pug
  59. 4 4
      services/web/app/views/project/editor/feature-onboarding.pug
  60. 5 1
      services/web/app/views/project/editor/header.pug
  61. 17 9
      services/web/app/views/project/editor/review-panel.pug
  62. 2 2
      services/web/app/views/subscriptions/new.pug
  63. 1 1
      services/web/package.json
  64. BIN
      services/web/public/apple-touch-icon-precomposed.png
  65. 8 2
      services/web/public/coffee/ide/editor/Document.coffee
  66. 12 1
      services/web/public/coffee/ide/editor/EditorManager.coffee
  67. 0 4
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  68. 131 78
      services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee
  69. 44 23
      services/web/public/coffee/ide/editor/directives/aceEditor/undo/UndoManager.coffee
  70. 39 7
      services/web/public/coffee/ide/editor/sharejs/vendor/client/ace.coffee
  71. 10 4
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee
  72. 20 13
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee
  73. 2 0
      services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
  74. 1 1
      services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
  75. 85 39
      services/web/public/coffee/ide/review-panel/RangesTracker.coffee
  76. 27 16
      services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee
  77. 8 2
      services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee
  78. 8 1
      services/web/public/coffee/ide/settings/controllers/ProjectNameController.coffee
  79. BIN
      services/web/public/favicon.ico
  80. 29 0
      services/web/public/img/brand/404-visual.svg
  81. 10 0
      services/web/public/img/brand/500-visual-plug.svg
  82. 12 0
      services/web/public/img/brand/500-visual-socket.svg
  83. 7 0
      services/web/public/img/brand/500-visual-tail.svg
  84. 8 0
      services/web/public/img/brand/lion-grey.svg
  85. 8 0
      services/web/public/img/brand/lion.svg
  86. 0 0
      services/web/public/img/brand/logo-horizontal.svg
  87. BIN
      services/web/public/img/favicon.png
  88. BIN
      services/web/public/img/favicon@2x.png
  89. BIN
      services/web/public/img/feature-page/courtney-gibbons.jpg
  90. BIN
      services/web/public/img/feature-page/feat-accept-poster.jpg
  91. BIN
      services/web/public/img/feature-page/feat-accept.mp4
  92. BIN
      services/web/public/img/feature-page/feat-changes-poster.jpg
  93. BIN
      services/web/public/img/feature-page/feat-changes.mp4
  94. BIN
      services/web/public/img/feature-page/feat-discuss-poster.jpg
  95. BIN
      services/web/public/img/feature-page/feat-discuss.mp4
  96. BIN
      services/web/public/img/feature-page/feat-todos-poster.jpg
  97. BIN
      services/web/public/img/feature-page/feat-todos.mp4
  98. BIN
      services/web/public/img/feature-page/intro-poster.jpg
  99. BIN
      services/web/public/img/feature-page/intro.mp4
  100. BIN
      services/web/public/img/feature-page/joanna-ellis-monaghan.jpg

+ 1 - 0
services/web/.nvmrc

@@ -0,0 +1 @@
+6.9.5

+ 11 - 3
services/web/app/coffee/Features/Analytics/AnalyticsController.coffee

@@ -1,7 +1,15 @@
 AnalyticsManager = require "./AnalyticsManager"
 AnalyticsManager = require "./AnalyticsManager"
+Errors = require "../Errors/Errors"
+AuthenticationController = require("../Authentication/AuthenticationController")
 
 
 module.exports = AnalyticsController =
 module.exports = AnalyticsController =
 	recordEvent: (req, res, next) ->
 	recordEvent: (req, res, next) ->
-		AnalyticsManager.recordEvent req.session?.user?._id, req.params.event, req.body, (error) ->
-			return next(error) if error?
-			res.send 204
+		user_id = AuthenticationController.getLoggedInUserId(req) or req.sessionID
+		AnalyticsManager.recordEvent user_id, req.params.event, req.body, (error) ->
+			if error instanceof Errors.ServiceNotConfiguredError
+				# ignore, no-op
+				return res.send(204)
+			else if error?
+				return next(error)
+			else
+				return res.send 204

+ 11 - 2
services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee

@@ -2,6 +2,7 @@ settings = require "settings-sharelatex"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 _ = require "underscore"
 _ = require "underscore"
 request = require "request"
 request = require "request"
+Errors = require '../Errors/Errors'
 
 
 
 
 makeRequest = (opts, callback)->
 makeRequest = (opts, callback)->
@@ -10,12 +11,20 @@ makeRequest = (opts, callback)->
 		opts.url = "#{settings.apis.analytics.url}#{urlPath}"
 		opts.url = "#{settings.apis.analytics.url}#{urlPath}"
 		request opts, callback
 		request opts, callback
 	else
 	else
-		callback()
-
+		callback(new Errors.ServiceNotConfiguredError('Analytics service not configured'))
 
 
 
 
 module.exports =
 module.exports =
 
 
+	identifyUser: (user_id, old_user_id, callback = (error)->)->
+		opts =
+			body:
+				old_user_id:old_user_id
+			json:true
+			method:"POST"
+			timeout:1000
+			url: "/user/#{user_id}/identify"
+		makeRequest opts, callback
 
 
 	recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
 	recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
 		if user_id+"" == settings.smokeTest?.userId+""
 		if user_id+"" == settings.smokeTest?.userId+""

+ 2 - 1
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -2,7 +2,7 @@ AuthenticationManager = require ("./AuthenticationManager")
 LoginRateLimiter = require("../Security/LoginRateLimiter")
 LoginRateLimiter = require("../Security/LoginRateLimiter")
 UserGetter = require "../User/UserGetter"
 UserGetter = require "../User/UserGetter"
 UserUpdater = require "../User/UserUpdater"
 UserUpdater = require "../User/UserUpdater"
-Metrics = require('../../infrastructure/Metrics')
+Metrics = require('metrics-sharelatex')
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 querystring = require('querystring')
 querystring = require('querystring')
 Url = require("url")
 Url = require("url")
@@ -87,6 +87,7 @@ module.exports = AuthenticationController =
 					LoginRateLimiter.recordSuccessfulLogin(email)
 					LoginRateLimiter.recordSuccessfulLogin(email)
 					AuthenticationController._recordSuccessfulLogin(user._id)
 					AuthenticationController._recordSuccessfulLogin(user._id)
 					Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
 					Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
+					Analytics.identifyUser(user._id, req.sessionID)
 					logger.log email: email, user_id: user._id.toString(), "successful log in"
 					logger.log email: email, user_id: user._id.toString(), "successful log in"
 					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

+ 4 - 0
services/web/app/coffee/Features/Authorization/AuthorizationManager.coffee

@@ -4,6 +4,8 @@ User = require("../../models/User").User
 PrivilegeLevels = require("./PrivilegeLevels")
 PrivilegeLevels = require("./PrivilegeLevels")
 PublicAccessLevels = require("./PublicAccessLevels")
 PublicAccessLevels = require("./PublicAccessLevels")
 Errors = require("../Errors/Errors")
 Errors = require("../Errors/Errors")
+ObjectId = require("mongojs").ObjectId
+
 
 
 module.exports = AuthorizationManager =
 module.exports = AuthorizationManager =
 	# Get the privilege level that the user has for the project
 	# Get the privilege level that the user has for the project
@@ -13,6 +15,8 @@ module.exports = AuthorizationManager =
 	#   * becausePublic: true if the access level is only because the project is public.
 	#   * becausePublic: true if the access level is only because the project is public.
 	getPrivilegeLevelForProject: (user_id, project_id, callback = (error, privilegeLevel, becausePublic) ->) ->
 	getPrivilegeLevelForProject: (user_id, project_id, callback = (error, privilegeLevel, becausePublic) ->) ->
 		getPublicAccessLevel = () ->
 		getPublicAccessLevel = () ->
+			if !ObjectId.isValid(project_id)
+				return callback(new Error("invalid project id"))
 			Project.findOne { _id: project_id }, { publicAccesLevel: 1 }, (error, project) ->
 			Project.findOne { _id: project_id }, { publicAccesLevel: 1 }, (error, project) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				if !project?
 				if !project?

+ 1 - 1
services/web/app/coffee/Features/BetaProgram/BetaProgramHandler.coffee

@@ -1,6 +1,6 @@
 User = require("../../models/User").User
 User = require("../../models/User").User
 logger = require 'logger-sharelatex'
 logger = require 'logger-sharelatex'
-metrics = require("../../infrastructure/Metrics")
+metrics = require("metrics-sharelatex")
 
 
 module.exports = BetaProgramHandler =
 module.exports = BetaProgramHandler =
 
 

+ 2 - 2
services/web/app/coffee/Features/Blog/BlogController.coffee

@@ -10,7 +10,7 @@ module.exports = BlogController =
 		url = req.url?.toLowerCase()
 		url = req.url?.toLowerCase()
 		blogUrl = "#{settings.apis.blog.url}#{url}"
 		blogUrl = "#{settings.apis.blog.url}#{url}"
 
 
-		extensionsToProxy = [".png", ".xml", ".jpeg", ".json", ".zip", ".eps", ".gif"]
+		extensionsToProxy = [".png", ".xml", ".jpeg", ".jpg", ".json", ".zip", ".eps", ".gif"]
 
 
 		shouldProxy = _.find extensionsToProxy, (extension)->
 		shouldProxy = _.find extensionsToProxy, (extension)->
 			url.indexOf(extension) != -1
 			url.indexOf(extension) != -1
@@ -42,4 +42,4 @@ module.exports = BlogController =
 		upstream = request.get(originUrl)
 		upstream = request.get(originUrl)
 		upstream.on "error", (error) ->
 		upstream.on "error", (error) ->
 			logger.error err: error, "blog proxy error"
 			logger.error err: error, "blog proxy error"
-		upstream.pipe res
+		upstream.pipe res

+ 1 - 2
services/web/app/coffee/Features/Blog/BlogHandler.coffee

@@ -10,7 +10,7 @@ module.exports = BlogHandler =
 		opts = 
 		opts = 
 			url:blogUrl
 			url:blogUrl
 			json:true
 			json:true
-			timeout:500
+			timeout:1000
 		request.get opts, (err, res, announcements)->
 		request.get opts, (err, res, announcements)->
 			if err?
 			if err?
 				return callback err
 				return callback err
@@ -18,7 +18,6 @@ module.exports = BlogHandler =
 				return callback("blog announcement returned non 200")
 				return callback("blog announcement returned non 200")
 			logger.log announcementsLength: announcements?.length, "announcements returned"
 			logger.log announcementsLength: announcements?.length, "announcements returned"
 			announcements = _.map announcements, (announcement)->
 			announcements = _.map announcements, (announcement)->
-				announcement.url = "/blog#{announcement.url}"
 				announcement.date = new Date(announcement.date)
 				announcement.date = new Date(announcement.date)
 				return announcement
 				return announcement
 			callback(err, announcements)
 			callback(err, announcements)

+ 34 - 3
services/web/app/coffee/Features/Chat/ChatController.coffee

@@ -4,9 +4,9 @@ logger = require("logger-sharelatex")
 AuthenticationController = require('../Authentication/AuthenticationController')
 AuthenticationController = require('../Authentication/AuthenticationController')
 UserInfoManager = require('../User/UserInfoManager')
 UserInfoManager = require('../User/UserInfoManager')
 UserInfoController = require('../User/UserInfoController')
 UserInfoController = require('../User/UserInfoController')
-CommentsController = require('../Comments/CommentsController')
+async = require "async"
 
 
-module.exports =
+module.exports = ChatController =
 	sendMessage: (req, res, next)->
 	sendMessage: (req, res, next)->
 		project_id = req.params.project_id
 		project_id = req.params.project_id
 		content = req.body.content
 		content = req.body.content
@@ -28,7 +28,38 @@ module.exports =
 		logger.log project_id:project_id, query:query, "getting messages"
 		logger.log project_id:project_id, query:query, "getting messages"
 		ChatApiHandler.getGlobalMessages project_id, query.limit, query.before, (err, messages) ->
 		ChatApiHandler.getGlobalMessages project_id, query.limit, query.before, (err, messages) ->
 			return next(err) if err?
 			return next(err) if err?
-			CommentsController._injectUserInfoIntoThreads {global: { messages: messages }}, (err) ->
+			ChatController._injectUserInfoIntoThreads {global: { messages: messages }}, (err) ->
 				return next(err) if err?
 				return next(err) if err?
 				logger.log length: messages?.length, "sending messages to client"
 				logger.log length: messages?.length, "sending messages to client"
 				res.json messages
 				res.json messages
+
+	_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

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

@@ -1,111 +0,0 @@
-ChatApiHandler = require("../Chat/ChatApiHandler")
-EditorRealTimeController = require("../Editor/EditorRealTimeController")
-logger = require("logger-sharelatex")
-AuthenticationController = require('../Authentication/AuthenticationController')
-UserInfoManager = require('../User/UserInfoManager')
-UserInfoController = require('../User/UserInfoController')
-DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
-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
-	
-	deleteThread: (req, res, next) ->
-		{project_id, doc_id, thread_id} = req.params
-		logger.log {project_id, doc_id, thread_id}, "deleting comment thread"
-		DocumentUpdaterHandler.deleteThread project_id, doc_id, thread_id, (err) ->
-			return next(err) if err?
-			ChatApiHandler.deleteThread project_id, thread_id, (err, threads) ->
-				return next(err) if err?
-				EditorRealTimeController.emitToRoom project_id, "delete-thread", thread_id, (err)->
-				res.send 204
-	
-	editMessage: (req, res, next) ->
-		{project_id, thread_id, message_id} = req.params
-		{content} = req.body
-		logger.log {project_id, thread_id, message_id}, "editing message thread"
-		ChatApiHandler.editMessage project_id, thread_id, message_id, content, (err) ->
-			return next(err) if err?
-			EditorRealTimeController.emitToRoom project_id, "edit-message", thread_id, message_id, content, (err)->
-			res.send 204
-	
-	deleteMessage: (req, res, next) ->
-		{project_id, thread_id, message_id} = req.params
-		logger.log {project_id, thread_id, message_id}, "deleting message"
-		ChatApiHandler.deleteMessage project_id, thread_id, message_id, (err, threads) ->
-			return next(err) if err?
-			EditorRealTimeController.emitToRoom project_id, "delete-message", thread_id, message_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

+ 1 - 1
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -1,4 +1,4 @@
-Metrics = require "../../infrastructure/Metrics"
+Metrics = require "metrics-sharelatex"
 Project = require("../../models/Project").Project
 Project = require("../../models/Project").Project
 CompileManager = require("./CompileManager")
 CompileManager = require("./CompileManager")
 ClsiManager = require("./ClsiManager")
 ClsiManager = require("./ClsiManager")

+ 1 - 1
services/web/app/coffee/Features/Compile/CompileManager.coffee

@@ -6,7 +6,7 @@ Project = require("../../models/Project").Project
 ProjectRootDocManager = require "../Project/ProjectRootDocManager"
 ProjectRootDocManager = require "../Project/ProjectRootDocManager"
 UserGetter = require "../User/UserGetter"
 UserGetter = require "../User/UserGetter"
 ClsiManager = require "./ClsiManager"
 ClsiManager = require "./ClsiManager"
-Metrics = require('../../infrastructure/Metrics')
+Metrics = require('metrics-sharelatex')
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 rateLimiter = require("../../infrastructure/RateLimiter")
 rateLimiter = require("../../infrastructure/RateLimiter")
 
 

+ 1 - 1
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

@@ -4,7 +4,7 @@ settings = require 'settings-sharelatex'
 _ = require 'underscore'
 _ = require 'underscore'
 async = require 'async'
 async = require 'async'
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
-metrics = require('../../infrastructure/Metrics')
+metrics = require('metrics-sharelatex')
 redis = require("redis-sharelatex")
 redis = require("redis-sharelatex")
 rclient = redis.createClient(settings.redis.web)
 rclient = redis.createClient(settings.redis.web)
 Project = require("../../models/Project").Project
 Project = require("../../models/Project").Project

+ 1 - 1
services/web/app/coffee/Features/Downloads/ProjectDownloadsController.coffee

@@ -1,5 +1,5 @@
 logger                  = require "logger-sharelatex"
 logger                  = require "logger-sharelatex"
-Metrics                 = require "../../infrastructure/Metrics"
+Metrics                 = require "metrics-sharelatex"
 Project                 = require("../../models/Project").Project
 Project                 = require("../../models/Project").Project
 ProjectZipStreamManager = require "./ProjectZipStreamManager"
 ProjectZipStreamManager = require "./ProjectZipStreamManager"
 DocumentUpdaterHandler  = require "../DocumentUpdater/DocumentUpdaterHandler"
 DocumentUpdaterHandler  = require "../DocumentUpdater/DocumentUpdaterHandler"

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

@@ -1,5 +1,5 @@
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
-Metrics = require('../../infrastructure/Metrics')
+Metrics = require('metrics-sharelatex')
 sanitize = require('sanitizer')
 sanitize = require('sanitizer')
 ProjectEntityHandler = require('../Project/ProjectEntityHandler')
 ProjectEntityHandler = require('../Project/ProjectEntityHandler')
 ProjectOptionsHandler = require('../Project/ProjectOptionsHandler')
 ProjectOptionsHandler = require('../Project/ProjectOptionsHandler')

+ 4 - 2
services/web/app/coffee/Features/Editor/EditorHttpController.coffee

@@ -7,7 +7,7 @@ ProjectGetter = require('../Project/ProjectGetter')
 UserGetter = require('../User/UserGetter')
 UserGetter = require('../User/UserGetter')
 AuthorizationManager = require("../Authorization/AuthorizationManager")
 AuthorizationManager = require("../Authorization/AuthorizationManager")
 ProjectEditorHandler = require('../Project/ProjectEditorHandler')
 ProjectEditorHandler = require('../Project/ProjectEditorHandler')
-Metrics = require('../../infrastructure/Metrics')
+Metrics = require('metrics-sharelatex')
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
 CollaboratorsInviteHandler = require("../Collaborators/CollaboratorsInviteHandler")
 CollaboratorsInviteHandler = require("../Collaborators/CollaboratorsInviteHandler")
 PrivilegeLevels = require "../Authorization/PrivilegeLevels"
 PrivilegeLevels = require "../Authorization/PrivilegeLevels"
@@ -96,8 +96,10 @@ module.exports = EditorHttpController =
 		EditorController.addFolder project_id, parent_folder_id, name, "editor", (error, doc) ->
 		EditorController.addFolder project_id, parent_folder_id, name, "editor", (error, doc) ->
 			if error == "project_has_to_many_files"
 			if error == "project_has_to_many_files"
 				res.status(400).json(req.i18n.translate("project_has_to_many_files"))
 				res.status(400).json(req.i18n.translate("project_has_to_many_files"))
+			else if error?.message == 'invalid element name'
+				res.status(400).json(req.i18n.translate('invalid_file_name'))
 			else if error?
 			else if error?
-				next(error)
+				res.status(500).json(req.i18n.translate('generic_something_went_wrong'))
 			else
 			else
 				res.json doc
 				res.json doc
 
 

+ 2 - 1
services/web/app/coffee/Features/Email/EmailSender.coffee

@@ -1,5 +1,5 @@
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
-metrics = require('../../infrastructure/Metrics')
+metrics = require('metrics-sharelatex')
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
 nodemailer = require("nodemailer")
 nodemailer = require("nodemailer")
 sesTransport = require('nodemailer-ses-transport')
 sesTransport = require('nodemailer-ses-transport')
@@ -72,6 +72,7 @@ module.exports =
 			client.sendMail options, (err, res)->
 			client.sendMail options, (err, res)->
 				if err?
 				if err?
 					logger.err err:err, "error sending message"
 					logger.err err:err, "error sending message"
+					err = new Error('Cannot send email')
 				else
 				else
 					logger.log "Message sent to #{options.to}"
 					logger.log "Message sent to #{options.to}"
 				callback(err)
 				callback(err)

+ 10 - 1
services/web/app/coffee/Features/Errors/Errors.coffee

@@ -5,5 +5,14 @@ NotFoundError = (message) ->
 	return error
 	return error
 NotFoundError.prototype.__proto__ = Error.prototype
 NotFoundError.prototype.__proto__ = Error.prototype
 
 
+
+ServiceNotConfiguredError = (message) ->
+	error = new Error(message)
+	error.name = "ServiceNotConfiguredError"
+	error.__proto__ = ServiceNotConfiguredError.prototype
+	return error
+
+
 module.exports = Errors =
 module.exports = Errors =
-	NotFoundError: NotFoundError
+	NotFoundError: NotFoundError
+	ServiceNotConfiguredError: ServiceNotConfiguredError

+ 14 - 2
services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee

@@ -13,6 +13,9 @@ module.exports = FileStoreHandler =
 			if err?
 			if err?
 				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "error stating file"
 				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "error stating file"
 				callback(err)
 				callback(err)
+			if !stat?
+				logger.err project_id:project_id, file_id:file_id, fsPath:fsPath, "stat is not available, can not check file from disk"
+				return callback(new Error("error getting stat, not available"))
 			if !stat.isFile()
 			if !stat.isFile()
 				logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "tried to upload symlink, not contining"
 				logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "tried to upload symlink, not contining"
 				return callback(new Error("can not upload symlink"))
 				return callback(new Error("can not upload symlink"))
@@ -25,10 +28,19 @@ module.exports = FileStoreHandler =
 				timeout:fiveMinsInMs
 				timeout:fiveMinsInMs
 			writeStream = request(opts)
 			writeStream = request(opts)
 			readStream.pipe writeStream
 			readStream.pipe writeStream
-			writeStream.on "end", callback
+
+			writeStream.on 'response', (response) ->
+				if response.statusCode not in [200, 201]
+					err = new Error("non-ok response from filestore for upload: #{response.statusCode}")
+					logger.err {err, statusCode: response.statusCode}, "error uploading to filestore"
+					callback(err)
+				else
+					callback(null)
+
 			readStream.on "error", (err)->
 			readStream.on "error", (err)->
 				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the read stream of uploadFileFromDisk"
 				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the read stream of uploadFileFromDisk"
 				callback err
 				callback err
+
 			writeStream.on "error", (err)->
 			writeStream.on "error", (err)->
 				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the write stream of uploadFileFromDisk"
 				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the write stream of uploadFileFromDisk"
 				callback err
 				callback err
@@ -79,4 +91,4 @@ module.exports = FileStoreHandler =
 			callback(err)
 			callback(err)
 
 
 	_buildUrl: (project_id, file_id)->
 	_buildUrl: (project_id, file_id)->
-		return "#{settings.apis.filestore.url}/project/#{project_id}/file/#{file_id}"
+		return "#{settings.apis.filestore.url}/project/#{project_id}/file/#{file_id}"

+ 2 - 4
services/web/app/coffee/Features/Project/ProjectApiController.coffee

@@ -4,11 +4,9 @@ logger = require("logger-sharelatex")
 
 
 module.exports = 
 module.exports = 
 
 
-	getProjectDetails : (req, res)->
+	getProjectDetails : (req, res, next)->
 		{project_id} = req.params
 		{project_id} = req.params
 		ProjectDetailsHandler.getDetails project_id, (err, projDetails)->
 		ProjectDetailsHandler.getDetails project_id, (err, projDetails)->
-			if err?
-				logger.log err:err, project_id:project_id, "something went wrong getting project details"
-				return res.sendStatus 500
+			return next(err) if err?
 			res.json(projDetails)
 			res.json(projDetails)
 
 

+ 6 - 1
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -4,7 +4,7 @@ projectDeleter = require("./ProjectDeleter")
 projectDuplicator = require("./ProjectDuplicator")
 projectDuplicator = require("./ProjectDuplicator")
 projectCreationHandler = require("./ProjectCreationHandler")
 projectCreationHandler = require("./ProjectCreationHandler")
 editorController = require("../Editor/EditorController")
 editorController = require("../Editor/EditorController")
-metrics = require('../../infrastructure/Metrics')
+metrics = require('metrics-sharelatex')
 User = require('../../models/User').User
 User = require('../../models/User').User
 TagsHandler = require("../Tags/TagsHandler")
 TagsHandler = require("../Tags/TagsHandler")
 SubscriptionLocator = require("../Subscription/SubscriptionLocator")
 SubscriptionLocator = require("../Subscription/SubscriptionLocator")
@@ -224,6 +224,11 @@ module.exports = ProjectController =
 				cb = underscore.once(cb)
 				cb = underscore.once(cb)
 				if !user_id?
 				if !user_id?
 					return cb()
 					return cb()
+				timestamp = user_id.toString().substring(0,8)
+				userSignupDate = new Date( parseInt( timestamp, 16 ) * 1000 )
+				if userSignupDate > new Date("2017-03-09") # 8th March
+					# Don't show for users who registered after it was released
+					return cb(null, false)
 				timeout = setTimeout cb, 500
 				timeout = setTimeout cb, 500
 				AnalyticsManager.getLastOccurance user_id, "shown-track-changes-onboarding-2", (error, event) ->
 				AnalyticsManager.getLastOccurance user_id, "shown-track-changes-onboarding-2", (error, event) ->
 					clearTimeout timeout
 					clearTimeout timeout

+ 8 - 2
services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee

@@ -1,6 +1,6 @@
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 async = require("async")
 async = require("async")
-metrics = require('../../infrastructure/Metrics')
+metrics = require('metrics-sharelatex')
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
 ObjectId = require('mongoose').Types.ObjectId	
 ObjectId = require('mongoose').Types.ObjectId	
 Project = require('../../models/Project').Project
 Project = require('../../models/Project').Project
@@ -11,7 +11,8 @@ fs = require('fs')
 Path = require "path"
 Path = require "path"
 _ = require "underscore"
 _ = require "underscore"
 
 
-module.exports =
+module.exports = ProjectCreationHandler =
+
 	createBlankProject : (owner_id, projectName, callback = (error, project) ->)->
 	createBlankProject : (owner_id, projectName, callback = (error, project) ->)->
 		metrics.inc("project-creation")
 		metrics.inc("project-creation")
 		logger.log owner_id:owner_id, projectName:projectName, "creating blank project"
 		logger.log owner_id:owner_id, projectName:projectName, "creating blank project"
@@ -79,5 +80,10 @@ module.exports =
 				output = _.template(template.toString(), data)
 				output = _.template(template.toString(), data)
 				callback null, output.split("\n")
 				callback null, output.split("\n")
 
 
+metrics.timeAsyncMethod(
+	ProjectCreationHandler, 'createBlankProject',
+	'mongo.ProjectCreationHandler',
+	logger
+)
 
 
 
 

+ 2 - 0
services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee

@@ -5,6 +5,7 @@ logger = require("logger-sharelatex")
 tpdsUpdateSender = require '../ThirdPartyDataStore/TpdsUpdateSender'
 tpdsUpdateSender = require '../ThirdPartyDataStore/TpdsUpdateSender'
 _ = require("underscore")
 _ = require("underscore")
 PublicAccessLevels = require("../Authorization/PublicAccessLevels")
 PublicAccessLevels = require("../Authorization/PublicAccessLevels")
+Errors = require("../Errors/Errors")
 
 
 module.exports = 
 module.exports = 
 
 
@@ -13,6 +14,7 @@ module.exports =
 			if err?
 			if err?
 				logger.err err:err, project_id:project_id, "error getting project"
 				logger.err err:err, project_id:project_id, "error getting project"
 				return callback(err)
 				return callback(err)
+			return callback(new Errors.NotFoundError("project not found")) if !project?
 			UserGetter.getUser project.owner_ref, (err, user) ->
 			UserGetter.getUser project.owner_ref, (err, user) ->
 				return callback(err) if err?
 				return callback(err) if err?
 				details =
 				details =

+ 12 - 5
services/web/app/coffee/Features/Project/ProjectDuplicator.coffee

@@ -15,9 +15,11 @@ module.exports = ProjectDuplicator =
 	_copyDocs: (newProject, originalRootDoc, originalFolder, desFolder, docContents, callback)->
 	_copyDocs: (newProject, originalRootDoc, originalFolder, desFolder, docContents, callback)->
 		setRootDoc = _.once (doc_id)->
 		setRootDoc = _.once (doc_id)->
 			projectEntityHandler.setRootDoc newProject._id, doc_id
 			projectEntityHandler.setRootDoc newProject._id, doc_id
-
-		jobs = originalFolder.docs.map (doc)->
+		docs = originalFolder.docs or []
+		jobs = docs.map (doc)->
 			return (cb)->
 			return (cb)->
+				if !doc?._id?
+					return callback()
 				content = docContents[doc._id.toString()]
 				content = docContents[doc._id.toString()]
 				projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, (err, newDoc)->
 				projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, (err, newDoc)->
 					if err?
 					if err?
@@ -30,7 +32,8 @@ module.exports = ProjectDuplicator =
 		async.series jobs, callback
 		async.series jobs, callback
 
 
 	_copyFiles: (newProject, originalProject_id, originalFolder, desFolder, callback)->
 	_copyFiles: (newProject, originalProject_id, originalFolder, desFolder, callback)->
-		jobs = originalFolder.fileRefs.map (file)->
+		fileRefs = originalFolder.fileRefs or []
+		jobs = fileRefs.map (file)->
 			return (cb)->
 			return (cb)->
 				projectEntityHandler.copyFileFromExistingProjectWithProject newProject, desFolder._id, originalProject_id, file, cb
 				projectEntityHandler.copyFileFromExistingProjectWithProject newProject, desFolder._id, originalProject_id, file, cb
 		async.parallelLimit jobs, 5, callback
 		async.parallelLimit jobs, 5, callback
@@ -40,10 +43,14 @@ module.exports = ProjectDuplicator =
 		ProjectGetter.getProject newProject_id, {rootFolder:true, name:true}, (err, newProject)->
 		ProjectGetter.getProject newProject_id, {rootFolder:true, name:true}, (err, newProject)->
 			if err?
 			if err?
 				logger.err project_id:newProject_id, "could not get project"
 				logger.err project_id:newProject_id, "could not get project"
-				return cb(err)
+				return callback(err)
+
+			folders = originalFolder.folders or []
 
 
-			jobs = originalFolder.folders.map (childFolder)->
+			jobs = folders.map (childFolder)->
 				return (cb)->
 				return (cb)->
+					if !childFolder?._id?
+						return cb()
 					projectEntityHandler.addFolderWithProject newProject, desFolder?._id, childFolder.name, (err, newFolder)->
 					projectEntityHandler.addFolderWithProject newProject, desFolder?._id, childFolder.name, (err, newFolder)->
 						return cb(err) if err?
 						return cb(err) if err?
 						ProjectDuplicator._copyFolderRecursivly newProject_id, originalProject_id, originalRootDoc, childFolder, newFolder, docContents, cb
 						ProjectDuplicator._copyFolderRecursivly newProject_id, originalProject_id, originalRootDoc, childFolder, newFolder, docContents, cb

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

@@ -1,6 +1,8 @@
 _ = require("underscore")
 _ = require("underscore")
 
 
 module.exports = ProjectEditorHandler =
 module.exports = ProjectEditorHandler =
+	trackChangesAvailable: false
+
 	buildProjectModelView: (project, members, invites) ->
 	buildProjectModelView: (project, members, invites) ->
 		result =
 		result =
 			_id        : project._id
 			_id        : project._id
@@ -20,11 +22,6 @@ module.exports = ProjectEditorHandler =
 		if !result.invites?
 		if !result.invites?
 			result.invites = []
 			result.invites = []
 			
 			
-		trackChangesVisible = false
-		for member in members
-			if member.privilegeLevel == "owner" and (member.user?.featureSwitches?.track_changes or member.user?.betaProgram)
-				trackChangesVisible = true
-
 		{owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members)
 		{owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members)
 		result.owner = owner
 		result.owner = owner
 		result.members = members
 		result.members = members
@@ -38,7 +35,7 @@ module.exports = ProjectEditorHandler =
 			templates: false
 			templates: false
 			references: false
 			references: false
 			trackChanges: false
 			trackChanges: false
-			trackChangesVisible: trackChangesVisible
+			trackChangesVisible: ProjectEditorHandler.trackChangesAvailable
 		})
 		})
 
 
 		return result
 		return result

+ 8 - 0
services/web/app/coffee/Features/Project/ProjectGetter.coffee

@@ -1,4 +1,5 @@
 mongojs = require("../../infrastructure/mongojs")
 mongojs = require("../../infrastructure/mongojs")
+metrics = require("metrics-sharelatex")
 db = mongojs.db
 db = mongojs.db
 ObjectId = mongojs.ObjectId
 ObjectId = mongojs.ObjectId
 async = require "async"
 async = require "async"
@@ -57,3 +58,10 @@ module.exports = ProjectGetter =
 			CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, fields, (error, readAndWriteProjects, readOnlyProjects) ->
 			CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, fields, (error, readAndWriteProjects, readOnlyProjects) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				callback null, projects, readAndWriteProjects, readOnlyProjects
 				callback null, projects, readAndWriteProjects, readOnlyProjects
+
+
+[
+	'getProject',
+	'getProjectWithoutDocLines'
+].map (method) ->
+	metrics.timeAsyncMethod(ProjectGetter, method, 'mongo.ProjectGetter', logger)

+ 2 - 0
services/web/app/coffee/Features/Project/ProjectLocator.coffee

@@ -26,6 +26,8 @@ module.exports = ProjectLocator =
 			element = _.find searchFolder[elementType], (el)-> el?._id+'' == element_id+'' #need to ToString both id's for robustness
 			element = _.find searchFolder[elementType], (el)-> el?._id+'' == element_id+'' #need to ToString both id's for robustness
 			if !element? && searchFolder.folders? && searchFolder.folders.length != 0
 			if !element? && searchFolder.folders? && searchFolder.folders.length != 0
 				_.each searchFolder.folders, (folder, index)->
 				_.each searchFolder.folders, (folder, index)->
+					if !folder?
+						return
 					newPath = {}
 					newPath = {}
 					newPath[key] = value for own key,value of path #make a value copy of the string
 					newPath[key] = value for own key,value of path #make a value copy of the string
 					newPath.fileSystem += "/#{folder.name}"
 					newPath.fileSystem += "/#{folder.name}"

+ 4 - 1
services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee

@@ -9,13 +9,16 @@ module.exports =
 
 
 		webRouter.get '/tos', HomeController.externalPage("tos", "Terms of Service")
 		webRouter.get '/tos', HomeController.externalPage("tos", "Terms of Service")
 		webRouter.get '/about', HomeController.externalPage("about", "About Us")
 		webRouter.get '/about', HomeController.externalPage("about", "About Us")
+
 		webRouter.get '/security', HomeController.externalPage("security", "Security")
 		webRouter.get '/security', HomeController.externalPage("security", "Security")
 		webRouter.get '/privacy_policy', HomeController.externalPage("privacy", "Privacy Policy")
 		webRouter.get '/privacy_policy', HomeController.externalPage("privacy", "Privacy Policy")
 		webRouter.get '/planned_maintenance', HomeController.externalPage("planned_maintenance", "Planned Maintenance")
 		webRouter.get '/planned_maintenance', HomeController.externalPage("planned_maintenance", "Planned Maintenance")
 		webRouter.get '/style', HomeController.externalPage("style_guide", "Style Guide")
 		webRouter.get '/style', HomeController.externalPage("style_guide", "Style Guide")
 		webRouter.get '/jobs', HomeController.externalPage("jobs", "Jobs")
 		webRouter.get '/jobs', HomeController.externalPage("jobs", "Jobs")
 
 
+		webRouter.get '/track-changes-and-comments-in-latex', HomeController.externalPage("review-features-page", "Review features")
+
 		webRouter.get '/dropbox', HomeController.externalPage("dropbox", "Dropbox and ShareLaTeX")
 		webRouter.get '/dropbox', HomeController.externalPage("dropbox", "Dropbox and ShareLaTeX")
 
 
 		webRouter.get '/university', UniversityController.getIndexPage
 		webRouter.get '/university', UniversityController.getIndexPage
-		webRouter.get '/university/*', UniversityController.getPage
+		webRouter.get '/university/*', UniversityController.getPage

+ 24 - 18
services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee

@@ -469,33 +469,39 @@ module.exports = RecurlyWrapper =
 				logger.err err:error,  subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire,  "error exending trial"
 				logger.err err:error,  subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire,  "error exending trial"
 			callback(error)
 			callback(error)
 		)
 		)
-
-	_parseSubscriptionXml: (xml, callback) ->
-		RecurlyWrapper._parseXml xml, (error, data) ->
+	
+	listAccountActiveSubscriptions: (account_id, callback = (error, subscriptions) ->) ->
+		RecurlyWrapper.apiRequest {
+			url: "accounts/#{account_id}/subscriptions"
+			qs:
+				state: "active"
+			expect404: true
+		}, (error, response, body) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			if data? and data.subscription?
-				recurlySubscription = data.subscription
+			if response.statusCode == 404
+				return callback null, []
 			else
 			else
-				return callback "I don't understand the response from Recurly"
-			callback null, recurlySubscription
+				RecurlyWrapper._parseSubscriptionsXml body, callback
+
+	_parseSubscriptionsXml: (xml, callback) ->
+		RecurlyWrapper._parseXmlAndGetAttribute xml, "subscriptions", callback
+
+	_parseSubscriptionXml: (xml, callback) ->
+		RecurlyWrapper._parseXmlAndGetAttribute xml, "subscription", callback
 
 
 	_parseAccountXml: (xml, callback) ->
 	_parseAccountXml: (xml, callback) ->
-		RecurlyWrapper._parseXml xml, (error, data) ->
-			return callback(error) if error?
-			if data? and data.account?
-				account = data.account
-			else
-				return callback "I don't understand the response from Recurly"
-			callback null, account
+		RecurlyWrapper._parseXmlAndGetAttribute xml, "account", callback
 
 
 	_parseBillingInfoXml: (xml, callback) ->
 	_parseBillingInfoXml: (xml, callback) ->
+		RecurlyWrapper._parseXmlAndGetAttribute xml, "billing_info", callback
+
+	_parseXmlAndGetAttribute: (xml, attribute, callback) ->
 		RecurlyWrapper._parseXml xml, (error, data) ->
 		RecurlyWrapper._parseXml xml, (error, data) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			if data? and data.billing_info?
-				billingInfo = data.billing_info
+			if data? and data[attribute]?
+				return callback null, data[attribute]
 			else
 			else
-				return callback "I don't understand the response from Recurly"
-			callback null, billingInfo
+				return callback(new Error("I don't understand the response from Recurly"))
 
 
 	_parseXml: (xml, callback) ->
 	_parseXml: (xml, callback) ->
 		convertDataTypes = (data) ->
 		convertDataTypes = (data) ->

+ 33 - 25
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -46,31 +46,39 @@ module.exports = SubscriptionController =
 			if hasSubscription or !plan?
 			if hasSubscription or !plan?
 				res.redirect "/user/subscription"
 				res.redirect "/user/subscription"
 			else
 			else
-				currency = req.query.currency?.toUpperCase()
-				GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency, countryCode)->
-					return next(err) if err?
-					if recomendedCurrency? and !currency?
-						currency = recomendedCurrency
-					RecurlyWrapper.sign {
-						subscription:
-							plan_code : req.query.planCode
-							currency: currency
-						account_code: user._id
-					}, (error, signature) ->
-						return next(error) if error?
-						res.render "subscriptions/new",
-							title      : "subscribe"
-							plan_code: req.query.planCode
-							currency: currency
-							countryCode:countryCode
-							plan:plan
-							showStudentPlan: req.query.ssp
-							recurlyConfig: JSON.stringify
-								currency: currency
-								subdomain: Settings.apis.recurly.subdomain
-							showCouponField: req.query.scf
-							showVatField: req.query.svf
-							couponCode:      req.query.cc or ""
+				# LimitationsManager.userHasSubscription only checks Mongo. Double check with
+				# Recurly as well at this point (we don't do this most places for speed).
+				SubscriptionHandler.validateNoSubscriptionInRecurly user._id, (error, valid) ->
+					return next(error) if error?
+					if !valid
+						res.redirect "/user/subscription"
+						return
+					else
+						currency = req.query.currency?.toUpperCase()
+						GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency, countryCode)->
+							return next(err) if err?
+							if recomendedCurrency? and !currency?
+								currency = recomendedCurrency
+							RecurlyWrapper.sign {
+								subscription:
+									plan_code : req.query.planCode
+									currency: currency
+								account_code: user._id
+							}, (error, signature) ->
+								return next(error) if error?
+								res.render "subscriptions/new",
+									title      : "subscribe"
+									plan_code: req.query.planCode
+									currency: currency
+									countryCode:countryCode
+									plan:plan
+									showStudentPlan: req.query.ssp
+									recurlyConfig: JSON.stringify
+										currency: currency
+										subdomain: Settings.apis.recurly.subdomain
+									showCouponField: req.query.scf
+									showVatField: req.query.svf
+									couponCode:      req.query.cc or ""
 
 
 
 
 
 

+ 16 - 3
services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee

@@ -11,15 +11,28 @@ Analytics = require("../Analytics/AnalyticsManager")
 
 
 
 
 module.exports =
 module.exports =
+	validateNoSubscriptionInRecurly: (user_id, callback = (error, valid) ->) ->
+		RecurlyWrapper.listAccountActiveSubscriptions user_id, (error, subscriptions = []) ->
+			return callback(error) if error?
+			if subscriptions.length > 0
+				SubscriptionUpdater.syncSubscription subscriptions[0], user_id, (error) ->
+					return callback(error) if error?
+					return callback(null, false)
+			else
+				return callback(null, true)
 
 
 	createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
 	createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
 		self = @
 		self = @
 		clientTokenId = ""
 		clientTokenId = ""
-		RecurlyWrapper.createSubscription user, subscriptionDetails, recurly_token_id, (error, recurlySubscription)->
+		@validateNoSubscriptionInRecurly user._id, (error, valid) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			SubscriptionUpdater.syncSubscription recurlySubscription, user._id, (error) ->
+			if !valid
+				return callback(new Error("user already has subscription in recurly"))
+			RecurlyWrapper.createSubscription user, subscriptionDetails, recurly_token_id, (error, recurlySubscription)->
 				return callback(error) if error?
 				return callback(error) if error?
-				callback()
+				SubscriptionUpdater.syncSubscription recurlySubscription, user._id, (error) ->
+					return callback(error) if error?
+					callback()
 
 
 	updateSubscription: (user, plan_code, coupon_code, callback)->
 	updateSubscription: (user, plan_code, coupon_code, callback)->
 		logger.log user:user, plan_code:plan_code, coupon_code:coupon_code, "updating subscription"
 		logger.log user:user, plan_code:plan_code, coupon_code:coupon_code, "updating subscription"

+ 2 - 2
services/web/app/coffee/Features/SystemMessages/SystemMessageManager.coffee

@@ -23,7 +23,7 @@ module.exports = SystemMessageManager =
 	clearCache: () ->
 	clearCache: () ->
 		delete @_cachedMessages
 		delete @_cachedMessages
 				
 				
-CACHE_TIMEOUT = 5 * 60 * 1000 # 5 minutes		
+CACHE_TIMEOUT = 20 * 1000 # 20 seconds		
 setInterval () ->
 setInterval () ->
 	SystemMessageManager.clearCache()
 	SystemMessageManager.clearCache()
-, CACHE_TIMEOUT
+, CACHE_TIMEOUT

+ 1 - 1
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee

@@ -2,7 +2,7 @@ tpdsUpdateHandler = require('./TpdsUpdateHandler')
 UpdateMerger = require "./UpdateMerger"
 UpdateMerger = require "./UpdateMerger"
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 Path = require('path')
 Path = require('path')
-metrics = require("../../infrastructure/Metrics")
+metrics = require("metrics-sharelatex")
 
 
 module.exports =
 module.exports =
 	# mergeUpdate and deleteUpdate are used by Dropbox, where the project is only passed as the name, as the
 	# mergeUpdate and deleteUpdate are used by Dropbox, where the project is only passed as the name, as the

+ 1 - 1
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee

@@ -3,7 +3,7 @@ logger = require('logger-sharelatex')
 path = require('path')
 path = require('path')
 Project = require('../../models/Project').Project
 Project = require('../../models/Project').Project
 keys = require('../../infrastructure/Keys')
 keys = require('../../infrastructure/Keys')
-metrics = require("../../infrastructure/Metrics")
+metrics = require("metrics-sharelatex")
 request = require("request")
 request = require("request")
 CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
 CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
 
 

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

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

+ 0 - 42
services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee

@@ -1,42 +0,0 @@
-RangesManager = require "./RangesManager"
-logger = require "logger-sharelatex"
-UserInfoController = require "../User/UserInfoController"
-DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
-EditorRealTimeController = require("../Editor/EditorRealTimeController")
-TrackChangesManager = require "./TrackChangesManager"
-
-module.exports = TrackChangesController =
-	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

+ 0 - 5
services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee

@@ -1,5 +0,0 @@
-Project = require("../../models/Project").Project
-
-module.exports = TrackChangesManager =
-	toggleTrackChanges: (project_id, track_changes_on, callback = (error) ->) ->
-		Project.update {_id: project_id}, {track_changes: track_changes_on}, callback

+ 1 - 1
services/web/app/coffee/Features/Uploads/ArchiveManager.coffee

@@ -1,6 +1,6 @@
 child   = require "child_process"
 child   = require "child_process"
 logger  = require "logger-sharelatex"
 logger  = require "logger-sharelatex"
-metrics = require "../../infrastructure/Metrics"
+metrics = require "metrics-sharelatex"
 fs      = require "fs"
 fs      = require "fs"
 Path    = require "path"
 Path    = require "path"
 _ = require("underscore")
 _ = require("underscore")

+ 1 - 1
services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee

@@ -1,5 +1,5 @@
 logger  = require "logger-sharelatex"
 logger  = require "logger-sharelatex"
-metrics = require "../../infrastructure/Metrics"
+metrics = require "metrics-sharelatex"
 fs      = require "fs"
 fs      = require "fs"
 Path    = require "path"
 Path    = require "path"
 FileSystemImportManager = require "./FileSystemImportManager"
 FileSystemImportManager = require "./FileSystemImportManager"

+ 1 - 1
services/web/app/coffee/Features/User/UserController.coffee

@@ -5,7 +5,7 @@ User = require("../../models/User").User
 newsLetterManager = require('../Newsletter/NewsletterManager')
 newsLetterManager = require('../Newsletter/NewsletterManager')
 UserRegistrationHandler = require("./UserRegistrationHandler")
 UserRegistrationHandler = require("./UserRegistrationHandler")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
-metrics = require("../../infrastructure/Metrics")
+metrics = require("metrics-sharelatex")
 Url = require("url")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 AuthenticationController = require('../Authentication/AuthenticationController')
 AuthenticationController = require('../Authentication/AuthenticationController')

+ 9 - 1
services/web/app/coffee/Features/User/UserCreator.coffee

@@ -1,8 +1,10 @@
 User = require("../../models/User").User
 User = require("../../models/User").User
 UserLocator = require("./UserLocator")
 UserLocator = require("./UserLocator")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
+metrics = require('metrics-sharelatex')
 
 
-module.exports =
+
+module.exports = UserCreator =
 
 
 	getUserOrCreateHoldingAccount: (email, callback = (err, user)->)->
 	getUserOrCreateHoldingAccount: (email, callback = (err, user)->)->
 		self = @
 		self = @
@@ -36,3 +38,9 @@ module.exports =
 
 
 		user.save (err)->
 		user.save (err)->
 			callback(err, user)
 			callback(err, user)
+
+metrics.timeAsyncMethod(
+	UserCreator, 'createNewUser',
+	'mongo.UserCreator',
+	logger
+)

+ 10 - 1
services/web/app/coffee/Features/User/UserGetter.coffee

@@ -1,4 +1,6 @@
 mongojs = require("../../infrastructure/mongojs")
 mongojs = require("../../infrastructure/mongojs")
+metrics = require('metrics-sharelatex')
+logger = require('logger-sharelatex')
 db = mongojs.db
 db = mongojs.db
 ObjectId = mongojs.ObjectId
 ObjectId = mongojs.ObjectId
 
 
@@ -23,4 +25,11 @@ module.exports = UserGetter =
 		catch error
 		catch error
 			return callback error
 			return callback error
 		
 		
-		db.users.find { _id: { $in: user_ids} }, projection, callback
+		db.users.find { _id: { $in: user_ids} }, projection, callback
+
+
+[
+	'getUser',
+	'getUsers'
+].map (method) ->
+	metrics.timeAsyncMethod UserGetter, method, 'mongo.UserGetter', logger

+ 10 - 2
services/web/app/coffee/Features/User/UserLocator.coffee

@@ -1,8 +1,10 @@
 mongojs = require("../../infrastructure/mongojs")
 mongojs = require("../../infrastructure/mongojs")
+metrics = require("metrics-sharelatex")
 db = mongojs.db
 db = mongojs.db
 ObjectId = mongojs.ObjectId
 ObjectId = mongojs.ObjectId
+logger = require('logger-sharelatex')
 
 
-module.exports =
+module.exports = UserLocator =
 
 
 	findByEmail: (email, callback)->
 	findByEmail: (email, callback)->
 		email = email.trim()
 		email = email.trim()
@@ -10,4 +12,10 @@ module.exports =
 			callback(err, user)
 			callback(err, user)
 
 
 	findById: (_id, callback)->
 	findById: (_id, callback)->
-		db.users.findOne _id:ObjectId(_id+""), callback
+		db.users.findOne _id:ObjectId(_id+""), callback
+
+[
+	'findById',
+	'findByEmail'
+].map (method) ->
+	metrics.timeAsyncMethod UserLocator, method, 'mongo.UserLocator', logger

+ 3 - 0
services/web/app/coffee/Features/User/UserUpdater.coffee

@@ -1,5 +1,6 @@
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 mongojs = require("../../infrastructure/mongojs")
 mongojs = require("../../infrastructure/mongojs")
+metrics = require("metrics-sharelatex")
 db = mongojs.db
 db = mongojs.db
 ObjectId = mongojs.ObjectId
 ObjectId = mongojs.ObjectId
 UserLocator = require("./UserLocator")
 UserLocator = require("./UserLocator")
@@ -28,3 +29,5 @@ module.exports = UserUpdater =
 					return callback(err)
 					return callback(err)
 				callback()
 				callback()
 
 
+
+metrics.timeAsyncMethod UserUpdater, 'updateUser', 'mongo.UserUpdater', logger

+ 1 - 1
services/web/app/coffee/infrastructure/CrawlerLogger.coffee

@@ -1,4 +1,4 @@
-metrics = require('./Metrics')
+metrics = require('metrics-sharelatex')
 module.exports =
 module.exports =
 	log: (req)->
 	log: (req)->
 		if req.headers["user-agent"]?
 		if req.headers["user-agent"]?

+ 1 - 1
services/web/app/coffee/infrastructure/LockManager.coffee

@@ -1,4 +1,4 @@
-metrics = require('./Metrics')
+metrics = require('metrics-sharelatex')
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
 redis = require("redis-sharelatex")
 redis = require("redis-sharelatex")
 rclient = redis.createClient(Settings.redis.web)
 rclient = redis.createClient(Settings.redis.web)

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

@@ -1 +0,0 @@
-module.exports = require("metrics-sharelatex")

+ 1 - 1
services/web/app/coffee/infrastructure/PackageVersions.coffee

@@ -1,5 +1,5 @@
 version = {
 version = {
-	"pdfjs": "1.6.210p2"
+	"pdfjs": "1.7.225"
 	"moment": "2.9.0"
 	"moment": "2.9.0"
 	"ace": "1.2.5"
 	"ace": "1.2.5"
 }
 }

+ 1 - 1
services/web/app/coffee/infrastructure/RandomLogging.coffee

@@ -1,5 +1,5 @@
 _ = require('underscore')
 _ = require('underscore')
-metrics = require('./Metrics')
+metrics = require('metrics-sharelatex')
 
 
 do trackOpenSockets = ->
 do trackOpenSockets = ->
 	metrics.gauge("http.open-sockets", _.size(require('http').globalAgent.sockets.length), 0.5)
 	metrics.gauge("http.open-sockets", _.size(require('http').globalAgent.sockets.length), 0.5)

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

@@ -2,7 +2,7 @@ Path = require "path"
 express = require('express')
 express = require('express')
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
 logger = require 'logger-sharelatex'
 logger = require 'logger-sharelatex'
-metrics = require('./Metrics')
+metrics = require('metrics-sharelatex')
 crawlerLogger = require('./CrawlerLogger')
 crawlerLogger = require('./CrawlerLogger')
 expressLocals = require('./ExpressLocals')
 expressLocals = require('./ExpressLocals')
 Router = require('../router')
 Router = require('../router')
@@ -39,8 +39,6 @@ ErrorController = require "../Features/Errors/ErrorController"
 UserSessionsManager = require "../Features/User/UserSessionsManager"
 UserSessionsManager = require "../Features/User/UserSessionsManager"
 AuthenticationController = require "../Features/Authentication/AuthenticationController"
 AuthenticationController = require "../Features/Authentication/AuthenticationController"
 
 
-metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongojs/node_modules/mongodb"), logger)
-metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongoose/node_modules/mongodb"), logger)
 
 
 metrics.event_loop?.monitor(logger)
 metrics.event_loop?.monitor(logger)
 
 

+ 0 - 3
services/web/app/coffee/models/User.coffee

@@ -39,9 +39,6 @@ UserSchema = new Schema
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 							trackChanges:  { type:Boolean, default: Settings.defaultFeatures.trackChanges }
 							trackChanges:  { type:Boolean, default: Settings.defaultFeatures.trackChanges }
 						}
 						}
-	featureSwitches	  : {
-		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' ]
 	refered_user_count: { type:Number, default: 0 }
 	refered_user_count: { type:Number, default: 0 }

+ 1 - 17
services/web/app/coffee/router.coffee

@@ -9,7 +9,7 @@ Settings = require('settings-sharelatex')
 TpdsController = require('./Features/ThirdPartyDataStore/TpdsController')
 TpdsController = require('./Features/ThirdPartyDataStore/TpdsController')
 SubscriptionRouter = require './Features/Subscription/SubscriptionRouter'
 SubscriptionRouter = require './Features/Subscription/SubscriptionRouter'
 UploadsRouter = require './Features/Uploads/UploadsRouter'
 UploadsRouter = require './Features/Uploads/UploadsRouter'
-metrics = require('./infrastructure/Metrics')
+metrics = require('metrics-sharelatex')
 ReferalController = require('./Features/Referal/ReferalController')
 ReferalController = require('./Features/Referal/ReferalController')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 TagsController = require("./Features/Tags/TagsController")
 TagsController = require("./Features/Tags/TagsController")
@@ -40,8 +40,6 @@ 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")
@@ -177,11 +175,6 @@ module.exports = class Router
 		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", 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.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
 
 
@@ -232,15 +225,6 @@ module.exports = class Router
 
 
 		webRouter.get  "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
 		webRouter.get  "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
 		webRouter.post "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
 		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.delete "/project/:project_id/doc/:doc_id/thread/:thread_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteThread
-		webRouter.post "/project/:project_id/thread/:thread_id/messages/:message_id/edit", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.editMessage
-		webRouter.delete  "/project/:project_id/thread/:thread_id/messages/:message_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteMessage
 
 
 		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 - 3
services/web/app/views/beta_program/opt_in.pug

@@ -18,9 +18,7 @@ block content
 											| #{translate("beta_program_badge_description")}
 											| #{translate("beta_program_badge_description")}
 											span.beta-feature-badge
 											span.beta-feature-badge
 										p.text-centered
 										p.text-centered
-											strong We're currently testing track changes and commenting:
-										p.text-centered
-											img(src="/img/teasers/track-changes/track-changes-beta.png", style="max-width: 100%; border-bottom: 1px solid #ddd")
+											strong We're not currently testing anything in beta, but keep checking back!
 								.row.text-centered
 								.row.text-centered
 									.col-md-12
 									.col-md-12
 										if user.betaProgram
 										if user.betaProgram

+ 10 - 8
services/web/app/views/general/404.pug

@@ -3,11 +3,13 @@ extends ../layout
 block content
 block content
 	.content
 	.content
 		.container
 		.container
-			.row
-				.col-md-8.col-md-offset-2.text-center
-					.page-header
-						h2 #{translate("cant_find_page")}
-					p
-						a(href="/")
-							i.fa.fa-arrow-circle-o-left
-							|  #{translate("take_me_home")}
+			.error-container
+				.error-figure
+					img.error-img(
+						src="/img/brand/404-visual.svg"
+						alt="Not found"
+					)
+				.error-details
+					p.error-status Not found
+					p.error-description #{translate("cant_find_page")}
+					a.error-btn(href="/") Home

+ 29 - 17
services/web/app/views/general/500.pug

@@ -1,24 +1,36 @@
 doctype html
 doctype html
-html(itemscope, itemtype='http://schema.org/Product')
+html.full-height(itemscope, itemtype='http://schema.org/Product')
 	head
 	head
 		title Something went wrong
 		title Something went wrong
 		link(rel="icon", href="/favicon.ico")
 		link(rel="icon", href="/favicon.ico")
 		if buildCssPath
 		if buildCssPath
 			link(rel='stylesheet', href=buildCssPath('/style.css'))
 			link(rel='stylesheet', href=buildCssPath('/style.css'))
 		link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
 		link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
-	body
-		.content
-			.container
-				.row
-					.col-md-8.col-md-offset-2.text-center
-						.page-header
-							h2 Oh dear, something went wrong.
-						if buildImgPath
-							p
-								img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
-						p
-							| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
-						p
-							a(href="/")
-								i.fa.fa-arrow-circle-o-left
-								|  Take me home
+	body.full-height
+		.content.full-height
+			.container.full-height
+				.error-container.full-height
+					.error-figure.error-figure-500
+						img.error-img(
+							src="/img/brand/500-visual-socket.svg"
+							alt="Error"
+						)
+					.error-details
+						p.error-status Something went wrong, sorry.
+						p.error-description Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
+						a.error-btn(href="/") Home
+		//- .content
+		//- 	.container
+		//- 		.row
+		//- 			.col-md-8.col-md-offset-2.text-center
+		//- 				.page-header
+		//- 					h2 Oh dear, something went wrong.
+		//- 				if buildImgPath
+		//- 					p
+		//- 						img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
+		//- 				p
+		//- 					| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
+		//- 				p
+		//- 					a(href="/")
+		//- 						i.fa.fa-arrow-circle-o-left
+		//- 						|  Take me home

+ 5 - 1
services/web/app/views/layout.pug

@@ -18,7 +18,11 @@ html(itemscope, itemtype='http://schema.org/Product')
 		-else
 		-else
 			title= translate(title) + ' - ShareLaTeX, ' + translate("online_latex_editor") 
 			title= translate(title) + ' - ShareLaTeX, ' + translate("online_latex_editor") 
 
 
-		link(rel="icon", href="/favicon.ico")
+		link(rel="icon", href="favicon.ico")
+		link(rel="icon", sizes="192x192", href="touch-icon-192x192.png")
+		link(rel="apple-touch-icon-precomposed", href="favicon-152.png")
+		link(rel="mask-icon", href="mask-favicon.svg", color="#a93529")
+			
 		link(rel='stylesheet', href=buildCssPath('/style.css'))
 		link(rel='stylesheet', href=buildCssPath('/style.css'))
 
 
 		block _headLinks
 		block _headLinks

+ 12 - 7
services/web/app/views/project/editor.pug

@@ -7,13 +7,18 @@ block vars
 
 
 block content
 block content
 	.editor(ng-controller="IdeController").full-size
 	.editor(ng-controller="IdeController").full-size
-		.loading-screen(ng-show="state.loading")
-			.container
-				h3 #{translate("loading")}...
-				.progress
-					.progress-bar(style="width: 20%", ng-style="{'width': state.load_progress + '%'}")
-				p.text-center.text-danger(ng-if="state.error").ng-cloak 
-					span(ng-bind-html="state.error")
+		.loading-screen(ng-if="state.loading")
+			.loading-screen-lion-container
+				.loading-screen-lion(
+					style="height: 20%;"
+					ng-style="{ 'height': state.load_progress + '%' }"
+				)
+			h3.loading-screen-label(ng-if="!state.error") #{translate("loading")}
+				span.loading-screen-ellip .
+				span.loading-screen-ellip .
+				span.loading-screen-ellip .
+			p.loading-screen-error(ng-if="state.error").ng-cloak 
+				span(ng-bind-html="state.error")
 
 
 		include ./editor/feature-onboarding
 		include ./editor/feature-onboarding
 		
 		

+ 2 - 1
services/web/app/views/project/editor/editor.pug

@@ -19,7 +19,8 @@ div.full-size(
 			'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-layout-left': reviewPanel.layoutToLeft,\
-			'rp-loading-threads': reviewPanel.loadingThreads\
+			'rp-loading-threads': reviewPanel.loadingThreads,\
+			'rp-new-comment-ui': reviewPanel.newAddCommentUI\
 		}"
 		}"
 	)
 	)
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")

+ 4 - 4
services/web/app/views/project/editor/feature-onboarding.pug

@@ -32,7 +32,7 @@
 					autoplay
 					autoplay
 					loop
 					loop
 				)
 				)
-					source(src="/img/onboarding/review-panel/open-review.mp4", type="video/mp4")
+					source(ng-src="{{ '/img/onboarding/review-panel/open-review.mp4' }}", type="video/mp4")
 					img(src="/img/onboarding/review-panel/open-review.gif")
 					img(src="/img/onboarding/review-panel/open-review.gif")
 			div(ng-show="onboarding.innerStep === 2;")
 			div(ng-show="onboarding.innerStep === 2;")
 				video.feat-onboard-video(
 				video.feat-onboard-video(
@@ -40,7 +40,7 @@
 					autoplay
 					autoplay
 					loop
 					loop
 				)
 				)
-					source(src="/img/onboarding/review-panel/commenting.mp4", type="video/mp4")
+					source(ng-src="{{ '/img/onboarding/review-panel/commenting.mp4' }}", type="video/mp4")
 					img(src="/img/onboarding/review-panel/commenting.gif")
 					img(src="/img/onboarding/review-panel/commenting.gif")
 			div(ng-show="onboarding.innerStep === 3;")
 			div(ng-show="onboarding.innerStep === 3;")
 				video.feat-onboard-video(
 				video.feat-onboard-video(
@@ -48,7 +48,7 @@
 					autoplay
 					autoplay
 					loop
 					loop
 				)
 				)
-					source(src="/img/onboarding/review-panel/add-changes.mp4", type="video/mp4")
+					source(ng-src="{{ '/img/onboarding/review-panel/add-changes.mp4' }}", type="video/mp4")
 					img(src="/img/onboarding/review-panel/add-changes.gif")
 					img(src="/img/onboarding/review-panel/add-changes.gif")
 			div(ng-show="onboarding.innerStep === 4;")
 			div(ng-show="onboarding.innerStep === 4;")
 				video.feat-onboard-video(
 				video.feat-onboard-video(
@@ -56,7 +56,7 @@
 					autoplay
 					autoplay
 					loop
 					loop
 				)
 				)
-					source(src="/img/onboarding/review-panel/accept-changes.mp4", type="video/mp4")
+					source(ng-src="{{ '/img/onboarding/review-panel/accept-changes.mp4' }}", type="video/mp4")
 					img(src="/img/onboarding/review-panel/accept-changes.gif")
 					img(src="/img/onboarding/review-panel/accept-changes.gif")
 			button.btn.btn-primary.feat-onboard-nav-btn(
 			button.btn.btn-primary.feat-onboard-nav-btn(
 				ng-click="gotoNextStep();"
 				ng-click="gotoNextStep();"

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

@@ -30,6 +30,11 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 		span.name(
 		span.name(
 			ng-dblclick="!permissions.admin || startRenaming()",
 			ng-dblclick="!permissions.admin || startRenaming()",
 			ng-show="!state.renaming"
 			ng-show="!state.renaming"
+			tooltip="{{ project.name }}",
+			tooltip-class="project-name-tooltip"
+			tooltip-placement="bottom",
+			tooltip-append-to-body="true",
+			tooltip-enable="state.overflowed"
 		) {{ project.name }}
 		) {{ project.name }}
 
 
 		input.form-control(
 		input.form-control(
@@ -94,7 +99,6 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 			i.review-icon
 			i.review-icon
 			p.toolbar-label
 			p.toolbar-label
 				| #{translate("review")}
 				| #{translate("review")}
-				span(style="vertical-align: 20%; margin-left: 4px; padding: 2px 4px;").beta-feature-badge
 		a.btn.btn-full-height(
 		a.btn.btn-full-height(
 			href,
 			href,
 			ng-if="permissions.admin",
 			ng-if="permissions.admin",

+ 17 - 9
services/web/app/views/project/editor/review-panel.pug

@@ -1,10 +1,18 @@
 #review-panel
 #review-panel
-	a.rp-track-changes-indicator(
-		href
-		ng-if="editor.wantTrackChanges"
-		ng-click="toggleReviewPanel();"
-		ng-class="{ 'rp-track-changes-indicator-on-dark' : darkTheme }"
-	) !{translate("track_changes_is_on")}
+	.rp-in-editor-widgets
+		a.rp-track-changes-indicator(
+			href
+			ng-if="editor.wantTrackChanges"
+			ng-click="toggleReviewPanel();"
+			ng-class="{ 'rp-track-changes-indicator-on-dark' : darkTheme }"
+		) !{translate("track_changes_is_on")}
+		a.rp-add-comment-btn(
+			href
+			ng-if="reviewPanel.newAddCommentUI && reviewPanel.entries[editor.open_doc_id]['add-comment'] != null"
+			ng-click="addNewComment();"
+		) 
+			i.fa.fa-comment
+			|  #{translate("add_comment")}
 	.review-panel-toolbar
 	.review-panel-toolbar
 		resolved-comments-dropdown(
 		resolved-comments-dropdown(
 			class="rp-flex-block"
 			class="rp-flex-block"
@@ -314,7 +322,7 @@ script(type='text/ng-template', id='resolvedCommentEntryTemplate')
 script(type='text/ng-template', id='addCommentEntryTemplate')
 script(type='text/ng-template', id='addCommentEntryTemplate')
 	div
 	div
 		.rp-entry-callout.rp-entry-callout-add-comment
 		.rp-entry-callout.rp-entry-callout-add-comment
-		.rp-entry-indicator(
+		.rp-entry-indicator.rp-entry-indicator-add-comment(
 			ng-if="!commentState.adding"
 			ng-if="!commentState.adding"
 			ng-click="startNewComment(); onIndicatorClick();"
 			ng-click="startNewComment(); onIndicatorClick();"
 			tooltip=translate("add_comment")
 			tooltip=translate("add_comment")
@@ -340,10 +348,10 @@ script(type='text/ng-template', id='addCommentEntryTemplate')
 						ng-keypress="handleCommentKeyPress($event);"
 						ng-keypress="handleCommentKeyPress($event);"
 						placeholder=translate("add_your_comment_here")
 						placeholder=translate("add_your_comment_here")
 						focus-on="comment:new:open"
 						focus-on="comment:new:open"
-						ng-blur="submitNewComment()"
+						ng-blur="submitNewComment($event)"
 					)
 					)
 				.rp-entry-actions
 				.rp-entry-actions
-					button.rp-entry-button(
+					button.rp-entry-button.rp-entry-button-cancel(
 						ng-click="cancelNewComment();"
 						ng-click="cancelNewComment();"
 					) 
 					) 
 						i.fa.fa-times
 						i.fa.fa-times

+ 2 - 2
services/web/app/views/subscriptions/new.pug

@@ -4,11 +4,11 @@ block scripts
 	script(src="https://js.recurly.com/v3/recurly.js")
 	script(src="https://js.recurly.com/v3/recurly.js")
 
 
 	script(type='text/javascript').
 	script(type='text/javascript').
-		window.recomendedCurrency = '#{currency}'
 		window.countryCode = '#{countryCode}'
 		window.countryCode = '#{countryCode}'
 		window.plan_code = '#{plan_code}'
 		window.plan_code = '#{plan_code}'
 		window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
 		window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
-		window.couponCode = "#{couponCode}"
+		window.couponCode = !{JSON.stringify(couponCode)}
+		window.recomendedCurrency = !{JSON.stringify(currency.slice(0,3))}
 
 
 block content
 block content
 	.content.content-alt
 	.content.content-alt

+ 1 - 1
services/web/package.json

@@ -35,7 +35,7 @@
     "lynx": "0.1.1",
     "lynx": "0.1.1",
     "marked": "^0.3.5",
     "marked": "^0.3.5",
     "method-override": "^2.3.3",
     "method-override": "^2.3.3",
-    "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.6.0",
+    "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.7.1",
     "mimelib": "0.2.14",
     "mimelib": "0.2.14",
     "mocha": "1.17.1",
     "mocha": "1.17.1",
     "mongojs": "2.4.0",
     "mongojs": "2.4.0",

BIN
services/web/public/apple-touch-icon-precomposed.png


+ 8 - 2
services/web/public/coffee/ide/editor/Document.coffee

@@ -353,11 +353,17 @@ define [
 				@ranges.applyOp op, { user_id: track_changes_as }
 				@ranges.applyOp op, { user_id: track_changes_as }
 			if old_id_seed?
 			if old_id_seed?
 				@ranges.setIdSeed(old_id_seed)
 				@ranges.setIdSeed(old_id_seed)
+			if remote_op
+				# With remote ops, Ace hasn't been updated when we receive this op,
+				# so defer updating track changes until it has
+				setTimeout () => @emit "ranges:dirty"
+			else
+				@emit "ranges:dirty"
 		
 		
 		_catchUpRanges: (changes = [], comments = []) ->
 		_catchUpRanges: (changes = [], comments = []) ->
 			# We've just been given the current server's ranges, but need to apply any local ops we have.
 			# 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.
 			# Reset to the server state then apply our local ops again.
-			@ranges.emit "clear"
+			@emit "ranges:clear"
 			@ranges.changes = changes
 			@ranges.changes = changes
 			@ranges.comments = comments
 			@ranges.comments = comments
 			@ranges.track_changes = @doc.track_changes
 			@ranges.track_changes = @doc.track_changes
@@ -367,4 +373,4 @@ define [
 			for op in @doc.getPendingOp() or []
 			for op in @doc.getPendingOp() or []
 				@ranges.setIdSeed(@doc.track_changes_id_seeds.pending)
 				@ranges.setIdSeed(@doc.track_changes_id_seeds.pending)
 				@ranges.applyOp(op, { user_id: @track_changes_as })
 				@ranges.applyOp(op, { user_id: @track_changes_as })
-			@ranges.emit "redraw"
+			@emit "ranges:redraw"

+ 12 - 1
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -121,11 +121,22 @@ define [
 
 
 		_bindToDocumentEvents: (doc, sharejs_doc) ->
 		_bindToDocumentEvents: (doc, sharejs_doc) ->
 			sharejs_doc.on "error", (error, meta) =>
 			sharejs_doc.on "error", (error, meta) =>
-				if error?.message?.match "maxDocLength"
+				if error?.message?
+					message = error.message
+				else if typeof error == "string"
+					message = error
+				else
+					message = ""
+				if message.match "maxDocLength"
 					@ide.showGenericMessageModal(
 					@ide.showGenericMessageModal(
 						"Document Too Long"
 						"Document Too Long"
 						"Sorry, this file is too long to be edited manually. Please upload it directly."
 						"Sorry, this file is too long to be edited manually. Please upload it directly."
 					)
 					)
+				else if message.match "too many comments or tracked changes"
+					@ide.showGenericMessageModal(
+						"Too many comments or tracked changes"
+						"Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments."
+					)
 				else
 				else
 					@ide.socket.disconnect()
 					@ide.socket.disconnect()
 					@ide.reportError(error, meta)
 					@ide.reportError(error, meta)

+ 0 - 4
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -322,10 +322,6 @@ define [
 					doc = session.getDocument()
 					doc = session.getDocument()
 					doc.on "change", onChange
 					doc.on "change", onChange
 
 
-					sharejs_doc.on "remoteop.recordRemote", (op, oldSnapshot, msg) ->
-						undoManager.nextUpdateIsRemote = true
-						trackChangesManager.nextUpdateMetaData = msg?.meta
-
 					editor.initing = true
 					editor.initing = true
 					sharejs_doc.attachToAce(editor)
 					sharejs_doc.attachToAce(editor)
 					editor.initing = false
 					editor.initing = false

+ 131 - 78
services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee

@@ -14,11 +14,11 @@ define [
 				return if !track_changes?
 				return if !track_changes?
 				@setTrackChanges(track_changes)
 				@setTrackChanges(track_changes)
 			
 			
-			@$scope.$watch "sharejsDoc", (doc) =>
+			@$scope.$watch "sharejsDoc", (doc, oldDoc) =>
 				return if !doc?
 				return if !doc?
-				@disconnectFromRangesTracker()
-				@rangesTracker = doc.ranges
-				@connectToRangesTracker()
+				if oldDoc?
+					@disconnectFromDoc(oldDoc)
+				@connectToDoc(doc)
 			
 			
 			@$scope.$on "comment:add", (e, thread_id, offset, length) =>
 			@$scope.$on "comment:add", (e, thread_id, offset, length) =>
 				@addCommentToSelection(thread_id, offset, length)
 				@addCommentToSelection(thread_id, offset, length)
@@ -36,10 +36,10 @@ define [
 				@removeCommentId(comment_id)
 				@removeCommentId(comment_id)
 			
 			
 			@$scope.$on "comment:resolve_threads", (e, thread_ids) =>
 			@$scope.$on "comment:resolve_threads", (e, thread_ids) =>
-				@resolveCommentByThreadIds(thread_ids)
+				@hideCommentsByThreadIds(thread_ids)
 			
 			
 			@$scope.$on "comment:unresolve_thread", (e, thread_id) =>
 			@$scope.$on "comment:unresolve_thread", (e, thread_id) =>
-				@unresolveCommentByThreadId(thread_id)
+				@showCommentByThreadId(thread_id)
 			
 			
 			@$scope.$on "review-panel:recalculate-screen-positions", () =>
 			@$scope.$on "review-panel:recalculate-screen-positions", () =>
 				@recalculateReviewEntriesScreenPositions()
 				@recalculateReviewEntriesScreenPositions()
@@ -73,16 +73,24 @@ define [
 						_scrollTimeout = null
 						_scrollTimeout = null
 					, 200
 					, 200
 
 
+			@_resetCutState()
+			onCut = () => @onCut()
+			onPaste = () => @onPaste()
+
 			bindToAce = () =>
 			bindToAce = () =>
 				@editor.on "changeSelection", onChangeSelection
 				@editor.on "changeSelection", onChangeSelection
 				@editor.on "change", onChangeSelection # Selection also moves with updates elsewhere in the document
 				@editor.on "change", onChangeSelection # Selection also moves with updates elsewhere in the document
 				@editor.on "changeSession", onChangeSession
 				@editor.on "changeSession", onChangeSession
+				@editor.on "cut", onCut
+				@editor.on "paste", onPaste
 				@editor.renderer.on "resize", onResize
 				@editor.renderer.on "resize", onResize
 
 
 			unbindFromAce = () =>
 			unbindFromAce = () =>
 				@editor.off "changeSelection", onChangeSelection
 				@editor.off "changeSelection", onChangeSelection
 				@editor.off "change", onChangeSelection
 				@editor.off "change", onChangeSelection
 				@editor.off "changeSession", onChangeSession
 				@editor.off "changeSession", onChangeSession
+				@editor.off "cut", onCut
+				@editor.off "paste", onPaste
 				@editor.renderer.off "resize", onResize
 				@editor.renderer.off "resize", onResize
 
 
 			@$scope.$watch "trackChangesEnabled", (enabled) =>
 			@$scope.$watch "trackChangesEnabled", (enabled) =>
@@ -92,18 +100,11 @@ define [
 				else
 				else
 					unbindFromAce()
 					unbindFromAce()
 		
 		
-		disconnectFromRangesTracker: () ->
+		disconnectFromDoc: (doc) ->
 			@changeIdToMarkerIdMap = {}
 			@changeIdToMarkerIdMap = {}
-
-			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"
+			doc.off "ranges:clear"
+			doc.off "ranges:redraw"
+			doc.off "ranges:dirty"
 
 
 		setTrackChanges: (value) ->
 		setTrackChanges: (value) ->
 			if value
 			if value
@@ -111,56 +112,15 @@ define [
 			else
 			else
 				@$scope.sharejsDoc?.track_changes_as = null
 				@$scope.sharejsDoc?.track_changes_as = null
 		
 		
-		connectToRangesTracker: () ->
+		connectToDoc: (doc) ->
+			@rangesTracker = doc.ranges
 			@setTrackChanges(@$scope.trackChanges)
 			@setTrackChanges(@$scope.trackChanges)
 			
 			
-			# 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
-				setTimeout () =>
-					@_onInsertAdded(change)
-					@broadcastChange()
-			@rangesTracker.on "insert:removed", (change) =>
-				sl_console.log "[insert:removed]", change
-				setTimeout () =>
-					@_onInsertRemoved(change)
-					@broadcastChange()
-			@rangesTracker.on "delete:added", (change) =>
-				sl_console.log "[delete:added]", change
-				setTimeout () =>
-					@_onDeleteAdded(change)
-					@broadcastChange()
-			@rangesTracker.on "delete:removed", (change) =>
-				sl_console.log "[delete:removed]", change
-				setTimeout () =>
-					@_onDeleteRemoved(change)
-					@broadcastChange()
-			@rangesTracker.on "changes:moved", (changes) =>
-				sl_console.log "[changes:moved]", changes
-				setTimeout () =>
-					@_onChangesMoved(changes)
-					@broadcastChange()
-
-			@rangesTracker.on "comment:added", (comment) =>
-				sl_console.log "[comment:added]", comment
-				setTimeout () =>
-					@_onCommentAdded(comment)
-					@broadcastChange()
-			@rangesTracker.on "comment:moved", (comment) =>
-				sl_console.log "[comment:moved]", comment
-				setTimeout () =>
-					@_onCommentMoved(comment)
-					@broadcastChange()
-			@rangesTracker.on "comment:removed", (comment) =>
-				sl_console.log "[comment:removed]", comment
-				setTimeout () =>
-					@_onCommentRemoved(comment)
-					@broadcastChange()
-			
-			@rangesTracker.on "clear", () =>
+			doc.on "ranges:dirty", () =>
+				@updateAnnotations()
+			doc.on "ranges:clear", () =>
 				@clearAnnotations()
 				@clearAnnotations()
-			@rangesTracker.on "redraw", () =>
+			doc.on "ranges:redraw", () =>
 				@redrawAnnotations()
 				@redrawAnnotations()
 		
 		
 		clearAnnotations: () ->
 		clearAnnotations: () ->
@@ -181,6 +141,55 @@ define [
 				@_onCommentAdded(comment)
 				@_onCommentAdded(comment)
 			
 			
 			@broadcastChange()
 			@broadcastChange()
+		
+		_doneUpdateThisLoop: false
+		_pendingUpdates: false
+		updateAnnotations: () ->
+			# Doc updates with multiple ops, like search/replace or block comments
+			# will call this with every individual op in a single event loop. So only
+			# do the first this loop, then schedule an update for the next loop for the rest.
+			if !@_doneUpdateThisLoop
+				@_doUpdateAnnotations()
+				@_doneUpdateThisLoop = true
+				setTimeout () =>
+					if @_pendingUpdates
+						@_doUpdateAnnotations()
+					@_doneUpdateThisLoop = false
+					@_pendingUpdates = false
+			else
+				@_pendingUpdates = true
+
+		_doUpdateAnnotations: () ->
+			dirty = @rangesTracker.getDirtyState()
+			
+			updateMarkers = false
+			
+			for id, change of dirty.change.added
+				if change.op.i?
+					@_onInsertAdded(change)
+				else if change.op.d?
+					@_onDeleteAdded(change)
+			for id, change of dirty.change.removed
+				if change.op.i?
+					@_onInsertRemoved(change)
+				else if change.op.d?
+					@_onDeleteRemoved(change)
+			for id, change of dirty.change.moved
+				updateMarkers = true
+				@_onChangeMoved(change)
+				
+			for id, comment of dirty.comment.added
+				@_onCommentAdded(comment)
+			for id, comment of dirty.comment.removed
+				@_onCommentRemoved(comment)
+			for id, comment of dirty.comment.moved
+				updateMarkers = true
+				@_onCommentMoved(comment)
+			
+			@rangesTracker.resetDirtyState()
+			if updateMarkers
+				@editor.renderer.updateBackMarkers()
+			@broadcastChange()
 
 
 		addComment: (offset, content, thread_id) ->
 		addComment: (offset, content, thread_id) ->
 			op = { c: content, p: offset, t: thread_id }
 			op = { c: content, p: offset, t: thread_id }
@@ -200,6 +209,7 @@ define [
 		
 		
 		acceptChangeId: (change_id) ->
 		acceptChangeId: (change_id) ->
 			@rangesTracker.removeChangeId(change_id)
 			@rangesTracker.removeChangeId(change_id)
+			@updateAnnotations()
 		
 		
 		rejectChangeId: (change_id) ->
 		rejectChangeId: (change_id) ->
 			change = @rangesTracker.getChange(change_id)
 			change = @rangesTracker.getChange(change_id)
@@ -208,21 +218,26 @@ define [
 			if change.op.d?
 			if change.op.d?
 				content = change.op.d
 				content = change.op.d
 				position = @_shareJsOffsetToAcePosition(change.op.p)
 				position = @_shareJsOffsetToAcePosition(change.op.p)
+				session.$fromReject = true # Tell track changes to cancel out delete
 				session.insert(position, content)
 				session.insert(position, content)
+				session.$fromReject = false
 			else if change.op.i?
 			else if change.op.i?
 				start = @_shareJsOffsetToAcePosition(change.op.p)
 				start = @_shareJsOffsetToAcePosition(change.op.p)
 				end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
 				end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
 				editor_text = session.getDocument().getTextRange({start, end})
 				editor_text = session.getDocument().getTextRange({start, end})
 				if editor_text != change.op.i
 				if editor_text != change.op.i
 					throw new Error("Op to be removed (#{JSON.stringify(change.op)}), does not match editor text, '#{editor_text}'")
 					throw new Error("Op to be removed (#{JSON.stringify(change.op)}), does not match editor text, '#{editor_text}'")
+				session.$fromReject = true
 				session.remove({start, end})
 				session.remove({start, end})
+				session.$fromReject = false
 			else
 			else
 				throw new Error("unknown change: #{JSON.stringify(change)}")
 				throw new Error("unknown change: #{JSON.stringify(change)}")
 
 
 		removeCommentId: (comment_id) ->
 		removeCommentId: (comment_id) ->
 			@rangesTracker.removeCommentId(comment_id)
 			@rangesTracker.removeCommentId(comment_id)
+			@updateAnnotations()
 
 
-		resolveCommentByThreadIds: (thread_ids) ->
+		hideCommentsByThreadIds: (thread_ids) ->
 			resolve_ids = {}
 			resolve_ids = {}
 			for id in thread_ids
 			for id in thread_ids
 				resolve_ids[id] = true
 				resolve_ids[id] = true
@@ -231,12 +246,55 @@ define [
 					@_onCommentRemoved(comment)
 					@_onCommentRemoved(comment)
 			@broadcastChange()
 			@broadcastChange()
 			
 			
-		unresolveCommentByThreadId: (thread_id) ->
+		showCommentByThreadId: (thread_id) ->
 			for comment in @rangesTracker?.comments or []
 			for comment in @rangesTracker?.comments or []
 				if comment.op.t == thread_id
 				if comment.op.t == thread_id
 					@_onCommentAdded(comment)
 					@_onCommentAdded(comment)
 			@broadcastChange()
 			@broadcastChange()
 
 
+		_resetCutState: () ->
+			@_cutState = {
+				text: null
+				comments: []
+				docId: null
+			}
+
+		onCut: () ->
+			@_resetCutState()
+			selection = @editor.getSelectionRange()
+			selection_start = @_aceRangeToShareJs(selection.start)
+			selection_end = @_aceRangeToShareJs(selection.end)
+			@_cutState.text = @editor.getSelectedText()
+			@_cutState.docId = @$scope.docId
+			for comment in @rangesTracker.comments
+				comment_start = comment.op.p
+				comment_end = comment_start + comment.op.c.length
+				if selection_start <= comment_start and comment_end <= selection_end
+					@_cutState.comments.push {
+						offset: comment.op.p - selection_start
+						text: comment.op.c
+						comment: comment
+					}
+
+		onPaste: () =>
+			@editor.once "change", (change) =>
+				return if change.action != "insert"
+				pasted_text = change.lines.join("\n")
+				paste_offset = @_aceRangeToShareJs(change.start)
+				# We have to wait until the change has been processed by the range tracker, 
+				# since if we move the ops into place beforehand, they will be moved again
+				# when the changes are processed by the range tracker. This ranges:dirty
+				# event is fired after the doc has applied the changes to the range tracker.
+				@$scope.sharejsDoc.on "ranges:dirty.paste", () =>
+					@$scope.sharejsDoc.off "ranges:dirty.paste" # Doc event emitter uses namespaced events
+					if pasted_text == @_cutState.text and @$scope.docId == @_cutState.docId
+						for {comment, offset, text} in @_cutState.comments
+							op = { c: text, p: paste_offset + offset, t: comment.id }
+							@$scope.sharejsDoc.submitOp op # Resubmitting an existing comment op (by thread id) will move it
+					@_resetCutState()
+					# Check that comments still match text. Will throw error if not.
+					@rangesTracker.validate(@editor.getValue())
+
 		checkMapping: () ->
 		checkMapping: () ->
 			# TODO: reintroduce this check
 			# TODO: reintroduce this check
 			session = @editor.getSession()
 			session = @editor.getSession()
@@ -421,23 +479,18 @@ define [
 			lines = @editor.getSession().getDocument().getAllLines()
 			lines = @editor.getSession().getDocument().getAllLines()
 			return AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
 			return AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
 		
 		
-		_onChangesMoved: (changes) ->
-			# TODO: PERFORMANCE: Only run through the Ace lines once, and calculate all
-			# change positions as we go.
-			for change in changes
-				start = @_shareJsOffsetToAcePosition(change.op.p)
-				if change.op.i?
-					end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
-				else
-					end = start
-				@_updateMarker(change.id, start, end)
-			@editor.renderer.updateBackMarkers()
+		_onChangeMoved: (change) ->
+			start = @_shareJsOffsetToAcePosition(change.op.p)
+			if change.op.i?
+				end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
+			else
+				end = start
+			@_updateMarker(change.id, start, end)
 		
 		
 		_onCommentMoved: (comment) ->
 		_onCommentMoved: (comment) ->
 			start = @_shareJsOffsetToAcePosition(comment.op.p)
 			start = @_shareJsOffsetToAcePosition(comment.op.p)
 			end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
 			end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
 			@_updateMarker(comment.id, start, end)
 			@_updateMarker(comment.id, start, end)
-			@editor.renderer.updateBackMarkers()
 	
 	
 		_updateMarker: (change_id, start, end) ->
 		_updateMarker: (change_id, start, end) ->
 			return if !@changeIdToMarkerIdMap[change_id]?
 			return if !@changeIdToMarkerIdMap[change_id]?

+ 44 - 23
services/web/public/coffee/ide/editor/directives/aceEditor/undo/UndoManager.coffee

@@ -11,10 +11,10 @@ define [
 				show_remote_warning: false
 				show_remote_warning: false
 				
 				
 			@reset()
 			@reset()
-			@nextUpdateIsRemote = false
 
 
 			@editor.on "changeSession", (e) =>
 			@editor.on "changeSession", (e) =>
 				@reset()
 				@reset()
+				@session = e.session
 				e.session.setUndoManager(@)
 				e.session.setUndoManager(@)
 
 
 		showUndoConflictWarning: () ->
 		showUndoConflictWarning: () ->
@@ -38,20 +38,44 @@ define [
 				@firstUpdate = false
 				@firstUpdate = false
 				return
 				return
 			aceDeltaSets = options.args[0]
 			aceDeltaSets = options.args[0]
-			@session = options.args[1]
 			return if !aceDeltaSets?
 			return if !aceDeltaSets?
+			@session = options.args[1]
 
 
-			lines = @session.getDocument().getAllLines()
-			linesBeforeChange = @_revertAceDeltaSetsOnDocLines(aceDeltaSets, lines)
-			simpleDeltaSets = @_aceDeltaSetsToSimpleDeltaSets(aceDeltaSets, linesBeforeChange)
-			@undoStack.push(
-				deltaSets: simpleDeltaSets
-				remote: @nextUpdateIsRemote
-			)
+			# We need to split the delta sets into local or remote groups before pushing onto
+			# the undo stack, since these are treated differently.
+			splitDeltaSets = []
+			currentDeltaSet = null # Make global to this function
+			do newDeltaSet = () ->
+				currentDeltaSet = {group: "doc", deltas: []}
+				splitDeltaSets.push currentDeltaSet
+			currentRemoteState = null
+
+			for deltaSet in aceDeltaSets or []
+				if deltaSet.group == "doc" # ignore code folding etc.
+					for delta in deltaSet.deltas
+						if currentDeltaSet.remote? and currentDeltaSet.remote != !!delta.remote
+							newDeltaSet()
+						currentDeltaSet.deltas.push delta
+						currentDeltaSet.remote = !!delta.remote
+
+			# The lines are currently as they are after applying all these deltas, but to turn into simple deltas,
+			# we need the lines before each delta group.
+			docLines = @session.getDocument().getAllLines()
+			docLines = @_revertAceDeltaSetsOnDocLines(aceDeltaSets, docLines)
+			for deltaSet in splitDeltaSets
+				{simpleDeltaSet, docLines}  = @_aceDeltaSetToSimpleDeltaSet(deltaSet, docLines)
+				frame = {
+					deltaSets: [simpleDeltaSet]
+					remote: deltaSet.remote
+				}
+				@undoStack.push frame
 			@redoStack = []
 			@redoStack = []
-			@nextUpdateIsRemote = false
 
 
 		undo: (dontSelect) ->
 		undo: (dontSelect) ->
+			# We rely on the doclines being in sync with the undo stack, so make sure
+			# any pending undo deltas are processed.
+			@session.$syncInformUndoManager()
+
 			localUpdatesMade = @_shiftLocalChangeToTopOfUndoStack()
 			localUpdatesMade = @_shiftLocalChangeToTopOfUndoStack()
 			return if !localUpdatesMade
 			return if !localUpdatesMade
 
 
@@ -206,19 +230,16 @@ define [
 						throw "Unknown delta type"
 						throw "Unknown delta type"
 			return doc.split("\n")
 			return doc.split("\n")
 
 
-		_aceDeltaSetsToSimpleDeltaSets: (aceDeltaSets, docLines) ->
-			simpleDeltaSets = []
-			for deltaSet in aceDeltaSets
-				if deltaSet.group == "doc" # ignore fold changes
-					simpleDeltas = []
-					for delta in deltaSet.deltas
-						simpleDeltas.push @_aceDeltaToSimpleDelta(delta, docLines)
-						docLines = @_applyAceDeltasToDocLines([delta], docLines)
-					simpleDeltaSets.push {
-						deltas: simpleDeltas
-						group: deltaSet.group
-					}
-			return simpleDeltaSets
+		_aceDeltaSetToSimpleDeltaSet: (deltaSet, docLines) ->
+			simpleDeltas = []
+			for delta in deltaSet.deltas
+				simpleDeltas.push @_aceDeltaToSimpleDelta(delta, docLines)
+				docLines = @_applyAceDeltasToDocLines([delta], docLines)
+			simpleDeltaSet = {
+				deltas: simpleDeltas
+				group: deltaSet.group
+			}
+			return {simpleDeltaSet, docLines} 
 
 
 		_simpleDeltaSetsToAceDeltaSets: (simpleDeltaSets, docLines) ->
 		_simpleDeltaSetsToAceDeltaSets: (simpleDeltaSets, docLines) ->
 			for deltaSet in simpleDeltaSets
 			for deltaSet in simpleDeltaSets

+ 39 - 7
services/web/public/coffee/ide/editor/sharejs/vendor/client/ace.coffee

@@ -3,7 +3,7 @@
 Range = ace.require("ace/range").Range
 Range = ace.require("ace/range").Range
 
 
 # Convert an ace delta into an op understood by share.js
 # Convert an ace delta into an op understood by share.js
-applyToShareJS = (editorDoc, delta, doc) ->
+applyToShareJS = (editorDoc, delta, doc, fromUndo) ->
   # Get the start position of the range, in no. of characters
   # Get the start position of the range, in no. of characters
   getStartOffsetPosition = (start) ->
   getStartOffsetPosition = (start) ->
     # This is quite inefficient - getLines makes a copy of the entire
     # This is quite inefficient - getLines makes a copy of the entire
@@ -27,11 +27,11 @@ applyToShareJS = (editorDoc, delta, doc) ->
   switch delta.action
   switch delta.action
     when 'insert'
     when 'insert'
       text = delta.lines.join('\n')
       text = delta.lines.join('\n')
-      doc.insert pos, text
+      doc.insert pos, text, fromUndo
       
       
     when 'remove'
     when 'remove'
       text = delta.lines.join('\n')
       text = delta.lines.join('\n')
-      doc.del pos, text.length
+      doc.del pos, text.length, fromUndo
 
 
     else throw new Error "unknown action: #{delta.action}"
     else throw new Error "unknown action: #{delta.action}"
   
   
@@ -78,8 +78,10 @@ window.sharejs.extendDoc 'attach_ace', (editor, keepEditorContents, maxDocLength
     if maxDocLength? and editorDoc.getValue().length > maxDocLength
     if maxDocLength? and editorDoc.getValue().length > maxDocLength
         doc.emit "error", new Error("document length is greater than maxDocLength")
         doc.emit "error", new Error("document length is greater than maxDocLength")
         return
         return
+
+    fromUndo = !!(editor.getSession().$fromUndo or editor.getSession().$fromReject)
     
     
-    applyToShareJS editorDoc, change, doc
+    applyToShareJS editorDoc, change, doc, fromUndo
 
 
     check()
     check()
 
 
@@ -108,16 +110,46 @@ window.sharejs.extendDoc 'attach_ace', (editor, keepEditorContents, maxDocLength
 
 
     row:row, column:offset
     row:row, column:offset
 
 
+  # We want to insert a remote:true into the delta if the op comes from the
+  # underlying sharejs doc (which means it is from a remote op), so we have to do
+  # the work of editorDoc.insert and editorDoc.remove manually. These methods are
+  # copied from ace.js doc#insert and #remove, and then inject the remote:true
+  # flag into the delta.
   doc.on 'insert', (pos, text) ->
   doc.on 'insert', (pos, text) ->
+    if (editorDoc.getLength() <= 1)
+        editorDoc.$detectNewLine(text)
+
+    lines = editorDoc.$split(text)
+    position = offsetToPos(pos)
+    start = editorDoc.clippedPos(position.row, position.column)
+    end = {
+        row: start.row + lines.length - 1,
+        column: (if lines.length == 1 then start.column else 0) + lines[lines.length - 1].length
+    }
+
     suppress = true
     suppress = true
-    editorDoc.insert offsetToPos(pos), text
+    editorDoc.applyDelta({
+        start: start,
+        end: end,
+        action: "insert",
+        lines: lines,
+        remote: true
+    });
     suppress = false
     suppress = false
     check()
     check()
 
 
   doc.on 'delete', (pos, text) ->
   doc.on 'delete', (pos, text) ->
-    suppress = true
     range = Range.fromPoints offsetToPos(pos), offsetToPos(pos + text.length)
     range = Range.fromPoints offsetToPos(pos), offsetToPos(pos + text.length)
-    editorDoc.remove range
+    start = editorDoc.clippedPos(range.start.row, range.start.column)
+    end = editorDoc.clippedPos(range.end.row, range.end.column)
+    suppress = true
+    editorDoc.applyDelta({
+        start: start,
+        end: end,
+        action: "remove",
+        lines: editorDoc.getLinesForRange({start: start, end: end})
+        remote: true
+    });
     suppress = false
     suppress = false
     check()
     check()
 
 

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

@@ -11,14 +11,20 @@ text.api =
   # Get the text contents of a document
   # Get the text contents of a document
   getText: -> @snapshot
   getText: -> @snapshot
 
 
-  insert: (pos, text, callback) ->
-    op = [{p:pos, i:text}]
+  insert: (pos, text, fromUndo, callback) ->
+    op = {p:pos, i:text}
+    if fromUndo
+      op.u = true
+    op = [op]
     
     
     @submitOp op, callback
     @submitOp op, callback
     op
     op
   
   
-  del: (pos, length, callback) ->
-    op = [{p:pos, d:@snapshot[pos...(pos + length)]}]
+  del: (pos, length, fromUndo, callback) ->
+    op = {p:pos, d:@snapshot[pos...(pos + length)]}
+    if fromUndo
+      op.u = true
+    op = [op]
 
 
     @submitOp op, callback
     @submitOp op, callback
     op
     op

+ 20 - 13
services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee

@@ -56,6 +56,13 @@ text.apply = (snapshot, op) ->
       throw new Error "Unknown op type"
       throw new Error "Unknown op type"
   snapshot
   snapshot
 
 
+cloneAndModify = (op, modifications) ->
+  newOp = {}
+  for k,v of op
+    newOp[k] = v
+  for k,v of modifications
+    newOp[k] = v
+  return newOp
 
 
 # Exported for use by the random op generator.
 # Exported for use by the random op generator.
 #
 #
@@ -69,10 +76,10 @@ text._append = append = (newOp, c) ->
     last = newOp[newOp.length - 1]
     last = newOp[newOp.length - 1]
 
 
     # Compose the insert into the previous insert if possible
     # Compose the insert into the previous insert if possible
-    if last.i? && c.i? and last.p <= c.p <= (last.p + last.i.length)
-      newOp[newOp.length - 1] = {i:strInject(last.i, c.p - last.p, c.i), p:last.p}
-    else if last.d? && c.d? and c.p <= last.p <= (c.p + c.d.length)
-      newOp[newOp.length - 1] = {d:strInject(c.d, last.p - c.p, last.d), p:c.p}
+    if last.i? && c.i? and last.p <= c.p <= (last.p + last.i.length) and last.u == c.u
+      newOp[newOp.length - 1] = cloneAndModify(last, {i:strInject(last.i, c.p - last.p, c.i)})
+    else if last.d? && c.d? and c.p <= last.p <= (c.p + c.d.length) and last.u == c.u
+      newOp[newOp.length - 1] = cloneAndModify(last, {d:strInject(c.d, last.p - c.p, last.d), p: c.p})
     else
     else
       newOp.push c
       newOp.push c
 
 
@@ -150,25 +157,25 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
   checkValidOp [otherC]
   checkValidOp [otherC]
 
 
   if c.i?
   if c.i?
-    append dest, {i:c.i, p:transformPosition(c.p, otherC, side == 'right')}
+    append dest, cloneAndModify(c, {p:transformPosition(c.p, otherC, side == 'right')})
 
 
   else if c.d? # 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
-        append dest, {d:s[...otherC.p - c.p], p:c.p}
+        append dest, cloneAndModify(c, {d:s[...otherC.p - c.p]})
         s = s[(otherC.p - c.p)..]
         s = s[(otherC.p - c.p)..]
       if s != ''
       if s != ''
-        append dest, {d:s, p:c.p + otherC.i.length}
+        append dest, cloneAndModify(c, {d:s, p:c.p + otherC.i.length})
 
 
     else if otherC.d? # 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, cloneAndModify(c, {p:c.p - otherC.d.length})
       else if c.p + c.d.length <= otherC.p
       else if c.p + c.d.length <= otherC.p
         append dest, c
         append dest, c
       else
       else
         # They overlap somewhere.
         # They overlap somewhere.
-        newC = {d:'', p:c.p}
+        newC = cloneAndModify(c, {d:''})
         if c.p < otherC.p
         if c.p < otherC.p
           newC.d = c.d[...(otherC.p - c.p)]
           newC.d = c.d[...(otherC.p - c.p)]
         if c.p + c.d.length > otherC.p + otherC.d.length
         if c.p + c.d.length > otherC.p + otherC.d.length
@@ -198,18 +205,18 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
       if c.p < otherC.p < c.p + c.c.length
       if c.p < otherC.p < c.p + c.c.length
         offset = otherC.p - c.p
         offset = otherC.p - c.p
         new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...])
         new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...])
-        append dest, {c:new_c, p:c.p, t: c.t}
+        append dest, cloneAndModify(c, {c:new_c})
       else
       else
-        append dest, {c:c.c, p:transformPosition(c.p, otherC, true), t: c.t}
+        append dest, cloneAndModify(c, {p:transformPosition(c.p, otherC, true)})
     
     
     else if otherC.d?
     else if otherC.d?
       if c.p >= otherC.p + otherC.d.length
       if c.p >= otherC.p + otherC.d.length
-        append dest, {c:c.c, p:c.p - otherC.d.length, t: c.t}
+        append dest, cloneAndModify(c, {p:c.p - otherC.d.length})
       else if c.p + c.c.length <= otherC.p
       else if c.p + c.c.length <= otherC.p
         append dest, c
         append dest, c
       else # Delete overlaps comment
       else # Delete overlaps comment
         # They overlap somewhere.
         # They overlap somewhere.
-        newC = {c:'', p:c.p, t: c.t}
+        newC = cloneAndModify(c, {c:''})
         if c.p < otherC.p
         if c.p < otherC.p
           newC.c = c.c[...(otherC.p - c.p)]
           newC.c = c.c[...(otherC.p - c.p)]
         if c.p + c.c.length > otherC.p + otherC.d.length
         if c.p + c.c.length > otherC.p + otherC.d.length

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

@@ -173,6 +173,8 @@ define [
 			@_findEntityByPathInFolder @$scope.rootFolder, path
 			@_findEntityByPathInFolder @$scope.rootFolder, path
 
 
 		_findEntityByPathInFolder: (folder, path) ->
 		_findEntityByPathInFolder: (folder, path) ->
+			if !path? or !folder?
+				return null
 			parts = path.split("/")
 			parts = path.split("/")
 			name = parts.shift()
 			name = parts.shift()
 			rest = parts.join("/")
 			rest = parts.join("/")

+ 1 - 1
services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee

@@ -93,11 +93,11 @@ define [
 				if !name? or name.length == 0
 				if !name? or name.length == 0
 					return
 					return
 				$scope.state.inflight = true
 				$scope.state.inflight = true
-				$scope.state.inflight = true
 				ide.fileTreeManager
 				ide.fileTreeManager
 					.createFolder(name, parent_folder)
 					.createFolder(name, parent_folder)
 					.error (e)->
 					.error (e)->
 						$scope.error = e
 						$scope.error = e
+						$scope.state.inflight = false
 					.success () ->
 					.success () ->
 						$scope.state.inflight = false
 						$scope.state.inflight = false
 						$modalInstance.close()
 						$modalInstance.close()

+ 85 - 39
services/web/public/coffee/ide/review-panel/RangesTracker.coffee

@@ -1,5 +1,8 @@
-load = (EventEmitter) ->
-	class RangesTracker extends EventEmitter
+# This file is shared between document-updater and web, so that the server and client share
+# an identical track changes implementation. Do not edit it directly in web or document-updater,
+# instead edit it at https://github.com/sharelatex/ranges-tracker, where it has a suite of tests
+load = () ->
+	class RangesTracker
 		# 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,6 +39,7 @@ load = (EventEmitter) ->
 		#     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: (@changes = [], @comments = []) ->
 		constructor: (@changes = [], @comments = []) ->
 			@setIdSeed(RangesTracker.generateIdSeed())
 			@setIdSeed(RangesTracker.generateIdSeed())
+			@resetDirtyState()
 
 
 		getIdSeed: () ->
 		getIdSeed: () ->
 			return @id_seed
 			return @id_seed
@@ -75,8 +79,15 @@ load = (EventEmitter) ->
 			comment = @getComment(comment_id)
 			comment = @getComment(comment_id)
 			return if !comment?
 			return if !comment?
 			@comments = @comments.filter (c) -> c.id != comment_id
 			@comments = @comments.filter (c) -> c.id != comment_id
-			@emit "comment:removed", comment
+			@_markAsDirty comment, "comment", "removed"
 		
 		
+		moveCommentId: (comment_id, position, text) ->
+			for comment in @comments
+				if comment.id == comment_id
+					comment.op.p = position
+					comment.op.c = text
+					@_markAsDirty comment, "comment", "moved"
+
 		getChange: (change_id) ->
 		getChange: (change_id) ->
 			change = null
 			change = null
 			for c in @changes
 			for c in @changes
@@ -89,6 +100,18 @@ load = (EventEmitter) ->
 			change = @getChange(change_id)
 			change = @getChange(change_id)
 			return if !change?
 			return if !change?
 			@_removeChange(change)
 			@_removeChange(change)
+		
+		validate: (text) ->
+			for change in @changes
+				if change.op.i?
+					content = text.slice(change.op.p, change.op.p + change.op.i.length)
+					if content != change.op.i
+						throw new Error("Change (#{JSON.stringify(change)}) doesn't match text (#{JSON.stringify(content)})")
+			for comment in @comments
+				content = text.slice(comment.op.p, comment.op.p + comment.op.c.length)
+				if content != comment.op.c
+					throw new Error("Comment (#{JSON.stringify(comment)}) doesn't match text (#{JSON.stringify(content)})")
+			return true
 
 
 		applyOp: (op, metadata = {}) ->
 		applyOp: (op, metadata = {}) ->
 			metadata.ts ?= new Date()
 			metadata.ts ?= new Date()
@@ -103,29 +126,37 @@ load = (EventEmitter) ->
 				@addComment(op, metadata)
 				@addComment(op, metadata)
 			else
 			else
 				throw new Error("unknown op type")
 				throw new Error("unknown op type")
-			
+
+		applyOps: (ops, metadata = {}) ->
+			for op in ops
+				@applyOp(op, metadata)
+
 		addComment: (op, metadata) ->
 		addComment: (op, metadata) ->
-			# TODO: Don't allow overlapping comments?
-			@comments.push comment = {
-				id: op.t or @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
+			existing = @getComment(op.t)
+			if existing?
+				@moveCommentId(op.t, op.p, op.c)
+				return existing
+			else
+				@comments.push comment = {
+					id: op.t or @newId()
+					op: # Copy because we'll modify in place
+						c: op.c
+						p: op.p
+						t: op.t
+					metadata
+				}
+				@_markAsDirty comment, "comment", "added"
+				return comment
 		
 		
 		applyInsertToComments: (op) ->
 		applyInsertToComments: (op) ->
 			for comment in @comments
 			for comment in @comments
 				if op.p <= comment.op.p
 				if op.p <= comment.op.p
 					comment.op.p += op.i.length
 					comment.op.p += op.i.length
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 				else if op.p < comment.op.p + comment.op.c.length
 				else if op.p < comment.op.p + comment.op.c.length
 					offset = op.p - comment.op.p
 					offset = op.p - comment.op.p
 					comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...]
 					comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...]
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 
 
 		applyDeleteToComments: (op) ->
 		applyDeleteToComments: (op) ->
 			op_start = op.p
 			op_start = op.p
@@ -138,7 +169,7 @@ load = (EventEmitter) ->
 				if op_end <= comment_start
 				if op_end <= comment_start
 					# delete is fully before comment
 					# delete is fully before comment
 					comment.op.p -= op_length
 					comment.op.p -= op_length
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 				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
@@ -161,12 +192,13 @@ load = (EventEmitter) ->
 					
 					
 					comment.op.p = Math.min(comment_start, op_start)
 					comment.op.p = Math.min(comment_start, op_start)
 					comment.op.c = remaining_before + remaining_after
 					comment.op.c = remaining_before + remaining_after
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 
 
 		applyInsertToChanges: (op, metadata) ->
 		applyInsertToChanges: (op, metadata) ->
 			op_start = op.p
 			op_start = op.p
 			op_length = op.i.length
 			op_length = op.i.length
 			op_end = op.p + op_length
 			op_end = op.p + op_length
+			undoing = !!op.u
 
 
 
 
 			already_merged = false
 			already_merged = false
@@ -184,8 +216,9 @@ load = (EventEmitter) ->
 						change.op.p += op_length
 						change.op.p += op_length
 						moved_changes.push change
 						moved_changes.push change
 					else if op_start == change_start
 					else if op_start == change_start
-						# If the insert matches the start of the delete, just remove it from the delete instead
-						if change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i
+						# If we are undoing, then we want to cancel any existing delete ranges if we can.
+						# Check if the insert matches the start of the delete, and just remove it from the delete instead if so.
+						if undoing and change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i
 							change.op.d = change.op.d.slice(op.i.length)
 							change.op.d = change.op.d.slice(op.i.length)
 							change.op.p += op.i.length
 							change.op.p += op.i.length
 							if change.op.d == ""
 							if change.op.d == ""
@@ -203,15 +236,15 @@ load = (EventEmitter) ->
 					# Only merge inserts if they are from the same user
 					# Only merge inserts if they are from the same user
 					is_same_user = metadata.user_id == change.metadata.user_id
 					is_same_user = metadata.user_id == change.metadata.user_id
 					
 					
-					# If this is an insert op at the end of an existing insert with a delete following, and it cancels out the following
-					# delete then we shouldn't append it to this insert, but instead only cancel the following delete.
+					# If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also
+					# an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete.
 					# E.g.
 					# E.g.
 					#                   foo|<--- about to insert 'b' here
 					#                   foo|<--- about to insert 'b' here
 					#  inserted 'foo'  --^ ^-- deleted 'bar'
 					#  inserted 'foo'  --^ ^-- deleted 'bar'
 					# should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
 					# should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
 					next_change = @changes[i+1]
 					next_change = @changes[i+1]
 					is_op_adjacent_to_next_delete = next_change? and next_change.op.d? and op.p == change_end and next_change.op.p == op.p
 					is_op_adjacent_to_next_delete = next_change? and next_change.op.d? and op.p == change_end and next_change.op.p == op.p
-					will_op_cancel_next_delete = is_op_adjacent_to_next_delete and next_change.op.d.slice(0, op.i.length) == op.i
+					will_op_cancel_next_delete = undoing and is_op_adjacent_to_next_delete and next_change.op.d.slice(0, op.i.length) == op.i
 					
 					
 					# If there is a delete at the start of the insert, and we're inserting
 					# If there is a delete at the start of the insert, and we're inserting
 					# at the start, we SHOULDN'T merge since the delete acts as a partition.
 					# at the start, we SHOULDN'T merge since the delete acts as a partition.
@@ -281,8 +314,8 @@ load = (EventEmitter) ->
 			for change in remove_changes
 			for change in remove_changes
 				@_removeChange change
 				@_removeChange change
 			
 			
-			if moved_changes.length > 0
-				@emit "changes:moved", moved_changes
+			for change in moved_changes
+				@_markAsDirty change, "change", "moved"
 		
 		
 		applyDeleteToChanges: (op, metadata) ->
 		applyDeleteToChanges: (op, metadata) ->
 			op_start = op.p
 			op_start = op.p
@@ -406,8 +439,8 @@ load = (EventEmitter) ->
 					@_removeChange change
 					@_removeChange change
 					moved_changes = moved_changes.filter (c) -> c != change
 					moved_changes = moved_changes.filter (c) -> c != change
 			
 			
-			if moved_changes.length > 0
-				@emit "changes:moved", moved_changes
+			for change in moved_changes
+				@_markAsDirty change, "change", "moved"
 
 
 		_addOp: (op, metadata) ->
 		_addOp: (op, metadata) ->
 			change = {
 			change = {
@@ -427,17 +460,11 @@ load = (EventEmitter) ->
 				else
 				else
 					return -1
 					return -1
 
 
-			if op.d?
-				@emit "delete:added", change
-			else if op.i?
-				@emit "insert:added", change
+			@_markAsDirty(change, "change", "added")
 		
 		
 		_removeChange: (change) ->
 		_removeChange: (change) ->
 			@changes = @changes.filter (c) -> c.id != change.id
 			@changes = @changes.filter (c) -> c.id != change.id
-			if change.op.d?
-				@emit "delete:removed", change
-			else if change.op.i?
-				@emit "insert:removed", change
+			@_markAsDirty change, "change", "removed"
 			
 			
 		_applyOpModifications: (content, op_modifications) ->
 		_applyOpModifications: (content, op_modifications) ->
 			# Put in descending position order, with deleting first if at the same offset 
 			# Put in descending position order, with deleting first if at the same offset 
@@ -486,13 +513,32 @@ load = (EventEmitter) ->
 					previous_change = change
 					previous_change = change
 			return { moved_changes, remove_changes }
 			return { moved_changes, remove_changes }
 		
 		
+		resetDirtyState: () ->
+			@_dirtyState = {
+				comment: {
+					moved: {}
+					removed: {}
+					added: {}
+				}
+				change: {
+					moved: {}
+					removed: {}
+					added: {}
+				}
+			}
+		
+		getDirtyState: () ->
+			return @_dirtyState
+		
+		_markAsDirty: (object, type, action) ->
+			@_dirtyState[type][action][object.id] = object
+		
 		_clone: (object) ->
 		_clone: (object) ->
 			clone = {}
 			clone = {}
 			(clone[k] = v for k,v of object)
 			(clone[k] = v for k,v of object)
 			return clone
 			return clone
 
 
 if define?
 if define?
-	define ["utils/EventEmitter"], load
+	define [], load
 else
 else
-	EventEmitter = require("events").EventEmitter
-	module.exports = load(EventEmitter)
+	module.exports = load()

+ 27 - 16
services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee

@@ -4,7 +4,7 @@ define [
 	"ide/colors/ColorManager"
 	"ide/colors/ColorManager"
 	"ide/review-panel/RangesTracker"
 	"ide/review-panel/RangesTracker"
 ], (App, EventEmitter, ColorManager, RangesTracker) ->
 ], (App, EventEmitter, ColorManager, RangesTracker) ->
-	App.controller "ReviewPanelController", ($scope, $element, ide, $timeout, $http, $modal, event_tracking, localStorage) ->
+	App.controller "ReviewPanelController", ($scope, $element, ide, $timeout, $http, $modal, event_tracking, sixpack, localStorage) ->
 		$reviewPanelEl = $element.find "#review-panel"
 		$reviewPanelEl = $element.find "#review-panel"
 
 
 		$scope.SubViews =
 		$scope.SubViews =
@@ -27,6 +27,14 @@ define [
 			layoutToLeft: false
 			layoutToLeft: false
 			rendererData: {}
 			rendererData: {}
 			loadingThreads: false
 			loadingThreads: false
+			newAddCommentUI: false # Test new UI for adding comments; remove afterwards.
+
+		$scope.shouldABAddCommentBtn = false
+		if $scope.user.signUpDate >= '2017-03-27'
+			sixpack.participate "add-comment-btn", [ "default", "editor-corner" ], (variation) ->
+				$scope.shouldABAddCommentBtn = true
+				$scope.variationABAddCommentBtn = variation
+				$scope.reviewPanel.newAddCommentUI = (variation == "editor-corner")
 
 
 		window.addEventListener "beforeunload", () ->
 		window.addEventListener "beforeunload", () ->
 			collapsedStates = {}
 			collapsedStates = {}
@@ -163,7 +171,11 @@ define [
 
 
 		$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
+			permEntries = {}
+			for entry, entryData of entries
+				if entry != "add-comment" or !$scope.reviewPanel.newAddCommentUI
+					permEntries[entry] = entryData 
+			Object.keys(permEntries).length
 		), (nEntries) ->
 		), (nEntries) ->
 			$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.project.features.trackChangesVisible
 			$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.project.features.trackChangesVisible
 
 
@@ -288,18 +300,11 @@ define [
 
 
 			delete entries["add-comment"]
 			delete entries["add-comment"]
 			if selection
 			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
-						length: selection_offset_end - selection_offset_start
-					}
+				entries["add-comment"] = {
+					type: "add-comment"
+					offset: selection_offset_start
+					length: selection_offset_end - selection_offset_start
+				}
 			
 			
 			for id, entry of entries
 			for id, entry of entries
 				if entry.type == "comment" and not $scope.reviewPanel.resolvedThreadIds[entry.thread_id]
 				if entry.type == "comment" and not $scope.reviewPanel.resolvedThreadIds[entry.thread_id]
@@ -323,11 +328,17 @@ define [
 			$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' }
 			event_tracking.sendMB "rp-change-rejected", { view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' }
 		
 		
+		$scope.addNewComment = () ->
+			$scope.$broadcast "comment:start_adding"
+			$scope.toggleReviewPanel()
+
 		$scope.startNewComment = () ->
 		$scope.startNewComment = () ->
 			$scope.$broadcast "comment:select_line"
 			$scope.$broadcast "comment:select_line"
 			$timeout () ->
 			$timeout () ->
 				$scope.$broadcast "review-panel:layout"
 				$scope.$broadcast "review-panel:layout"
-		
+			if $scope.shouldABAddCommentBtn and !$scope.ui.reviewPanelOpen
+				sixpack.convert "add-comment-btn"
+
 		$scope.submitNewComment = (content) ->
 		$scope.submitNewComment = (content) ->
 			return if !content? or content == ""
 			return if !content? or content == ""
 			doc_id = $scope.editor.open_doc_id
 			doc_id = $scope.editor.open_doc_id
@@ -396,7 +407,7 @@ define [
 			return if !thread?
 			return if !thread?
 			thread.resolved = true
 			thread.resolved = true
 			thread.resolved_by_user = formatUser(user)
 			thread.resolved_by_user = formatUser(user)
-			thread.resolved_at = new Date()
+			thread.resolved_at = new Date().toISOString()
 			$scope.reviewPanel.resolvedThreadIds[thread_id] = true
 			$scope.reviewPanel.resolvedThreadIds[thread_id] = true
 			$scope.$broadcast "comment:resolve_threads", [thread_id]
 			$scope.$broadcast "comment:resolve_threads", [thread_id]
 		
 		

+ 8 - 2
services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee

@@ -8,13 +8,16 @@ define [
 			onStartNew: "&"
 			onStartNew: "&"
 			onSubmit: "&"
 			onSubmit: "&"
 			onCancel: "&"
 			onCancel: "&"
-			onIndicatorClick: "&"
+			onIndicatorClick: "&"	
 			layoutToLeft: "="
 			layoutToLeft: "="
 		link: (scope, element, attrs) ->
 		link: (scope, element, attrs) ->
 			scope.state =
 			scope.state =
 				isAdding: false
 				isAdding: false
 				content: ""
 				content: ""
 
 
+			scope.$on "comment:start_adding", () ->
+				scope.startNewComment()
+
 			scope.startNewComment = () ->
 			scope.startNewComment = () ->
 				scope.state.isAdding = true
 				scope.state.isAdding = true
 				scope.onStartNew()
 				scope.onStartNew()
@@ -31,7 +34,10 @@ define [
 					if scope.state.content.length > 0 
 					if scope.state.content.length > 0 
 						scope.submitNewComment()
 						scope.submitNewComment()
 
 
-			scope.submitNewComment = () ->
+			scope.submitNewComment = (event) ->
+				# If this is from a blur event from clicking on cancel, ignore it.
+				if event? and event.type == "blur" and $(event.relatedTarget).hasClass("rp-entry-button-cancel")
+					return true
 				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 = ""

+ 8 - 1
services/web/public/coffee/ide/settings/controllers/ProjectNameController.coffee

@@ -2,9 +2,13 @@ define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
 	MAX_PROJECT_NAME_LENGTH = 150
 	MAX_PROJECT_NAME_LENGTH = 150
-	App.controller "ProjectNameController", ["$scope", "settings", "ide", ($scope, settings, ide) ->
+	App.controller "ProjectNameController", ["$scope", "$element", "settings", "ide", ($scope, $element, settings, ide) ->
+		projectNameReadOnlyEl = $element.find(".name")[0]
+
 		$scope.state =
 		$scope.state =
 			renaming: false
 			renaming: false
+			overflowed: false
+
 		$scope.inputs = {}
 		$scope.inputs = {}
 
 
 		$scope.startRenaming = () ->
 		$scope.startRenaming = () ->
@@ -29,4 +33,7 @@ define [
 		$scope.$watch "project.name", (name) ->
 		$scope.$watch "project.name", (name) ->
 			if name?
 			if name?
 				window.document.title = name + " - Online LaTeX Editor ShareLaTeX"
 				window.document.title = name + " - Online LaTeX Editor ShareLaTeX"
+				$scope.$applyAsync () ->
+					# This ensures that the element is measured *after* the binding is done (i.e. project name is rendered).
+					$scope.state.overflowed = (projectNameReadOnlyEl.scrollWidth > projectNameReadOnlyEl.clientWidth)
 	]
 	]

BIN
services/web/public/favicon.ico


+ 29 - 0
services/web/public/img/brand/404-visual.svg

@@ -0,0 +1,29 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="777.5" height="820.52" viewBox="0 0 777.5 820.52">
+  <g>
+    <g>
+      <circle cx="388.75" cy="388.75" r="388.75" fill="#798ed2" opacity="0.05"/>
+      <rect x="182.22" y="230.37" width="413.04" height="295.03" fill="#a93529"/>
+      <path d="M389.5,229.95s9.34-15.08,42.47-13.25,133.77,14.17,133.77,14.17V492.25s-91.3-16.45-141.84-11.88c-22.82,2.06-35.25,11.88-35.25,11.88S390.13,230.63,389.5,229.95Z" fill="#fff"/>
+      <path d="M389.18,230.52s-10.59-15.5-43.72-13.67S211.7,231,211.7,231V492.41S303,476,353.53,480.52c22.82,2.06,35.25,11.88,35.25,11.88S388.54,231.2,389.18,230.52Z" fill="#fff"/>
+      <path d="M388.78,487.15v5.26s0-1.88,0-5.26Z" fill="#e9e6d9"/>
+      <g>
+        <circle cx="195.09" cy="656.25" r="12.92" fill="#a93529"/>
+        <path d="M194.85,696.16c2.64,5.28,23.77,6.3,46.21,6.16,22.66-.14,43.81-.66,46.53-5.94,6.85-13.27-34.66-41.88-46.42-41.88C227.16,654.5,187.73,681.91,194.85,696.16Z" fill="#a93529"/>
+        <circle cx="220.92" cy="630.4" r="12.92" fill="#a93529"/>
+        <circle cx="261.54" cy="630.4" r="12.92" fill="#a93529"/>
+        <circle cx="287.38" cy="656.25" r="12.92" fill="#a93529"/>
+      </g>
+      <g>
+        <circle cx="490.12" cy="774.44" r="12.92" fill="#a93529"/>
+        <path d="M489.89,814.35c2.64,5.28,23.77,6.3,46.21,6.16,22.66-.14,43.81-.66,46.53-5.94,6.85-13.27-34.66-41.88-46.42-41.88C522.19,772.69,482.76,800.11,489.89,814.35Z" fill="#a93529"/>
+        <circle cx="515.96" cy="748.6" r="12.92" fill="#a93529"/>
+        <circle cx="556.58" cy="748.6" r="12.92" fill="#a93529"/>
+        <circle cx="582.41" cy="774.44" r="12.92" fill="#a93529"/>
+      </g>
+      <rect x="211.7" y="406.39" width="354.03" height="88.87" fill="#fff"/>
+      <path d="M502.94,451.45c-58.5-14.55-114.29-5-114.29,40.8L386.37,228s.41-11.71,16.15-24.17l-3.15,27.79L442.13,218l-25.65,30.49L427.12,257l-14.5,20.45,31.82,54.39L422.55,370.5l21.88,42.19-20.3,17.14Z" fill="#cfcfcf"/>
+      <path d="M353.53,480.52c22.82,2.06,35.25,11.88,35.25,11.88v2.86H211.7v-2.86S303,476,353.53,480.52Z" fill="#e7e6e6"/>
+      <path d="M423.89,480.38c-22.82,2.08-35.25,12-35.25,12v2.88H565.73v-2.88S474.43,475.76,423.89,480.38Z" fill="#e7e6e6"/>
+    </g>
+  </g>
+</svg>

+ 10 - 0
services/web/public/img/brand/500-visual-plug.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="650.95" height="260.49" viewBox="0 0 650.95 260.49">
+  <g>
+    <g>
+      <path d="M464.53,260.49c-27.17,0-75.15-13.71-105.35-46.51-25.23-27.4-26.69-55.26-28.09-82.2-1-18.92-1.92-36.79-11-53.72C290.25,22.75,182.7-.54.34,8.84L0,2.09C187.78-7.58,294.42,16.23,326,74.86c9.84,18.27,10.86,37.74,11.84,56.57,1.35,25.75,2.74,52.37,26.32,78,30.92,33.59,81.25,46.18,105.54,44.12,8.27-.7,9.89-2.86,10-3.1,2.59-4-15.91-19.6-29.41-31-30.14-25.42-67.66-57.07-58.22-86.53,5.29-16.54,24-28.7,57.23-37.18l1.67,6.55C420.66,110,403,121,398.54,135c-8.05,25.15,29.05,56.45,56.14,79.3,22,18.57,36.56,30.84,30.74,39.83-2.22,3.42-7.32,5.5-15.15,6.17C468.48,260.42,466.56,260.49,464.53,260.49Z" fill="#505050"/>
+      <path d="M526.92,129.37h0a9.79,9.79,0,0,1,10-9.55H641a9.79,9.79,0,0,1,10,9.55h0a9.79,9.79,0,0,1-10,9.55H536.87a9.79,9.79,0,0,1-10-9.55Zm10-46.66a9.79,9.79,0,0,1-10-9.55h0a9.79,9.79,0,0,1,10-9.55H641a9.79,9.79,0,0,1,10,9.55h0a9.79,9.79,0,0,1-10,9.55H536.87" fill="#919296" fill-rule="evenodd"/>
+      <rect x="444.49" y="57.18" width="68.71" height="88.17" fill="#919296"/>
+      <rect x="473.95" y="41.51" width="118.01" height="117.98" fill="#c1c2c5"/>
+    </g>
+  </g>
+</svg>

+ 12 - 0
services/web/public/img/brand/500-visual-socket.svg

@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="777.28" height="777.28" viewBox="0 0 777.28 777.28">
+  <g>
+    <g>
+      <circle cx="388.64" cy="388.64" r="388.64" fill="#798ed2" opacity="0.05"/>
+      <g>
+        <rect x="241.27" y="232.25" width="294.75" height="294.75" fill="#c1c2c5"/>
+        <path d="M388.64,468.11a88.49,88.49,0,1,0-88.49-88.49A88.66,88.66,0,0,0,388.64,468.11Z" fill="#919296" fill-rule="evenodd"/>
+        <path d="M352.86,366.1a13.27,13.27,0,1,1-13.27,13.27,13.27,13.27,0,0,1,13.27-13.27Zm71.9,0a13.27,13.27,0,1,1-13.27,13.27A13.27,13.27,0,0,1,424.75,366.1Z" fill="#505050" fill-rule="evenodd"/>
+      </g>
+    </g>
+  </g>
+</svg>

Разница между файлами не показана из-за своего большого размера
+ 7 - 0
services/web/public/img/brand/500-visual-tail.svg


+ 8 - 0
services/web/public/img/brand/lion-grey.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 431" width="500" height="431">
+	<g>
+		<circle cx="291.77" cy="172.48" r="11.72" fill="#acadb2"/>
+		<path d="M307.12,310.59a4.71,4.71,0,0,0-5.89-3.08c-12.93,4.05-29.57,6.22-46.52,6.56v-57c0-.14-0.07-0.25-0.08-0.39,14.13-4.8,43.14-26.44,37.62-37.14-2.48-4.81-21.73-5.28-42.35-5.41-20.43-.13-39.65.8-42.06,5.61-5.81,11.62,22.39,32.84,37.49,37.13,0,0.07,0,.13,0,0.2v57c-17-.35-33.6-2.52-46.52-6.58a4.7,4.7,0,0,0-2.82,9c15.16,4.76,34.61,7.13,54.07,7.13s38.86-2.37,54-7.11A4.71,4.71,0,0,0,307.12,310.59Z" fill="#acadb2"/>
+		<circle cx="208.27" cy="172.48" r="11.72" fill="#acadb2"/>
+		<path d="M249.3,405c2.88,9.34,5.7,19.73,7.43,25.28,37.9-.39,72.72-0.26,109.1,0-0.09-14.67-.93-23.15-1-35.49l19.91,21.41c18.71-36.35,41.83-62.9,48.36-103.75,26.95-17,36.71-51.27,55.46-77.28-9.25-4.64-18.86-5.69-27.55-10,11.42-3.81,27-7,39-11-16.71-26.89-30-52.75-45.65-78.11-7.54-14.6-22.8-18.82-37.65-7.17,0.35-10.22,15.18-29.16,10.76-37.39-12.87-24-26.86-50.76-41-74.1L357,41.24,368.26,0.75C340.85,1.44,312,3.18,287,2.26c-19.65-.73-37.79,6.58-41.27,29.92C243.87,21.81,240.54,12.77,226.88,8,196.14-2.66,138.19.13,123.25,1.39c5.28,13,6.07,27.18,11.07,39.46L107.79,28.42C96.56,49,84,68.43,71.62,87c-8.47,16.32-12,31.15,13.72,45-14.26-.49-25-4.33-34.37-4.65C34.13,157.58,15.5,184.53,0,214.81c16.88-.43,33.29,7.54,43.2,7.29h0c-8,5.5-10.43,16.17-22.73,24.61,22.64,19.05,17.76,60.78,48.78,67.77-0.73,5.22-4.71,14.07-2.2,19.05,14.17,28.1,32.86,51.37,48.65,79.92,9.39-13.15,19.17-17.67,26.48-27.21l-4.61,42.83c17.87,0.61,35,1.42,52.21,1.74C211.09,431.22,242.32,431.25,249.3,405ZM348.58,86.26c4.24-6.39,25.13,3.51,28.49,9.37,2.82,4.91.82,29.09-6.37,29.42-2.86.13-8.14-8.58-13.63-18S347,88.63,348.58,86.26ZM122.66,95.63c3.36-5.85,24.25-15.75,28.49-9.37,1.57,2.37-3.06,11.43-8.5,20.77S131.88,125.17,129,125C121.84,124.72,119.84,100.54,122.66,95.63Zm20,290h0Zm20.9-86.71,6.21-10.75-43.25-74.92,61.74-106.94H311.74l61.75,106.94-43.26,74.92,6.21,10.76-43.22,74.86H206.78Z" fill="#acadb2"/>
+	</g>
+</svg>

+ 8 - 0
services/web/public/img/brand/lion.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 431" width="500" height="431">
+	<g>
+		<circle cx="291.77" cy="172.48" r="11.72" fill="#a93529"/>
+		<path d="M307.12,310.59a4.71,4.71,0,0,0-5.89-3.08c-12.93,4.05-29.57,6.22-46.52,6.56v-57c0-.14-0.07-0.25-0.08-0.39,14.13-4.8,43.14-26.44,37.62-37.14-2.48-4.81-21.73-5.28-42.35-5.41-20.43-.13-39.65.8-42.06,5.61-5.81,11.62,22.39,32.84,37.49,37.13,0,0.07,0,.13,0,0.2v57c-17-.35-33.6-2.52-46.52-6.58a4.7,4.7,0,0,0-2.82,9c15.16,4.76,34.61,7.13,54.07,7.13s38.86-2.37,54-7.11A4.71,4.71,0,0,0,307.12,310.59Z" fill="#a93529"/>
+		<circle cx="208.27" cy="172.48" r="11.72" fill="#a93529"/>
+		<path d="M249.3,405c2.88,9.34,5.7,19.73,7.43,25.28,37.9-.39,72.72-0.26,109.1,0-0.09-14.67-.93-23.15-1-35.49l19.91,21.41c18.71-36.35,41.83-62.9,48.36-103.75,26.95-17,36.71-51.27,55.46-77.28-9.25-4.64-18.86-5.69-27.55-10,11.42-3.81,27-7,39-11-16.71-26.89-30-52.75-45.65-78.11-7.54-14.6-22.8-18.82-37.65-7.17,0.35-10.22,15.18-29.16,10.76-37.39-12.87-24-26.86-50.76-41-74.1L357,41.24,368.26,0.75C340.85,1.44,312,3.18,287,2.26c-19.65-.73-37.79,6.58-41.27,29.92C243.87,21.81,240.54,12.77,226.88,8,196.14-2.66,138.19.13,123.25,1.39c5.28,13,6.07,27.18,11.07,39.46L107.79,28.42C96.56,49,84,68.43,71.62,87c-8.47,16.32-12,31.15,13.72,45-14.26-.49-25-4.33-34.37-4.65C34.13,157.58,15.5,184.53,0,214.81c16.88-.43,33.29,7.54,43.2,7.29h0c-8,5.5-10.43,16.17-22.73,24.61,22.64,19.05,17.76,60.78,48.78,67.77-0.73,5.22-4.71,14.07-2.2,19.05,14.17,28.1,32.86,51.37,48.65,79.92,9.39-13.15,19.17-17.67,26.48-27.21l-4.61,42.83c17.87,0.61,35,1.42,52.21,1.74C211.09,431.22,242.32,431.25,249.3,405ZM348.58,86.26c4.24-6.39,25.13,3.51,28.49,9.37,2.82,4.91.82,29.09-6.37,29.42-2.86.13-8.14-8.58-13.63-18S347,88.63,348.58,86.26ZM122.66,95.63c3.36-5.85,24.25-15.75,28.49-9.37,1.57,2.37-3.06,11.43-8.5,20.77S131.88,125.17,129,125C121.84,124.72,119.84,100.54,122.66,95.63Zm20,290h0Zm20.9-86.71,6.21-10.75-43.25-74.92,61.74-106.94H311.74l61.75,106.94-43.26,74.92,6.21,10.76-43.22,74.86H206.78Z" fill="#a93529"/>
+	</g>
+</svg>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
services/web/public/img/brand/logo-horizontal.svg


BIN
services/web/public/img/favicon.png


BIN
services/web/public/img/favicon@2x.png


BIN
services/web/public/img/feature-page/courtney-gibbons.jpg


BIN
services/web/public/img/feature-page/feat-accept-poster.jpg


BIN
services/web/public/img/feature-page/feat-accept.mp4


BIN
services/web/public/img/feature-page/feat-changes-poster.jpg


BIN
services/web/public/img/feature-page/feat-changes.mp4


BIN
services/web/public/img/feature-page/feat-discuss-poster.jpg


BIN
services/web/public/img/feature-page/feat-discuss.mp4


BIN
services/web/public/img/feature-page/feat-todos-poster.jpg


BIN
services/web/public/img/feature-page/feat-todos.mp4


BIN
services/web/public/img/feature-page/intro-poster.jpg


BIN
services/web/public/img/feature-page/intro.mp4


BIN
services/web/public/img/feature-page/joanna-ellis-monaghan.jpg


Некоторые файлы не были показаны из-за большого количества измененных файлов